@markuplint/rules 5.2.0 → 5.4.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 (123) hide show
  1. package/CHANGELOG.md +48 -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-presentational-children/index.js +3 -1
  30. package/lib/no-aria-on-unsupported-element/index.js +2 -2
  31. package/lib/no-aria-on-unsupported-element/meta.d.ts +1 -1
  32. package/lib/no-aria-on-unsupported-element/meta.js +1 -1
  33. package/lib/no-boolean-attr-value/index.js +2 -2
  34. package/lib/no-content-after-body/index.d.ts +2 -2
  35. package/lib/no-content-after-body/index.js +2 -2
  36. package/lib/no-contradictory-aria-prop/index.js +2 -1
  37. package/lib/no-default-aria-value/index.js +2 -1
  38. package/lib/no-default-value/index.js +2 -2
  39. package/lib/no-deprecated-aria-prop/index.js +2 -1
  40. package/lib/no-deprecated-attr/index.js +2 -1
  41. package/lib/no-disallowed-attr/index.d.ts +7 -1
  42. package/lib/no-disallowed-attr/index.js +13 -20
  43. package/lib/no-duplicate-attr/index.js +1 -2
  44. package/lib/no-duplicate-autofocus/index.js +2 -2
  45. package/lib/no-duplicate-h1/index.d.ts +1 -1
  46. package/lib/no-duplicate-h1/index.js +1 -1
  47. package/lib/no-empty-table-track/index.d.ts +6 -2
  48. package/lib/no-empty-table-track/index.js +6 -2
  49. package/lib/no-experimental-features/index.d.ts +5 -1
  50. package/lib/no-experimental-features/index.js +6 -8
  51. package/lib/no-hardcoded-id/index.js +1 -1
  52. package/lib/no-ineffective-attr/index.js +2 -2
  53. package/lib/no-input-file-value/index.d.ts +10 -4
  54. package/lib/no-input-file-value/index.js +10 -4
  55. package/lib/no-invalid-aria-prop-value/index.js +2 -1
  56. package/lib/no-invalid-attr-value/index.d.ts +3 -0
  57. package/lib/no-invalid-attr-value/index.js +9 -24
  58. package/lib/no-nonstandard-features/index.d.ts +5 -1
  59. package/lib/no-nonstandard-features/index.js +6 -8
  60. package/lib/no-obsolete-attr/index.js +3 -3
  61. package/lib/no-prohibited-naming/index.js +2 -1
  62. package/lib/no-redundant-accessible-name/index.js +17 -1
  63. package/lib/no-redundant-aria-prop/index.js +2 -1
  64. package/lib/no-refer-to-non-existent-id/index.js +4 -4
  65. package/lib/no-restricted-attr/index.d.ts +4 -0
  66. package/lib/no-restricted-attr/index.js +5 -22
  67. package/lib/no-stray-head-or-body-tag/index.d.ts +6 -4
  68. package/lib/no-stray-head-or-body-tag/index.js +11 -6
  69. package/lib/no-unknown-attr/index.d.ts +7 -1
  70. package/lib/no-unknown-attr/index.js +11 -25
  71. package/lib/no-unpaired-srcset-sizes/index.d.ts +3 -2
  72. package/lib/no-unpaired-srcset-sizes/index.js +23 -17
  73. package/lib/no-unsupported-browser-features/compat-data.d.ts +15 -2
  74. package/lib/no-unsupported-browser-features/compat-data.js +48 -3
  75. package/lib/no-unsupported-browser-features/index.d.ts +5 -1
  76. package/lib/no-unsupported-browser-features/index.js +6 -7
  77. package/lib/permitted-contents/choice.js +19 -8
  78. package/lib/permitted-contents/content-model.d.ts +24 -1
  79. package/lib/permitted-contents/content-model.js +56 -9
  80. package/lib/permitted-contents/count-pattern.js +36 -13
  81. package/lib/permitted-contents/debug.browser.d.ts +18 -0
  82. package/lib/permitted-contents/debug.browser.js +27 -0
  83. package/lib/permitted-contents/debug.d.ts +18 -0
  84. package/lib/permitted-contents/debug.js +27 -0
  85. package/lib/permitted-contents/index.js +2 -2
  86. package/lib/permitted-contents/matches-selector.js +40 -18
  87. package/lib/permitted-contents/order.js +23 -11
  88. package/lib/permitted-contents/represent-transparent-nodes.js +3 -1
  89. package/lib/permitted-contents/transparent.js +6 -2
  90. package/lib/placeholder-label-option/index.d.ts +3 -3
  91. package/lib/placeholder-label-option/index.js +163 -37
  92. package/lib/progress-value-bounds/index.d.ts +1 -1
  93. package/lib/progress-value-bounds/index.js +1 -1
  94. package/lib/require-attr/index.d.ts +6 -1
  95. package/lib/require-attr/index.js +13 -5
  96. package/lib/require-h1/index.d.ts +1 -1
  97. package/lib/require-h1/index.js +1 -1
  98. package/lib/role-supports-aria-prop/index.js +2 -1
  99. package/lib/sizes-auto-requires-lazy-loading/index.d.ts +3 -1
  100. package/lib/sizes-auto-requires-lazy-loading/index.js +30 -16
  101. package/lib/srcset-sizes-constraint/parse-srcset.d.ts +6 -5
  102. package/lib/srcset-sizes-constraint/parse-srcset.js +8 -7
  103. package/lib/srcset-sizes-constraint/sibling-helpers.d.ts +31 -0
  104. package/lib/srcset-sizes-constraint/sibling-helpers.js +58 -0
  105. package/lib/valid-speculation-rules/index.js +1 -1
  106. package/lib/wai-aria/checkings/contradictory-aria-prop.js +3 -5
  107. package/lib/wai-aria/checkings/default-value.js +1 -1
  108. package/lib/wai-aria/checkings/deprecated-props.js +2 -2
  109. package/lib/wai-aria/checkings/element-supports-aria-prop.js +5 -4
  110. package/lib/wai-aria/checkings/interaction-in-hidden.d.ts +1 -1
  111. package/lib/wai-aria/checkings/interaction-in-hidden.js +1 -1
  112. package/lib/wai-aria/checkings/no-global-prop.js +1 -1
  113. package/lib/wai-aria/checkings/presentational-children.d.ts +21 -10
  114. package/lib/wai-aria/checkings/presentational-children.js +240 -26
  115. package/lib/wai-aria/checkings/prohibited-naming.js +5 -4
  116. package/lib/wai-aria/checkings/redundant-aria-prop.js +3 -5
  117. package/lib/wai-aria/checkings/required-prop.js +1 -4
  118. package/lib/wai-aria/checkings/role-supports-aria-prop.js +4 -3
  119. package/lib/wai-aria/checkings/tab-requires-tabpanel.d.ts +3 -2
  120. package/lib/wai-aria/checkings/tab-requires-tabpanel.js +3 -2
  121. package/lib/wai-aria/checkings/value.d.ts +1 -1
  122. package/lib/wai-aria/checkings/value.js +2 -2
  123. package/package.json +15 -15
package/CHANGELOG.md CHANGED
@@ -3,6 +3,54 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [5.4.0](https://github.com/markuplint/markuplint/compare/v5.3.0...v5.4.0) (2026-10-29)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **rules:** do not report descendant attributes that take part in a text alternative ([46002a7](https://github.com/markuplint/markuplint/commit/46002a75e06f877938d134ce0958a625e5d989d3)), closes [#4408](https://github.com/markuplint/markuplint/issues/4408)
11
+
12
+
13
+ # [5.3.0](https://github.com/markuplint/markuplint/compare/v5.2.0...v5.3.0) (2026-10-21)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **github:** retry unreachable spec pages before failing the check ([02c7d35](https://github.com/markuplint/markuplint/commit/02c7d353037f9cf0c04bb11aa3c5827c129a7231))
18
+ * make published packages loadable from require() and put types first ([723106f](https://github.com/markuplint/markuplint/commit/723106f51aac4e60dc02db2c1a894617368b0253)), closes [#4198](https://github.com/markuplint/markuplint/issues/4198)
19
+ * **rules:** accept a pretender that stands for a component in permitted-contents ([0efc2c9](https://github.com/markuplint/markuplint/commit/0efc2c9b2ef423a9b9e72128f3d6e2eb4ba4dd22))
20
+ * **rules:** apply a tag rule of permitted-contents to every spelling of a Vue component ([f3f96dc](https://github.com/markuplint/markuplint/commit/f3f96dcff5cbd514b3aeb55fc387297fa7af6f5a))
21
+ * **rules:** apply the conditional contents of a tag rule to the component of a pretender ([df96f58](https://github.com/markuplint/markuplint/commit/df96f58af9be0ee20ca04f3cc22d17a681ba674e)), closes [#4352](https://github.com/markuplint/markuplint/issues/4352)
22
+ * **rules:** check upper-case ARIA attributes as the DOM names them ([29eaf3b](https://github.com/markuplint/markuplint/commit/29eaf3bfbfd40dc74977f0940a83e6f90eb7ddc6))
23
+ * **rules:** compare attribute names as the DOM does in the rules that list attributes ([51faee6](https://github.com/markuplint/markuplint/commit/51faee6f13a8f09f136438873991f0581071012a))
24
+ * **rules:** do not count a self-referencing aria-labelledby as a naming source ([84efeb1](https://github.com/markuplint/markuplint/commit/84efeb136260bcf0dd7161b38d69a9fadccb7834)), closes [#4217](https://github.com/markuplint/markuplint/issues/4217)
25
+ * **rules:** do not report a placeholder option whose value is not known ([edfc526](https://github.com/markuplint/markuplint/commit/edfc5262a9e784ed80ec0d1a5000f7ed55b94583)), closes [#32](https://github.com/markuplint/markuplint/issues/32)
26
+ * **rules:** do not translate the suggested property name in no-unknown-attr ([bb22ae2](https://github.com/markuplint/markuplint/commit/bb22ae2e4a1ac5ef9b36e922892e8f608e363940))
27
+ * **rules:** find a compat key by the name the DOM gives it, in any spelling ([a5b286b](https://github.com/markuplint/markuplint/commit/a5b286b57afba6ef7a33073e650b983df1d8cc46))
28
+ * **rules:** fix case-sensitive attribute lookup, duplicate landmarks and raw text scan ([a98b850](https://github.com/markuplint/markuplint/commit/a98b850cd64f4a277e4fd65ff5b7bb20c170d38b)), closes [#4180](https://github.com/markuplint/markuplint/issues/4180)
29
+ * **rules:** follow the spec's definition of the placeholder label option ([4797eec](https://github.com/markuplint/markuplint/commit/4797eec2f2b7a45d3ffdb251f2401f977327725a))
30
+ * **rules:** judge loading and sizes of the img the same way in both directions ([9cdab16](https://github.com/markuplint/markuplint/commit/9cdab1618e923f6271dea95998c42fbe260848b5))
31
+ * **rules:** keep ranking the other sources of a self-referencing aria-labelledby element ([33563fb](https://github.com/markuplint/markuplint/commit/33563fbc76e46d26562e230bec9d378a8d564e1b)), closes [#4251](https://github.com/markuplint/markuplint/issues/4251) [#4251](https://github.com/markuplint/markuplint/issues/4251) [#4250](https://github.com/markuplint/markuplint/issues/4250) [#4217](https://github.com/markuplint/markuplint/issues/4217)
32
+ * **rules:** look attributes up in the spec as the DOM names them ([149d3fd](https://github.com/markuplint/markuplint/commit/149d3fd63b762ff334307977ec3059dc839ccbc3))
33
+ * **rules:** name the spelling in the case-sensitive-attr-name report on foreign elements ([b63ebf4](https://github.com/markuplint/markuplint/commit/b63ebf4c0de648428dbf72220dda321e594b8728))
34
+ * **rules:** order attributes and match ignoreFeatures keys as the DOM names them ([f955369](https://github.com/markuplint/markuplint/commit/f955369505bb3a7fc739c99cf8b32827a4e10e46))
35
+ * **rules:** recognize sizes="auto" the way the spec defines it ([91cfae3](https://github.com/markuplint/markuplint/commit/91cfae3ef8fccc7d7139a3c523d9f7f1bc99c386))
36
+ * **rules:** require the img after a source to allow auto-sizes, not only to load lazily ([9774217](https://github.com/markuplint/markuplint/commit/977421797466a13028f59228c8b40a521f3a6e82))
37
+ * **rules:** skip case-sensitive-attr-name for case-sensitive languages ([238f011](https://github.com/markuplint/markuplint/commit/238f011b8393644057a6258cc5444101f3d18deb)), closes [#4229](https://github.com/markuplint/markuplint/issues/4229)
38
+ * **rules:** skip class-naming when no pattern is configured ([7387c16](https://github.com/markuplint/markuplint/commit/7387c167dbb0a22e480924b556e1fae62635ffc4)), closes [#4202](https://github.com/markuplint/markuplint/issues/4202)
39
+ * **rules:** skip no-redundant-accessible-name for a self-referencing aria-labelledby with aria-label ([a46715f](https://github.com/markuplint/markuplint/commit/a46715f0c46283709274cbf16d5484089236dec1)), closes [#4217](https://github.com/markuplint/markuplint/issues/4217) [#4250](https://github.com/markuplint/markuplint/issues/4250) [#4217](https://github.com/markuplint/markuplint/issues/4217)
40
+ * **rules:** sort the props of a component in attr-order by the name as written ([a2d3b48](https://github.com/markuplint/markuplint/commit/a2d3b48c810b5800dbf718975d4758461656b588))
41
+ * **rules:** tell the namespace of a component's prop by the name as written ([157f434](https://github.com/markuplint/markuplint/commit/157f434b9dc4bb0f51c3fb77b219f38ef4035dd8))
42
+ * **selector:** match a prefixed attribute by its local name as well ([a9beb11](https://github.com/markuplint/markuplint/commit/a9beb112b87bf9ade372ede0fe0d7958d05027fc))
43
+ * supersede no-input-file-value with no-disallowed-attr ([2450fa1](https://github.com/markuplint/markuplint/commit/2450fa11c6cd0c32271ccfaed237fbd3b4b31953))
44
+
45
+ ### Features
46
+
47
+ * **github:** watch the spec passages that rules cite ([c0203d1](https://github.com/markuplint/markuplint/commit/c0203d14d9db3168d9720375c08edaeed2691aa0))
48
+
49
+ ### Performance Improvements
50
+
51
+ * **rules:** guard debug logging in permitted-contents and attribute checks ([db3281e](https://github.com/markuplint/markuplint/commit/db3281e048cdecfae6a51cb50b3bfb2f19a73e5c))
52
+
53
+
6
54
  # [5.2.0](https://github.com/markuplint/markuplint/compare/v5.1.1...v5.2.0) (2026-10-13)
7
55
 
8
56
  ### Bug Fixes
package/CLAUDE.md CHANGED
@@ -21,34 +21,25 @@ Repo-wide meta specs directly under `src/` (e.g. `mirrors-parse-error-codes.spec
21
21
 
22
22
  ### Categories
23
23
 
24
- | Category | When to use |
25
- | ------------ | --------------------------------------------------------- |
26
- | `valid` | Test expects 0 violations |
27
- | `invalid` | Test expects 1+ violations |
28
- | `fix` | Test checks `fixedCode` or auto-fix behavior |
29
- | `parser` | Test uses a non-default parser (Vue, React, Pug, etc.) |
30
- | `issue-NNNN` | Regression test for a GitHub issue (use the issue number) |
24
+ | Category | When to use |
25
+ | ------------ | ----------------------------------------------------------------- |
26
+ | `valid` | Test expects 0 violations |
27
+ | `invalid` | Test expects 1+ violations |
28
+ | `fix` | Test checks `fixedCode` or auto-fix behavior |
29
+ | `parser` | Test uses a non-default parser (Vue, React, Pug, etc.) |
30
+ | `debug` | Test checks only debug-logging behavior (not `valid` / `invalid`) |
31
+ | `issue-NNNN` | Regression test for a GitHub issue (use the issue number) |
31
32
 
32
33
  ### Rules
33
34
 
34
35
  - IDs go on `test()` blocks only (not `describe()`)
35
36
  - Numbers are sequential per category per file, starting at `001`
36
37
  - Multiple tests for the same issue: `[rule-name-issue-NNNN-001]`, `[rule-name-issue-NNNN-002]`
38
+ - The number is that of the issue whose behavior the test guards. A test added later for that behavior — by an issue that only asks for tests, or with a fix found while writing them — continues its numbers, next to its earlier tests
37
39
  - When adding a new test, assign the next available number in the appropriate category
40
+ - An existing test whose assertion was flipped keeps its ID, category included (`.claude/rules/spec-files.md`)
38
41
  - Run `/list-rule-test` (or `node .claude/skills/list-rule-test/scripts/list-rule-test.mjs --no-id`) to check for missing IDs — the only expected hits are the exempt meta specs noted above
39
42
 
40
- ## Sample identifiers in specs
41
-
42
- <!-- cspell:ignore myform mymap -->
43
-
44
- Sample `id` / `name` values in test markup must be kebab-case (`my-form`), suffixed with a digit
45
- (`form1`), or a plain dictionary word (`target`). Never a camelCase coinage (`myform`, `mymap`,
46
- `targetEl`): CSpell decomposes compound words only when every part is at least three characters,
47
- so a two-letter prefix glued to a word is looked up whole, misses the dictionary, and fails the
48
- lint — on the `dev` line the same one-line fix has been made in four separate PRs (#3826, #3831,
49
- #3833, #3932). The v6 line has no CSpell, so a coinage introduced there surfaces only once the
50
- branch merges somewhere that does.
51
-
52
43
  ## Writing a rule's README and `schema.json` description
53
44
 
54
45
  Both files feed the website build (`website/scripts/prebuild/rule-content.mts` injects the
@@ -72,6 +63,15 @@ turning the incorrect example into a copy of the correct one. Apply it to `READM
72
63
  The CI website check only runs when a PR already touches `website/`, so a new rule can land with a
73
64
  broken description and detonate in someone else's PR. Run `yarn site:build` before pushing.
74
65
 
66
+ ## Citing a specification
67
+
68
+ A README, a source comment, `meta.ts` and `schema.json` cite the passage that justifies a rule. `yarn spec-citations` checks every citation against the live specification (what it checks and why: the module comment of `scripts/spec-citations/cli.ts`), and `scripts/spec-citations/citations-policy.spec.ts` holds the conventions below offline. After editing a citation, run `yarn spec-citations` (read-only) and fix what it reports. Its lock file is refreshed by the scheduled workflow's PR, never by hand.
69
+
70
+ - Quote a spec sentence verbatim, as a blockquote in a README or as `>` lines in a source comment, and name its source: in a README the `Cite: [..](url)` line after the quote (`[..](url)より引用` in Japanese), in a comment an `@see` URL in the same comment. Never quote inline in double quotes: the monitor cannot check it. `[…]` marks what the quote leaves out.
71
+ - Cite a section by its fragment. A `§4.10.7` beside the link is compared with the section the fragment is in; the number of an ancestor section is accepted.
72
+ - `README.ja.md` cites the HTML Living Standard through its translation (`https://momdo.github.io/html/…`, the same page and anchor as the English README) and quotes the translation's own text. That text stays English where the translation is behind the original, and the monitor reports it once the translation catches up. For WCAG, the translation is waic.jp's.
73
+ - Cite W3C specs at an edition-less URL (`https://w3c.github.io/accname/`, `https://w3c.github.io/aria/`, `https://www.w3.org/TR/WCAG/`, `https://www.w3.org/TR/html-aria/`). WCAG's Techniques and Understanding pages have no edition-less URL; cite the latest edition (`https://www.w3.org/WAI/WCAG22/…`). Not `w3c.github.io/html-aria/`: that is unrendered ReSpec source whose anchors exist only after a browser builds them.
74
+
75
75
  ## Assertion Convention
76
76
 
77
77
  When asserting reported violations in rule specs, use `toStrictEqual` with the exact violation object `{ severity, line, col, message, raw }`. Never loose or partial matchers for violation objects. (Assertions on other values — counts, fixed code strings, registry invariants — use whatever matcher fits.)
@@ -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 { checkingNoGlobalProp } from '../wai-aria/checkings/no-global-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
  export default createRule({
7
8
  meta,
@@ -13,7 +14,7 @@ export default createRule({
13
14
  return;
14
15
  if (!elSpec.globalAttrs['#ARIAAttrs'])
15
16
  return;
16
- const propAttrs = el.attributes.filter(attr => /^aria-/i.test(attr.name));
17
+ const propAttrs = el.attributes.filter(attr => isAriaAttr(attr));
17
18
  if (propAttrs.length === 0)
18
19
  return;
19
20
  const ariaVersion = el.rule.options?.version ?? document.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
package/lib/attr-check.js CHANGED
@@ -22,7 +22,9 @@ export function attrCheck(t, name, value, isCustomRule, spec, allAttrNames) {
22
22
  }
23
23
  // Existence
24
24
  if (!spec) {
25
- log('The "%s" attribute DOES\'NT EXIST in the spec', name);
25
+ if (log.enabled) {
26
+ log('The "%s" attribute DOES\'NT EXIST in the spec', name);
27
+ }
26
28
  const baseMessage = t('{0} is {1:c}', t('the "{0*}" {1}', name, 'attribute'), 'disallowed');
27
29
  const candidate = allAttrNames ? getCandidate(name, allAttrNames) : undefined;
28
30
  if (candidate) {
@@ -36,16 +38,6 @@ export function attrCheck(t, name, value, isCustomRule, spec, allAttrNames) {
36
38
  message: baseMessage,
37
39
  };
38
40
  }
39
- const nameCaseSensitive = /[A-Z]/.test(spec.name);
40
- if (nameCaseSensitive && name !== spec.name) {
41
- log('The "%s" attribute name is unmatched in case-sensitive', name);
42
- return {
43
- invalidType: 'non-existent',
44
- message: t('{0} is {1:c}', t('the "{0*}" {1}', name, 'attribute'), 'disallowed') +
45
- t('. ') +
46
- t('Did you mean "{0*}"?', spec.name),
47
- };
48
- }
49
41
  if (spec.noUse) {
50
42
  return {
51
43
  invalidType: 'disallowed-attr',
@@ -55,7 +47,9 @@ export function attrCheck(t, name, value, isCustomRule, spec, allAttrNames) {
55
47
  // Safety net: In normal flow, `isValidAttr()` resolves ConditionalAttributeType[]
56
48
  // before calling this function (#3598). This guard handles direct calls.
57
49
  if (isConditionalAttributeTypeArray(spec.type)) {
58
- log('The "%s" attribute uses ConditionalAttributeType[] — unresolved, treating as valid', name);
50
+ if (log.enabled) {
51
+ log('The "%s" attribute uses ConditionalAttributeType[] — unresolved, treating as valid', name);
52
+ }
59
53
  return false;
60
54
  }
61
55
  const types = toNonNullableArrayFromItemOrArray(spec.type);
@@ -1,11 +1,11 @@
1
1
  import type { Translator } from '@markuplint/i18n';
2
2
  import type { PlainData } from '@markuplint/ml-config';
3
- import type { Element, RuleConfigValue } from '@markuplint/ml-core';
3
+ import type { Attr, Element, RuleConfigValue } from '@markuplint/ml-core';
4
4
  import type { Attribute } from '@markuplint/ml-spec';
5
5
  /**
6
6
  * Result of resolving whether an attribute name is known to the spec and,
7
7
  * if so, whether it currently applies. Shared by `no-unknown-attr` (acts on
8
- * `unknown`/`case-mismatch`), `no-disallowed-attr` (acts on `no-use`/
8
+ * `unknown`), `no-disallowed-attr` (acts on `no-use`/
9
9
  * `condition-not-met`), and `no-invalid-attr-value` (acts on `ok`, using the
10
10
  * resolved `spec.type` for value-checking). Splitting this into one function
11
11
  * keeps the three rules' notion of "what the spec says about this name" in
@@ -16,9 +16,6 @@ export type AttrEligibility = {
16
16
  } | {
17
17
  readonly status: 'unknown';
18
18
  readonly candidate: string | undefined;
19
- } | {
20
- readonly status: 'case-mismatch';
21
- readonly expectedName: string;
22
19
  } | {
23
20
  readonly status: 'no-use';
24
21
  } | {
@@ -30,10 +27,20 @@ export type AttrEligibility = {
30
27
  /**
31
28
  * Resolves an attribute name against the element's `attrSpecs`, following
32
29
  * the same order the pre-split `invalid-attr` rule used: existence, then
33
- * case-sensitivity, then `noUse`, then `condition` (with
34
- * `ConditionalAttributeType[]` resolved to a concrete type first, per #3598).
30
+ * `noUse`, then `condition` (with `ConditionalAttributeType[]` resolved to a
31
+ * concrete type first).
32
+ *
33
+ * Whether a spelling is the name of a spec is up to the attribute, and the DOM decides it
34
+ * (`MLAttr#isNamedBy`): `viewbox` is `viewBox` where the HTML parser reads it, and is not where
35
+ * code sets the name. Ask with the attribute (`attr`); without it, `name` is a name of the spec
36
+ * and is looked up as written, which is what a rule does for the native attribute of an ARIA one.
37
+ *
38
+ * @param name - The attribute name (a name of the spec when `attr` is not given)
39
+ * @param node - The element
40
+ * @param attrSpecs - The specs of the attributes of the element
41
+ * @param attr - The attribute that is being resolved
35
42
  */
36
- export declare function resolveAttrEligibility<T extends RuleConfigValue, O extends PlainData>(name: string, node: Element<T, O>, attrSpecs: readonly Attribute[]): AttrEligibility;
43
+ export declare function resolveAttrEligibility<T extends RuleConfigValue, O extends PlainData>(name: string, node: Element<T, O>, attrSpecs: readonly Attribute[], attr?: Attr<T, O>): AttrEligibility;
37
44
  /**
38
45
  * Formats the "did you mean" suffix shared by `no-unknown-attr`'s two typo
39
46
  * paths (spec-driven candidate, case-sensitivity mismatch).
@@ -1,6 +1,6 @@
1
1
  import { isConditionalAttributeTypeArray } from '@markuplint/ml-spec';
2
2
  import { getCandidate } from '@markuplint/types';
3
- import { attrMatches, conditionDependsOnDynamicAttr } from './helpers.js';
3
+ import { attrMatches, conditionDependsOnDynamicAttr, findAttrSpec, namespaceKeyOf } from './helpers.js';
4
4
  /**
5
5
  * Attribute namespaces that are exempt from spec-based name/eligibility
6
6
  * checking regardless of what `attrSpecs` says: `data-*` and `adapt-*` are
@@ -11,24 +11,33 @@ const EXEMPT_NAME_PATTERN = /^(?:data-.+|adapt-.+|aria-.+|role)$/;
11
11
  /**
12
12
  * Resolves an attribute name against the element's `attrSpecs`, following
13
13
  * the same order the pre-split `invalid-attr` rule used: existence, then
14
- * case-sensitivity, then `noUse`, then `condition` (with
15
- * `ConditionalAttributeType[]` resolved to a concrete type first, per #3598).
14
+ * `noUse`, then `condition` (with `ConditionalAttributeType[]` resolved to a
15
+ * concrete type first).
16
+ *
17
+ * Whether a spelling is the name of a spec is up to the attribute, and the DOM decides it
18
+ * (`MLAttr#isNamedBy`): `viewbox` is `viewBox` where the HTML parser reads it, and is not where
19
+ * code sets the name. Ask with the attribute (`attr`); without it, `name` is a name of the spec
20
+ * and is looked up as written, which is what a rule does for the native attribute of an ARIA one.
21
+ *
22
+ * @param name - The attribute name (a name of the spec when `attr` is not given)
23
+ * @param node - The element
24
+ * @param attrSpecs - The specs of the attributes of the element
25
+ * @param attr - The attribute that is being resolved
16
26
  */
17
27
  export function resolveAttrEligibility(name,
18
28
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
19
- node, attrSpecs) {
20
- if (EXEMPT_NAME_PATTERN.test(name)) {
29
+ node, attrSpecs,
30
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
31
+ attr) {
32
+ // The name that the DOM gives the attribute is what a pattern of a namespace is tested against
33
+ if (EXEMPT_NAME_PATTERN.test(attr ? namespaceKeyOf(attr) : name)) {
21
34
  return { status: 'exempt' };
22
35
  }
23
- let spec = attrSpecs.find(s => s.name.toLowerCase() === name.toLowerCase());
36
+ let spec = attr ? findAttrSpec(attrSpecs, attr) : attrSpecs.find(s => s.name === name);
24
37
  if (!spec) {
25
38
  const candidate = getCandidate(name, attrSpecs.map(s => s.name));
26
39
  return { status: 'unknown', candidate };
27
40
  }
28
- const nameCaseSensitive = /[A-Z]/.test(spec.name);
29
- if (nameCaseSensitive && name !== spec.name) {
30
- return { status: 'case-mismatch', expectedName: spec.name };
31
- }
32
41
  if (spec.noUse) {
33
42
  return { status: 'no-use' };
34
43
  }
@@ -1,4 +1,5 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
+ import { namespaceKeyOf } from '../helpers.js';
2
3
  import meta from './meta.js';
3
4
  function compareStrings(a, b) {
4
5
  if (a < b) {
@@ -57,15 +58,16 @@ eventAttrs) {
57
58
  return {
58
59
  entryIndex,
59
60
  order: 'alphabetical',
60
- match: (name) => name === entry,
61
+ match: (attr) => attr.isNamedBy(entry),
61
62
  };
62
63
  }
63
64
  const order = entry.order ?? 'alphabetical';
64
65
  if (entry.name != null) {
66
+ const entryName = entry.name;
65
67
  return {
66
68
  entryIndex,
67
69
  order,
68
- match: (name) => name === entry.name,
70
+ match: (attr) => attr.isNamedBy(entryName),
69
71
  };
70
72
  }
71
73
  if (entry.pattern != null) {
@@ -84,7 +86,8 @@ eventAttrs) {
84
86
  return {
85
87
  entryIndex,
86
88
  order,
87
- match: (name) => re.test(name),
89
+ // The name as written: a pattern takes no flags, so it is not folded
90
+ match: (attr) => re.test(attr.name),
88
91
  };
89
92
  }
90
93
  if (entry.group != null) {
@@ -95,42 +98,42 @@ eventAttrs) {
95
98
  return {
96
99
  entryIndex,
97
100
  order: groupOrder,
98
- match: (name) => globalAttrs.has(name),
101
+ match: (attr) => globalAttrs.has(namespaceKeyOf(attr)),
99
102
  };
100
103
  }
101
104
  case 'event': {
102
105
  return {
103
106
  entryIndex,
104
107
  order: groupOrder,
105
- match: (name) => eventAttrs.has(name),
108
+ match: (attr) => eventAttrs.has(namespaceKeyOf(attr)),
106
109
  };
107
110
  }
108
111
  case 'aria': {
109
112
  return {
110
113
  entryIndex,
111
114
  order: groupOrder,
112
- match: (name) => /^aria-.+$/.test(name),
115
+ match: (attr) => /^aria-.+$/.test(namespaceKeyOf(attr)),
113
116
  };
114
117
  }
115
118
  case 'role': {
116
119
  return {
117
120
  entryIndex,
118
121
  order: groupOrder,
119
- match: (name) => name === 'role',
122
+ match: (attr) => namespaceKeyOf(attr) === 'role',
120
123
  };
121
124
  }
122
125
  case 'data': {
123
126
  return {
124
127
  entryIndex,
125
128
  order: groupOrder,
126
- match: (name) => /^data-.+$/.test(name),
129
+ match: (attr) => /^data-.+$/.test(namespaceKeyOf(attr)),
127
130
  };
128
131
  }
129
132
  case 'spread': {
130
133
  return {
131
134
  entryIndex,
132
135
  order: groupOrder,
133
- match: (_name, isSpread) => isSpread,
136
+ match: (_attr, isSpread) => isSpread,
134
137
  };
135
138
  }
136
139
  }
@@ -144,9 +147,9 @@ eventAttrs) {
144
147
  }
145
148
  function matchEntry(
146
149
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
147
- compiledEntries, name, isSpread, fallbackIndex) {
150
+ compiledEntries, attr, isSpread, fallbackIndex) {
148
151
  for (const entry of compiledEntries) {
149
- if (entry.match(name, isSpread)) {
152
+ if (entry.match(attr, isSpread)) {
150
153
  return { entryIndex: entry.entryIndex, order: entry.order };
151
154
  }
152
155
  }
@@ -155,21 +158,21 @@ compiledEntries, name, isSpread, fallbackIndex) {
155
158
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
156
159
  function compareWithinGroup(a, b, order) {
157
160
  if (order === 'alphabetical') {
158
- return compareStrings(a.name, b.name);
161
+ return compareStrings(namespaceKeyOf(a.attr), namespaceKeyOf(b.attr));
159
162
  }
160
163
  if (order === 'source-order') {
161
164
  return a.index - b.index;
162
165
  }
163
166
  if (Array.isArray(order)) {
164
- const ai = order.indexOf(a.name);
165
- const bi = order.indexOf(b.name);
167
+ const ai = order.findIndex(name => a.attr.isNamedBy(name));
168
+ const bi = order.findIndex(name => b.attr.isNamedBy(name));
166
169
  const aIdx = ai === -1 ? order.length : ai;
167
170
  const bIdx = bi === -1 ? order.length : bi;
168
171
  if (aIdx !== bIdx) {
169
172
  return aIdx - bIdx;
170
173
  }
171
174
  if (ai === -1 && bi === -1) {
172
- return compareStrings(a.name, b.name);
175
+ return compareStrings(namespaceKeyOf(a.attr), namespaceKeyOf(b.attr));
173
176
  }
174
177
  return 0;
175
178
  }
@@ -180,7 +183,7 @@ function sortAttrs(attrInfos,
180
183
  compiledEntries, alphabetical) {
181
184
  const fallbackIndex = compiledEntries.length;
182
185
  const tagged = attrInfos.map(info => {
183
- const { entryIndex, order } = matchEntry(compiledEntries, info.name, info.isSpread, fallbackIndex);
186
+ const { entryIndex, order } = matchEntry(compiledEntries, info.attr, info.isSpread, fallbackIndex);
184
187
  return { info, entryIndex, order };
185
188
  });
186
189
  return tagged
@@ -190,7 +193,9 @@ compiledEntries, alphabetical) {
190
193
  }
191
194
  const isFallback = a.entryIndex === fallbackIndex;
192
195
  if (isFallback) {
193
- return alphabetical ? compareStrings(a.info.name, b.info.name) : a.info.index - b.info.index;
196
+ return alphabetical
197
+ ? compareStrings(namespaceKeyOf(a.info.attr), namespaceKeyOf(b.info.attr))
198
+ : a.info.index - b.info.index;
194
199
  }
195
200
  return compareWithinGroup(a.info, b.info, a.order);
196
201
  })
@@ -246,6 +251,7 @@ export default createRule({
246
251
  const attrInfos = attrs.map((attr, i) => {
247
252
  const nameNode = attr.nameNode;
248
253
  return {
254
+ attr,
249
255
  name: attr.name,
250
256
  index: i,
251
257
  isSpread: nameNode == null,
@@ -257,7 +263,7 @@ export default createRule({
257
263
  });
258
264
  const sorted = isAlphabeticalOnly
259
265
  ? alphabetical
260
- ? [...attrInfos].toSorted((a, b) => compareStrings(a.name, b.name))
266
+ ? [...attrInfos].toSorted((a, b) => compareStrings(namespaceKeyOf(a.attr), namespaceKeyOf(b.attr)))
261
267
  : attrInfos
262
268
  : sortAttrs(attrInfos, compiledEntries, alphabetical);
263
269
  // Find first violation
@@ -1,13 +1,19 @@
1
1
  import { createRule, getAttrSpecs } from '@markuplint/ml-core';
2
+ import { findAttrSpec } from '../helpers.js';
2
3
  import meta from './meta.js';
3
4
  export default createRule({
4
5
  meta: meta,
5
6
  defaultSeverity: 'warning',
6
7
  defaultValue: 'lower',
7
8
  async verify({ document, report, t }) {
9
+ // JSX props (`onClick`, `suppressHydrationWarning`) are JavaScript identifiers,
10
+ // not case-insensitive HTML attribute names; lowercasing them breaks the app.
11
+ if (document.attrNameCaseSensitive) {
12
+ return;
13
+ }
8
14
  await document.walkOn('Attr', attr => {
9
15
  const el = attr.ownerElement;
10
- if (el.isForeignElement || el.elementType !== 'html') {
16
+ if (el.elementType !== 'html') {
11
17
  return;
12
18
  }
13
19
  const value = attr.rule.value;
@@ -24,11 +30,24 @@ export default createRule({
24
30
  return;
25
31
  }
26
32
  const name = attr.name;
27
- if (attrSpecs) {
28
- const spec = attrSpecs.find(spec => spec.name === name);
29
- if (spec && spec.caseSensitive) {
30
- return;
33
+ const spec = attrSpecs ? findAttrSpec(attrSpecs, attr) : undefined;
34
+ if (el.isForeignElement) {
35
+ // What the browser has for the name is the spelling of the spec, whatever the option says.
36
+ if (spec && spec.name !== name) {
37
+ const nameNode = attr.nameNode;
38
+ report({
39
+ scope: attr,
40
+ line: nameNode?.startLine,
41
+ col: nameNode?.startCol,
42
+ raw: nameNode?.raw,
43
+ message: t(`{0} ${ms} be {1}`, t('the "{0*}" {1}', name, 'attribute'), `"${spec.name}"`),
44
+ fix: nameNode ? fixer => fixer.replaceText(nameNode, spec.name) : undefined, // eslint-disable-line @typescript-eslint/strict-boolean-expressions
45
+ });
31
46
  }
47
+ return;
48
+ }
49
+ if (spec?.caseSensitive) {
50
+ return;
32
51
  }
33
52
  if (deny.test(name)) {
34
53
  const nameNode = attr.nameNode;
@@ -9,6 +9,12 @@ export default createRule({
9
9
  async verify({ document, report, t }) {
10
10
  await document.walkOn('Element', el => {
11
11
  const classPatterns = toNoEmptyStringArrayFromStringOrArray(el.rule.value).filter(className => !!className && typeof className === 'string');
12
+ // No pattern means the rule is disabled for this element (`null` is
13
+ // documented as "disable"); without this every class name would be
14
+ // reported against an empty pattern list. See #4202.
15
+ if (classPatterns.length === 0) {
16
+ return;
17
+ }
12
18
  const attrs = el.getAttributeToken('class');
13
19
  for (const attr of attrs) {
14
20
  if (attr.isDynamicValue) {
package/lib/debug.d.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  import debug from 'debug';
2
2
  export type Log = debug.Debugger;
3
- /** Enable by setting `DEBUG` to include `ml-rules` (e.g. `DEBUG=ml-rules`). */
3
+ /**
4
+ * Enable by setting `DEBUG` to include `ml-rules` (e.g. `DEBUG=ml-rules`).
5
+ *
6
+ * Read `.enabled` at call time; never cache it at module load. `debug.enable()` (including via `verbosely()` /
7
+ * `enableDebug()`) can switch a namespace on after import. The `permitted-contents` functions read it once on entry to
8
+ * decide whether to build log arguments. Enabling debug while one runs is not seen until the next call; disabling
9
+ * takes effect at once because the logger itself re-checks.
10
+ */
4
11
  export declare const log: debug.Debugger;
package/lib/debug.js CHANGED
@@ -1,3 +1,10 @@
1
1
  import debug from 'debug';
2
- /** Enable by setting `DEBUG` to include `ml-rules` (e.g. `DEBUG=ml-rules`). */
2
+ /**
3
+ * Enable by setting `DEBUG` to include `ml-rules` (e.g. `DEBUG=ml-rules`).
4
+ *
5
+ * Read `.enabled` at call time; never cache it at module load. `debug.enable()` (including via `verbosely()` /
6
+ * `enableDebug()`) can switch a namespace on after import. The `permitted-contents` functions read it once on entry to
7
+ * decide whether to build log arguments. Enabling debug while one runs is not seen until the next call; disabling
8
+ * takes effect at once because the logger itself re-checks.
9
+ */
3
10
  export const log = debug('ml-rules');
@@ -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 { checkingElementSupportsAriaProp } from '../wai-aria/checkings/element-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 ARIA in
@@ -24,7 +25,7 @@ export default createRule({
24
25
  return;
25
26
  if (!elSpec.globalAttrs['#ARIAAttrs'])
26
27
  return;
27
- const propAttrs = el.attributes.filter(attr => /^aria-/i.test(attr.name));
28
+ const propAttrs = el.attributes.filter(attr => isAriaAttr(attr));
28
29
  if (propAttrs.length === 0)
29
30
  return;
30
31
  const ariaVersion = el.rule.options?.version ?? document.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Require the `form` attribute on a form-associated element to reference
3
- * an actual `<form>` element by ID. Per HTML LS §4.10.18.6: "If
4
- * specified, the value must be the ID of a form element in the
5
- * element's tree." Existence of the ID itself is the responsibility of
3
+ * an actual `<form>` element by ID. Per HTML LS §4.10.18.3:
4
+ *
5
+ * > If a listed form-associated element has a form attribute specified, then that attribute's value must be the ID of a
6
+ * > form element in the element's tree.
7
+ *
8
+ * Existence of the ID itself is the responsibility of
6
9
  * `no-refer-to-non-existent-id`; this rule only checks the element type
7
10
  * when the ID resolves.
8
11
  *
@@ -21,9 +21,12 @@ const FORM_ASSOCIATED_SELECTOR = [
21
21
  ].join(',');
22
22
  /**
23
23
  * Require the `form` attribute on a form-associated element to reference
24
- * an actual `<form>` element by ID. Per HTML LS §4.10.18.6: "If
25
- * specified, the value must be the ID of a form element in the
26
- * element's tree." Existence of the ID itself is the responsibility of
24
+ * an actual `<form>` element by ID. Per HTML LS §4.10.18.3:
25
+ *
26
+ * > If a listed form-associated element has a form attribute specified, then that attribute's value must be the ID of a
27
+ * > form element in the element's tree.
28
+ *
29
+ * Existence of the ID itself is the responsibility of
27
30
  * `no-refer-to-non-existent-id`; this rule only checks the element type
28
31
  * when the ID resolves.
29
32
  *