Skills for AEM Forms on Edge Delivery Services: building forms with the Form block.
96%
Does it follow best practices?
Impact
—
Average score across 6 eval scenarios
Passed
No findings from the security scan
#!/usr/bin/env node
/*
* Static validator for an AEM Forms (Edge Delivery Services) custom form component.
*
* Checks the files the create-custom-component skill generates — folder layout, the
* `_<name>.json` authoring schema, the `<name>.js` decorator, registration in
* `blocks/form/mappings.js` / `_form.json` / `models/_component-definition.json`, and
* whether `npm run build:json` output includes the component. No dependencies; Node 18+.
*
* Exit codes: 0 = no errors (warnings allowed), 1 = errors found, 2 = usage error.
*/
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { dirname, join, resolve } from 'node:path';
const USAGE = `validate-component.mjs — statically validate an AEM Forms custom component
Usage:
node validate-component.mjs <component-name> [--root <project-root>] [--json]
Options:
--root <dir> Repository root containing package.json and blocks/form
(default: current directory).
--json Print machine-readable JSON instead of text.
-h, --help Show this help.
Exit codes: 0 = pass (warnings allowed), 1 = errors found, 2 = usage error.`;
const AUTHORING_COMPONENTS = new Set([
'text', 'textarea', 'richtext', 'number', 'boolean', 'select', 'multiselect',
'radio-group', 'checkbox-group', 'date-time', 'container', 'tab', 'reference',
'aem-content', 'aem-content-fragment', 'aem-experience-fragment', 'aem-tag',
]);
const OPTION_COMPONENTS = new Set(['select', 'multiselect', 'radio-group', 'checkbox-group']);
const README_SECTIONS = [
{ label: 'How to Use / Authoring Properties', re: /^##\s+(How to Use|Authoring Properties)\b/im },
{ label: 'Usage Notes', re: /^##\s+Usage Notes\b/im },
];
function parseArgs(argv) {
const opts = { name: null, root: null, json: false, help: false };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '-h' || arg === '--help') opts.help = true;
else if (arg === '--json') opts.json = true;
else if (arg === '--root') {
opts.root = argv[i + 1];
i += 1;
} else if (arg.startsWith('--root=')) opts.root = arg.slice('--root='.length);
else if (arg.startsWith('-')) throw new Error(`Unknown option: ${arg}`);
else if (!opts.name) opts.name = arg;
else throw new Error(`Unexpected argument: ${arg}`);
}
return opts;
}
function resolveRoot(root) {
return resolve(root || process.cwd());
}
function readText(file) {
return existsSync(file) ? readFileSync(file, 'utf8') : null;
}
function stringsInArrayLiteral(source, varName) {
const re = new RegExp(`\\b${varName}\\s*=\\s*(?:new\\s+Set\\(\\s*)?\\[([^\\]]*)\\]`);
const match = source.match(re);
if (!match) return null;
return [...match[1].matchAll(/['"`]([^'"`]+)['"`]/g)].map((m) => m[1]);
}
function findDeep(node, key, out = []) {
if (Array.isArray(node)) node.forEach((n) => findDeep(n, key, out));
else if (node && typeof node === 'object') {
Object.entries(node).forEach(([k, v]) => {
if (k === key) out.push({ value: v, parent: node });
findDeep(v, key, out);
});
}
return out;
}
const ESLINT_CONFIGS = ['.eslintrc.cjs', '.eslintrc.js', '.eslintrc.json', '.eslintrc', 'eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs'];
/** Finds per-model limits of the `xwalk/max-cells` rule in the repository's ESLint config. */
export function findMaxCellsConfig(root) {
for (const file of ESLINT_CONFIGS) {
const text = readText(join(root, file));
const at = text?.search(/['"]xwalk\/max-cells['"]/) ?? -1;
if (at >= 0) {
const open = text.indexOf('{', at);
let depth = 0;
let end = open;
for (; end < text.length; end += 1) {
if (text[end] === '{') depth += 1;
else if (text[end] === '}' && (depth -= 1) === 0) break;
}
const body = open < 0 ? '' : text.slice(open + 1, end).replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g, '');
const limits = Object.fromEntries([...body.matchAll(/['"]?([\w*.-]+)['"]?\s*:\s*(\d+)/g)].map((m) => [m[1], Number(m[2])]));
return { file, limits };
}
}
return null;
}
/** Cell count of a built model, as eslint-plugin-xwalk's max-cells rule computes it. */
export function countCells(model) {
const names = [];
const collect = (fields = []) => fields.forEach(({ name: n, component, ...rest }) => {
if (component === 'tab') return;
if (component === 'container') collect(rest.fields);
else names.push(n);
});
collect(model.fields);
const collapsed = names.filter((n) => {
const suffix = ['MimeType', 'Text', 'Title', 'Type', 'Alt'].find((x) => typeof n === 'string' && n.endsWith(x));
return !(suffix && names.includes(n.slice(0, -suffix.length)));
});
return new Set(collapsed.map((n) => String(n).match(/^([^_]+)(_.+)?$/)?.[1]).filter(Boolean)).size;
}
export function validateComponent(name, rootDir) {
const root = resolveRoot(rootDir);
const checks = [];
const add = (level, id, message) => checks.push({ level, id, message });
const pass = (id, message) => add('pass', id, message);
const error = (id, message) => add('error', id, message);
const warn = (id, message) => add('warn', id, message);
const formDir = join(root, 'blocks/form');
const compDir = join(formDir, 'components', name);
const jsFile = join(compDir, `${name}.js`);
const cssFile = join(compDir, `${name}.css`);
const jsonFile = join(compDir, `_${name}.json`);
const readmeFile = join(compDir, 'README.md');
const rel = (p) => p.slice(root.length + 1);
const finish = () => {
const errors = checks.filter((c) => c.level === 'error').length;
const warnings = checks.filter((c) => c.level === 'warn').length;
return {
component: name, root, status: errors ? 'fail' : 'pass', errors, warnings, checks,
};
};
// ---- naming & layout -------------------------------------------------------------
if (!/^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$/.test(name)) {
error('name.format', `"${name}" is not a valid component name (letters, digits, single hyphens)`);
} else if (name !== name.toLowerCase()) {
warn('name.case', `"${name}" contains uppercase letters; prefer lowercase kebab-case`);
} else {
pass('name.format', `"${name}" is kebab-case`);
}
if (!existsSync(formDir)) {
error('project.form-block', `No blocks/form directory under ${root}. Run from the repository root or pass --root.`);
return finish();
}
if (!existsSync(compDir) || !statSync(compDir).isDirectory()) {
error('files.folder', `Missing component folder ${rel(compDir)}`);
return finish();
}
pass('files.folder', `Found ${rel(compDir)}`);
const js = readText(jsFile);
if (js === null) {
const others = readdirSync(compDir).filter((f) => f.endsWith('.js'));
error('files.js', `Missing ${rel(jsFile)} (folder and JS file names must match)${others.length ? `; found ${others.join(', ')}` : ''}`);
} else pass('files.js', `Found ${rel(jsFile)}`);
const hasCss = existsSync(cssFile);
if (hasCss) pass('files.css', `Found ${rel(cssFile)}`);
const jsonText = readText(jsonFile);
let schema = null;
if (jsonText === null) {
error('files.json', `Missing ${rel(jsonFile)}`);
} else {
try {
schema = JSON.parse(jsonText);
pass('files.json', `${rel(jsonFile)} is valid JSON`);
} catch (e) {
error('files.json', `${rel(jsonFile)} is not valid JSON: ${e.message}`);
}
}
const readme = readText(readmeFile);
if (readme === null) {
warn('files.readme', `Missing ${rel(readmeFile)}`);
} else {
const missing = README_SECTIONS.filter((s) => !s.re.test(readme)).map((s) => s.label);
if (!/^#\s+\S/m.test(readme)) missing.unshift('# <Component Name> title');
if (missing.length) warn('files.readme', `README.md is missing sections: ${missing.join(', ')}`);
else pass('files.readme', 'README.md has the required sections');
}
// ---- JSON schema -----------------------------------------------------------------
if (schema) {
const definitions = Array.isArray(schema.definitions) ? schema.definitions : [];
const viewTypeOf = (d) => d?.plugins?.xwalk?.page?.template?.['fd:viewType'];
const definition = definitions.find((d) => d?.id === name)
|| definitions.find((d) => viewTypeOf(d) === name)
|| (definitions.length === 1 ? definitions[0] : undefined);
if (!definitions.length) error('json.definitions', 'JSON has no "definitions" array');
else if (!definition) {
error('json.definitions', `No definition with "id" or "fd:viewType" "${name}" (found ids: ${definitions.map((d) => d?.id).join(', ')})`);
} else if (definition.id !== name) {
warn('json.definitions', `Definition id "${definition.id}" differs from component name "${name}"; new components should use "id": "${name}"`);
} else pass('json.definitions', `Definition "${name}" present`);
const page = definition?.plugins?.xwalk?.page;
const template = page?.template;
if (definition && !template) {
error('json.template', `Definition "${name}" has no plugins.xwalk.page.template`);
}
if (template) {
const viewType = template['fd:viewType'];
if (viewType === name) pass('json.viewType', `fd:viewType is "${name}"`);
else if (viewType === undefined) error('json.viewType', `template is missing "fd:viewType": "${name}" — the runtime will never load this component`);
else error('json.viewType', `fd:viewType "${viewType}" does not match component name "${name}"`);
if (template.fieldType) pass('json.fieldType', `Extends OOTB fieldType "${template.fieldType}"`);
else error('json.fieldType', 'template is missing "fieldType" (the OOTB type being extended)');
}
if (page) {
if (typeof page.resourceType === 'string' && page.resourceType.startsWith('core/fd/components/form/')) {
pass('json.resourceType', `resourceType ${page.resourceType}`);
} else {
error('json.resourceType', `resourceType "${page.resourceType}" should be an AEM Forms core component (core/fd/components/form/...)`);
}
}
const models = Array.isArray(schema.models) ? schema.models : [];
const modelIds = [name, definition?.id, template?.model].filter(Boolean);
const model = models.find((m) => modelIds.includes(m?.id));
if (!model) {
error('json.model', `No model with "id": "${name}" — the properties dialog will be empty`);
} else if (!Array.isArray(model.fields)) {
error('json.model', `Model "${name}" has no "fields" array`);
} else {
pass('json.model', `Model "${name}" has ${model.fields.length} top-level field entries`);
validateFields(model.fields, 'models[0].fields');
}
// Every "..." reference must resolve to a file relative to the JSON file.
const refs = findDeep(schema, '...').map((r) => r.value).filter((v) => typeof v === 'string');
const broken = refs.filter((ref) => !existsSync(resolve(dirname(jsonFile), ref.split('#')[0])));
if (broken.length) error('json.refs', `Unresolvable "..." references: ${broken.join(', ')}`);
else if (refs.length) pass('json.refs', `${refs.length} shared-field reference(s) resolve`);
}
function validateFields(fields, path) {
const seen = new Map();
fields.forEach((field, i) => {
const at = `${path}[${i}]`;
if (!field || typeof field !== 'object') {
error('json.fields', `${at} is not an object`);
return;
}
if (field['...'] && !field.component) return; // pure include
if (!field.component) {
error('json.fields', `${at} is missing "component"`);
return;
}
if (!AUTHORING_COMPONENTS.has(field.component)) {
warn('json.fields', `${at} uses unknown authoring component "${field.component}"`);
}
if (field.component !== 'tab' && !field.name) {
error('json.fields', `${at} (${field.component}) is missing "name"`);
}
if (field.name && field.component !== 'container' && field.component !== 'tab') {
if (seen.has(field.name)) error('json.fields', `Duplicate field name "${field.name}" at ${seen.get(field.name)} and ${at}`);
else seen.set(field.name, at);
}
if (OPTION_COMPONENTS.has(field.component) && !Array.isArray(field.options) && !field['...']) {
warn('json.fields', `${at} "${field.name}" (${field.component}) has no "options" array`);
}
if (Array.isArray(field.fields)) validateFields(field.fields, `${at}.fields`);
});
}
// ---- JS decorator ----------------------------------------------------------------
if (js !== null) {
const syntax = spawnSync(process.execPath, ['--input-type=module', '--check'], { input: js, encoding: 'utf8' });
if (syntax.status === 0) pass('js.syntax', `${name}.js parses as an ES module`);
else error('js.syntax', `${name}.js has a syntax error: ${(syntax.stderr || '').split('\n').find((l) => /Error/.test(l)) || 'see node --check'}`);
if (/export\s+default\s+(async\s+)?function\b|export\s+default\s+[A-Za-z_$][\w$]*\s*;|export\s*\{[^}]*\bas\s+default\b/.test(js)) {
pass('js.export', 'Has a default export (decorate)');
} else {
error('js.export', `${name}.js must \`export default function decorate(element, fd, container, formId)\``);
}
const subscribeImport = js.match(/import\s*\{[^}]*\bsubscribe\b[^}]*\}\s*from\s*['"]([^'"]+)['"]/);
const usesSubscribe = /\bsubscribe\s*\(/.test(js.replace(/\.\s*subscribe\s*\(/g, ''));
if (subscribeImport) {
if (subscribeImport[1] === '../../rules/index.js') pass('js.subscribe-import', 'Imports subscribe from ../../rules/index.js');
else error('js.subscribe-import', `subscribe is imported from "${subscribeImport[1]}"; expected '../../rules/index.js'`);
} else if (usesSubscribe) {
error('js.subscribe-import', 'subscribe() is called but not imported from ../../rules/index.js');
}
if (subscribeImport || usesSubscribe) {
if (/listenChanges\s*:\s*true/.test(js)) pass('js.listenChanges', 'subscribe() uses { listenChanges: true }');
else warn('js.listenChanges', 'subscribe() is called without { listenChanges: true } (legacy pattern)');
}
if (/\.subscribe\s*\([\s\S]{0,400}?,\s*['"]change['"]\s*\)/.test(js)) {
warn('js.legacy-change', "fieldModel.subscribe(cb, 'change') is the legacy pattern; use subscribe(..., { listenChanges: true }) and handle eventType === 'change'");
}
const changeEvents = [...js.matchAll(/new\s+Event\(\s*['"]change['"]\s*(,\s*\{[^}]*\})?\s*\)/g)];
if (changeEvents.some((m) => !/bubbles\s*:\s*true/.test(m[1] || ''))) {
warn('js.change-bubbles', "new Event('change') without { bubbles: true } will not reach the form model");
}
if (/querySelector\(\s*`#\$\{/.test(js)) {
warn('js.child-selector', 'querySelector(`#${id}`) selects the inner input; use `[data-id="${id}"]` to get the field wrapper');
}
const badImports = [...js.matchAll(/(?:import|from)\s*['"](\.{1,2}\/[^'"]+)['"]/g)]
.map((m) => m[1]).filter((p) => !/\.(m?js|json|css)$/.test(p));
if (badImports.length) error('js.import-ext', `Relative imports must include the .js extension: ${badImports.join(', ')}`);
if (/\bdocument\.querySelector(All)?\(/.test(js)) {
warn('js.scope', 'Uses document.querySelector — scope DOM queries to the component element where possible');
}
}
// ---- registration ----------------------------------------------------------------
const mappingsFile = join(formDir, 'mappings.js');
const mappings = readText(mappingsFile);
let noCss = [];
if (mappings === null) {
error('register.mappings', `Missing ${rel(mappingsFile)}`);
} else {
const custom = stringsInArrayLiteral(mappings, 'customComponents') || [];
const ootb = stringsInArrayLiteral(mappings, 'OOTBComponentDecorators') || [];
noCss = stringsInArrayLiteral(mappings, 'NO_CSS_COMPONENTS') || [];
const inCustom = custom.includes(name);
const inOotb = ootb.includes(name);
if (inCustom && inOotb) warn('register.mappings', `"${name}" is listed in both customComponents and OOTBComponentDecorators`);
else if (inCustom || inOotb) pass('register.mappings', `Registered in ${inCustom ? 'customComponents' : 'OOTBComponentDecorators'} (mappings.js)`);
else error('register.mappings', `"${name}" is not in customComponents or OOTBComponentDecorators in blocks/form/mappings.js — it will never load`);
}
if (!hasCss && !noCss.includes(name)) {
warn('files.css', `No ${name}.css and not listed in NO_CSS_COMPONENTS — the loader will request a missing stylesheet`);
}
const formJson = readText(join(formDir, '_form.json'));
if (formJson !== null) {
try {
const form = JSON.parse(formJson);
const filter = (form.filters || []).find((f) => f.id === 'form');
if (filter && Array.isArray(filter.components)) {
if (filter.components.includes(name)) pass('register.form-filter', '_form.json "form" filter allows the component');
else warn('register.form-filter', `"${name}" is not in the _form.json "form" filter — authors cannot insert it in Universal Editor`);
}
} catch (e) {
error('register.form-filter', `blocks/form/_form.json is not valid JSON: ${e.message}`);
}
}
const defIndex = readText(join(root, 'models/_component-definition.json'));
if (defIndex !== null) {
const ref = `components/${name}/_${name}.json`;
if (defIndex.includes(ref) || /components\/\*\/_\*\.json/.test(defIndex) || /components\/\*\/_\*\.json/.test(formJson || '')) {
pass('register.definition-index', 'Component definition is included in models/_component-definition.json');
} else {
warn('register.definition-index', `models/_component-definition.json does not reference ${ref}`);
}
}
// ---- build:json output -----------------------------------------------------------
const builtModels = readText(join(root, 'component-models.json'));
const builtDefs = readText(join(root, 'component-definition.json'));
let builtModel = null;
if (builtModels === null || builtDefs === null) {
warn('build.json', 'component-models.json / component-definition.json not found — run `npm run build:json`');
} else {
let models = [];
try { models = JSON.parse(builtModels); } catch { /* reported below */ }
builtModel = (Array.isArray(models) && models.find((m) => m?.id === name)) || null;
const hasDef = builtDefs.includes(`"fd:viewType": "${name}"`) || builtDefs.includes(`"fd:viewType":"${name}"`);
if (builtModel && hasDef) pass('build.json', 'Built component-models.json and component-definition.json include the component');
else warn('build.json', `Built JSON is stale (model: ${builtModel ? 'yes' : 'no'}, definition: ${hasDef ? 'yes' : 'no'}) — run \`npm run build:json\``);
}
// ---- repository lint limits (eslint-plugin-xwalk max-cells) ------------------------
const lintConfig = findMaxCellsConfig(root);
if (lintConfig) {
const { file, limits } = lintConfig;
const limit = limits[name] ?? limits['*'] ?? 4;
const where = `'xwalk/max-cells' in ${file}`;
if (builtModel) {
const cells = countCells(builtModel);
if (cells > limit) {
error('lint.max-cells', `Model "${name}" renders ${cells} cells but ${where} allows ${limit}${name in limits ? '' : ' (default)'} — add \`'${name}': ${cells}\` to it`);
} else pass('lint.max-cells', `Model "${name}" has ${cells} cells, within ${where} (${limit})`);
} else if (!(name in limits)) {
warn('lint.max-cells', `${where} has no entry for "${name}"; run \`npm run build:json\` and re-validate to get the required cell count`);
}
}
return finish();
}
function printText(result) {
const icon = { pass: '✔', warn: '⚠', error: '✖' };
console.log(`Validating custom component "${result.component}" in ${result.root}\n`);
result.checks.forEach((c) => console.log(`${icon[c.level]} [${c.id}] ${c.message}`));
console.log(`\n${result.status.toUpperCase()}: ${result.errors} error(s), ${result.warnings} warning(s)`);
}
const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
if (isMain) {
let opts;
try {
opts = parseArgs(process.argv.slice(2));
} catch (e) {
console.error(`${e.message}\n\n${USAGE}`);
process.exit(2);
}
if (opts.help) {
console.log(USAGE);
process.exit(0);
}
if (!opts.name) {
console.error(`Missing <component-name>\n\n${USAGE}`);
process.exit(2);
}
const result = validateComponent(opts.name, opts.root);
if (opts.json) console.log(JSON.stringify(result, null, 2));
else printText(result);
process.exit(result.errors ? 1 : 0);
}