@markuplint/rules 4.12.0 → 5.0.0-alpha.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 (83) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/lib/attr-check.d.ts +5 -2
  3. package/lib/attr-check.js +13 -3
  4. package/lib/character-reference/index.d.ts +2 -1
  5. package/lib/character-reference/index.js +5 -1
  6. package/lib/correct-aspect-ratio/index.d.ts +5 -0
  7. package/lib/correct-aspect-ratio/index.js +80 -0
  8. package/lib/correct-aspect-ratio/meta.d.ts +4 -0
  9. package/lib/correct-aspect-ratio/meta.js +3 -0
  10. package/lib/correct-aspect-ratio/resolve-image-size.d.ts +24 -0
  11. package/lib/correct-aspect-ratio/resolve-image-size.js +165 -0
  12. package/lib/create-message.d.ts +2 -2
  13. package/lib/create-message.js +4 -1
  14. package/lib/deprecated-element/index.d.ts +2 -4
  15. package/lib/deprecated-element/index.js +4 -6
  16. package/lib/end-tag/index.js +1 -1
  17. package/lib/heading-levels/index.js +0 -1
  18. package/lib/helpers.d.ts +1 -0
  19. package/lib/helpers.js +8 -15
  20. package/lib/index.d.ts +30 -41
  21. package/lib/index.js +12 -0
  22. package/lib/invalid-attr/index.d.ts +12 -28
  23. package/lib/invalid-attr/index.js +50 -116
  24. package/lib/link-types/index.d.ts +10 -0
  25. package/lib/link-types/index.js +180 -0
  26. package/lib/link-types/meta.d.ts +5 -0
  27. package/lib/link-types/meta.js +4 -0
  28. package/lib/neighbor-popovers/index.js +3 -5
  29. package/lib/no-orphaned-end-tag/index.d.ts +2 -2
  30. package/lib/no-orphaned-end-tag/index.js +3 -4
  31. package/lib/no-refer-to-non-existent-id/index.d.ts +1 -1
  32. package/lib/no-refer-to-non-existent-id/index.js +2 -2
  33. package/lib/no-unsupported-features/compat-data.d.ts +71 -0
  34. package/lib/no-unsupported-features/compat-data.js +204 -0
  35. package/lib/no-unsupported-features/index.d.ts +24 -0
  36. package/lib/no-unsupported-features/index.js +139 -0
  37. package/lib/no-unsupported-features/meta.d.ts +5 -0
  38. package/lib/no-unsupported-features/meta.js +4 -0
  39. package/lib/no-unsupported-features/resolve-browsers.d.ts +30 -0
  40. package/lib/no-unsupported-features/resolve-browsers.js +125 -0
  41. package/lib/permitted-contents/choice.js +1 -1
  42. package/lib/permitted-contents/count-pattern.js +1 -1
  43. package/lib/permitted-contents/order.js +1 -1
  44. package/lib/permitted-contents/represent-transparent-nodes.d.ts +26 -1
  45. package/lib/permitted-contents/represent-transparent-nodes.js +96 -13
  46. package/lib/permitted-contents/utils.d.ts +1 -1
  47. package/lib/permitted-contents/utils.js +22 -35
  48. package/lib/redundant-accessible-name/index.d.ts +10 -0
  49. package/lib/redundant-accessible-name/index.js +123 -0
  50. package/lib/redundant-accessible-name/meta.d.ts +5 -0
  51. package/lib/redundant-accessible-name/meta.js +4 -0
  52. package/lib/require-accessible-name/index.d.ts +3 -8
  53. package/lib/require-accessible-name/index.js +6 -5
  54. package/lib/require-dialog-autofocus/index.d.ts +22 -0
  55. package/lib/require-dialog-autofocus/index.js +62 -0
  56. package/lib/require-dialog-autofocus/meta.d.ts +5 -0
  57. package/lib/require-dialog-autofocus/meta.js +4 -0
  58. package/lib/required-attr/index.d.ts +8 -1
  59. package/lib/required-attr/index.js +27 -4
  60. package/lib/required-element/index.js +1 -1
  61. package/lib/srcset-sizes-constraint/index.d.ts +6 -0
  62. package/lib/srcset-sizes-constraint/index.js +108 -0
  63. package/lib/srcset-sizes-constraint/meta.d.ts +5 -0
  64. package/lib/srcset-sizes-constraint/meta.js +4 -0
  65. package/lib/srcset-sizes-constraint/parse-srcset.d.ts +40 -0
  66. package/lib/srcset-sizes-constraint/parse-srcset.js +56 -0
  67. package/lib/table-row-column-alignment/grid.js +22 -29
  68. package/lib/wai-aria/checkings/abstract-role.js +5 -2
  69. package/lib/wai-aria/checkings/disallowed-prop.js +5 -2
  70. package/lib/wai-aria/checkings/implicit-role.js +5 -2
  71. package/lib/wai-aria/checkings/non-existent-role.d.ts +1 -0
  72. package/lib/wai-aria/checkings/non-existent-role.js +9 -2
  73. package/lib/wai-aria/checkings/permitted-roles.js +5 -2
  74. package/lib/wai-aria/checkings/presentational-children.js +3 -2
  75. package/lib/wai-aria/checkings/required-accessibility-parent-role.d.ts +25 -0
  76. package/lib/wai-aria/checkings/required-accessibility-parent-role.js +56 -0
  77. package/lib/wai-aria/checkings/required-owned-elements.d.ts +5 -4
  78. package/lib/wai-aria/checkings/required-owned-elements.js +57 -28
  79. package/lib/wai-aria/checkings/required-prop.js +5 -2
  80. package/lib/wai-aria/index.js +12 -4
  81. package/lib/wai-aria/types.d.ts +9 -2
  82. package/package.json +15 -10
  83. package/schema.json +18 -0
@@ -1,15 +1,3 @@
1
- var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
2
- if (kind === "m") throw new TypeError("Private method is not writable");
3
- if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
4
- if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
5
- return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
6
- };
7
- var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
8
- if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
9
- if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
- return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
- };
12
- var _Collection_locked, _Collection_matched, _Collection_nodes, _Collection_origin;
13
1
  import { createSelector } from '@markuplint/selector';
14
2
  import { bgGreen, green, bgRed, bgBlue, blue, bgMagenta, cyan } from './debug.js';
15
3
  import { transparentMode } from './represent-transparent-nodes.js';
@@ -229,7 +217,7 @@ export function mergeHints(
229
217
  a,
230
218
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
231
219
  b) {
232
- const missing = [a.missing, b.missing].sort((a, b) => (b?.barelyMatchedElements ?? 0) - (a?.barelyMatchedElements ?? 0))[0];
220
+ const missing = [a.missing, b.missing].toSorted((a, b) => (b?.barelyMatchedElements ?? 0) - (a?.barelyMatchedElements ?? 0))[0];
233
221
  return cleanObject({
234
222
  ...a,
235
223
  ...b,
@@ -263,6 +251,10 @@ export function cleanObject(object) {
263
251
  * into matched and unmatched sets as the validation algorithm progresses.
264
252
  */
265
253
  export class Collection {
254
+ #locked = new Set();
255
+ #matched = new Set();
256
+ #nodes;
257
+ #origin;
266
258
  /**
267
259
  * Creates a new Collection from the given child nodes.
268
260
  *
@@ -271,36 +263,32 @@ export class Collection {
271
263
  constructor(
272
264
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
273
265
  origin) {
274
- _Collection_locked.set(this, new Set());
275
- _Collection_matched.set(this, new Set());
276
- _Collection_nodes.set(this, void 0);
277
- _Collection_origin.set(this, void 0);
278
- __classPrivateFieldSet(this, _Collection_origin, [...origin], "f");
279
- __classPrivateFieldSet(this, _Collection_nodes, new Set(__classPrivateFieldGet(this, _Collection_origin, "f")), "f");
266
+ this.#origin = [...origin];
267
+ this.#nodes = new Set(this.#origin);
280
268
  }
281
269
  /**
282
270
  * Returns a copy of the currently matched nodes in insertion order.
283
271
  */
284
272
  get matched() {
285
- return [...__classPrivateFieldGet(this, _Collection_matched, "f")];
273
+ return [...this.#matched];
286
274
  }
287
275
  /**
288
276
  * Returns the number of currently matched nodes.
289
277
  */
290
278
  get matchedCount() {
291
- return __classPrivateFieldGet(this, _Collection_matched, "f").size;
279
+ return this.#matched.size;
292
280
  }
293
281
  /**
294
282
  * Returns a copy of all original nodes in their original order.
295
283
  */
296
284
  get nodes() {
297
- return [...__classPrivateFieldGet(this, _Collection_origin, "f")];
285
+ return [...this.#origin];
298
286
  }
299
287
  /**
300
288
  * Returns the nodes that have not yet been matched, preserving original order.
301
289
  */
302
290
  get unmatched() {
303
- return [...__classPrivateFieldGet(this, _Collection_nodes, "f")].filter(n => !__classPrivateFieldGet(this, _Collection_matched, "f").has(n));
291
+ return [...this.#nodes].filter(n => !this.#matched.has(n));
304
292
  }
305
293
  /**
306
294
  * Adds nodes to the matched set. All nodes must belong to the original
@@ -312,14 +300,14 @@ export class Collection {
312
300
  addMatched(
313
301
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
314
302
  nodes) {
315
- const i = __classPrivateFieldGet(this, _Collection_matched, "f").size;
303
+ const i = this.#matched.size;
316
304
  for (const node of nodes) {
317
- if (!__classPrivateFieldGet(this, _Collection_nodes, "f").has(node)) {
305
+ if (!this.#nodes.has(node)) {
318
306
  throw new ReferenceError(`External Node: ${node.nodeName}`);
319
307
  }
320
- __classPrivateFieldGet(this, _Collection_matched, "f").add(node);
308
+ this.#matched.add(node);
321
309
  }
322
- return i < __classPrivateFieldGet(this, _Collection_matched, "f").size;
310
+ return i < this.#matched.size;
323
311
  }
324
312
  /**
325
313
  * Reverts the matched set to the last locked state, discarding
@@ -327,7 +315,7 @@ export class Collection {
327
315
  * when a pattern match attempt fails after a zero-match.
328
316
  */
329
317
  back() {
330
- __classPrivateFieldSet(this, _Collection_matched, new Set(__classPrivateFieldGet(this, _Collection_locked, "f")), "f");
318
+ this.#matched = new Set(this.#locked);
331
319
  }
332
320
  /**
333
321
  * Saves the current matched set as a checkpoint that `back()` can
@@ -335,7 +323,7 @@ export class Collection {
335
323
  * the known-good state.
336
324
  */
337
325
  lock() {
338
- __classPrivateFieldSet(this, _Collection_locked, new Set(__classPrivateFieldGet(this, _Collection_matched, "f")), "f");
326
+ this.#locked = new Set(this.#matched);
339
327
  }
340
328
  /**
341
329
  * Trims the matched set to at most `max` entries by removing
@@ -345,9 +333,9 @@ export class Collection {
345
333
  * @param max - The maximum number of matched nodes to retain.
346
334
  */
347
335
  max(max) {
348
- const sliced = [...__classPrivateFieldGet(this, _Collection_matched, "f")].slice(max);
336
+ const sliced = [...this.#matched].slice(max);
349
337
  for (const n of sliced)
350
- __classPrivateFieldGet(this, _Collection_matched, "f").delete(n);
338
+ this.#matched.delete(n);
351
339
  }
352
340
  /**
353
341
  * Returns a colored string representation of all nodes for debug logging.
@@ -360,9 +348,9 @@ export class Collection {
360
348
  */
361
349
  toString(highlightExtraNodes = false) {
362
350
  const out = [];
363
- for (const n of __classPrivateFieldGet(this, _Collection_origin, "f")) {
351
+ for (const n of this.#origin) {
364
352
  const raw = n.is(n.TEXT_NODE) ? `:text(${n.raw.replaceAll('\n', '\\n')})` : n.raw;
365
- if (__classPrivateFieldGet(this, _Collection_locked, "f").has(n)) {
353
+ if (this.#locked.has(n)) {
366
354
  if (transparentMode.has(n)) {
367
355
  out.push(bgBlue.bold(raw));
368
356
  }
@@ -371,7 +359,7 @@ export class Collection {
371
359
  }
372
360
  continue;
373
361
  }
374
- if (__classPrivateFieldGet(this, _Collection_matched, "f").has(n)) {
362
+ if (this.#matched.has(n)) {
375
363
  if (transparentMode.has(n)) {
376
364
  out.push(blue.bold(raw));
377
365
  }
@@ -399,7 +387,6 @@ export class Collection {
399
387
  return `[ ${out.join(', ')} ]`;
400
388
  }
401
389
  }
402
- _Collection_locked = new WeakMap(), _Collection_matched = new WeakMap(), _Collection_nodes = new WeakMap(), _Collection_origin = new WeakMap();
403
390
  /**
404
391
  * Error class representing an unsupported content model feature.
405
392
  * Thrown when the validation engine encounters a pattern type or
@@ -0,0 +1,10 @@
1
+ import type { RuleConfigValue } from '@markuplint/ml-core';
2
+ /**
3
+ * Rule that detects elements with multiple accessible name sources
4
+ * where a higher-priority source overrides a lower-priority one.
5
+ */
6
+ declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<RuleConfigValue, {
7
+ checkTitleFallback: boolean;
8
+ checkPlaceholderFallback: boolean;
9
+ }>>;
10
+ export default _default;
@@ -0,0 +1,123 @@
1
+ import { createRule, getComputedRole } from '@markuplint/ml-core';
2
+ import { ARIA_RECOMMENDED_VERSION, isExposed } from '@markuplint/ml-spec';
3
+ import { accnameMayBeMutable, getOwnedLabel } from '../helpers.js';
4
+ import meta from './meta.js';
5
+ /**
6
+ * Rule that detects elements with multiple accessible name sources
7
+ * where a higher-priority source overrides a lower-priority one.
8
+ */
9
+ export default createRule({
10
+ meta,
11
+ defaultSeverity: 'warning',
12
+ defaultOptions: {
13
+ checkTitleFallback: false,
14
+ checkPlaceholderFallback: false,
15
+ },
16
+ async verify({ document, report, t }) {
17
+ await document.walkOn('Element', el => {
18
+ const ariaVersion = ARIA_RECOMMENDED_VERSION;
19
+ // Skip mutable elements (template expressions etc.)
20
+ if (accnameMayBeMutable(el, document)) {
21
+ return;
22
+ }
23
+ // Skip hidden elements
24
+ if (!isExposed(el, document.specs, ariaVersion)) {
25
+ return;
26
+ }
27
+ // Skip nameFrom: "prohibited" roles (generic, none, presentation)
28
+ const computed = getComputedRole(document.specs, el, ariaVersion);
29
+ if (computed.role?.accessibleNameProhibited) {
30
+ return;
31
+ }
32
+ const sources = collectNamingSources(el, document, el.rule.options, computed);
33
+ if (sources.length >= 2) {
34
+ const winner = sources[0];
35
+ const overridden = sources.slice(1);
36
+ for (const loser of overridden) {
37
+ report({
38
+ scope: el,
39
+ message: t('The accessible name from "{0*}" overrides "{1*}"', winner.label, loser.label),
40
+ });
41
+ }
42
+ }
43
+ });
44
+ },
45
+ });
46
+ function collectNamingSources(
47
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
48
+ el,
49
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
50
+ document, options,
51
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
52
+ computed) {
53
+ const sources = [];
54
+ // 1. aria-labelledby
55
+ const ariaLabelledby = el.getAttribute('aria-labelledby');
56
+ if (ariaLabelledby && ariaLabelledby.trim()) {
57
+ const ids = ariaLabelledby.trim().split(/\s+/);
58
+ const hasResolvableId = ids.some(id => document.querySelector(`[id="${id}"]`));
59
+ if (hasResolvableId) {
60
+ sources.push({ label: 'aria-labelledby' });
61
+ }
62
+ }
63
+ // 2. aria-label
64
+ const ariaLabel = el.getAttribute('aria-label');
65
+ if (ariaLabel && ariaLabel.trim()) {
66
+ sources.push({ label: 'aria-label' });
67
+ }
68
+ // 3. label (explicit or implicit) — only for labelable elements
69
+ const ownedLabel = getOwnedLabel(el, document);
70
+ if (ownedLabel) {
71
+ sources.push({ label: 'label' });
72
+ }
73
+ // 4. alt — img, area, input[type=image]
74
+ if (el.matches('img, area, input[type=image]')) {
75
+ const alt = el.getAttribute('alt');
76
+ if (alt != null && alt.trim()) {
77
+ sources.push({ label: 'alt' });
78
+ }
79
+ }
80
+ // 5. content — role allows nameFromContent
81
+ if (computed.role?.accessibleNameFromContent) {
82
+ const text = el.textContent.trim();
83
+ if (text) {
84
+ sources.push({ label: 'content' });
85
+ }
86
+ }
87
+ // 6. value — input[type=button/submit/reset]
88
+ if (el.matches('input[type=button], input[type=submit], input[type=reset]')) {
89
+ const value = el.getAttribute('value');
90
+ if (value != null && value.trim()) {
91
+ sources.push({ label: 'value' });
92
+ }
93
+ }
94
+ // 7. legend — fieldset (direct child only)
95
+ if (el.matches('fieldset')) {
96
+ const legend = el.querySelector(':scope > legend');
97
+ if (legend && legend.textContent.trim()) {
98
+ sources.push({ label: 'legend' });
99
+ }
100
+ }
101
+ // 8. caption — table (direct child only)
102
+ if (el.matches('table')) {
103
+ const caption = el.querySelector(':scope > caption');
104
+ if (caption && caption.textContent.trim()) {
105
+ sources.push({ label: 'caption' });
106
+ }
107
+ }
108
+ // 9. title (optional)
109
+ if (options.checkTitleFallback) {
110
+ const title = el.getAttribute('title');
111
+ if (title != null && title.trim()) {
112
+ sources.push({ label: 'title' });
113
+ }
114
+ }
115
+ // 10. placeholder (optional)
116
+ if (options.checkPlaceholderFallback) {
117
+ const placeholder = el.getAttribute('placeholder');
118
+ if (placeholder != null && placeholder.trim()) {
119
+ sources.push({ label: 'placeholder' });
120
+ }
121
+ }
122
+ return sources;
123
+ }
@@ -0,0 +1,5 @@
1
+ /** Rule metadata for the `redundant-accessible-name` rule, categorized as accessibility. */
2
+ declare const _default: {
3
+ readonly category: "a11y";
4
+ };
5
+ export default _default;
@@ -0,0 +1,4 @@
1
+ /** Rule metadata for the `redundant-accessible-name` rule, categorized as accessibility. */
2
+ export default {
3
+ category: 'a11y',
4
+ };
@@ -1,11 +1,4 @@
1
1
  import type { ARIAVersion } from '@markuplint/ml-spec';
2
- /**
3
- * Configuration options for the require-accessible-name rule.
4
- */
5
- type Option = {
6
- /** The ARIA specification version to use for role and accessible name computation. */
7
- ariaVersion: ARIAVersion;
8
- };
9
2
  /**
10
3
  * Rule that requires elements with roles that need accessible names to have one.
11
4
  *
@@ -13,5 +6,7 @@ type Option = {
13
6
  * has `accessibleNameRequired` set to `true`, this rule verifies that the element
14
7
  * provides a non-empty accessible name.
15
8
  */
16
- declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<boolean, Option>>;
9
+ declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<import("@markuplint/ml-core").RuleConfigValue, {
10
+ ariaVersion: ARIAVersion | undefined;
11
+ }>>;
17
12
  export default _default;
@@ -12,25 +12,26 @@ import meta from './meta.js';
12
12
  export default createRule({
13
13
  meta: meta,
14
14
  defaultOptions: {
15
- ariaVersion: ARIA_RECOMMENDED_VERSION,
15
+ ariaVersion: undefined,
16
16
  },
17
17
  async verify({ document, report, t }) {
18
18
  await document.walkOn('Element', el => {
19
+ const ariaVersion = el.rule.options?.ariaVersion ?? document.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
19
20
  if (accnameMayBeMutable(el, document)) {
20
21
  return;
21
22
  }
22
- if (!isExposed(el, document.specs, el.rule.options.ariaVersion)) {
23
+ if (!isExposed(el, document.specs, ariaVersion)) {
23
24
  return;
24
25
  }
25
- const computed = getComputedRole(document.specs, el, el.rule.options.ariaVersion);
26
+ const computed = getComputedRole(document.specs, el, ariaVersion);
26
27
  if (!computed.role) {
27
28
  return;
28
29
  }
29
- const roleSpec = getRoleSpec(document.specs, computed.role.name, el.namespaceURI, el.rule.options.ariaVersion);
30
+ const roleSpec = getRoleSpec(document.specs, computed.role.name, el.namespaceURI, ariaVersion);
30
31
  if (!roleSpec || !roleSpec.accessibleNameRequired) {
31
32
  return;
32
33
  }
33
- const hasAccessibleName = !!el.getAccessibleName(el.rule.options.ariaVersion).trim();
34
+ const hasAccessibleName = !!el.getAccessibleName(ariaVersion).trim();
34
35
  if (!hasAccessibleName) {
35
36
  report({ scope: el, message: t('Require {0}', 'accessible name') });
36
37
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Rule that requires a `<dialog>` element referenced by a `show-modal` command
3
+ * to contain a descendant (or itself) with the `autofocus` attribute.
4
+ *
5
+ * Without `autofocus`, the browser's dialog focusing steps fall back to the
6
+ * `<dialog>` element itself, which is not ideal for accessibility — screen
7
+ * reader users may miss the dialog content, and keyboard users may need
8
+ * extra tab presses to reach interactive elements.
9
+ *
10
+ * Detection algorithm:
11
+ * 1. Find all `<button>` elements with both `command` and `commandfor` attributes
12
+ * 2. Filter to triggers whose `command` value is `show-modal` (case-insensitive)
13
+ * 3. Resolve the referenced `<dialog>` via `document.getElementById(commandfor)`
14
+ * 4. Skip non-dialog targets and already-reported dialogs (deduplication)
15
+ * 5. Report if neither the dialog nor any descendant has the `autofocus` attribute
16
+ *
17
+ * @see https://html.spec.whatwg.org/multipage/interactive-elements.html#dialog-focusing-steps
18
+ * @see https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element
19
+ * @see https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
20
+ */
21
+ declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<import("@markuplint/ml-core").RuleConfigValue, undefined>>;
22
+ export default _default;
@@ -0,0 +1,62 @@
1
+ import { createRule } from '@markuplint/ml-core';
2
+ import meta from './meta.js';
3
+ /**
4
+ * Rule that requires a `<dialog>` element referenced by a `show-modal` command
5
+ * to contain a descendant (or itself) with the `autofocus` attribute.
6
+ *
7
+ * Without `autofocus`, the browser's dialog focusing steps fall back to the
8
+ * `<dialog>` element itself, which is not ideal for accessibility — screen
9
+ * reader users may miss the dialog content, and keyboard users may need
10
+ * extra tab presses to reach interactive elements.
11
+ *
12
+ * Detection algorithm:
13
+ * 1. Find all `<button>` elements with both `command` and `commandfor` attributes
14
+ * 2. Filter to triggers whose `command` value is `show-modal` (case-insensitive)
15
+ * 3. Resolve the referenced `<dialog>` via `document.getElementById(commandfor)`
16
+ * 4. Skip non-dialog targets and already-reported dialogs (deduplication)
17
+ * 5. Report if neither the dialog nor any descendant has the `autofocus` attribute
18
+ *
19
+ * @see https://html.spec.whatwg.org/multipage/interactive-elements.html#dialog-focusing-steps
20
+ * @see https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element
21
+ * @see https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
22
+ */
23
+ export default createRule({
24
+ defaultSeverity: 'warning',
25
+ meta: meta,
26
+ verify({ document, report, t }) {
27
+ const triggers = document.querySelectorAll('button[command][commandfor]');
28
+ const reportedDialogIds = new Set();
29
+ for (const trigger of triggers) {
30
+ const command = trigger.getAttribute('command');
31
+ if (!command || command.toLowerCase() !== 'show-modal') {
32
+ continue;
33
+ }
34
+ const targetId = trigger.getAttribute('commandfor');
35
+ if (!targetId) {
36
+ continue;
37
+ }
38
+ if (reportedDialogIds.has(targetId)) {
39
+ continue;
40
+ }
41
+ const target = document.getElementById(targetId);
42
+ if (!target) {
43
+ continue;
44
+ }
45
+ if (target.localName !== 'dialog') {
46
+ continue;
47
+ }
48
+ if (target.hasAttribute('autofocus')) {
49
+ continue;
50
+ }
51
+ const autofocusDescendants = target.querySelectorAll('[autofocus]');
52
+ if (autofocusDescendants.length > 0) {
53
+ continue;
54
+ }
55
+ reportedDialogIds.add(targetId);
56
+ report({
57
+ scope: target,
58
+ message: t('The "{0*}" element referenced by a "{1*}" command requires an element with the "{2*}" attribute', 'dialog', 'show-modal', 'autofocus'),
59
+ });
60
+ }
61
+ },
62
+ });
@@ -0,0 +1,5 @@
1
+ /** Rule metadata for the `require-dialog-autofocus` rule, categorized as accessibility. */
2
+ declare const _default: {
3
+ readonly category: "a11y";
4
+ };
5
+ export default _default;
@@ -0,0 +1,4 @@
1
+ /** Rule metadata for the `require-dialog-autofocus` rule, categorized as accessibility. */
2
+ export default {
3
+ category: 'a11y',
4
+ };
@@ -9,6 +9,13 @@ type Attr = {
9
9
  /** One or more allowed values for the attribute, if constrained. */
10
10
  value?: string | string[];
11
11
  };
12
+ /**
13
+ * Configuration options for the `required-attr` rule.
14
+ */
15
+ type Options = {
16
+ /** Attribute names to exclude from required-attribute checks. */
17
+ readonly ignoreAttrs?: readonly string[];
18
+ };
12
19
  /**
13
20
  * Rule that validates elements have all required attributes.
14
21
  *
@@ -16,5 +23,5 @@ type Attr = {
16
23
  * specified in the rule configuration. Also validates that required attribute values
17
24
  * match expected patterns when value constraints are provided.
18
25
  */
19
- declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<RequiredAttributes, undefined>>;
26
+ declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<RequiredAttributes, Options>>;
20
27
  export default _default;
@@ -11,6 +11,7 @@ import meta from './meta.js';
11
11
  export default createRule({
12
12
  meta: meta,
13
13
  defaultValue: [],
14
+ defaultOptions: {},
14
15
  async verify({ document, report, t }) {
15
16
  await document.walkOn('Element', el => {
16
17
  if (el.hasSpreadAttr) {
@@ -18,6 +19,7 @@ export default createRule({
18
19
  }
19
20
  const customRequiredAttrs = typeof el.rule.value === 'string' ? [el.rule.value] : el.rule.value;
20
21
  const attrSpec = getAttrSpecs(el, document.specs);
22
+ const ignoreAttrs = new Set(el.rule.options.ignoreAttrs);
21
23
  const attributeSpecs = {};
22
24
  if (attrSpec && el.elementType === 'html') {
23
25
  for (const spec of attrSpec) {
@@ -51,12 +53,33 @@ export default createRule({
51
53
  };
52
54
  }
53
55
  for (const spec of Object.values(attributeSpecs)) {
56
+ if (ignoreAttrs.has(spec.name)) {
57
+ if (spec.requiredEither) {
58
+ const activeCandidates = spec.requiredEither.filter(n => !ignoreAttrs.has(n));
59
+ if (activeCandidates.length > 0) {
60
+ const invalid = !activeCandidates.some(attrName => el.hasAttribute(attrName));
61
+ if (invalid) {
62
+ const sortedCandidate = activeCandidates.toSorted();
63
+ const expects = sortedCandidate.length === 1
64
+ ? t('the "{0*}" {1}', sortedCandidate[0], 'attribute')
65
+ : t('{0} {1}', sortedCandidate
66
+ .map(attrName => t('the "{0*}"', attrName))
67
+ // eslint-disable-next-line unicorn/no-array-reduce
68
+ .reduce((a, b) => t('{0} or {1}', a, b)), t('attribute'));
69
+ const message = t('{0} expects {1}', t('the "{0*}" {1}', el.localName, 'element'), expects);
70
+ report({ scope: el, message });
71
+ }
72
+ }
73
+ }
74
+ continue;
75
+ }
54
76
  const didntHave = !el.hasAttribute(spec.name);
55
77
  const candidate = [spec.name];
56
78
  let invalid = false;
57
79
  if (spec.requiredEither) {
58
80
  candidate.push(...spec.requiredEither);
59
- invalid = !candidate.some(attrName => el.hasAttribute(attrName));
81
+ const activeCandidates = candidate.filter(n => !ignoreAttrs.has(n));
82
+ invalid = !activeCandidates.some(attrName => el.hasAttribute(attrName));
60
83
  }
61
84
  else if (spec.required === true) {
62
85
  invalid = attrMatches(el, spec.condition) && didntHave;
@@ -65,11 +88,11 @@ export default createRule({
65
88
  const selector = typeof spec.required === 'string' ? spec.required : spec.required.join(',');
66
89
  invalid = el.matches(selector) && didntHave;
67
90
  }
68
- candidate.sort();
91
+ const sortedCandidate = candidate.filter(n => !ignoreAttrs.has(n)).toSorted();
69
92
  if (invalid) {
70
- const expects = candidate.length === 1
93
+ const expects = sortedCandidate.length === 1
71
94
  ? t('the "{0*}" {1}', spec.name, 'attribute')
72
- : t('{0} {1}', candidate
95
+ : t('{0} {1}', sortedCandidate
73
96
  .map(attrName => t('the "{0*}"', attrName))
74
97
  // eslint-disable-next-line unicorn/no-array-reduce
75
98
  .reduce((a, b) => t('{0} or {1}', a, b)), t('attribute'));
@@ -13,7 +13,7 @@ export default createRule({
13
13
  defaultValue: [],
14
14
  defaultOptions: {
15
15
  ignoreHasMutableContents: true,
16
- ignoreOmittedElements: false,
16
+ ignoreOmittedElements: true,
17
17
  },
18
18
  async verify({ document, report, t }) {
19
19
  const hasMutableContent = document.nodeList.some(n => n.is(n.ELEMENT_NODE) && n.hasMutableChildren());
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Rule that enforces WHATWG constraints between the `srcset`, `sizes`,
3
+ * and `loading` attributes on `<img>` and `<source>` elements.
4
+ */
5
+ declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<boolean, undefined>>;
6
+ export default _default;