@markuplint/rules 5.4.0 → 5.6.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 (116) hide show
  1. package/CHANGELOG.md +34 -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 +2 -2
  8. package/lib/consistent-table-row-length/index.js +1 -1
  9. package/lib/create-message.js +6 -5
  10. package/lib/element-supports-aria-prop/index.js +2 -2
  11. package/lib/form-attr-references-form/index.js +3 -3
  12. package/lib/helpers.d.ts +96 -5
  13. package/lib/helpers.js +187 -29
  14. package/lib/input-list-references-datalist/index.js +3 -3
  15. package/lib/itemprop-requires-itemscope/index.js +28 -11
  16. package/lib/label-for-references-labelable/index.js +3 -3
  17. package/lib/label-has-control/index.d.ts +17 -0
  18. package/lib/label-has-control/index.js +28 -1
  19. package/lib/label-no-multiple-controls/index.d.ts +4 -5
  20. package/lib/label-no-multiple-controls/index.js +8 -8
  21. package/lib/landmark-roles/roles.d.ts +13 -1
  22. package/lib/landmark-roles/roles.js +22 -0
  23. package/lib/link-types/index.js +8 -8
  24. package/lib/map-id-name-match/index.js +1 -1
  25. package/lib/meter-value-bounds/index.js +3 -3
  26. package/lib/no-ambiguous-navigable-target-names/index.js +5 -1
  27. package/lib/no-aria-hidden-on-hidden-until-found/index.js +1 -1
  28. package/lib/no-aria-on-presentational-children/index.js +4 -0
  29. package/lib/no-aria-on-unsupported-element/index.js +2 -2
  30. package/lib/no-boolean-attr-value/index.js +2 -1
  31. package/lib/no-broken-fragment-link/index.d.ts +5 -0
  32. package/lib/no-broken-fragment-link/index.js +8 -2
  33. package/lib/no-consecutive-br/index.js +1 -1
  34. package/lib/no-content-after-body/index.js +2 -2
  35. package/lib/no-contradictory-aria-prop/index.js +2 -2
  36. package/lib/no-default-aria-value/index.js +2 -2
  37. package/lib/no-default-value/index.js +4 -0
  38. package/lib/no-deprecated-aria-prop/index.js +2 -2
  39. package/lib/no-disallowed-ancestor/index.d.ts +16 -1
  40. package/lib/no-disallowed-ancestor/index.js +68 -5
  41. package/lib/no-disallowed-attr/index.js +5 -1
  42. package/lib/no-duplicate-autofocus/index.js +1 -1
  43. package/lib/no-duplicate-dt/index.js +1 -1
  44. package/lib/no-duplicate-h1/index.js +18 -14
  45. package/lib/no-duplicate-id/index.d.ts +9 -0
  46. package/lib/no-duplicate-id/index.js +23 -4
  47. package/lib/no-duplicate-sibling-attr/index.js +1 -1
  48. package/lib/no-duplicate-visible-main/index.js +1 -1
  49. package/lib/no-empty-table-track/index.js +1 -1
  50. package/lib/no-extra-selected-options/index.js +1 -1
  51. package/lib/no-ineffective-attr/index.js +4 -0
  52. package/lib/no-input-file-value/index.js +1 -1
  53. package/lib/no-invalid-aria-prop-value/index.js +3 -3
  54. package/lib/no-invalid-attr-value/index.js +12 -0
  55. package/lib/no-malformed-character-reference/index.js +1 -1
  56. package/lib/no-nested-top-level-landmark/index.js +20 -17
  57. package/lib/no-prohibited-naming/index.js +2 -2
  58. package/lib/no-redundant-accessible-name/index.js +15 -8
  59. package/lib/no-redundant-aria-prop/index.js +2 -2
  60. package/lib/no-refer-to-non-existent-id/index.d.ts +15 -0
  61. package/lib/no-refer-to-non-existent-id/index.js +47 -16
  62. package/lib/no-restricted-attr/index.js +2 -1
  63. package/lib/no-restricted-element/index.js +4 -2
  64. package/lib/no-skipped-heading-level/index.js +9 -4
  65. package/lib/no-stray-head-or-body-tag/index.d.ts +2 -1
  66. package/lib/no-stray-head-or-body-tag/index.js +4 -3
  67. package/lib/no-table-cell-overlap/index.js +3 -2
  68. package/lib/no-table-span-overflow/index.js +1 -1
  69. package/lib/no-translate.d.ts +17 -0
  70. package/lib/no-translate.js +29 -0
  71. package/lib/no-unclosed-element-at-eof/index.js +1 -1
  72. package/lib/no-unescaped-char/index.js +1 -1
  73. package/lib/no-unsupported-browser-features/index.d.ts +9 -0
  74. package/lib/no-unsupported-browser-features/index.js +10 -7
  75. package/lib/permitted-contents/content-model.d.ts +14 -0
  76. package/lib/permitted-contents/content-model.js +21 -1
  77. package/lib/permitted-contents/index.js +6 -8
  78. package/lib/permitted-contents/matches-selector.js +32 -5
  79. package/lib/permitted-contents/represent-transparent-nodes.js +100 -68
  80. package/lib/permitted-contents/slot-content.d.ts +18 -1
  81. package/lib/permitted-contents/slot-content.js +49 -2
  82. package/lib/permitted-contents/start.d.ts +8 -1
  83. package/lib/permitted-contents/start.js +39 -6
  84. package/lib/permitted-contents/utils.d.ts +11 -0
  85. package/lib/permitted-contents/utils.js +15 -0
  86. package/lib/placeholder-label-option/index.js +4 -26
  87. package/lib/progress-value-bounds/index.js +2 -2
  88. package/lib/require-accessible-name/index.js +2 -2
  89. package/lib/require-adjacent-popover/index.js +4 -2
  90. package/lib/require-ancestor/index.d.ts +37 -1
  91. package/lib/require-ancestor/index.js +82 -9
  92. package/lib/require-ancestor/meta.d.ts +5 -0
  93. package/lib/require-ancestor/meta.js +5 -0
  94. package/lib/require-attr/index.js +4 -1
  95. package/lib/require-datetime/index.js +3 -2
  96. package/lib/require-dialog-autofocus/index.js +8 -6
  97. package/lib/require-element/index.js +8 -2
  98. package/lib/require-h1/index.js +4 -8
  99. package/lib/require-landmark-label/index.js +17 -15
  100. package/lib/role-supports-aria-prop/index.js +2 -2
  101. package/lib/sizes-auto-requires-lazy-loading/index.d.ts +4 -2
  102. package/lib/sizes-auto-requires-lazy-loading/index.js +4 -2
  103. package/lib/usemap-references-map/index.js +12 -3
  104. package/lib/valid-speculation-rules/index.js +1 -1
  105. package/lib/wai-aria/checkings/contradictory-aria-prop.js +2 -2
  106. package/lib/wai-aria/checkings/interaction-in-hidden.js +6 -2
  107. package/lib/wai-aria/checkings/presentational-children.js +38 -17
  108. package/lib/wai-aria/checkings/redundant-aria-prop.js +1 -1
  109. package/lib/wai-aria/checkings/required-accessibility-parent-role.js +4 -2
  110. package/lib/wai-aria/checkings/required-owned-elements.d.ts +2 -0
  111. package/lib/wai-aria/checkings/required-owned-elements.js +175 -21
  112. package/lib/wai-aria/checkings/required-prop.js +2 -1
  113. package/lib/wai-aria/checkings/tab-requires-tabpanel.js +10 -6
  114. package/lib/wai-aria/checkings/value.d.ts +1 -4
  115. package/lib/wai-aria/checkings/value.js +8 -19
  116. package/package.json +14 -11
@@ -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,7 +1,13 @@
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
+ /**
6
+ * Labels are names (shown as written, `{n*}`) except `content`, which is a word and is translated.
7
+ */
8
+ function showLabel(t, label) {
9
+ return label === 'content' ? t('content') : label;
10
+ }
5
11
  export default createRule({
6
12
  meta,
7
13
  defaultSeverity: 'warning',
@@ -13,7 +19,7 @@ export default createRule({
13
19
  await document.walkOn('Element', el => {
14
20
  const ariaVersion = ARIA_RECOMMENDED_VERSION;
15
21
  // Skip mutable elements (template expressions etc.)
16
- if (accnameMayBeMutable(el, document)) {
22
+ if (accnameMayBeMutable(el)) {
17
23
  return;
18
24
  }
19
25
  // Skip hidden elements
@@ -32,7 +38,7 @@ export default createRule({
32
38
  for (const loser of overridden) {
33
39
  report({
34
40
  scope: el,
35
- message: t('The accessible name from "{0*}" overrides "{1*}"', winner.label, loser.label),
41
+ message: t('The accessible name from "{0*}" overrides "{1*}"', showLabel(t, winner.label), showLabel(t, loser.label)),
36
42
  });
37
43
  }
38
44
  }
@@ -61,13 +67,14 @@ computed) {
61
67
  //
62
68
  // Known limitation: step 2C (Embedded Control) is not modeled, so a self-referencing
63
69
  // textbox, combobox, listbox or range is treated like any other element.
64
- const ariaLabelledby = el.getAttribute('aria-labelledby');
65
- if (ariaLabelledby && ariaLabelledby.trim()) {
70
+ const ariaLabelledbyAttr = el.getAttributeNode('aria-labelledby');
71
+ const ariaLabelledby = ariaLabelledbyAttr?.value;
72
+ if (ariaLabelledbyAttr && ariaLabelledby && ariaLabelledby.trim()) {
66
73
  const ids = ariaLabelledby.trim().split(/\s+/);
67
74
  // An IDREF resolves to the first element with that id, so with duplicate ids a
68
75
  // 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}"]`));
76
+ const isSelfReferencing = ids.some(id => getElementByIdInTree(el, id, ariaLabelledbyAttr.isExpressionValue) === el);
77
+ const hasResolvableId = ids.some(id => getElementByIdInTree(el, id, ariaLabelledbyAttr.isExpressionValue));
71
78
  if (hasResolvableId && !isSelfReferencing) {
72
79
  sources.push({ label: 'aria-labelledby' });
73
80
  }
@@ -78,7 +85,7 @@ computed) {
78
85
  sources.push({ label: 'aria-label' });
79
86
  }
80
87
  // 3. label (explicit or implicit) — only for labelable elements
81
- const ownedLabel = getOwnedLabel(el, document);
88
+ const ownedLabel = getOwnedLabel(el);
82
89
  if (ownedLabel) {
83
90
  sources.push({ label: 'label' });
84
91
  }
@@ -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({
@@ -2,6 +2,7 @@ import { createRule } from '@markuplint/ml-core';
2
2
  import { check, isEnum, isPattern } from '@markuplint/types';
3
3
  import { attrCheck } from '../attr-check.js';
4
4
  import { createNameOptionLookup } from '../helpers.js';
5
+ import { noTranslate } from '../no-translate.js';
5
6
  import meta from './meta.js';
6
7
  export default createRule({
7
8
  meta: meta,
@@ -73,7 +74,7 @@ type) {
73
74
  return t('{0} is matched with the below disallowed patterns: {1}', t('the "{0*}" {1}', name, 'attribute'), type.pattern);
74
75
  }
75
76
  if (typeof type !== 'string' && isEnum(type)) {
76
- return t('{0} is disallowed to accept the following values: {1}', t('the "{0*}" {1}', name, 'attribute'), t(type.enum));
77
+ return t('{0} is disallowed to accept the following values: {1}', t('the "{0*}" {1}', name, 'attribute'), t(type.enum.map(noTranslate)));
77
78
  }
78
79
  return t('{0} is disallowed', t('{0} of {1}', t('the {0}', 'type'), t('the "{0*}" {1}', name, 'attribute')));
79
80
  }
@@ -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":
@@ -52,13 +53,13 @@ export default createRule({
52
53
  if (STRAY_HEAD_END_TAG.test(text.raw)) {
53
54
  report({
54
55
  scope: text,
55
- message: t('{0} detected', t('a stray "{0}" end tag', 'head')),
56
+ message: t('{0} detected', t('a stray "{0*}" end tag', 'head')),
56
57
  });
57
58
  }
58
59
  if (DUPLICATE_BODY_START_TAG.test(text.raw)) {
59
60
  report({
60
61
  scope: text,
61
- message: t('{0} detected', t('a duplicate "{0}" start tag', 'body')),
62
+ message: t('{0} detected', t('a duplicate "{0*}" start tag', 'body')),
62
63
  });
63
64
  }
64
65
  });
@@ -1,5 +1,6 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
2
  import { Grid } from '../table-row-column-alignment/grid.js';
3
+ import { noTranslate } from '../no-translate.js';
3
4
  import meta from './meta.js';
4
5
  /**
5
6
  * HTML LS [§4.9.12.1 *Forming a table*](https://html.spec.whatwg.org/multipage/tables.html#forming-a-table)
@@ -14,7 +15,7 @@ import meta from './meta.js';
14
15
  export default createRule({
15
16
  meta,
16
17
  verify({ document, report, t }) {
17
- const tables = document.querySelectorAll('table');
18
+ const tables = document.deepQuerySelectorAll('table');
18
19
  for (const table of tables) {
19
20
  const grid = new Grid(table);
20
21
  if (grid.hasOverlapped()) {
@@ -22,7 +23,7 @@ export default createRule({
22
23
  scope: table,
23
24
  message: t(
24
25
  //
25
- '{0} are causing {1}', t(['rowspan', 'colspan'], true), 'cell overlap'),
26
+ '{0} are causing {1}', t(['rowspan', 'colspan'].map(noTranslate), true), 'cell overlap'),
26
27
  });
27
28
  }
28
29
  }
@@ -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()) {
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Marks a value so that the translator of `@markuplint/i18n` shows it as written.
3
+ *
4
+ * The translator looks a placeholder's value up as a keyword (`list` is `リスト` with `locale: 'ja'`),
5
+ * and so it does each item of a list, `t(['row', 'list'])`. A placeholder written `{n*}` is not
6
+ * translated, but a list has no such mark: an item that is data (a role name, an attribute value, a
7
+ * token the author wrote) is wrapped in `%…%`, the translator's own form of "not a keyword".
8
+ *
9
+ * The translator reads `%…%` around a whole value as that mark and `%%` at an end as one `%`, so a
10
+ * value that has a `%` at an end is written with it doubled: `%list%` is shown as `%list%`, not
11
+ * `list`. A `%` inside a value needs nothing, as no keyword has one. An empty value is returned as
12
+ * it is: wrapped, `%%` would be read back as a stray `%`.
13
+ *
14
+ * @param value - The text to show as written
15
+ * @returns The text for an item of a list, or for a placeholder, given to `t()`
16
+ */
17
+ export declare function noTranslate(value: string): string;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Marks a value so that the translator of `@markuplint/i18n` shows it as written.
3
+ *
4
+ * The translator looks a placeholder's value up as a keyword (`list` is `リスト` with `locale: 'ja'`),
5
+ * and so it does each item of a list, `t(['row', 'list'])`. A placeholder written `{n*}` is not
6
+ * translated, but a list has no such mark: an item that is data (a role name, an attribute value, a
7
+ * token the author wrote) is wrapped in `%…%`, the translator's own form of "not a keyword".
8
+ *
9
+ * The translator reads `%…%` around a whole value as that mark and `%%` at an end as one `%`, so a
10
+ * value that has a `%` at an end is written with it doubled: `%list%` is shown as `%list%`, not
11
+ * `list`. A `%` inside a value needs nothing, as no keyword has one. An empty value is returned as
12
+ * it is: wrapped, `%%` would be read back as a stray `%`.
13
+ *
14
+ * @param value - The text to show as written
15
+ * @returns The text for an item of a list, or for a placeholder, given to `t()`
16
+ */
17
+ export function noTranslate(value) {
18
+ if (value === '') {
19
+ return value;
20
+ }
21
+ if (!value.includes('%')) {
22
+ return `%${value}%`;
23
+ }
24
+ // `%%` is the escaped form of one `%` at an end, so a lone `%` is a single escaped one
25
+ if (value === '%') {
26
+ return '%%';
27
+ }
28
+ return value.replace(/^%/, '%%').replace(/%$/, '%%');
29
+ }
@@ -89,7 +89,7 @@ export default createRule({
89
89
  return;
90
90
  report({
91
91
  scope: el,
92
- message: t('{0} is not closed at the end of the file', t('the "{0}" {1}', el.localName, 'element')),
92
+ message: t('{0} is not closed at the end of the file', t('the "{0*}" {1}', el.localName, 'element')),
93
93
  });
94
94
  });
95
95
  },
@@ -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;
@@ -1,3 +1,5 @@
1
+ import type { UnsupportedResult } from './compat-data.js';
2
+ import { createRule } from '@markuplint/ml-core';
1
3
  /**
2
4
  * Options for the `no-unsupported-browser-features` rule.
3
5
  */
@@ -17,3 +19,10 @@ type Options = {
17
19
  };
18
20
  declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<boolean, Options>>;
19
21
  export default _default;
22
+ /**
23
+ * Format an unsupported feature message listing affected browsers.
24
+ *
25
+ * The double-underscore prefix marks this as exposed only for testing: the browsers a feature
26
+ * was removed from are not reachable through the data of the dependencies.
27
+ */
28
+ export declare function __formatUnsupportedMessage(t: Parameters<Parameters<typeof createRule>[0]['verify']>[0]['t'], featureName: string, featureType: string, unsupported: readonly UnsupportedResult[]): string;
@@ -34,7 +34,7 @@ export default createRule({
34
34
  if (unsupported.length > 0) {
35
35
  report({
36
36
  scope: el,
37
- message: formatUnsupportedMessage(t, elName, 'element', unsupported),
37
+ message: __formatUnsupportedMessage(t, elName, 'element', unsupported),
38
38
  });
39
39
  }
40
40
  }
@@ -53,7 +53,7 @@ export default createRule({
53
53
  line: attr.nameNode?.startLine,
54
54
  col: attr.nameNode?.startCol,
55
55
  raw: attr.nameNode?.raw,
56
- message: formatUnsupportedMessage(t, attrName, 'attribute', unsupported),
56
+ message: __formatUnsupportedMessage(t, attrName, 'attribute', unsupported),
57
57
  });
58
58
  }
59
59
  }
@@ -63,18 +63,21 @@ export default createRule({
63
63
  });
64
64
  /**
65
65
  * Format an unsupported feature message listing affected browsers.
66
+ *
67
+ * The double-underscore prefix marks this as exposed only for testing: the browsers a feature
68
+ * was removed from are not reachable through the data of the dependencies.
66
69
  */
67
- function formatUnsupportedMessage(t, featureName, featureType, unsupported) {
70
+ export function __formatUnsupportedMessage(t, featureName, featureType, unsupported) {
68
71
  const browserDetails = unsupported
69
72
  .map(u => {
70
73
  if (u.addedVersion === false) {
71
74
  if (u.removedVersion) {
72
- return `${u.displayName} (removed in ${u.removedVersion})`;
75
+ return t('{0*} (removed in {1*})', u.displayName, u.removedVersion);
73
76
  }
74
- return `${u.displayName} (not supported)`;
77
+ return t('{0*} (not supported)', u.displayName);
75
78
  }
76
- return `${u.displayName} (added in ${u.addedVersion}, target: ${u.targetVersion})`;
79
+ return t('{0*} (added in {1*}, target: {2*})', u.displayName, u.addedVersion, u.targetVersion);
77
80
  })
78
81
  .join(', ');
79
- return t('{0} is not supported in {1}', t('the "{0*}" {1}', featureName, featureType), browserDetails);
82
+ return t('{0} is not supported in {1*}', t('the "{0*}" {1}', featureName, featureType), browserDetails);
80
83
  }
@@ -40,6 +40,20 @@ export declare function resolveContentModel(el: Element, rules: readonly TagRule
40
40
  * @returns Whether the rule is written for the element
41
41
  */
42
42
  export declare function hasTagRule(el: Element, rule: TagRule): boolean;
43
+ /**
44
+ * Whether the user's tag rules include one written for the element whose content model governs
45
+ * the children of `el` (see {@link getContentOwner}).
46
+ *
47
+ * Such a rule lists the components that the element takes by the name that they are written with,
48
+ * and the components are evaluated by it. Without one, a component that no pretender resolved renders
49
+ * something that is not known, and what holds whatever it renders is the only thing to report.
50
+ *
51
+ * @param el - The element whose children are validated
52
+ * @param rules - The user's tag rules
53
+ * @param mode - Which identity to resolve against
54
+ * @returns Whether a tag rule is written for the element
55
+ */
56
+ export declare function hasUserTagRule(el: Element, rules: readonly TagRule[], mode: Mode): boolean;
43
57
  /**
44
58
  * Returns a Specs object that merges the base ML spec with user-defined
45
59
  * tag rules. Results are cached by the specs of the document and the serialized rules to avoid redundant
@@ -41,7 +41,8 @@ el, rules, options, mode) {
41
41
  },
42
42
  ];
43
43
  }
44
- const result = start(model, el, rules, specs, options, mode);
44
+ const ignoreUnresolvedComponents = options.ignoreHasMutableChildren && !hasUserTagRule(el, rules, mode);
45
+ const result = start(model, el, rules, specs, options, mode, ignoreUnresolvedComponents);
45
46
  return result;
46
47
  }
47
48
  /**
@@ -124,6 +125,25 @@ export function hasTagRule(
124
125
  el, rule) {
125
126
  return rule.tag === el.rawName || el.matchesTagNameVariant(rule.tag);
126
127
  }
128
+ /**
129
+ * Whether the user's tag rules include one written for the element whose content model governs
130
+ * the children of `el` (see {@link getContentOwner}).
131
+ *
132
+ * Such a rule lists the components that the element takes by the name that they are written with,
133
+ * and the components are evaluated by it. Without one, a component that no pretender resolved renders
134
+ * something that is not known, and what holds whatever it renders is the only thing to report.
135
+ *
136
+ * @param el - The element whose children are validated
137
+ * @param rules - The user's tag rules
138
+ * @param mode - Which identity to resolve against
139
+ * @returns Whether a tag rule is written for the element
140
+ */
141
+ export function hasUserTagRule(
142
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
143
+ el, rules, mode) {
144
+ const owner = getContentOwner(el, mode);
145
+ return rules.some(rule => hasTagRule(owner, rule));
146
+ }
127
147
  /**
128
148
  * Cache for merged specs: for each base spec of a document, keyed by the JSON-serialized tag rules.
129
149
  * Avoids re-merging the same set of user-defined tag rules on every element check.