Skip to content

Commit bf91dbf

Browse files
author
Peter Stenger
committed
feat: add recursive child schemas
1 parent c789ff0 commit bf91dbf

13 files changed

Lines changed: 868 additions & 71 deletions

README.md

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,57 @@ Schemas validate the tag's flat attribute object. The tag name is implicit from
373373

374374
Attribute values are coerced by JSON Schema (`"2"` can satisfy an integer, valueless attributes become `true`). Attribute values containing mustache are treated as unknown runtime values, so value-dependent schema errors are waived while presence and unknown-attribute checks still run.
375375

376+
Custom tags can also declare parent-owned child schemas. Child schemas validate a direct child tag's flat attribute object only in the context of that parent:
377+
378+
```jsonc
379+
{
380+
"customTags": [
381+
{
382+
"name": "pl-multiple-choice",
383+
"schema": "elements/pl-multiple-choice/pl-multiple-choice.schema.json",
384+
"children": {
385+
"tags": [
386+
{
387+
"name": "pl-answer",
388+
"schema": "elements/pl-multiple-choice/pl-answer.schema.json",
389+
},
390+
],
391+
},
392+
},
393+
{ "name": "pl-answer" },
394+
],
395+
}
396+
```
397+
398+
`children.mode` defaults to `"strict"`, so the example above allows only direct `<pl-answer>` HTML elements under `<pl-multiple-choice>`. Set `"mode": "loose"` to keep unlisted direct child elements allowed while still schema-validating listed child tags when they appear. Mustache sections are transparent for this check: `<pl-answer>` inside `{{#cond}}...{{/cond}}` still counts as a direct child of the surrounding parent element.
399+
400+
Parent-owned child schemas do not create a global schema for the child tag. A bare `<pl-answer>` outside `<pl-multiple-choice>` is recognized by `{ "name": "pl-answer" }`, but it does not use the multiple-choice-specific child schema.
401+
402+
If a child tag is declared only inside `children.tags` and is not listed as a top-level `customTags` entry, it is recognized only as a child-owned tag and may appear only as a direct child of the parent tags that declared it. Listing the same tag at the top level means it can also appear in any other context.
403+
404+
`children.tags` can be nested recursively. Each level still validates only direct children, so this keeps `<pl-answer>` scoped to `<pl-multiple-choice>` while giving `<pl-answer>` its own allowed direct child tags:
405+
406+
```jsonc
407+
{
408+
"customTags": [
409+
{
410+
"name": "pl-multiple-choice",
411+
"children": {
412+
"tags": [
413+
{
414+
"name": "pl-answer",
415+
"schema": "elements/pl-multiple-choice/pl-answer.schema.json",
416+
"children": {
417+
"tags": [{ "name": "pl-answer-feedback" }],
418+
},
419+
},
420+
],
421+
},
422+
},
423+
],
424+
}
425+
```
426+
376427
Example schema:
377428

378429
```jsonc
@@ -395,13 +446,16 @@ Schemas must declare draft-06 using `http://json-schema.org/draft-06/schema#` (t
395446

396447
Schema diagnostics are phrased in HTML/element terms rather than JSON-Schema vocabulary, so template authors aren't asked to translate `instancePath` and `additionalProperty` back into the markup they wrote. Examples:
397448

398-
| Schema constraint | Diagnostic |
399-
| --------------------------------- | -------------------------------------------------------------------------------- |
400-
| `required: ["answers-name"]` | `<pl-multiple-choice> is missing required attribute "answers-name".` |
401-
| `additionalProperties: false` | `Unknown attribute "extra" on <pl-multiple-choice>.` |
402-
| `properties.display.enum` | `Attribute "display" on <pl-multiple-choice> must be one of: "block", "inline".` |
403-
| `properties.size.type: "integer"` | `Attribute "size" on <pl-multiple-choice> must be integer.` |
404-
| `properties.weight.minimum: 0` | `Attribute "weight" on <pl-multiple-choice> must be >= 0.` |
449+
| Schema constraint | Diagnostic |
450+
| --------------------------------- | ----------------------------------------------------------------------------------------------- |
451+
| `required: ["answers-name"]` | `<pl-multiple-choice> is missing required attribute "answers-name".` |
452+
| `additionalProperties: false` | `Unknown attribute "extra" on <pl-multiple-choice>.` |
453+
| strict unlisted child | `<pl-multiple-choice> only allows these child elements: <pl-answer>.` |
454+
| child-only tag outside its parent | `<pl-answer> may only appear as a direct child of these parent elements: <pl-multiple-choice>.` |
455+
| child `additionalProperties` | `Unknown attribute "ranking" on <pl-answer> inside <pl-multiple-choice>.` |
456+
| `properties.display.enum` | `Attribute "display" on <pl-multiple-choice> must be one of: "block", "inline".` |
457+
| `properties.size.type: "integer"` | `Attribute "size" on <pl-multiple-choice> must be integer.` |
458+
| `properties.weight.minimum: 0` | `Attribute "weight" on <pl-multiple-choice> must be >= 0.` |
405459

406460
Constraints without a rewriter fall through to ajv's localized text. Every diagnostic carries `ruleName: "customTagSchema"` and points at the element or attribute — see [Disabling Lint Rules](#disabling-lint-rules) to silence them per-region.
407461

js/cli/check.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
SchemaRegistry,
2424
} from '../shared/customTagSchemaLoader.js';
2525
import type { TagValidator } from '../shared/tagValidators.js';
26+
import { collectCustomTagNames } from '../shared/customCodeTags.js';
2627

2728
// ── Types ──
2829

@@ -387,7 +388,7 @@ export async function run(args: string[]): Promise<number> {
387388
const errorOutput: string[] = [];
388389

389390
const rules = config?.rules;
390-
const customTagNames = config?.customTags?.map((t) => t.name);
391+
const customTagNames = collectCustomTagNames(config?.customTags);
391392
const customRules = config?.customRules;
392393
const ruleFilterBase = configDir ?? cwd;
393394

js/linter/customTagSchemaChecker.ts

Lines changed: 147 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@ import localizeEn from 'ajv-i18n/localize/en/index.js';
33
import type { BalanceNode } from './htmlBalanceChecker.js';
44
import type { FixableError } from './mustacheChecks.js';
55
import type {
6+
ChildTagSchemaConfig,
7+
CompiledChildTagConfig,
68
CompiledTagSchema,
79
SchemaRegistry,
810
} from '../shared/customTagSchemaLoader.js';
9-
import { getTagName, isHtmlElementType } from '../shared/nodeHelpers.js';
11+
import {
12+
getTagName,
13+
isHtmlElementType,
14+
isMustacheSection,
15+
} from '../shared/nodeHelpers.js';
1016

1117
interface AttributeInfo {
1218
attrNode: BalanceNode;
@@ -236,23 +242,32 @@ function attributeNameForError(error: ErrorObject): string | null {
236242
return pathSegments(error.instancePath)[0] ?? null;
237243
}
238244

239-
function messageForError(error: ErrorObject, tag: string): string {
245+
function tagContext(tag: string, parentTag?: string): string {
246+
return parentTag ? `<${tag}> inside <${parentTag}>` : `<${tag}>`;
247+
}
248+
249+
function messageForError(
250+
error: ErrorObject,
251+
tag: string,
252+
parentTag?: string,
253+
): string {
254+
const context = tagContext(tag, parentTag);
240255
if (
241256
error.keyword === 'required' &&
242257
typeof error.params.missingProperty === 'string'
243258
) {
244-
return `<${tag}> is missing required attribute "${error.params.missingProperty}".`;
259+
return `${context} is missing required attribute "${error.params.missingProperty}".`;
245260
}
246261
if (
247262
error.keyword === 'additionalProperties' &&
248263
typeof error.params.additionalProperty === 'string'
249264
) {
250-
return `Unknown attribute "${error.params.additionalProperty}" on <${tag}>.`;
265+
return `Unknown attribute "${error.params.additionalProperty}" on ${context}.`;
251266
}
252267
const attrName = attributeNameForError(error);
253268
const phrase = constraintPhrase(error);
254269
if (attrName && phrase) {
255-
return `Attribute "${attrName}" on <${tag}> must ${phrase}.`;
270+
return `Attribute "${attrName}" on ${context} must ${phrase}.`;
256271
}
257272
return error.message ?? 'does not satisfy custom tag schema';
258273
}
@@ -284,7 +299,11 @@ function isBranchOf(child: ErrorObject, wrapper: ErrorObject): boolean {
284299
);
285300
}
286301

287-
function mergeErrors(errors: ErrorObject[], tag: string): MergedError[] {
302+
function mergeErrors(
303+
errors: ErrorObject[],
304+
tag: string,
305+
parentTag?: string,
306+
): MergedError[] {
288307
const byPath = new Map<string, ErrorObject[]>();
289308
for (const error of errors) {
290309
const list = byPath.get(error.instancePath);
@@ -318,15 +337,15 @@ function mergeErrors(errors: ErrorObject[], tag: string): MergedError[] {
318337
if (attrName && phrases.length > 0) {
319338
result.push({
320339
error: altWrapper,
321-
message: `Attribute "${attrName}" on <${tag}> must ${phrases.join(' or ')}.`,
340+
message: `Attribute "${attrName}" on ${tagContext(tag, parentTag)} must ${phrases.join(' or ')}.`,
322341
});
323342
continue;
324343
}
325344
}
326345
const wrappers = new Set(['if', 'allOf']);
327346
for (const error of group) {
328347
if (wrappers.has(error.keyword) && group.length > 1) continue;
329-
result.push({ error, message: messageForError(error, tag) });
348+
result.push({ error, message: messageForError(error, tag, parentTag) });
330349
}
331350
}
332351
return result;
@@ -335,6 +354,7 @@ function mergeErrors(errors: ErrorObject[], tag: string): MergedError[] {
335354
function validateElement(
336355
compiled: CompiledTagSchema,
337356
element: BalanceNode,
357+
parentTag?: string,
338358
): FixableError[] {
339359
const tag = getTagName(element)?.toLowerCase();
340360
if (!tag) return [];
@@ -346,29 +366,143 @@ function validateElement(
346366
(error) => !mentionsDynamicAttribute(error, built.context, compiled.schema),
347367
);
348368
localizeEn(errors.filter((error) => error.keyword !== 'errorMessage'));
349-
return mergeErrors(errors, tag).map(({ error, message }) => ({
369+
return mergeErrors(errors, tag, parentTag).map(({ error, message }) => ({
350370
node: nodeForError(error, built.context),
351371
message,
352372
}));
353373
}
354374

375+
function collectDirectHtmlChildren(
376+
node: BalanceNode,
377+
out: BalanceNode[] = [],
378+
): BalanceNode[] {
379+
for (const child of node.children) {
380+
if (isHtmlElementType(child)) {
381+
out.push(child);
382+
} else if (isMustacheSection(child)) {
383+
collectDirectHtmlChildren(child, out);
384+
}
385+
}
386+
return out;
387+
}
388+
389+
function strictChildMessage(parentTag: string, allowedTags: string[]): string {
390+
const tags = allowedTags.map((tag) => `<${tag}>`).join(', ');
391+
return `<${parentTag}> only allows these child elements: ${tags}.`;
392+
}
393+
394+
function orphanChildMessage(tag: string, parentTags: string[]): string {
395+
const tags = parentTags.map((parentTag) => `<${parentTag}>`).join(', ');
396+
return `<${tag}> may only appear as a direct child of these parent elements: ${tags}.`;
397+
}
398+
399+
function childrenForElement(
400+
tag: string,
401+
scopedConfig: CompiledChildTagConfig | undefined,
402+
globalChildren: Map<string, ChildTagSchemaConfig>,
403+
): ChildTagSchemaConfig | undefined {
404+
return scopedConfig?.children ?? globalChildren.get(tag);
405+
}
406+
355407
export function checkCustomTagSchemas(
356408
rootNode: BalanceNode,
357409
registry: SchemaRegistry | undefined,
358410
): FixableError[] {
359-
if (!registry || registry.schemas.size === 0) return [];
411+
if (
412+
!registry ||
413+
(registry.schemas.size === 0 && registry.children.size === 0)
414+
) {
415+
return [];
416+
}
360417
const errors: FixableError[] = [];
361418
const schemas = registry.schemas;
419+
const children = registry.children;
420+
const topLevelTags = registry.topLevelTags;
421+
const childParents = registry.childParents;
422+
423+
function shouldSkipOrphanForStrictParent(
424+
tag: string,
425+
parentChildren: ChildTagSchemaConfig | undefined,
426+
): boolean {
427+
return parentChildren?.mode === 'strict' && !parentChildren.tags.has(tag);
428+
}
362429

363-
function visit(node: BalanceNode): void {
430+
function visit(
431+
node: BalanceNode,
432+
directParentTag: string | undefined,
433+
scopedConfig: CompiledChildTagConfig | undefined,
434+
parentChildren: ChildTagSchemaConfig | undefined,
435+
): void {
364436
if (isHtmlElementType(node)) {
365437
const tag = getTagName(node)?.toLowerCase();
438+
if (tag && !topLevelTags.has(tag)) {
439+
const allowedParents = childParents.get(tag);
440+
if (
441+
allowedParents &&
442+
!allowedParents.has(directParentTag ?? '') &&
443+
!shouldSkipOrphanForStrictParent(tag, parentChildren)
444+
) {
445+
errors.push({
446+
node,
447+
message: orphanChildMessage(tag, Array.from(allowedParents)),
448+
});
449+
}
450+
}
366451
const compiled = tag ? schemas.get(tag) : undefined;
367452
if (compiled) errors.push(...validateElement(compiled, node));
453+
const childConfig = tag
454+
? childrenForElement(tag, scopedConfig, children)
455+
: undefined;
456+
if (tag && childConfig) {
457+
const allowedTags = Array.from(childConfig.tags.keys());
458+
for (const child of collectDirectHtmlChildren(node)) {
459+
const childTag = getTagName(child)?.toLowerCase();
460+
if (!childTag) continue;
461+
const childEntry = childConfig.tags.get(childTag);
462+
if (!childEntry) {
463+
if (childConfig.mode === 'strict') {
464+
errors.push({
465+
node: child,
466+
message: strictChildMessage(tag, allowedTags),
467+
});
468+
}
469+
continue;
470+
}
471+
if (childEntry.schema) {
472+
errors.push(...validateElement(childEntry.schema, child, tag));
473+
}
474+
}
475+
}
476+
for (const child of node.children) {
477+
if (isHtmlElementType(child) || isMustacheSection(child)) {
478+
const childTag = isHtmlElementType(child)
479+
? getTagName(child)?.toLowerCase()
480+
: undefined;
481+
const childEntry =
482+
childTag && childConfig
483+
? childConfig.tags.get(childTag)
484+
: undefined;
485+
visit(child, tag, childEntry, childConfig);
486+
} else {
487+
visit(child, directParentTag, scopedConfig, parentChildren);
488+
}
489+
}
490+
return;
491+
}
492+
for (const child of node.children) {
493+
if (isHtmlElementType(child)) {
494+
const childTag = getTagName(child)?.toLowerCase();
495+
const childEntry =
496+
childTag && parentChildren
497+
? parentChildren.tags.get(childTag)
498+
: undefined;
499+
visit(child, directParentTag, childEntry, parentChildren);
500+
} else {
501+
visit(child, directParentTag, scopedConfig, parentChildren);
502+
}
368503
}
369-
for (const child of node.children) visit(child);
370504
}
371505

372-
visit(rootNode);
506+
visit(rootNode, undefined, undefined, undefined);
373507
return errors;
374508
}

0 commit comments

Comments
 (0)