@markuplint/rules 5.2.0 → 5.3.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 (122) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/CLAUDE.md +19 -19
  3. package/lib/aria-prop-requires-role/index.js +2 -1
  4. package/lib/attr-check.js +6 -12
  5. package/lib/attr-eligibility.d.ts +15 -8
  6. package/lib/attr-eligibility.js +19 -10
  7. package/lib/attr-order/index.js +24 -18
  8. package/lib/case-sensitive-attr-name/index.js +24 -5
  9. package/lib/class-naming/index.js +6 -0
  10. package/lib/debug.d.ts +8 -1
  11. package/lib/debug.js +8 -1
  12. package/lib/element-supports-aria-prop/index.js +2 -1
  13. package/lib/form-attr-references-form/index.d.ts +6 -3
  14. package/lib/form-attr-references-form/index.js +6 -3
  15. package/lib/helpers.d.ts +111 -1
  16. package/lib/helpers.js +212 -2
  17. package/lib/input-list-references-datalist/index.d.ts +8 -6
  18. package/lib/input-list-references-datalist/index.js +8 -6
  19. package/lib/label-for-references-labelable/index.d.ts +8 -5
  20. package/lib/label-for-references-labelable/index.js +8 -5
  21. package/lib/landmark-roles/roles.js +14 -2
  22. package/lib/map-id-name-match/index.d.ts +3 -3
  23. package/lib/map-id-name-match/index.js +3 -3
  24. package/lib/no-ambiguous-navigable-target-names/index.js +2 -1
  25. package/lib/no-aria-hidden-on-hidden-until-found/index.d.ts +9 -6
  26. package/lib/no-aria-hidden-on-hidden-until-found/index.js +9 -6
  27. package/lib/no-aria-hidden-on-hidden-until-found/meta.d.ts +1 -1
  28. package/lib/no-aria-hidden-on-hidden-until-found/meta.js +1 -1
  29. package/lib/no-aria-on-unsupported-element/index.js +2 -2
  30. package/lib/no-aria-on-unsupported-element/meta.d.ts +1 -1
  31. package/lib/no-aria-on-unsupported-element/meta.js +1 -1
  32. package/lib/no-boolean-attr-value/index.js +2 -2
  33. package/lib/no-content-after-body/index.d.ts +2 -2
  34. package/lib/no-content-after-body/index.js +2 -2
  35. package/lib/no-contradictory-aria-prop/index.js +2 -1
  36. package/lib/no-default-aria-value/index.js +2 -1
  37. package/lib/no-default-value/index.js +2 -2
  38. package/lib/no-deprecated-aria-prop/index.js +2 -1
  39. package/lib/no-deprecated-attr/index.js +2 -1
  40. package/lib/no-disallowed-attr/index.d.ts +7 -1
  41. package/lib/no-disallowed-attr/index.js +13 -20
  42. package/lib/no-duplicate-attr/index.js +1 -2
  43. package/lib/no-duplicate-autofocus/index.js +2 -2
  44. package/lib/no-duplicate-h1/index.d.ts +1 -1
  45. package/lib/no-duplicate-h1/index.js +1 -1
  46. package/lib/no-empty-table-track/index.d.ts +6 -2
  47. package/lib/no-empty-table-track/index.js +6 -2
  48. package/lib/no-experimental-features/index.d.ts +5 -1
  49. package/lib/no-experimental-features/index.js +6 -8
  50. package/lib/no-hardcoded-id/index.js +1 -1
  51. package/lib/no-ineffective-attr/index.js +2 -2
  52. package/lib/no-input-file-value/index.d.ts +10 -4
  53. package/lib/no-input-file-value/index.js +10 -4
  54. package/lib/no-invalid-aria-prop-value/index.js +2 -1
  55. package/lib/no-invalid-attr-value/index.d.ts +3 -0
  56. package/lib/no-invalid-attr-value/index.js +9 -24
  57. package/lib/no-nonstandard-features/index.d.ts +5 -1
  58. package/lib/no-nonstandard-features/index.js +6 -8
  59. package/lib/no-obsolete-attr/index.js +3 -3
  60. package/lib/no-prohibited-naming/index.js +2 -1
  61. package/lib/no-redundant-accessible-name/index.js +17 -1
  62. package/lib/no-redundant-aria-prop/index.js +2 -1
  63. package/lib/no-refer-to-non-existent-id/index.js +4 -4
  64. package/lib/no-restricted-attr/index.d.ts +4 -0
  65. package/lib/no-restricted-attr/index.js +5 -22
  66. package/lib/no-stray-head-or-body-tag/index.d.ts +6 -4
  67. package/lib/no-stray-head-or-body-tag/index.js +11 -6
  68. package/lib/no-unknown-attr/index.d.ts +7 -1
  69. package/lib/no-unknown-attr/index.js +11 -25
  70. package/lib/no-unpaired-srcset-sizes/index.d.ts +3 -2
  71. package/lib/no-unpaired-srcset-sizes/index.js +23 -17
  72. package/lib/no-unsupported-browser-features/compat-data.d.ts +15 -2
  73. package/lib/no-unsupported-browser-features/compat-data.js +48 -3
  74. package/lib/no-unsupported-browser-features/index.d.ts +5 -1
  75. package/lib/no-unsupported-browser-features/index.js +6 -7
  76. package/lib/permitted-contents/choice.js +19 -8
  77. package/lib/permitted-contents/content-model.d.ts +24 -1
  78. package/lib/permitted-contents/content-model.js +56 -9
  79. package/lib/permitted-contents/count-pattern.js +36 -13
  80. package/lib/permitted-contents/debug.browser.d.ts +18 -0
  81. package/lib/permitted-contents/debug.browser.js +27 -0
  82. package/lib/permitted-contents/debug.d.ts +18 -0
  83. package/lib/permitted-contents/debug.js +27 -0
  84. package/lib/permitted-contents/index.js +2 -2
  85. package/lib/permitted-contents/matches-selector.js +40 -18
  86. package/lib/permitted-contents/order.js +23 -11
  87. package/lib/permitted-contents/represent-transparent-nodes.js +3 -1
  88. package/lib/permitted-contents/transparent.js +6 -2
  89. package/lib/placeholder-label-option/index.d.ts +3 -3
  90. package/lib/placeholder-label-option/index.js +163 -37
  91. package/lib/progress-value-bounds/index.d.ts +1 -1
  92. package/lib/progress-value-bounds/index.js +1 -1
  93. package/lib/require-attr/index.d.ts +6 -1
  94. package/lib/require-attr/index.js +13 -5
  95. package/lib/require-h1/index.d.ts +1 -1
  96. package/lib/require-h1/index.js +1 -1
  97. package/lib/role-supports-aria-prop/index.js +2 -1
  98. package/lib/sizes-auto-requires-lazy-loading/index.d.ts +3 -1
  99. package/lib/sizes-auto-requires-lazy-loading/index.js +30 -16
  100. package/lib/srcset-sizes-constraint/parse-srcset.d.ts +6 -5
  101. package/lib/srcset-sizes-constraint/parse-srcset.js +8 -7
  102. package/lib/srcset-sizes-constraint/sibling-helpers.d.ts +31 -0
  103. package/lib/srcset-sizes-constraint/sibling-helpers.js +58 -0
  104. package/lib/valid-speculation-rules/index.js +1 -1
  105. package/lib/wai-aria/checkings/contradictory-aria-prop.js +3 -5
  106. package/lib/wai-aria/checkings/default-value.js +1 -1
  107. package/lib/wai-aria/checkings/deprecated-props.js +2 -2
  108. package/lib/wai-aria/checkings/element-supports-aria-prop.js +5 -4
  109. package/lib/wai-aria/checkings/interaction-in-hidden.d.ts +1 -1
  110. package/lib/wai-aria/checkings/interaction-in-hidden.js +1 -1
  111. package/lib/wai-aria/checkings/no-global-prop.js +1 -1
  112. package/lib/wai-aria/checkings/presentational-children.d.ts +4 -4
  113. package/lib/wai-aria/checkings/presentational-children.js +6 -5
  114. package/lib/wai-aria/checkings/prohibited-naming.js +5 -4
  115. package/lib/wai-aria/checkings/redundant-aria-prop.js +3 -5
  116. package/lib/wai-aria/checkings/required-prop.js +1 -4
  117. package/lib/wai-aria/checkings/role-supports-aria-prop.js +4 -3
  118. package/lib/wai-aria/checkings/tab-requires-tabpanel.d.ts +3 -2
  119. package/lib/wai-aria/checkings/tab-requires-tabpanel.js +3 -2
  120. package/lib/wai-aria/checkings/value.d.ts +1 -1
  121. package/lib/wai-aria/checkings/value.js +2 -2
  122. package/package.json +15 -15
@@ -1,4 +1,5 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
+ import { decodeEntities } from '@markuplint/shared';
2
3
  import meta from './meta.js';
3
4
  /**
4
5
  * HTML LS §4.10.7 The select element:
@@ -7,9 +8,9 @@ import meta from './meta.js';
7
8
  * > multiple attribute specified, and has a display size of 1, then the
8
9
  * > select element must have a placeholder label option.
9
10
  *
10
- * `hasPlaceholderLabelOption` decides whether an existing option already
11
- * satisfies the requirement; `needPlaceholderLabelOption` gates whether the
12
- * requirement applies to this `<select>`.
11
+ * `needPlaceholderLabelOption` gates whether the requirement applies to this
12
+ * `<select>`; `hasPlaceholderLabelOption` then decides whether an existing
13
+ * option already satisfies it.
13
14
  *
14
15
  * @see https://html.spec.whatwg.org/multipage/form-elements.html#the-select-element
15
16
  */
@@ -17,10 +18,10 @@ export default createRule({
17
18
  meta: meta,
18
19
  verify({ document, report, t }) {
19
20
  for (const select of document.querySelectorAll('select')) {
20
- if (hasPlaceholderLabelOption(select)) {
21
+ if (!needPlaceholderLabelOption(select)) {
21
22
  continue;
22
23
  }
23
- if (!needPlaceholderLabelOption(select)) {
24
+ if (hasPlaceholderLabelOption(select)) {
24
25
  continue;
25
26
  }
26
27
  report({
@@ -50,54 +51,179 @@ select) {
50
51
  if (hasMultiple) {
51
52
  return false;
52
53
  }
53
- const size = select.getAttribute('size') ?? '1';
54
- if (size !== '1') {
55
- return false;
56
- }
57
- return true;
54
+ return displaySize(select) === 1;
58
55
  }
59
56
  /**
60
57
  * HTML LS §4.10.7 The select element — placeholder label option definition:
61
58
  *
62
- * > If a select element has a required attribute specified, and has a
63
- * > display size of 1; and if the value of the first option element in the
64
- * > select element's list of options (if any) is the empty string, and that
65
- * > option element's parent node is the select element (and not an optgroup
66
- * > element), then that option is the select element's placeholder label
67
- * > option.
59
+ * > If a select element has a required attribute specified, and has a display size of 1; and if the value of the first
60
+ * > option element in the select element's list of options (if any) is the empty string, and that option element does
61
+ * > not have an ancestor optgroup in between itself and the select element, then that option is the select element's
62
+ * > placeholder label option.
68
63
  *
69
- * The "(if any)" clause requires a first option to exist for the placeholder
70
- * label option to exist at all; an empty `<select>` therefore has no
71
- * placeholder label option and must be reported by `verify`.
64
+ * The conditions on the `<select>` itself (required, display size) are
65
+ * `needPlaceholderLabelOption`'s, which `verify` asks first; this decides the
66
+ * rest. The "(if any)" clause requires a first option to exist for the
67
+ * placeholder label option to exist at all; an empty `<select>` therefore has
68
+ * no placeholder label option and must be reported by `verify`.
72
69
  *
73
70
  * @see https://html.spec.whatwg.org/multipage/form-elements.html#placeholder-label-option
74
71
  */
75
72
  function hasPlaceholderLabelOption(
76
73
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
77
74
  select) {
78
- // > has a required attribute specified
79
- if (!select.hasAttribute('required')) {
75
+ // > the first option element in the select element's list of options (if any)
76
+ // No first option → no placeholder label option exists.
77
+ const first = firstOption(select, false);
78
+ if (!first) {
80
79
  return false;
81
80
  }
82
- // > does not have a multiple attribute specified
83
- if (select.hasAttribute('multiple')) {
81
+ // > does not have an ancestor optgroup in between itself and the select element
82
+ if (first.insideOptgroup) {
84
83
  return false;
85
84
  }
86
- // > has a display size of 1
87
- const size = select.getAttribute('size') ?? '1';
88
- if (size !== '1') {
89
- return false;
85
+ // > the value of [it] is the empty string
86
+ // An option whose value cannot be known (a template interpolation as its text) is not reported.
87
+ const value = optionValue(first.option);
88
+ return value === '' || value === null;
89
+ }
90
+ /**
91
+ * The display size of a `<select>`.
92
+ *
93
+ * > The display size of a select element is the result of applying the rules for parsing non-negative integers to the
94
+ * > value of the element's size attribute, if it has one and parsing it is successful. If applying those rules to the
95
+ * > attribute's value is not successful, or if the size attribute is absent, then the element's display size is 4 if
96
+ * > the element's multiple content attribute is present, and 1 otherwise.
97
+ *
98
+ * `null` when the size is given by an expression (`:size="n"`, `size={n}`): it
99
+ * is not known until the template runs, so the rule must not assume it is 1.
100
+ *
101
+ * @see https://html.spec.whatwg.org/multipage/form-elements.html#concept-select-size
102
+ */
103
+ function displaySize(
104
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
105
+ select) {
106
+ const size = select.getAttributeNode('size');
107
+ if (size?.isDynamicValue) {
108
+ return null;
90
109
  }
91
- // > the value of the first option element in the select element's list of options … is the empty string
92
- // No first option → no placeholder label option exists.
93
- const firstOption = select.querySelector('option');
94
- if (!firstOption) {
95
- return false;
110
+ return parseNonNegativeInteger(size?.value ?? null) ?? (select.hasAttribute('multiple') ? 4 : 1);
111
+ }
112
+ /**
113
+ * The rules for parsing non-negative integers: leading ASCII whitespace is
114
+ * skipped, an optional `+` or `-` is read, then the leading run of ASCII
115
+ * digits; whatever follows the digits is ignored (`"3abc"` is 3, `"01"` is 1).
116
+ * No digits, or a negative number, is an error. `-0` is not less than zero.
117
+ *
118
+ * @see https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#rules-for-parsing-non-negative-integers
119
+ */
120
+ function parseNonNegativeInteger(text) {
121
+ const match = /^[\t\n\f\r ]*([+-]?)(\d+)/.exec(text ?? '');
122
+ if (!match) {
123
+ return null;
96
124
  }
97
- // > that option element's parent node is the select element (and not an optgroup element)
98
- if (firstOption.parentElement?.localName === 'optgroup') {
99
- return false;
125
+ const value = Number(match[2]);
126
+ return match[1] === '-' && value !== 0 ? null : value;
127
+ }
128
+ /**
129
+ * The first option of a select's list of options, and whether an optgroup lies
130
+ * between it and the select.
131
+ *
132
+ * Following the algorithm literally, an `option` found while descending is
133
+ * appended and its subtree is skipped; so are the subtrees of a nested `select`,
134
+ * `hr` or `datalist`, and of an `optgroup` that has an `optgroup` ancestor below
135
+ * the select. Any other element, such as the wrapper that a customizable select
136
+ * allows, is looked through.
137
+ *
138
+ * @see https://html.spec.whatwg.org/multipage/form-elements.html#concept-select-option-list
139
+ */
140
+ function firstOption(
141
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
142
+ parent, insideOptgroup) {
143
+ for (const child of parent.childNodes) {
144
+ if (!child.is(child.ELEMENT_NODE)) {
145
+ continue;
146
+ }
147
+ switch (child.localName) {
148
+ case 'option': {
149
+ return { option: child, insideOptgroup };
150
+ }
151
+ case 'select':
152
+ case 'hr':
153
+ case 'datalist': {
154
+ continue;
155
+ }
156
+ case 'optgroup': {
157
+ if (insideOptgroup) {
158
+ continue;
159
+ }
160
+ const found = firstOption(child, true);
161
+ if (found) {
162
+ return found;
163
+ }
164
+ continue;
165
+ }
166
+ default: {
167
+ const found = firstOption(child, insideOptgroup);
168
+ if (found) {
169
+ return found;
170
+ }
171
+ }
172
+ }
100
173
  }
101
- const value = firstOption.getAttribute('value');
102
- return value === '' || value === null;
174
+ return null;
175
+ }
176
+ /**
177
+ * The value of an `<option>`, or `null` when it cannot be known statically.
178
+ *
179
+ * > The value of an option element is the value of the value content attribute, if there is one, or, if there is not,
180
+ * > the result of get HTML-aware text content given this and false.
181
+ *
182
+ * @see https://html.spec.whatwg.org/multipage/form-elements.html#attr-option-value
183
+ */
184
+ function optionValue(
185
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
186
+ option) {
187
+ const valueAttr = option.getAttributeNode('value');
188
+ if (valueAttr) {
189
+ // A value given by an expression (`:value="v"`, `value={v}`) is not known until the template runs.
190
+ return valueAttr.isDynamicValue ? null : valueAttr.value;
191
+ }
192
+ const text = htmlAwareTextContent(option);
193
+ return text === null ? null : stripAndCollapseAsciiWhitespace(text);
194
+ }
195
+ /**
196
+ * Get HTML-aware text content with `includeAltText` false: the data of the
197
+ * Text descendants. The algorithm also skips `script` and `img` subtrees; that
198
+ * changes nothing here, because the HTML parser's tree does not expose a
199
+ * script's source as a Text node and an `img` has no text descendants.
200
+ * `null` when a template construct stands where text would be.
201
+ *
202
+ * @see https://html.spec.whatwg.org/multipage/form-elements.html#get-html-aware-text-content
203
+ */
204
+ function htmlAwareTextContent(
205
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
206
+ element) {
207
+ let text = '';
208
+ for (const child of element.childNodes) {
209
+ if (child.is(child.TEXT_NODE)) {
210
+ // The node holds the source text; a character reference such as `&#32;` has to be decoded first.
211
+ text += decodeEntities(child.textContent ?? '');
212
+ }
213
+ else if (child.is(child.ELEMENT_NODE)) {
214
+ const inner = htmlAwareTextContent(child);
215
+ if (inner === null) {
216
+ return null;
217
+ }
218
+ text += inner;
219
+ }
220
+ else if (child.is(child.MARKUPLINT_PREPROCESSOR_BLOCK)) {
221
+ return null;
222
+ }
223
+ }
224
+ return text;
225
+ }
226
+ /** ASCII whitespace only: `trim()` would also strip a no-break space, which is part of the text. */
227
+ function stripAndCollapseAsciiWhitespace(text) {
228
+ return text.replaceAll(/[\t\n\f\r ]+/g, ' ').replaceAll(/^ | $/g, '');
103
229
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Enforce HTML LS §4.10.14 inequalities for `<progress>`:
2
+ * Enforce HTML LS §4.10.13 inequalities for `<progress>`:
3
3
  * `value ≤ max` when both attributes are present, and `value ≤ 1` when
4
4
  * `max` is absent. Authored-but-unparsable values, out-of-range `max`
5
5
  * (≤ 0), and negative `value` are deferred to `no-invalid-attr-value`.
@@ -11,7 +11,7 @@ function parseFloatAttr(raw) {
11
11
  return Number.isFinite(n) ? n : null;
12
12
  }
13
13
  /**
14
- * Enforce HTML LS §4.10.14 inequalities for `<progress>`:
14
+ * Enforce HTML LS §4.10.13 inequalities for `<progress>`:
15
15
  * `value ≤ max` when both attributes are present, and `value ≤ 1` when
16
16
  * `max` is absent. Authored-but-unparsable values, out-of-range `max`
17
17
  * (≤ 0), and negative `value` are deferred to `no-invalid-attr-value`.
@@ -13,7 +13,12 @@ type Attr = {
13
13
  * Configuration options for the `require-attr` rule.
14
14
  */
15
15
  type Options = {
16
- /** Attribute names to exclude from require-attribute checks. */
16
+ /**
17
+ * Attribute names to exclude from require-attribute checks, compared as the DOM compares attribute
18
+ * names.
19
+ *
20
+ * @see https://markuplint.dev/docs/rules/no-unknown-attr#attribute-name-case
21
+ */
17
22
  readonly ignoreAttrs?: readonly string[];
18
23
  };
19
24
  declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<RequiredAttributes, Options>>;
@@ -12,7 +12,8 @@ export default createRule({
12
12
  }
13
13
  const customRequiredAttrs = typeof el.rule.value === 'string' ? [el.rule.value] : el.rule.value;
14
14
  const attrSpec = getAttrSpecs(el, document.specs);
15
- const ignoreAttrs = new Set(el.rule.options.ignoreAttrs);
15
+ const ignoreAttrs = el.rule.options.ignoreAttrs ?? [];
16
+ const isIgnored = (name) => ignoreAttrs.some(ignored => el.isSameAttrName(ignored, name));
16
17
  const attributeSpecs = {};
17
18
  if (attrSpec && el.elementType === 'html') {
18
19
  for (const spec of attrSpec) {
@@ -39,6 +40,13 @@ export default createRule({
39
40
  values.push(req.value);
40
41
  }
41
42
  }
43
+ // A name of the rule value that the spec table names too is one requirement, and the
44
+ // rule value's is the one that counts
45
+ for (const specName of Object.keys(attributeSpecs)) {
46
+ if (el.isSameAttrName(specName, name)) {
47
+ delete attributeSpecs[specName];
48
+ }
49
+ }
42
50
  attributeSpecs[name] = {
43
51
  name,
44
52
  values,
@@ -46,9 +54,9 @@ export default createRule({
46
54
  };
47
55
  }
48
56
  for (const spec of Object.values(attributeSpecs)) {
49
- if (ignoreAttrs.has(spec.name)) {
57
+ if (isIgnored(spec.name)) {
50
58
  if (spec.requiredEither) {
51
- const activeCandidates = spec.requiredEither.filter(n => !ignoreAttrs.has(n));
59
+ const activeCandidates = spec.requiredEither.filter(n => !isIgnored(n));
52
60
  if (activeCandidates.length > 0) {
53
61
  const invalid = !activeCandidates.some(attrName => el.hasAttribute(attrName));
54
62
  if (invalid) {
@@ -71,7 +79,7 @@ export default createRule({
71
79
  let invalid = false;
72
80
  if (spec.requiredEither) {
73
81
  candidate.push(...spec.requiredEither);
74
- const activeCandidates = candidate.filter(n => !ignoreAttrs.has(n));
82
+ const activeCandidates = candidate.filter(n => !isIgnored(n));
75
83
  invalid = !activeCandidates.some(attrName => el.hasAttribute(attrName));
76
84
  }
77
85
  else if (spec.required === true) {
@@ -81,7 +89,7 @@ export default createRule({
81
89
  const selector = typeof spec.required === 'string' ? spec.required : spec.required.join(',');
82
90
  invalid = el.matches(selector) && didntHave;
83
91
  }
84
- const sortedCandidate = candidate.filter(n => !ignoreAttrs.has(n)).toSorted();
92
+ const sortedCandidate = candidate.filter(n => !isIgnored(n)).toSorted();
85
93
  if (invalid) {
86
94
  const expects = sortedCandidate.length === 1
87
95
  ? t('the "{0*}" {1}', spec.name, 'attribute')
@@ -11,7 +11,7 @@ export interface Options {
11
11
  * technique, not the criterion itself, so this rule's default severity is
12
12
  * `warning`.
13
13
  *
14
- * @see https://www.w3.org/WAI/WCAG21/Techniques/html/H42
14
+ * @see https://www.w3.org/WAI/WCAG22/Techniques/html/H42
15
15
  */
16
16
  declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<boolean, Options>>;
17
17
  export default _default;
@@ -6,7 +6,7 @@ import meta from './meta.js';
6
6
  * technique, not the criterion itself, so this rule's default severity is
7
7
  * `warning`.
8
8
  *
9
- * @see https://www.w3.org/WAI/WCAG21/Techniques/html/H42
9
+ * @see https://www.w3.org/WAI/WCAG22/Techniques/html/H42
10
10
  */
11
11
  export default createRule({
12
12
  meta: meta,
@@ -2,6 +2,7 @@ import { createRule, getComputedRole, ariaSpecs, getSpec } from '@markuplint/ml-
2
2
  import { ARIA_RECOMMENDED_VERSION } from '@markuplint/ml-spec';
3
3
  import { checkingRoleSupportsAriaProp } from '../wai-aria/checkings/role-supports-aria-prop.js';
4
4
  import { defaultOptions } from '../wai-aria/default-options.js';
5
+ import { isAriaAttr } from '../helpers.js';
5
6
  import meta from './meta.js';
6
7
  /**
7
8
  * Split from the former `wai-aria-disallowed-props` rule (#3989): the
@@ -20,7 +21,7 @@ export default createRule({
20
21
  return;
21
22
  if (elSpec && !elSpec.globalAttrs['#ARIAAttrs'])
22
23
  return;
23
- const propAttrs = el.attributes.filter(attr => /^aria-/i.test(attr.name));
24
+ const propAttrs = el.attributes.filter(attr => isAriaAttr(attr));
24
25
  if (propAttrs.length === 0)
25
26
  return;
26
27
  const ariaVersion = el.rule.options?.version ?? document.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
@@ -4,7 +4,9 @@
4
4
  * image starts loading — so it only makes sense together with
5
5
  * `loading="lazy"`, which the spec requires. Split out of the former
6
6
  * `srcset-sizes-constraint` rule's Checks 3 (`<img>`) and 4 (`<source>`, which
7
- * checks the following sibling `<img>` instead of itself).
7
+ * checks the following sibling `<img>` instead of itself). For the `<source>`,
8
+ * the sibling `<img>` must allow auto-sizes: `loading="lazy"` and a `sizes` of
9
+ * `auto` (`allowsAutoSizes`), not `loading="lazy"` alone.
8
10
  *
9
11
  * Gated on `srcset` being present, matching the source rule this was split
10
12
  * from: `sizes="auto"` has nothing to auto-size without candidates to choose
@@ -1,6 +1,6 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
2
  import { hasSizesAuto } from '../srcset-sizes-constraint/parse-srcset.js';
3
- import { findFollowingImg } from '../srcset-sizes-constraint/sibling-helpers.js';
3
+ import { findFollowingImg, isAutoSizes, isLazyLoading } from '../srcset-sizes-constraint/sibling-helpers.js';
4
4
  import meta from './meta.js';
5
5
  /**
6
6
  * HTML LS § the img element / the source element: `sizes="auto"` defers
@@ -8,7 +8,9 @@ import meta from './meta.js';
8
8
  * image starts loading — so it only makes sense together with
9
9
  * `loading="lazy"`, which the spec requires. Split out of the former
10
10
  * `srcset-sizes-constraint` rule's Checks 3 (`<img>`) and 4 (`<source>`, which
11
- * checks the following sibling `<img>` instead of itself).
11
+ * checks the following sibling `<img>` instead of itself). For the `<source>`,
12
+ * the sibling `<img>` must allow auto-sizes: `loading="lazy"` and a `sizes` of
13
+ * `auto` (`allowsAutoSizes`), not `loading="lazy"` alone.
12
14
  *
13
15
  * Gated on `srcset` being present, matching the source rule this was split
14
16
  * from: `sizes="auto"` has nothing to auto-size without candidates to choose
@@ -38,28 +40,40 @@ export default createRule({
38
40
  const sizesValue = sizesAttr?.value ?? null;
39
41
  const sizesIsDynamic = sizesAttr?.isDynamicValue;
40
42
  // img[sizes=auto] → loading=lazy required
41
- if (localName === 'img' && sizesValue != null && !sizesIsDynamic && hasSizesAuto(sizesValue)) {
42
- const loading = el.getAttribute('loading');
43
- if (loading !== 'lazy') {
44
- report({
45
- scope: el,
46
- line: sizesAttr.valueNode?.startLine,
47
- col: sizesAttr.valueNode?.startCol,
48
- raw: sizesAttr.valueNode?.raw,
49
- message: 'The "sizes" attribute with "auto" requires the "loading" attribute to be "lazy"',
50
- });
51
- }
43
+ // The keyword is matched ASCII case-insensitively; a dynamic
44
+ // `loading` is not known and is not reported.
45
+ if (localName === 'img' &&
46
+ sizesValue != null &&
47
+ !sizesIsDynamic &&
48
+ hasSizesAuto(sizesValue) &&
49
+ isLazyLoading(el) === false) {
50
+ report({
51
+ scope: el,
52
+ line: sizesAttr.valueNode?.startLine,
53
+ col: sizesAttr.valueNode?.startCol,
54
+ raw: sizesAttr.valueNode?.raw,
55
+ message: 'The "sizes" attribute with "auto" requires the "loading" attribute to be "lazy"',
56
+ });
52
57
  }
53
- // source[sizes=auto] → following sibling img must have loading=lazy
58
+ // source[sizes=auto] → the following sibling img must allow
59
+ // auto-sizes: loading=lazy and sizes=auto (HTML LS §
60
+ // allows-auto-sizes, see `allowsAutoSizes`). A dynamic loading / sizes on the img is not known.
54
61
  if (localName === 'source' && sizesValue != null && !sizesIsDynamic && hasSizesAuto(sizesValue)) {
55
62
  const img = findFollowingImg(el);
56
- if (!img || img.getAttribute('loading') !== 'lazy') {
63
+ const lazy = img ? isLazyLoading(img) : false;
64
+ const auto = img ? isAutoSizes(img) : false;
65
+ if (lazy === false || auto === false) {
66
+ // Say everything that is missing at once, so that fixing
67
+ // one does not reveal the other.
68
+ const missing = [lazy === false && 'loading="lazy"', auto === false && 'sizes="auto"'].filter(Boolean);
57
69
  report({
58
70
  scope: el,
59
71
  line: sizesAttr.valueNode?.startLine,
60
72
  col: sizesAttr.valueNode?.startCol,
61
73
  raw: sizesAttr.valueNode?.raw,
62
- message: 'The "source" element with sizes="auto" requires the following sibling "img" element to have loading="lazy"',
74
+ message: !img || (missing.length === 1 && lazy === false)
75
+ ? 'The "source" element with sizes="auto" requires the following sibling "img" element to have loading="lazy"'
76
+ : `The "source" element with sizes="auto" requires the following sibling "img" element to have ${missing.join(' and ')}`,
63
77
  });
64
78
  }
65
79
  }
@@ -33,14 +33,15 @@ type ParseResult = {
33
33
  */
34
34
  export declare function parseSrcset(value: string): ParseResult;
35
35
  /**
36
- * Check whether a `sizes` attribute value starts with `auto`.
36
+ * Check whether a `sizes` attribute value is `auto` or starts with `auto,`.
37
37
  *
38
- * Per the spec, `auto` is only valid as the first entry in a sizes list.
39
- * Examples: `"auto"`, `"auto, 100vw"`, `"AUTO"`.
38
+ * HTML LS § allows-auto-sizes: the value is "auto" (ASCII case-insensitive),
39
+ * or starts with "auto," (ASCII case-insensitive). The value is not trimmed.
40
+ * Examples: `"auto"`, `"auto, 100vw"`, `"AUTO,100vw"`. Not: `"auto 100vw"`.
40
41
  *
41
42
  * @param value - The raw `sizes` attribute value string
42
- * @returns `true` if the value starts with `auto` (case-insensitive)
43
- * @see https://html.spec.whatwg.org/multipage/embedded-content.html#the-img-element
43
+ * @returns `true` if the value is `auto` or starts with `auto,`
44
+ * @see https://html.spec.whatwg.org/multipage/embedded-content.html#allows-auto-sizes
44
45
  */
45
46
  export declare function hasSizesAuto(value: string): boolean;
46
47
  export {};
@@ -47,16 +47,17 @@ export function parseSrcset(value) {
47
47
  return { candidates, hasWidth, hasDensity, hasImplied };
48
48
  }
49
49
  /**
50
- * Check whether a `sizes` attribute value starts with `auto`.
50
+ * Check whether a `sizes` attribute value is `auto` or starts with `auto,`.
51
51
  *
52
- * Per the spec, `auto` is only valid as the first entry in a sizes list.
53
- * Examples: `"auto"`, `"auto, 100vw"`, `"AUTO"`.
52
+ * HTML LS § allows-auto-sizes: the value is "auto" (ASCII case-insensitive),
53
+ * or starts with "auto," (ASCII case-insensitive). The value is not trimmed.
54
+ * Examples: `"auto"`, `"auto, 100vw"`, `"AUTO,100vw"`. Not: `"auto 100vw"`.
54
55
  *
55
56
  * @param value - The raw `sizes` attribute value string
56
- * @returns `true` if the value starts with `auto` (case-insensitive)
57
- * @see https://html.spec.whatwg.org/multipage/embedded-content.html#the-img-element
57
+ * @returns `true` if the value is `auto` or starts with `auto,`
58
+ * @see https://html.spec.whatwg.org/multipage/embedded-content.html#allows-auto-sizes
58
59
  */
59
60
  export function hasSizesAuto(value) {
60
- const v = value.trim().toLowerCase();
61
- return v === 'auto' || /^auto[\s,]/.test(v);
61
+ const v = value.toLowerCase();
62
+ return v === 'auto' || v.startsWith('auto,');
62
63
  }
@@ -19,3 +19,34 @@ export declare function followingElementSiblings(el: Element<boolean>): Generato
19
19
  * @returns The first following sibling `<img>` element, or `null` if none found
20
20
  */
21
21
  export declare function findFollowingImg(el: Element<boolean>): Element<boolean> | null;
22
+ /**
23
+ * Whether an `<img>`'s `loading` attribute is in the Lazy state.
24
+ *
25
+ * @param img - The `<img>` element
26
+ * @returns `true` / `false`, or `null` when it cannot be known statically (a
27
+ * dynamic `loading` value, or spread attributes that may supply it)
28
+ */
29
+ export declare function isLazyLoading(img: Element<boolean>): boolean | null;
30
+ /**
31
+ * Whether an `<img>` "allows auto-sizes".
32
+ *
33
+ * HTML LS § allows-auto-sizes: its `loading` attribute is in the Lazy state,
34
+ * and its `sizes` attribute's value is `auto` (ASCII case-insensitive) or
35
+ * starts with `auto,`. `loading="lazy"` alone is not enough: that is what
36
+ * the `<source>` direction of `no-unpaired-srcset-sizes` and
37
+ * `sizes-auto-requires-lazy-loading` both depend on.
38
+ *
39
+ * @see https://html.spec.whatwg.org/multipage/embedded-content.html#allows-auto-sizes
40
+ * @param img - The `<img>` element
41
+ * @returns `true` / `false`, or `null` when it cannot be known statically
42
+ */
43
+ export declare function allowsAutoSizes(img: Element<boolean>): boolean | null;
44
+ /**
45
+ * Whether an `<img>`'s `sizes` attribute is `auto` or starts with `auto,`
46
+ * (the `sizes` half of "allows auto-sizes"; see `hasSizesAuto`).
47
+ *
48
+ * @param img - The `<img>` element
49
+ * @returns `true` / `false`, or `null` when it cannot be known statically (a
50
+ * dynamic `sizes` value, or spread attributes that may supply it)
51
+ */
52
+ export declare function isAutoSizes(img: Element<boolean>): boolean | null;
@@ -1,3 +1,4 @@
1
+ import { hasSizesAuto } from './parse-srcset.js';
1
2
  /**
2
3
  * Iterate the element's following element siblings in document order.
3
4
  *
@@ -35,3 +36,60 @@ el) {
35
36
  }
36
37
  return null;
37
38
  }
39
+ /**
40
+ * Whether an `<img>`'s `loading` attribute is in the Lazy state.
41
+ *
42
+ * @param img - The `<img>` element
43
+ * @returns `true` / `false`, or `null` when it cannot be known statically (a
44
+ * dynamic `loading` value, or spread attributes that may supply it)
45
+ */
46
+ export function isLazyLoading(
47
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
48
+ img) {
49
+ const loading = img.getAttributeNode('loading');
50
+ if (loading?.isDynamicValue || (!loading && img.hasSpreadAttr)) {
51
+ return null;
52
+ }
53
+ // The keyword is matched ASCII case-insensitively (HTML LS § lazy-loading attributes).
54
+ return loading?.value.toLowerCase() === 'lazy';
55
+ }
56
+ /**
57
+ * Whether an `<img>` "allows auto-sizes".
58
+ *
59
+ * HTML LS § allows-auto-sizes: its `loading` attribute is in the Lazy state,
60
+ * and its `sizes` attribute's value is `auto` (ASCII case-insensitive) or
61
+ * starts with `auto,`. `loading="lazy"` alone is not enough: that is what
62
+ * the `<source>` direction of `no-unpaired-srcset-sizes` and
63
+ * `sizes-auto-requires-lazy-loading` both depend on.
64
+ *
65
+ * @see https://html.spec.whatwg.org/multipage/embedded-content.html#allows-auto-sizes
66
+ * @param img - The `<img>` element
67
+ * @returns `true` / `false`, or `null` when it cannot be known statically
68
+ */
69
+ export function allowsAutoSizes(
70
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
71
+ img) {
72
+ const lazy = isLazyLoading(img);
73
+ const auto = isAutoSizes(img);
74
+ if (lazy === false || auto === false) {
75
+ return false;
76
+ }
77
+ return lazy === null || auto === null ? null : true;
78
+ }
79
+ /**
80
+ * Whether an `<img>`'s `sizes` attribute is `auto` or starts with `auto,`
81
+ * (the `sizes` half of "allows auto-sizes"; see `hasSizesAuto`).
82
+ *
83
+ * @param img - The `<img>` element
84
+ * @returns `true` / `false`, or `null` when it cannot be known statically (a
85
+ * dynamic `sizes` value, or spread attributes that may supply it)
86
+ */
87
+ export function isAutoSizes(
88
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
89
+ img) {
90
+ const sizes = img.getAttributeNode('sizes');
91
+ if (sizes?.isDynamicValue || (!sizes && img.hasSpreadAttr)) {
92
+ return null;
93
+ }
94
+ return sizes != null && hasSizesAuto(sizes.value);
95
+ }
@@ -16,7 +16,7 @@ function isPlainObject(value) {
16
16
  * corpus. When the spec changes, update this set/the SR_* sets below, the error
17
17
  * message strings, and the README tables.
18
18
  *
19
- * @see https://html.spec.whatwg.org/multipage/speculative-loading.html
19
+ * @see https://html.spec.whatwg.org/multipage/speculative-loading.html#speculation-rules
20
20
  */
21
21
  // Valid keys per HTML LS § 7.6.1.2 "parse a speculation rule" / the rule-set
22
22
  // object grammar. Unknown keys are rejected by the spec (the rule returns null),
@@ -22,7 +22,7 @@ export const checkingContradictoryAriaProp = ({ attr, propSpecs, attrSpecs }) =>
22
22
  if (attr.isDynamicValue) {
23
23
  return;
24
24
  }
25
- const propSpec = propSpecs.find(p => p.name === attr.name);
25
+ const propSpec = propSpecs.find(p => p.name === attr.nameKey);
26
26
  if (!propSpec?.equivalentHtmlAttrs) {
27
27
  return;
28
28
  }
@@ -30,13 +30,11 @@ export const checkingContradictoryAriaProp = ({ attr, propSpecs, attrSpecs }) =>
30
30
  const htmlAttrSpec = attrSpecs.find(a => a.name === equivalentHtmlAttr.htmlAttrName);
31
31
  const eligibility = resolveAttrEligibility(equivalentHtmlAttr.htmlAttrName, attr.ownerElement, attrSpecs);
32
32
  // Skip only when the native attribute doesn't apply to this element at all
33
- // (unknown name, case mismatch, or an unmet condition) — mirrors the old
33
+ // (unknown name or an unmet condition) — mirrors the old
34
34
  // `invalidType === 'non-existent'` check. `noUse` still proceeds: an
35
35
  // explicitly-forbidden native attribute can still be "implicit" for
36
36
  // equivalence purposes.
37
- if (eligibility.status === 'unknown' ||
38
- eligibility.status === 'case-mismatch' ||
39
- eligibility.status === 'condition-not-met') {
37
+ if (eligibility.status === 'unknown' || eligibility.status === 'condition-not-met') {
40
38
  continue;
41
39
  }
42
40
  const value = attr.value.trim().toLowerCase();