8000 feat: add special `templateRoot` meta property for `<template>` element · html-validate/html-validate@beccfda · GitHub
[go: up one dir, main page]

Skip to content

Commit beccfda

Browse files
committed
feat: add special templateRoot meta property for <template> element
1 parent 773b6cf commit beccfda

File tree

16 files changed

+133
-36
lines changed

16 files changed

+133
-36
lines changed

docs/usage/elements.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ export interface MetaElement {
4848
formAssociated?: FormAssociated;
4949
labelable?: boolean | MetaLabelableCallback;
5050

51+
/* ignore DOM ancestry */
52+
templateRoot?: boolean;
53+
5154
/* WAI-ARIA attributes */
5255
aria?: MetaAria;
5356

@@ -217,6 +220,13 @@ This is typically elements input elements such as `<input>`.
217220

218221
[whatwg-labelable]: https://html.spec.whatwg.org/multipage/forms.html#category-label
219222

223+
### `templateRoot`
224+
225+
When set to `true`, this element has no impact on DOM ancestry.
226+
I.e., the `<template>` element (where allowed) can contain anything, as it does not directly affect the DOM tree.
227+
228+
If unset, defaults to `false`.
229+
220230
### `aria.implicitRole`
221231

222232
- type: `string | ((node: HtmlElementLike) => string | null)`

etc/browser.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,7 @@ export interface MetaData {
782782
scriptSupporting?: boolean;
783783
// (undocumented)
784784
sectioning?: boolean | MetaCategoryCallback;
785+
templateRoot?: boolean;
785786
// (undocumented)
786787
textContent?: TextContent | `${TextContent}`;
787788
// (undocumented)
@@ -819,6 +820,7 @@ export interface MetaElement extends Omit<MetaData, "deprecatedAttributes" | "re
819820
implicitRole: MetaImplicitRoleCallback;
820821
// (undocumented)
821822
tagName: string;
823+
templateRoot: boolean;
822824
// (undocumented)
823825
textContent?: TextContent;
824826
}

etc/index.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,7 @@ export interface MetaData {
879879
scriptSupporting?: boolean;
880880
// (undocumented)
881881
sectioning?: boolean | MetaCategoryCallback;
882+
templateRoot?: boolean;
882883
// (undocumented)
883884
textContent?: TextContent | `${TextContent}`;
884885
// (undocumented)
@@ -916,6 +917,7 @@ export interface MetaElement extends Omit<MetaData, "deprecatedAttributes" | "re
916917
implicitRole: MetaImplicitRoleCallback;
917918
// (undocumented)
918919
tagName: string;
920+
templateRoot: boolean;
919921
// (undocumented)
920922
textContent?: TextContent;
921923
}

src/__snapshots__/integration.spec.ts.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Jest Snapshot v1, https://goo.gl/fbAQLP
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
22

33
exports[`configuration smoketest test-files/config/cjs-config/file.html: config 1`] = `
44
{

src/cli/__snapshots__/integration.spec.ts.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Jest Snapshot v1, https://goo.gl/fbAQLP
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
22

33
exports[`should match results: test-files/config/cjs-config/file.html config 1`] = `
44
{

src/config/config.spec.ts

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -419,28 +419,18 @@ describe("config", () => {
419419
const elements = config.get().elements;
420420
const metatable = await config.getMetaTable();
421421
expect(elements).toEqual(["order-a", "order-b", "order-c"]);
422-
expect(metatable.getMetaFor("foo")).toEqual({
423-
tagName: "foo",
424-
aria: {
425-
implicitRole: expect.any(Function),
426-
naming: expect.any(Function),
427-
},
428-
attributes: {},
429-
focusable: false,
430-
implicitRole: expect.any(Function),
431-
permittedContent: ["baz"],
432-
});
433-
expect(metatable.getMetaFor("bar")).toEqual({
434-
tagName: "bar",
435-
aria: {
436-
implicitRole: expect.any(Function),
437-
naming: expect.any(Function),
438-
},
439-
attributes: {},
440-
focusable: false,
441-
implicitRole: expect.any(Function),
442-
permittedContent: ["baz"],
443-
});
422+
expect(metatable.getMetaFor("foo")).toEqual(
423+
expect.objectContaining({
424+
tagName: "foo",
425+
permittedContent: ["baz"],
426+
}),
427+
);
428+
expect(metatable.getMetaFor("bar")).toEqual(
429+
expect.objectContaining({
430+
tagName: "bar",
431+
permittedContent: ["baz"],
432+
}),
433+
);
444434
});
445435

446436
it("should handle extends being explicitly set to undefined", async () => {

src/elements/html5.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2712,6 +2712,7 @@ export default defineMetadata({
27122712
flow: true,
27132713
phrasing: true,
27142714
scriptSupporting: true,
2715+
templateRoot: true,
27152716
aria: {
27162717
naming: "prohibited",
27172718
},

src/meta/element.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,15 @@ export interface MetaData {
214214
formAssociated?: Partial<FormAssociated>;
215215
labelable?: boolean | MetaLabelableCallback;
216216

217+
/**
218+
* Set to `true` if this element should have no impact on DOM
219+
* ancestry. Default `false`.
220+
*
221+
* I.e., the `<template>` element (where allowed) can contain anything, as it
222+
* does not directly affect the DOM tree.
223+
*/
224+
templateRoot?: boolean;
225+
217226
/** @deprecated use {@link MetaAria.implicitRole} instead */
218227
implicitRole?: MetaImplicitRoleCallback;
219228

@@ -298,6 +307,15 @@ export interface MetaElement extends Omit<MetaData, "deprecatedAttributes" | "re
298307
focusable: boolean | MetaFocusableCallback;
299308
formAssociated?: FormAssociated;
300309

310+
/**
311+
* Set to `true` if this element should have no impact on DOM
312+
* ancestry. Default `false`.
313+
*
314+
* I.e., the `<template>` element (where allowed) can contain anything. as it
315+
* does not directly affect the DOM tree.
316+
*/
317+
templateRoot: boolean;
318+
301319
/** @deprecated Use {@link MetaAria.implicitRole} instead */
302320
implicitRole: MetaImplicitRoleCallback;
303321

src/meta/migrate.spec.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,30 @@ describe("formAssociated", () => {
183183
});
184184
});
185185

186+
describe("templateRoot", () => {
187+
it("should set to false by default", () => {
188+
expect.assertions(1);
189+
const src: MetaData = {};
190+
const result = migrateElement(src);
191+
expect(result.templateRoot).toBe(false);
192+
});
193+
194+
it("should retain original explicit value", () => {
195+
expect.assertions(2);
196+
const enabled: MetaData = { templateRoot: true };
197+
const disabled: MetaData = { templateRoot: false };
198+
expect(migrateElement(enabled).templateRoot).toBe(true);
199+
expect(migrateElement(disabled).templateRoot).toBe(false);
200+
});
201+
202+
it("should default to false when value is invalid", () => {
203+
expect.assertions(1);
204+
const src = { templateRoot: "foobar" } as unknown as MetaData;
205+
const result = migrateElement(src);
206+
expect(result.templateRoot).toBe(false);
207+
});
208+
});
209+
186210
describe("aria.implicitRole", () => {
187211
it("should normalize missing property", () => {
188212
expect.assertions(1);

src/meta/migrate.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export function migrateElement(src: MetaData): Omit<MetaElement, "tagName"> {
113113
textContent: src.textContent as TextContent | undefined,
114114
focusable: src.focusable ?? false,
115115
implicitRole,
116+
templateRoot: src.templateRoot === true,
116117
aria: {
117118
implicitRole,
118119
naming: normalizeAriaNaming(src.aria?.naming),

src/meta/table.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,7 @@ describe("MetaTable", () => {
512512
"focusable": false,
513513
"implicitRole": [Function],
514514
"tagName": "foo",
515+
"templateRoot": false,
515516
}
516517
`);
517518
expect(bar).toMatchInlineSnapshot(`
@@ -536,6 +537,7 @@ describe("MetaTable", () => {
536537
"implicitRole": [Function],
537538
"inherit": "foo",
538539
"tagName": "bar",
540+
"templateRoot": false,
539541
}
540542
`);
541543
});

src/parser/__snapshots__/parser.spec.ts.snap

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Jest Snapshot v1, https://goo.gl/fbAQLP
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
22

33
exports[`parser should postprocess elements allow modify element metadata 1`] = `
44
{
@@ -75,6 +75,7 @@ exports[`parser should postprocess elements allow modify element metadata 1`] =
7575
"dd",
7676
],
7777
"tagName": "i",
78+
"templateRoot": false,
7879
}
7980
`;
8081

@@ -140,5 +141,6 @@ exports[`parser should postprocess elements allow modify element metadata 2`] =
140141
],
141142
"phrasing": true,
142143
"tagName": "u",
144+
"templateRoot": false,
143145
}
144146
`;

src/plugin/plugin.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ describe("Plugin", () => {
228228
attributes: {},
229229
focusable: false,
230230
implicitRole: expect.any(Function),
231+
templateRoot: false,
231232
myMeta: 5,
232233
});
233234
});
@@ -267,6 +268,7 @@ describe("Plugin", () => {
267268
attributes: {},
268269
focusable: false,
269270
implicitRole: expect.any(Function),
271+
templateRoot: false,
270272
myMeta: 5,
271273
});
272274
});
@@ -327,6 +329,7 @@ describe("Plugin", () => {
327329
attributes: {},
328330
focusable: false,
329331
implicitRole: expect.any(Function),
332+
templateRoot: false,
330333
foo: "copied" /* foo is marked for copying */,
331334
bar: "original" /* bar is not marked for copying */,
332335
});

src/rules/element-permitted-content.spec.ts

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -378,16 +378,51 @@ describe("rule element-permitted-content", () => {
378378
const report = await htmlvalidate.validateString(markup);
379379
expect(report).toBeInvalid();
380380
expect(report).toMatchInlineCodeframe(`
381-
"error: <a> element is not permitted as a descendant of <a> (element-permitted-content) at inline:4:8:
382-
2 | <a href="">
383-
3 | <template>
384-
> 4 | <a href=""></a>
385-
| ^
386-
5 | </template>
387-
6 | </a>
388-
7 |
389-
Selector: a > template > a"
390-
`);
381+
"error: <a> element is not permitted as a descendant of <a> (element-permitted-content) at inline:4:8:
382+
2 | <a href="">
383+
3 | <template>
384+
> 4 | <a href=""></a>
385+
| ^
386+
5 | </template>
387+
6 | </a>
388+
7 |
389+
Selector: a > template > a"
390+
`);
391+
});
392+
393+
it("should report error when <template> metadata has disabled template root", async () => {
394+
expect.assertions(2);
395+
const htmlvalidate = new HtmlValidate({
396+
elements: [
397+
"html5",
398+
{
399+
template: {
400+
templateRoot: false,
401+
},
402+
},
403+
],
404+
rules: { "element-permitted-content": "error" },
405+
});
406+
const markup = /* HTML */ `
407+
<a href="">
408+
<template>
409+
<a href=""></a>
410+
</template>
411+
</a>
412+
`;
413+
const report = await htmlvalidate.validateString(markup);
414+
expect(report).toBeInvalid();
415+
expect(report).toMatchInlineCodeframe(`
416+
"error: <a> element is not permitted as a descendant of <a> (element-permitted-content) at inline:4:8:
417+
2 | <a href="">
418+
3 | <template>
419+
> 4 | <a href=""></a>
420+
| ^
421+
5 | </template>
422+
6 | </a>
423+
7 |
424+
Selector: a > template > a"
425+
`);
391426
});
392427
});
393428

src/rules/element-permitted-content.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ export interface DescendantContext {
2525
type RuleContext = ContentContext | DescendantContext;
2626

2727
function isNativeTemplate(node: HtmlElement): boolean {
28-
return Boolean(node.tagName === "template" && node.meta?.scriptSupporting);
28+
const { tagName, meta } = node;
29+
return Boolean(tagName === "template" && meta?.templateRoot && meta?.scriptSupporting);
2930
}
3031

3132
function getTransparentChildren(node: HtmlElement, transparent: boolean | string[]): HtmlElement[] {

src/schema/elements.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@
130130
"anyOf": [{ "type": "boolean" }, { "function": true }]
131131
},
132132

133+
"templateRoot": {
134+
"title": "Mark element as an element ignoring DOM ancestry, i.e. <template>.",
135+
"description": "The <template> element can contain any elements.",
136+
"type": "boolean"
137+
},
138+
133139
"deprecatedAttributes": {
134140
"title": "List of deprecated attributes",
135141
"type": "array",

0 commit comments

Comments
 (0)
0