@markuplint/rules 5.3.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/CLAUDE.md +4 -3
  3. package/lib/aria-prop-requires-role/index.js +2 -2
  4. package/lib/attr-eligibility.d.ts +21 -0
  5. package/lib/attr-eligibility.js +86 -6
  6. package/lib/attr-order/index.js +2 -1
  7. package/lib/attr-value-quotes/index.js +1 -1
  8. package/lib/consistent-table-row-length/index.js +1 -1
  9. package/lib/element-supports-aria-prop/index.js +2 -2
  10. package/lib/form-attr-references-form/index.js +2 -2
  11. package/lib/helpers.d.ts +43 -5
  12. package/lib/helpers.js +88 -26
  13. package/lib/input-list-references-datalist/index.js +2 -2
  14. package/lib/itemprop-requires-itemscope/index.js +27 -10
  15. package/lib/label-for-references-labelable/index.js +2 -2
  16. package/lib/label-has-control/index.d.ts +17 -0
  17. package/lib/label-has-control/index.js +28 -1
  18. package/lib/label-no-multiple-controls/index.d.ts +4 -5
  19. package/lib/label-no-multiple-controls/index.js +6 -6
  20. package/lib/landmark-roles/roles.d.ts +13 -1
  21. package/lib/landmark-roles/roles.js +22 -0
  22. package/lib/no-ambiguous-navigable-target-names/index.js +4 -0
  23. package/lib/no-aria-on-presentational-children/index.js +7 -1
  24. package/lib/no-aria-on-unsupported-element/index.js +2 -2
  25. package/lib/no-boolean-attr-value/index.js +2 -1
  26. package/lib/no-broken-fragment-link/index.d.ts +5 -0
  27. package/lib/no-broken-fragment-link/index.js +8 -2
  28. package/lib/no-consecutive-br/index.js +1 -1
  29. package/lib/no-contradictory-aria-prop/index.js +2 -2
  30. package/lib/no-default-aria-value/index.js +2 -2
  31. package/lib/no-default-value/index.js +4 -0
  32. package/lib/no-deprecated-aria-prop/index.js +2 -2
  33. package/lib/no-disallowed-ancestor/index.d.ts +16 -1
  34. package/lib/no-disallowed-ancestor/index.js +78 -4
  35. package/lib/no-disallowed-attr/index.js +4 -0
  36. package/lib/no-duplicate-dt/index.js +1 -1
  37. package/lib/no-duplicate-h1/index.js +18 -14
  38. package/lib/no-duplicate-id/index.d.ts +9 -0
  39. package/lib/no-duplicate-id/index.js +23 -4
  40. package/lib/no-empty-table-track/index.js +1 -1
  41. package/lib/no-ineffective-attr/index.js +4 -0
  42. package/lib/no-invalid-aria-prop-value/index.js +3 -3
  43. package/lib/no-invalid-attr-value/index.js +12 -0
  44. package/lib/no-nested-top-level-landmark/index.js +20 -17
  45. package/lib/no-prohibited-naming/index.js +2 -2
  46. package/lib/no-redundant-accessible-name/index.js +8 -7
  47. package/lib/no-redundant-aria-prop/index.js +2 -2
  48. package/lib/no-refer-to-non-existent-id/index.d.ts +15 -0
  49. package/lib/no-refer-to-non-existent-id/index.js +47 -16
  50. package/lib/no-restricted-element/index.js +4 -2
  51. package/lib/no-skipped-heading-level/index.js +9 -4
  52. package/lib/no-stray-head-or-body-tag/index.d.ts +2 -1
  53. package/lib/no-stray-head-or-body-tag/index.js +2 -1
  54. package/lib/no-table-cell-overlap/index.js +1 -1
  55. package/lib/no-table-span-overflow/index.js +1 -1
  56. package/lib/no-unescaped-char/index.js +1 -1
  57. package/lib/permitted-contents/index.js +2 -4
  58. package/lib/permitted-contents/matches-selector.js +28 -5
  59. package/lib/permitted-contents/represent-transparent-nodes.js +100 -68
  60. package/lib/permitted-contents/slot-content.d.ts +2 -1
  61. package/lib/permitted-contents/slot-content.js +28 -2
  62. package/lib/permitted-contents/start.js +4 -2
  63. package/lib/permitted-contents/utils.d.ts +11 -0
  64. package/lib/permitted-contents/utils.js +15 -0
  65. package/lib/placeholder-label-option/index.js +4 -26
  66. package/lib/require-accessible-name/index.js +2 -2
  67. package/lib/require-adjacent-popover/index.js +4 -2
  68. package/lib/require-ancestor/index.d.ts +20 -0
  69. package/lib/require-ancestor/index.js +33 -7
  70. package/lib/require-datetime/index.js +1 -1
  71. package/lib/require-dialog-autofocus/index.js +8 -6
  72. package/lib/require-h1/index.js +4 -8
  73. package/lib/require-landmark-label/index.js +17 -15
  74. package/lib/role-supports-aria-prop/index.js +2 -2
  75. package/lib/sizes-auto-requires-lazy-loading/index.d.ts +4 -2
  76. package/lib/sizes-auto-requires-lazy-loading/index.js +4 -2
  77. package/lib/usemap-references-map/index.js +11 -2
  78. package/lib/valid-speculation-rules/index.js +1 -1
  79. package/lib/wai-aria/checkings/interaction-in-hidden.js +6 -2
  80. package/lib/wai-aria/checkings/presentational-children.d.ts +17 -6
  81. package/lib/wai-aria/checkings/presentational-children.js +259 -25
  82. package/lib/wai-aria/checkings/required-accessibility-parent-role.js +2 -1
  83. package/lib/wai-aria/checkings/required-prop.js +2 -1
  84. package/lib/wai-aria/checkings/tab-requires-tabpanel.js +10 -6
  85. package/lib/wai-aria/checkings/value.d.ts +1 -4
  86. package/lib/wai-aria/checkings/value.js +5 -17
  87. package/package.json +11 -11
@@ -2,7 +2,7 @@ import { createRule, getComputedRole, ariaSpecs, getSpec } from '@markuplint/ml-
2
2
  import { ARIA_RECOMMENDED_VERSION } from '@markuplint/ml-spec';
3
3
  import { checkingValue } from '../wai-aria/checkings/value.js';
4
4
  import { defaultOptions } from '../wai-aria/default-options.js';
5
- import { isAriaAttr } from '../helpers.js';
5
+ import { getAriaAttrs } from '../helpers.js';
6
6
  import meta from './meta.js';
7
7
  export default createRule({
8
8
  meta,
@@ -14,14 +14,14 @@ export default createRule({
14
14
  return;
15
15
  if (!elSpec.globalAttrs['#ARIAAttrs'])
16
16
  return;
17
- const propAttrs = el.attributes.filter(attr => isAriaAttr(attr));
17
+ const propAttrs = getAriaAttrs(el);
18
18
  if (propAttrs.length === 0)
19
19
  return;
20
20
  const ariaVersion = el.rule.options?.version ?? document.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
21
21
  const computed = getComputedRole(document.specs, el, ariaVersion);
22
22
  const { props: propSpecs } = ariaSpecs(document.specs, ariaVersion);
23
23
  for (const attr of propAttrs) {
24
- report(checkingValue({ attr, role: computed.role, propSpecs, booleanish: document.booleanish }));
24
+ report(checkingValue({ attr, role: computed.role, propSpecs }));
25
25
  }
26
26
  });
27
27
  },
@@ -53,6 +53,18 @@ export default createRule({
53
53
  if (one === false || one.invalidType !== 'invalid-value') {
54
54
  continue;
55
55
  }
56
+ // The value of an expression is what the code results in, so a position in it is not a position
57
+ // in the source (an escape, a quote): the whole value that is written is reported
58
+ if (attr.isExpressionValue && valueNode) {
59
+ report({
60
+ scope: attr,
61
+ message: one.message,
62
+ line: valueNode.startLine,
63
+ col: valueNode.startCol,
64
+ raw: valueNode.raw,
65
+ });
66
+ continue;
67
+ }
56
68
  report({
57
69
  scope: attr,
58
70
  message: one.message,
@@ -1,5 +1,5 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
- import { computeLandmarkRoles, landmarkRoleElementUUIDList } from '../landmark-roles/roles.js';
2
+ import { computeLandmarkRolesOfEachTree, landmarkRoleElementUUIDList } from '../landmark-roles/roles.js';
3
3
  import meta from './meta.js';
4
4
  /**
5
5
  * Roles this rule requires to be top-level landmarks — not nested inside any
@@ -29,22 +29,25 @@ export default createRule({
29
29
  if (document.isFragment) {
30
30
  return;
31
31
  }
32
- const roles = computeLandmarkRoles(document);
33
- const uuidList = landmarkRoleElementUUIDList(roles);
34
- for (const role of topLevelRoles) {
35
- const elements = roles[role];
36
- for (const el of elements) {
37
- if (el.rule.disabled) {
38
- continue;
39
- }
40
- if (el.rule.options.ignoreRoles?.includes(role)) {
41
- continue;
42
- }
43
- if (el.isDescendantByUUIDList(uuidList)) {
44
- report({
45
- scope: el,
46
- message: t('{0} should be {1}', t('the "{0*}" {1}', role, 'role'), 'top level'),
47
- });
32
+ // Landmarks are nested in a landmark of their own tree: the content of a `template` element is
33
+ // a tree of its own, so a landmark outside of it does not make what is in it nested
34
+ for (const roles of computeLandmarkRolesOfEachTree(document)) {
35
+ const uuidList = landmarkRoleElementUUIDList(roles);
36
+ for (const role of topLevelRoles) {
37
+ const elements = roles[role];
38
+ for (const el of elements) {
39
+ if (el.rule.disabled) {
40
+ continue;
41
+ }
42
+ if (el.rule.options.ignoreRoles?.includes(role)) {
43
+ continue;
44
+ }
45
+ if (el.isDescendantByUUIDList(uuidList)) {
46
+ report({
47
+ scope: el,
48
+ message: t('{0} should be {1}', t('the "{0*}" {1}', role, 'role'), 'top level'),
49
+ });
50
+ }
48
51
  }
49
52
  }
50
53
  }
@@ -2,7 +2,7 @@ import { createRule, getComputedRole, ariaSpecs, getSpec } from '@markuplint/ml-
2
2
  import { ARIA_RECOMMENDED_VERSION } from '@markuplint/ml-spec';
3
3
  import { checkingProhibitedNaming } from '../wai-aria/checkings/prohibited-naming.js';
4
4
  import { defaultOptions } from '../wai-aria/default-options.js';
5
- import { isAriaAttr } from '../helpers.js';
5
+ import { getAriaAttrs } from '../helpers.js';
6
6
  import meta from './meta.js';
7
7
  /**
8
8
  * Split from the former `wai-aria-disallowed-props` rule (#3989): the naming-
@@ -23,7 +23,7 @@ export default createRule({
23
23
  return;
24
24
  if (elSpec && !elSpec.globalAttrs['#ARIAAttrs'])
25
25
  return;
26
- const propAttrs = el.attributes.filter(attr => isAriaAttr(attr));
26
+ const propAttrs = getAriaAttrs(el);
27
27
  if (propAttrs.length === 0)
28
28
  return;
29
29
  const ariaVersion = el.rule.options?.version ?? document.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
@@ -1,6 +1,6 @@
1
1
  import { createRule, getComputedRole } from '@markuplint/ml-core';
2
2
  import { ARIA_RECOMMENDED_VERSION, isExposed } from '@markuplint/ml-spec';
3
- import { accnameMayBeMutable, getOwnedLabel } from '../helpers.js';
3
+ import { accnameMayBeMutable, getElementByIdInTree, getOwnedLabel } from '../helpers.js';
4
4
  import meta from './meta.js';
5
5
  export default createRule({
6
6
  meta,
@@ -13,7 +13,7 @@ export default createRule({
13
13
  await document.walkOn('Element', el => {
14
14
  const ariaVersion = ARIA_RECOMMENDED_VERSION;
15
15
  // Skip mutable elements (template expressions etc.)
16
- if (accnameMayBeMutable(el, document)) {
16
+ if (accnameMayBeMutable(el)) {
17
17
  return;
18
18
  }
19
19
  // Skip hidden elements
@@ -61,13 +61,14 @@ computed) {
61
61
  //
62
62
  // Known limitation: step 2C (Embedded Control) is not modeled, so a self-referencing
63
63
  // textbox, combobox, listbox or range is treated like any other element.
64
- const ariaLabelledby = el.getAttribute('aria-labelledby');
65
- if (ariaLabelledby && ariaLabelledby.trim()) {
64
+ const ariaLabelledbyAttr = el.getAttributeNode('aria-labelledby');
65
+ const ariaLabelledby = ariaLabelledbyAttr?.value;
66
+ if (ariaLabelledbyAttr && ariaLabelledby && ariaLabelledby.trim()) {
66
67
  const ids = ariaLabelledby.trim().split(/\s+/);
67
68
  // An IDREF resolves to the first element with that id, so with duplicate ids a
68
69
  // reference only counts as self-referencing when this element is that first match.
69
- const isSelfReferencing = ids.some(id => id === el.id && document.querySelector(`[id="${id}"]`) === el);
70
- const hasResolvableId = ids.some(id => document.querySelector(`[id="${id}"]`));
70
+ const isSelfReferencing = ids.some(id => getElementByIdInTree(el, id, ariaLabelledbyAttr.isExpressionValue) === el);
71
+ const hasResolvableId = ids.some(id => getElementByIdInTree(el, id, ariaLabelledbyAttr.isExpressionValue));
71
72
  if (hasResolvableId && !isSelfReferencing) {
72
73
  sources.push({ label: 'aria-labelledby' });
73
74
  }
@@ -78,7 +79,7 @@ computed) {
78
79
  sources.push({ label: 'aria-label' });
79
80
  }
80
81
  // 3. label (explicit or implicit) — only for labelable elements
81
- const ownedLabel = getOwnedLabel(el, document);
82
+ const ownedLabel = getOwnedLabel(el);
82
83
  if (ownedLabel) {
83
84
  sources.push({ label: 'label' });
84
85
  }
@@ -2,7 +2,7 @@ import { createRule, ariaSpecs, getAttrSpecs, getSpec } from '@markuplint/ml-cor
2
2
  import { ARIA_RECOMMENDED_VERSION } from '@markuplint/ml-spec';
3
3
  import { checkingRedundantAriaProp } from '../wai-aria/checkings/redundant-aria-prop.js';
4
4
  import { defaultOptions } from '../wai-aria/default-options.js';
5
- import { isAriaAttr } from '../helpers.js';
5
+ import { getAriaAttrs } from '../helpers.js';
6
6
  import meta from './meta.js';
7
7
  /**
8
8
  * Split from the former `wai-aria-implicit-props` rule (#3989): the
@@ -22,7 +22,7 @@ export default createRule({
22
22
  return;
23
23
  if (!elSpec.globalAttrs['#ARIAAttrs'])
24
24
  return;
25
- const propAttrs = el.attributes.filter(attr => isAriaAttr(attr));
25
+ const propAttrs = getAriaAttrs(el);
26
26
  if (propAttrs.length === 0)
27
27
  return;
28
28
  const ariaVersion = el.rule.options?.version ?? document.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
@@ -1,4 +1,19 @@
1
1
  import type { ARIAVersion } from '@markuplint/ml-spec';
2
+ /**
3
+ * Reports a reference (an attribute of the `DOMID` type, or an ARIA property of the ID reference
4
+ * type) to an id that does not exist.
5
+ *
6
+ * An id is resolved in the tree of the reference: the document, the content of a `template` element
7
+ * and a declarative shadow root are trees of their own. The reference and the id are compared with
8
+ * the character references in the markup resolved.
9
+ *
10
+ * What it cannot tell it does not report: a document with a template-language block is skipped, an id
11
+ * that is not known until it is rendered (a dynamic value) suppresses the report in its tree only, and
12
+ * a `template` that is in no tree (a declarative shadow root is attached to its parent, or may be)
13
+ * refers to nothing.
14
+ *
15
+ * @see https://html.spec.whatwg.org/multipage/dom.html#the-id-attribute
16
+ */
2
17
  declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<import("@markuplint/ml-core").RuleConfigValue, {
3
18
  ariaVersion: ARIAVersion | undefined;
4
19
  }>>;
@@ -1,35 +1,55 @@
1
1
  import { createRule, getAttrSpecs, ariaSpecs } from '@markuplint/ml-core';
2
2
  import { ARIA_RECOMMENDED_VERSION } from '@markuplint/ml-spec';
3
- import { decodeEntities } from '@markuplint/shared';
4
- import { findAttrSpec } from '../helpers.js';
3
+ import { findAttrSpec, getDOMValue } from '../helpers.js';
5
4
  import meta from './meta.js';
5
+ /**
6
+ * Reports a reference (an attribute of the `DOMID` type, or an ARIA property of the ID reference
7
+ * type) to an id that does not exist.
8
+ *
9
+ * An id is resolved in the tree of the reference: the document, the content of a `template` element
10
+ * and a declarative shadow root are trees of their own. The reference and the id are compared with
11
+ * the character references in the markup resolved.
12
+ *
13
+ * What it cannot tell it does not report: a document with a template-language block is skipped, an id
14
+ * that is not known until it is rendered (a dynamic value) suppresses the report in its tree only, and
15
+ * a `template` that is in no tree (a declarative shadow root is attached to its parent, or may be)
16
+ * refers to nothing.
17
+ *
18
+ * @see https://html.spec.whatwg.org/multipage/dom.html#the-id-attribute
19
+ */
6
20
  export default createRule({
7
21
  meta: meta,
8
22
  defaultOptions: {
9
23
  ariaVersion: undefined,
10
24
  },
11
25
  async verify({ document, report, t }) {
12
- const idList = new Set();
13
- let hasDynamicId = false;
26
+ // An id is resolved in the tree of the reference: the document, the content of a `template`
27
+ // element and a declarative shadow root are trees of their own
28
+ const idListOfTree = new Map();
29
+ const treesWithDynamicId = new Set();
14
30
  const isMutable = document.nodeList.some(node => node.is(node.MARKUPLINT_PREPROCESSOR_BLOCK));
15
31
  if (isMutable) {
16
32
  return;
17
33
  }
18
- for (const el of document.querySelectorAll('[id]')) {
34
+ for (const el of document.deepQuerySelectorAll('[id]')) {
19
35
  const attr = el.getAttributeNode('id');
20
36
  if (!attr) {
21
37
  continue;
22
38
  }
39
+ const root = el.getRootNode();
23
40
  if (attr.isDynamicValue) {
24
- hasDynamicId = true;
41
+ treesWithDynamicId.add(root);
25
42
  }
26
- if (attr.valueType !== 'code') {
27
- idList.add(decodeEntities(attr.value));
43
+ const id = attr.valueType === 'code' ? null : getDOMValue(attr);
44
+ if (id != null) {
45
+ let idList = idListOfTree.get(root);
46
+ if (!idList) {
47
+ idList = new Set();
48
+ idListOfTree.set(root, idList);
49
+ }
50
+ idList.add(id);
28
51
  }
29
52
  }
30
- if (hasDynamicId) {
31
- return;
32
- }
33
53
  await document.walkOn('Attr', attr => {
34
54
  const attrSpec = getAttrSpecs(attr.ownerElement, document.specs);
35
55
  if (!attrSpec) {
@@ -41,7 +61,16 @@ export default createRule({
41
61
  if (attr.nameKey === 'id') {
42
62
  return;
43
63
  }
44
- const value = attr.value;
64
+ const root = attr.ownerElement.getRootNode();
65
+ // An element in no tree (a `template` that a declarative shadow root is attached to) refers
66
+ // to nothing, and an id that is not known until it is rendered may be what the reference means
67
+ if (root === attr.ownerElement || treesWithDynamicId.has(root)) {
68
+ return;
69
+ }
70
+ const idList = idListOfTree.get(root) ?? new Set();
71
+ // The value is split before the character references are resolved: `&#32;` is not a separator
72
+ const decode = (id) => getDOMValue(attr, id) ?? id;
73
+ const value = decode(attr.value);
45
74
  const spec = findAttrSpec(attrSpec, attr);
46
75
  if (spec) {
47
76
  // DOMID and DOMID List do not become in the array type.
@@ -58,10 +87,11 @@ export default createRule({
58
87
  });
59
88
  }
60
89
  if (typeof spec.type !== 'string' && 'separator' in spec.type && spec.type.token === 'DOMID') {
61
- const refs = value
90
+ const refs = attr.value
62
91
  .split(spec.type.separator === 'space' ? /\s/ : ',')
63
92
  .map(id => id.trim())
64
- .filter(Boolean);
93
+ .filter(Boolean)
94
+ .map(id => decode(id));
65
95
  for (const ref of refs) {
66
96
  if (!idList.has(ref)) {
67
97
  report({
@@ -88,10 +118,11 @@ export default createRule({
88
118
  });
89
119
  }
90
120
  else if (aria.value === 'ID reference list') {
91
- const refs = value
121
+ const refs = attr.value
92
122
  .split(/\s/)
93
123
  .map(id => id.trim())
94
- .filter(Boolean);
124
+ .filter(Boolean)
125
+ .map(id => decode(id));
95
126
  for (const ref of refs) {
96
127
  if (!idList.has(ref)) {
97
128
  report({
@@ -4,8 +4,10 @@ export default createRule({
4
4
  meta: meta,
5
5
  defaultValue: [],
6
6
  async verify({ document, report, t }) {
7
+ // A restricted element is restricted wherever it is: the content of a `template` element and a
8
+ // declarative shadow root are searched as well
7
9
  for (const query of document.rule.value) {
8
- const elements = document.querySelectorAll(query);
10
+ const elements = document.deepQuerySelectorAll(query);
9
11
  for (const el of elements) {
10
12
  const message = t('{0} is disallowed', t('the "{0*}" {1}', query, 'element'));
11
13
  report({
@@ -19,7 +21,7 @@ export default createRule({
19
21
  return;
20
22
  }
21
23
  for (const query of el.rule.value) {
22
- const elements = el.querySelectorAll(query);
24
+ const elements = el.deepQuerySelectorAll(query);
23
25
  for (const found of elements) {
24
26
  const message = t('{0} is disallowed', t('the "{0*}" {1}', query, 'element'));
25
27
  report({
@@ -15,17 +15,22 @@ export default createRule({
15
15
  meta: meta,
16
16
  defaultOptions: null,
17
17
  verify({ document, report, t }) {
18
- const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
19
- let prevLevel = null;
18
+ // The content of a `template` element and a declarative shadow root are trees of their own, and
19
+ // where they are inserted is not known from the markup: the headings of each tree are the
20
+ // sequence that the heading lead is judged in, as the headings of the document are.
21
+ const headings = document.deepQuerySelectorAll('h1, h2, h3, h4, h5, h6');
22
+ const prevLevelOfTree = new Map();
20
23
  for (const heading of headings) {
21
24
  const level = Number.parseInt(heading.nodeName.slice(1));
22
- if (prevLevel !== null && prevLevel + 1 < level) {
25
+ const root = heading.getRootNode();
26
+ const prevLevel = prevLevelOfTree.get(root);
27
+ if (prevLevel !== undefined && prevLevel + 1 < level) {
23
28
  report({
24
29
  scope: heading,
25
30
  message: t('{0} must not be skipped', t('Heading levels')),
26
31
  });
27
32
  }
28
- prevLevel = level;
33
+ prevLevelOfTree.set(root, level);
29
34
  }
30
35
  },
31
36
  });
@@ -4,7 +4,8 @@
4
4
  * > A start tag whose tag name is "body": Parse error. If the stack of open
5
5
  * > elements has only one node on it, or if the second element on the stack
6
6
  * > of open elements is not a body element, or if there is a template
7
- * > element on the stack of open elements, then ignore the token. […]
7
+ * > element on the stack of open elements, or if the parser's fragment parser
8
+ * > abort requested is true, then ignore the token. […]
8
9
  * > Otherwise: Set the frameset-ok flag to "not ok" […]
9
10
  *
10
11
  * "in body" insertion mode, "Any other end tag":
@@ -19,7 +19,8 @@ const DUPLICATE_BODY_START_TAG = /<body(?=[\s/>])/i;
19
19
  * > A start tag whose tag name is "body": Parse error. If the stack of open
20
20
  * > elements has only one node on it, or if the second element on the stack
21
21
  * > of open elements is not a body element, or if there is a template
22
- * > element on the stack of open elements, then ignore the token. […]
22
+ * > element on the stack of open elements, or if the parser's fragment parser
23
+ * > abort requested is true, then ignore the token. […]
23
24
  * > Otherwise: Set the frameset-ok flag to "not ok" […]
24
25
  *
25
26
  * "in body" insertion mode, "Any other end tag":
@@ -14,7 +14,7 @@ import meta from './meta.js';
14
14
  export default createRule({
15
15
  meta,
16
16
  verify({ document, report, t }) {
17
- const tables = document.querySelectorAll('table');
17
+ const tables = document.deepQuerySelectorAll('table');
18
18
  for (const table of tables) {
19
19
  const grid = new Grid(table);
20
20
  if (grid.hasOverlapped()) {
@@ -14,7 +14,7 @@ import meta from './meta.js';
14
14
  export default createRule({
15
15
  meta,
16
16
  verify({ document, report, t }) {
17
- const tables = document.querySelectorAll('table');
17
+ const tables = document.deepQuerySelectorAll('table');
18
18
  for (const table of tables) {
19
19
  const grid = new Grid(table);
20
20
  if (grid.hasOverlapped()) {
@@ -49,7 +49,7 @@ export default createRule({
49
49
  const ms = severity === 'error' ? 'must' : 'should';
50
50
  const message = t(`{0} ${ms} {1}`, 'Illegal characters', 'escape in character reference');
51
51
  for (const attr of node.attributes) {
52
- if (attr.isDynamicValue || attr.isDirective) {
52
+ if (attr.isExpressionValue || attr.isDirective) {
53
53
  continue;
54
54
  }
55
55
  const valueNode = attr.valueNode;
@@ -121,7 +121,8 @@ export default createRule({
121
121
  /**
122
122
  * Generates a localized, human-readable name for a child node based on its type.
123
123
  * Used in error messages to describe the offending node (e.g., 'the "div" element',
124
- * 'the text node', 'the comment', 'the doctype', 'the code block').
124
+ * 'the text node', 'the doctype', 'the code block'). A comment is not named: it is
125
+ * ignored when a content model is evaluated.
125
126
  *
126
127
  * In `'origin'` mode, uses {@link MLElement.rawName} so that the diagnostic refers
127
128
  * to the original AST identity (e.g. the component name `"Breadcrumbs"`) rather
@@ -143,9 +144,6 @@ scope, t, mode) {
143
144
  if (scope.is(scope.TEXT_NODE)) {
144
145
  return t('the {0}', 'text node');
145
146
  }
146
- if (scope.is(scope.CDATA_SECTION_NODE)) {
147
- return t('the {0}', 'comment');
148
- }
149
147
  if (scope.is(scope.DOCUMENT_TYPE_NODE)) {
150
148
  return t('the {0}', 'doctype');
151
149
  }
@@ -1,4 +1,5 @@
1
1
  import { contentModelCategoryToTagNames } from '@markuplint/ml-spec';
2
+ import { contentModelFirstChildBase, isContentModelFirstChild, isInterElementWhitespace } from '@markuplint/selector';
2
3
  import { depthLog } from './debug.js';
3
4
  import { cleanObject, matches } from './utils.js';
4
5
  /**
@@ -20,7 +21,7 @@ export function matchesSelector(query,
20
21
  childNode, specs, depth, mode) {
21
22
  const nodeLog = depthLog('node', depth);
22
23
  const nodeEnabled = nodeLog.enabled;
23
- const { selector, hasText, hasNonEmptyText, hasCustom } = optCondition(query, specs);
24
+ const { selector, selectorAsFirstChild, hasText, hasNonEmptyText, hasCustom } = optCondition(query, specs);
24
25
  if (childNode == null) {
25
26
  if (hasText) {
26
27
  if (nodeEnabled) {
@@ -47,7 +48,7 @@ childNode, specs, depth, mode) {
47
48
  }
48
49
  if (childNode.is(childNode.TEXT_NODE)) {
49
50
  if (hasNonEmptyText) {
50
- if (childNode.isWhitespace()) {
51
+ if (isInterElementWhitespace(childNode.raw)) {
51
52
  if (nodeEnabled) {
52
53
  nodeLog('<#nonEmptyText>.matches(%s) => WHITESPACE (rejected)', query);
53
54
  }
@@ -85,7 +86,7 @@ childNode, specs, depth, mode) {
85
86
  hint: {},
86
87
  };
87
88
  }
88
- if (childNode.isWhitespace()) {
89
+ if (isInterElementWhitespace(childNode.raw)) {
89
90
  if (nodeEnabled) {
90
91
  nodeLog('<#text>.matches(%s) => WHITESPACE', query);
91
92
  }
@@ -135,7 +136,14 @@ childNode, specs, depth, mode) {
135
136
  hint: {},
136
137
  };
137
138
  }
138
- const result = matches(selector, childNode, specs, mode);
139
+ let result = matches(selector, childNode, specs, mode);
140
+ // The siblings are looked at only for an element that the rest does not take.
141
+ // The first child is that of the element's own parent, also inside a transparent
142
+ // element, whose content model is derived from its parent's: `style` in
143
+ // `<div><p></p><noscript><style>` is the first child of the noscript.
144
+ if (!result.matched && selectorAsFirstChild != null && isContentModelFirstChild(childNode)) {
145
+ result = matches(selectorAsFirstChild, childNode, specs, mode);
146
+ }
139
147
  if (nodeEnabled) {
140
148
  nodeLog('%s.matches(%s) => %s', childNode.raw, query, result.matched);
141
149
  }
@@ -220,18 +228,21 @@ element, mode) {
220
228
  const conditionWithoutSpecs = {
221
229
  '#custom': {
222
230
  selector: '#custom',
231
+ selectorAsFirstChild: null,
223
232
  hasCustom: true,
224
233
  hasText: false,
225
234
  hasNonEmptyText: false,
226
235
  },
227
236
  '#text': {
228
237
  selector: '#text',
238
+ selectorAsFirstChild: null,
229
239
  hasCustom: false,
230
240
  hasText: true,
231
241
  hasNonEmptyText: false,
232
242
  },
233
243
  '#nonEmptyText': {
234
244
  selector: '#nonEmptyText',
245
+ selectorAsFirstChild: null,
235
246
  hasCustom: false,
236
247
  hasText: false,
237
248
  hasNonEmptyText: true,
@@ -271,7 +282,9 @@ function optCondition(query, specs) {
271
282
  let hasCustom = false;
272
283
  let hasText = false;
273
284
  let hasNonEmptyText = false;
274
- const selector = query.replace(/^:model\(([^)]+)\)|^#([a-z-]+)/, (_, $model, _model) => {
285
+ let asFirstChild = null;
286
+ const categoryPattern = /^:model\(([^)]+)\)|^#([a-z-]+)/;
287
+ const selector = query.replace(categoryPattern, (_, $model, _model) => {
275
288
  const tag = `#${$model ?? _model}`;
276
289
  if (tag === '#nonEmptyText') {
277
290
  hasNonEmptyText = true;
@@ -282,6 +295,7 @@ function optCondition(query, specs) {
282
295
  throw new Error(`${$model ?? _model} is empty`);
283
296
  }
284
297
  const selectors = [];
298
+ const firstChildBases = [];
285
299
  for (const selector of _selectors) {
286
300
  if (selector === '#custom') {
287
301
  hasCustom = true;
@@ -291,12 +305,21 @@ function optCondition(query, specs) {
291
305
  hasText = true;
292
306
  continue;
293
307
  }
308
+ const firstChildBase = contentModelFirstChildBase(selector);
309
+ if (firstChildBase != null) {
310
+ firstChildBases.push(firstChildBase);
311
+ continue;
312
+ }
294
313
  selectors.push(selector);
295
314
  }
315
+ if (firstChildBases.length > 0) {
316
+ asFirstChild = `:is(${firstChildBases.join(',')})`;
317
+ }
296
318
  return `:is(${selectors.join(',')})`;
297
319
  });
298
320
  const result = {
299
321
  selector,
322
+ selectorAsFirstChild: asFirstChild == null ? null : query.replace(categoryPattern, () => asFirstChild ?? ''),
300
323
  hasCustom,
301
324
  hasText,
302
325
  hasNonEmptyText,