@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
@@ -1,6 +1,7 @@
1
1
  import { createRule, getAttrSpecs, ariaSpecs } from '@markuplint/ml-core';
2
2
  import { ARIA_RECOMMENDED_VERSION } from '@markuplint/ml-spec';
3
3
  import { decodeEntities } from '@markuplint/shared';
4
+ import { findAttrSpec } from '../helpers.js';
4
5
  import meta from './meta.js';
5
6
  export default createRule({
6
7
  meta: meta,
@@ -37,12 +38,11 @@ export default createRule({
37
38
  if (attr.isDynamicValue) {
38
39
  return;
39
40
  }
40
- const name = attr.name;
41
- if (name.toLowerCase() === 'id') {
41
+ if (attr.nameKey === 'id') {
42
42
  return;
43
43
  }
44
44
  const value = attr.value;
45
- const spec = attrSpec.find(s => s.name === name);
45
+ const spec = findAttrSpec(attrSpec, attr);
46
46
  if (spec) {
47
47
  // DOMID and DOMID List do not become in the array type.
48
48
  if (Array.isArray(spec.type)) {
@@ -76,7 +76,7 @@ export default createRule({
76
76
  }
77
77
  }
78
78
  const { props } = ariaSpecs(document.specs, attr.rule.options.ariaVersion ?? document.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION);
79
- const aria = props.find(prop => prop.name === name);
79
+ const aria = props.find(prop => prop.name === attr.nameKey);
80
80
  if (aria) {
81
81
  if (aria.value === 'ID reference' && !idList.has(value)) {
82
82
  report({
@@ -10,6 +10,10 @@ type Option = {
10
10
  * already restricts. This is the rule's only concern — narrow, purely
11
11
  * user-defined denylisting, independent of spec conformance.
12
12
  *
13
+ * A name is compared as the DOM compares attribute names, so it is ASCII case-insensitive in the
14
+ * HTML syntax and for an HTML element that code sets through the DOM API (JSX, MDX, Vue), exact for an
15
+ * SVG/MathML element there, and a component's props are not folded.
16
+ *
13
17
  * @since 3.7.0
14
18
  */
15
19
  disallowAttrs?: (string | Attr)[] | Record<string, AttributeType | Pattern>;
@@ -1,32 +1,15 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
2
  import { check, isEnum, isPattern } from '@markuplint/types';
3
3
  import { attrCheck } from '../attr-check.js';
4
+ import { createNameOptionLookup } from '../helpers.js';
4
5
  import meta from './meta.js';
5
- function disallowedTypes(disallowAttrs) {
6
- const map = new Map();
7
- if (!disallowAttrs) {
8
- return map;
9
- }
10
- if (Array.isArray(disallowAttrs)) {
11
- for (const disallowAttr of disallowAttrs) {
12
- if (typeof disallowAttr === 'string') {
13
- map.set(disallowAttr, 'Any');
14
- continue;
15
- }
16
- map.set(disallowAttr.name, disallowAttr.value);
17
- }
18
- }
19
- else {
20
- for (const [name, type] of Object.entries(disallowAttrs)) {
21
- map.set(name, type);
22
- }
23
- }
24
- return map;
25
- }
26
6
  export default createRule({
27
7
  meta: meta,
28
8
  defaultOptions: {},
29
9
  async verify({ document, report, t }) {
10
+ // Whether an entry of `disallowAttrs` is the attribute is up to the attribute: the DOM decides
11
+ // whether two spellings are one name (`MLAttr#isNamedBy`).
12
+ const lookupDisallowed = createNameOptionLookup('Any');
30
13
  await document.walkOn('Attr', attr => {
31
14
  if (attr.isDirective) {
32
15
  return;
@@ -35,7 +18,7 @@ export default createRule({
35
18
  const value = attr.value;
36
19
  const valueNode = attr.valueNode;
37
20
  const attrName = attr.nameNode;
38
- const disallowValue = disallowedTypes(attr.rule.options.disallowAttrs).get(name);
21
+ const disallowValue = lookupDisallowed(attr.rule.options.disallowAttrs, attr);
39
22
  if (disallowValue === undefined) {
40
23
  return;
41
24
  }
@@ -4,13 +4,15 @@
4
4
  * > A start tag whose tag name is "body": Parse error. If the stack of open
5
5
  * > elements has only one node on it, or if the second element on the stack
6
6
  * > of open elements is not a body element, or if there is a template
7
- * > element on the stack of open elements, then ignore the token […].
8
- * > Otherwise, set the frameset-ok flag to "not ok" […]
7
+ * > element on the stack of open elements, then ignore the token. […]
8
+ * > Otherwise: Set the frameset-ok flag to "not ok" […]
9
9
  *
10
10
  * "in body" insertion mode, "Any other end tag":
11
11
  *
12
- * > Initialize node to be the current node […]. If node's tag name […] is
13
- * > not the same as the tag name of the token, then this is a parse error.
12
+ * > Initialize node to be the current node (the bottommost node of the
13
+ * > stack). Loop: If node is an HTML element with the same tag name as the
14
+ * > token: […] Otherwise, if node is in the special category, then this is a
15
+ * > parse error; ignore the token, and return.
14
16
  *
15
17
  * Neither of these fires by the presence of disallowed `<head>` content
16
18
  * itself (the "in head" insertion mode's own "Anything else" clause that
@@ -5,9 +5,12 @@ import meta from './meta.js';
5
5
  * "generic raw text element parsing algorithm" / "generic RCDATA element
6
6
  * parsing algorithm"): the parser never re-tokenizes tag-shaped substrings
7
7
  * inside it, so a literal `<body` or `</head` there is ordinary text, not a
8
- * candidate for either check below.
8
+ * candidate for either check below. `iframe`, `noembed` and `noframes` are
9
+ * RAWTEXT too but `@markuplint/parser-utils` still tokenizes their content
10
+ * as markup (`permitted-contents` relies on that), so they are skipped here
11
+ * rather than in the parser (#4180).
9
12
  */
10
- const RAW_TEXT_CONTAINERS = new Set(['script', 'style', 'title', 'textarea', 'xmp']);
13
+ const RAW_TEXT_CONTAINERS = new Set(['script', 'style', 'title', 'textarea', 'xmp', 'iframe', 'noembed', 'noframes']);
11
14
  const STRAY_HEAD_END_TAG = /<\/head(?=[\s/>])/i;
12
15
  const DUPLICATE_BODY_START_TAG = /<body(?=[\s/>])/i;
13
16
  /**
@@ -16,13 +19,15 @@ const DUPLICATE_BODY_START_TAG = /<body(?=[\s/>])/i;
16
19
  * > A start tag whose tag name is "body": Parse error. If the stack of open
17
20
  * > elements has only one node on it, or if the second element on the stack
18
21
  * > of open elements is not a body element, or if there is a template
19
- * > element on the stack of open elements, then ignore the token […].
20
- * > Otherwise, set the frameset-ok flag to "not ok" […]
22
+ * > element on the stack of open elements, then ignore the token. […]
23
+ * > Otherwise: Set the frameset-ok flag to "not ok" […]
21
24
  *
22
25
  * "in body" insertion mode, "Any other end tag":
23
26
  *
24
- * > Initialize node to be the current node […]. If node's tag name […] is
25
- * > not the same as the tag name of the token, then this is a parse error.
27
+ * > Initialize node to be the current node (the bottommost node of the
28
+ * > stack). Loop: If node is an HTML element with the same tag name as the
29
+ * > token: […] Otherwise, if node is in the special category, then this is a
30
+ * > parse error; ignore the token, and return.
26
31
  *
27
32
  * Neither of these fires by the presence of disallowed `<head>` content
28
33
  * itself (the "in head" insertion mode's own "Anything else" clause that
@@ -9,10 +9,16 @@ type Option = {
9
9
  * The value type is used by `no-invalid-attr-value` to validate the
10
10
  * attribute's value; here it only determines whether the name is known.
11
11
  *
12
+ * A name is compared as the DOM compares attribute names.
13
+ *
14
+ * @see https://markuplint.dev/docs/rules/no-unknown-attr#attribute-name-case
12
15
  * @since 3.7.0
13
16
  */
14
17
  allowAttrs?: (string | Attr)[] | Record<string, AttributeType | Pattern>;
15
- /** Attribute name prefix(es) to ignore during validation. */
18
+ /**
19
+ * Attribute name prefix(es) to ignore during validation, compared as the DOM compares attribute
20
+ * names, like `allowAttrs`.
21
+ */
16
22
  ignoreAttrNamePrefix?: string | string[];
17
23
  /** Whether to allow additional properties for pretender elements (defaults to `true`). */
18
24
  allowToAddPropertiesForPretender?: boolean;
@@ -1,19 +1,15 @@
1
1
  import { createRule, getAttrSpecs, getSpec } from '@markuplint/ml-core';
2
2
  import { resolveAttrEligibility, suggestionSuffix } from '../attr-eligibility.js';
3
+ import { createNameOptionLookup, createPrefixMatcher } from '../helpers.js';
3
4
  import meta from './meta.js';
4
- function allowedNames(allowAttrs) {
5
- if (!allowAttrs) {
6
- return new Set();
7
- }
8
- if (Array.isArray(allowAttrs)) {
9
- return new Set(allowAttrs.map(a => (typeof a === 'string' ? a : a.name)));
10
- }
11
- return new Set(Object.keys(allowAttrs));
12
- }
13
5
  export default createRule({
14
6
  meta: meta,
15
7
  defaultOptions: {},
16
8
  async verify({ document, report, t }) {
9
+ // Whether an entry of the options is the attribute is up to the attribute: the DOM decides
10
+ // whether two spellings are one name (`MLAttr#isNamedBy`, `MLAttr#isPrefixedBy`).
11
+ const lookupAllowed = createNameOptionLookup('Any');
12
+ const hasIgnoredPrefix = createPrefixMatcher();
17
13
  await document.walkOn('Attr', attr => {
18
14
  if (attr.isDirective) {
19
15
  return;
@@ -31,19 +27,15 @@ export default createRule({
31
27
  raw: attrName?.raw,
32
28
  message: t('{0} is {1:c}', t('the "{0*}" {1}', name, 'attribute'), 'disallowed') +
33
29
  t('. ') +
34
- t('Did you mean "{0}"?', attr.candidate),
30
+ t('Did you mean "{0*}"?', attr.candidate),
35
31
  });
36
32
  return;
37
33
  }
38
- if (attr.rule.options.ignoreAttrNamePrefix != null) {
39
- const prefixes = Array.isArray(attr.rule.options.ignoreAttrNamePrefix)
40
- ? attr.rule.options.ignoreAttrNamePrefix
41
- : [attr.rule.options.ignoreAttrNamePrefix];
42
- if (prefixes.some(prefix => name.indexOf(prefix) === 0)) {
43
- return;
44
- }
34
+ if (hasIgnoredPrefix(attr.rule.options.ignoreAttrNamePrefix, attr)) {
35
+ return;
45
36
  }
46
- if (allowedNames(attr.rule.options.allowAttrs).has(name)) {
37
+ // A name listed in `allowAttrs` is allowed whatever its value
38
+ if (lookupAllowed(attr.rule.options.allowAttrs, attr) !== undefined) {
47
39
  return;
48
40
  }
49
41
  if (attr.ownerElement.elementType !== 'html') {
@@ -53,7 +45,7 @@ export default createRule({
53
45
  if (!attrSpecs) {
54
46
  return;
55
47
  }
56
- const eligibility = resolveAttrEligibility(name, attr.ownerElement, attrSpecs);
48
+ const eligibility = resolveAttrEligibility(name, attr.ownerElement, attrSpecs, attr);
57
49
  let message;
58
50
  switch (eligibility.status) {
59
51
  case 'unknown': {
@@ -62,12 +54,6 @@ export default createRule({
62
54
  suggestionSuffix(t, eligibility.candidate);
63
55
  break;
64
56
  }
65
- case 'case-mismatch': {
66
- message =
67
- t('{0} is {1:c}', t('the "{0*}" {1}', name, 'attribute'), 'disallowed') +
68
- suggestionSuffix(t, eligibility.expectedName);
69
- break;
70
- }
71
57
  default: {
72
58
  return;
73
59
  }
@@ -5,8 +5,9 @@
5
5
  * one constraint.
6
6
  *
7
7
  * `<source>`'s direction (Check 5) is conditional: `sizes` "may" be omitted
8
- * when the following sibling `<img>` has `loading="lazy"` (auto-sizes
9
- * support). `<img>`'s direction (Check 1) has no such escape.
8
+ * when the following sibling `<img>` allows auto-sizes (`loading="lazy"` and
9
+ * `sizes="auto"`; see `allowsAutoSizes`). `<img>`'s direction (Check 1) has no
10
+ * such escape.
10
11
  */
11
12
  declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<boolean, undefined>>;
12
13
  export default _default;
@@ -1,6 +1,6 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
2
  import { parseSrcset } from '../srcset-sizes-constraint/parse-srcset.js';
3
- import { findFollowingImg } from '../srcset-sizes-constraint/sibling-helpers.js';
3
+ import { allowsAutoSizes, findFollowingImg, isLazyLoading } from '../srcset-sizes-constraint/sibling-helpers.js';
4
4
  import meta from './meta.js';
5
5
  /**
6
6
  * HTML LS § img/source `srcset` attributes: `sizes` and a width-descriptor
@@ -9,8 +9,9 @@ import meta from './meta.js';
9
9
  * one constraint.
10
10
  *
11
11
  * `<source>`'s direction (Check 5) is conditional: `sizes` "may" be omitted
12
- * when the following sibling `<img>` has `loading="lazy"` (auto-sizes
13
- * support). `<img>`'s direction (Check 1) has no such escape.
12
+ * when the following sibling `<img>` allows auto-sizes (`loading="lazy"` and
13
+ * `sizes="auto"`; see `allowsAutoSizes`). `<img>`'s direction (Check 1) has no
14
+ * such escape.
14
15
  */
15
16
  export default createRule({
16
17
  meta,
@@ -50,25 +51,30 @@ export default createRule({
50
51
  }
51
52
  // img / source-in-picture with w descriptors → sizes required.
52
53
  //
53
- // HTML LS § img srcset: unconditional — "the sizes attribute must
54
- // also be present" when any candidate uses a width descriptor.
54
+ // HTML LS § img srcset: unconditional — the sizes attribute must
55
+ // also be present when any candidate uses a width descriptor.
55
56
  //
56
- // HTML LS § source: conditional — sizes "may" be present with w
57
- // descriptors, but the following sibling img must support
58
- // auto-sizes for it to be omittable. img supports auto-sizes when
59
- // loading=lazy (the dimensionsAttribute condition is also part of
60
- // the spec but the lazy attribute is the user-facing trigger).
57
+ // HTML LS § source: conditional — sizes may be omitted only when
58
+ // the following sibling img allows auto-sizes (loading=lazy and
59
+ // sizes=auto; the same condition is the second source of truth for
60
+ // `sizes-auto-requires-lazy-loading`).
61
+ //
62
+ // @see https://html.spec.whatwg.org/multipage/embedded-content.html#allows-auto-sizes
61
63
  if (parsed && parsed.hasWidth && !sizesAttr) {
62
64
  // For `<source>`, `findFollowingImg` may return null (malformed
63
- // <picture> with no img). Optional chaining short-circuits to
64
- // undefined, and `undefined === 'lazy'` is false → sizes is
65
- // required. That's the correct fail-closed behaviour: an absent
66
- // img cannot supply auto-sizes for the source.
67
- const siblingImgLazy = localName === 'source' && findFollowingImg(el)?.getAttribute('loading') === 'lazy';
68
- if (!siblingImgLazy) {
65
+ // <picture> with no img): an absent img cannot supply
66
+ // auto-sizes, so sizes is required (fail-closed). A dynamic
67
+ // `loading` / `sizes` on the img is not known → not reported.
68
+ const img = localName === 'source' ? findFollowingImg(el) : null;
69
+ if (!img || allowsAutoSizes(img) === false) {
70
+ // A lazy img that is not auto-sized is the case a reader
71
+ // least expects to be rejected: name what would allow it.
72
+ const needsImgSizes = img != null && isLazyLoading(img) === true;
69
73
  report({
70
74
  scope: el,
71
- message: 'The "sizes" attribute is required when the "srcset" attribute uses width descriptors',
75
+ message: needsImgSizes
76
+ ? 'The "sizes" attribute is required when the "srcset" attribute uses width descriptors, unless the following sibling "img" element has loading="lazy" and sizes="auto"'
77
+ : 'The "sizes" attribute is required when the "srcset" attribute uses width descriptors',
72
78
  });
73
79
  }
74
80
  }
@@ -1,3 +1,5 @@
1
+ import type { PlainData } from '@markuplint/ml-config';
2
+ import type { Attr, RuleConfigValue } from '@markuplint/ml-core';
1
3
  import type { BrowserName, SupportStatement } from '@mdn/browser-compat-data';
2
4
  /**
3
5
  * Target browser with name and minimum version.
@@ -60,12 +62,23 @@ export declare function checkSupport(support: SupportStatement | undefined, targ
60
62
  * @returns Array of unsupported results (empty if all supported)
61
63
  */
62
64
  export declare function checkElementSupport(elementName: string, targets: readonly TargetBrowser[]): Promise<readonly UnsupportedResult[]>;
65
+ /**
66
+ * What the lookup of an attribute asks of the attribute: its name as the DOM gives it, and the name
67
+ * the DOM would give a key of BCD (`MLAttr#nameKey`, `MLAttr#keyOf`).
68
+ *
69
+ * It does not ask `MLAttr#isNamedBy`: that reads a name with a capital letter as the prop of a
70
+ * framework (`defaultValue`) and matches only its exact spelling, but a key of BCD is the spelling of
71
+ * an attribute of the browser, which the DOM folds (`privateToken` is `privatetoken`).
72
+ */
73
+ export type CompatAttr = Pick<Attr<RuleConfigValue, PlainData>, 'nameKey' | 'keyOf'>;
63
74
  /**
64
75
  * Check browser support for an HTML attribute on a specific element.
65
76
  *
66
77
  * @param elementName - The HTML element name (e.g., "input")
67
- * @param attrName - The attribute name (e.g., "list")
78
+ * @param attrName - The attribute name (e.g., "list"), as written
68
79
  * @param targets - Array of target browsers to check
80
+ * @param attr - The attribute, which decides whether a key of BCD that is spelled in another case is
81
+ * the attribute (the DOM folds a name that the HTML parser reads, and not a prop of a framework)
69
82
  * @returns Array of unsupported results (empty if all supported)
70
83
  */
71
- export declare function checkAttributeSupport(elementName: string, attrName: string, targets: readonly TargetBrowser[]): Promise<readonly UnsupportedResult[]>;
84
+ export declare function checkAttributeSupport(elementName: string, attrName: string, targets: readonly TargetBrowser[], attr?: CompatAttr): Promise<readonly UnsupportedResult[]>;
@@ -1,3 +1,5 @@
1
+ import { asciiLowerCase } from '@markuplint/shared';
2
+ import { memoizeByObject } from '../helpers.js';
1
3
  /**
2
4
  * Mapping from browserslist browser names to BCD browser names.
3
5
  */
@@ -187,20 +189,63 @@ export async function checkElementSupport(elementName, targets) {
187
189
  }
188
190
  return results;
189
191
  }
192
+ /**
193
+ * The keys of an element identifier by the key in ASCII lower case. BCD spells a few keys in camel
194
+ * case (`iframe.privateToken`), and the DOM can make a written name one of them (`PRIVATETOKEN` that
195
+ * the HTML parser folds), so the keys that a written name can be are found by this index and then
196
+ * asked of the attribute. It is built once per identifier, which BCD hands out as one object.
197
+ */
198
+ const foldedKeysOf = memoizeByObject((identifier) => {
199
+ const folded = new Map();
200
+ for (const key of Object.keys(identifier)) {
201
+ if (key === '__compat') {
202
+ continue;
203
+ }
204
+ const lower = asciiLowerCase(key);
205
+ const keys = folded.get(lower);
206
+ if (keys) {
207
+ keys.push(key);
208
+ }
209
+ else {
210
+ folded.set(lower, [key]);
211
+ }
212
+ }
213
+ return folded;
214
+ });
215
+ /**
216
+ * Finds the identifier of an attribute in the identifier of its element. The key as written comes
217
+ * first. With the attribute, another key is the attribute when the attribute says its name is that
218
+ * key; without it, only the name in lower case is tried, as there is nothing to ask.
219
+ */
220
+ function findAttrIdentifier(identifier, attrName, attr) {
221
+ const exact = identifier[attrName];
222
+ if (exact?.__compat) {
223
+ return exact;
224
+ }
225
+ if (!attr) {
226
+ return identifier[asciiLowerCase(attrName)];
227
+ }
228
+ const key = foldedKeysOf(identifier)
229
+ .get(asciiLowerCase(attr.nameKey))
230
+ ?.find(key => attr.keyOf(key) === attr.nameKey);
231
+ return key === undefined ? undefined : identifier[key];
232
+ }
190
233
  /**
191
234
  * Check browser support for an HTML attribute on a specific element.
192
235
  *
193
236
  * @param elementName - The HTML element name (e.g., "input")
194
- * @param attrName - The attribute name (e.g., "list")
237
+ * @param attrName - The attribute name (e.g., "list"), as written
195
238
  * @param targets - Array of target browsers to check
239
+ * @param attr - The attribute, which decides whether a key of BCD that is spelled in another case is
240
+ * the attribute (the DOM folds a name that the HTML parser reads, and not a prop of a framework)
196
241
  * @returns Array of unsupported results (empty if all supported)
197
242
  */
198
- export async function checkAttributeSupport(elementName, attrName, targets) {
243
+ export async function checkAttributeSupport(elementName, attrName, targets, attr) {
199
244
  const identifier = await getElementIdentifier(elementName);
200
245
  if (!identifier) {
201
246
  return [];
202
247
  }
203
- const attrIdentifier = identifier[attrName];
248
+ const attrIdentifier = findAttrIdentifier(identifier, attrName, attr);
204
249
  if (!attrIdentifier?.__compat) {
205
250
  return [];
206
251
  }
@@ -8,7 +8,11 @@ type Options = {
8
8
  readonly browserslistConfig?: string;
9
9
  /** Browserslist environment name (e.g., "production"). */
10
10
  readonly browserslistEnv?: string;
11
- /** Features to ignore. Element name ("dialog") or attribute pattern ("input[list]"). */
11
+ /**
12
+ * Features to ignore: an element name ("dialog") or an attribute of an element ("input[list]"). The
13
+ * element name is compared as the document compares tag names, the attribute name as the DOM
14
+ * compares it with the attribute that is checked.
15
+ */
12
16
  readonly ignoreFeatures?: readonly string[];
13
17
  };
14
18
  declare const _default: Readonly<import("@markuplint/ml-core").RuleSeed<boolean, Options>>;
@@ -1,4 +1,5 @@
1
1
  import { createRule } from '@markuplint/ml-core';
2
+ import { createIgnoreFeaturesLookup } from '../helpers.js';
2
3
  import { checkAttributeSupport, checkElementSupport } from './compat-data.js';
3
4
  import meta from './meta.js';
4
5
  import { clearBrowserslistCache, resolveTargetBrowsers } from './resolve-browsers.js';
@@ -17,7 +18,7 @@ export default createRule({
17
18
  if (targetBrowsers == null || targetBrowsers.length === 0) {
18
19
  return;
19
20
  }
20
- const ignoreFeatures = new Set(options.ignoreFeatures);
21
+ const ignored = createIgnoreFeaturesLookup(document.tagNameCaseSensitive, options.ignoreFeatures);
21
22
  await document.walkOn('Element', async (el) => {
22
23
  // Web components and authored elements (JSX/Vue/Svelte) reach this rule
23
24
  // via `pretenders`: they masquerade as a known HTML element on `el.localName`,
@@ -27,9 +28,8 @@ export default createRule({
27
28
  return;
28
29
  }
29
30
  const elName = el.localName;
30
- const elOptions = el.rule.options;
31
- const elIgnoreFeatures = new Set(elOptions.ignoreFeatures ?? ignoreFeatures);
32
- if (!elIgnoreFeatures.has(elName)) {
31
+ const elIgnoreFeatures = el.rule.options.ignoreFeatures;
32
+ if (!ignored.isElementIgnored(elIgnoreFeatures, elName)) {
33
33
  const unsupported = await checkElementSupport(elName, targetBrowsers);
34
34
  if (unsupported.length > 0) {
35
35
  report({
@@ -43,11 +43,10 @@ export default createRule({
43
43
  continue;
44
44
  }
45
45
  const attrName = attr.name;
46
- const ignoreKey = `${elName}[${attrName}]`;
47
- if (elIgnoreFeatures.has(ignoreKey)) {
46
+ if (ignored.isAttrIgnored(elIgnoreFeatures, elName, attr)) {
48
47
  continue;
49
48
  }
50
- const unsupported = await checkAttributeSupport(elName, attrName, targetBrowsers);
49
+ const unsupported = await checkAttributeSupport(elName, attrName, targetBrowsers, attr);
51
50
  if (unsupported.length > 0) {
52
51
  report({
53
52
  scope: attr,
@@ -1,4 +1,4 @@
1
- import { bgBlue, bgGreen, cmLog } from './debug.js';
1
+ import { bgBlue, bgGreen, depthLog } from './debug.js';
2
2
  import { order } from './order.js';
3
3
  import { Collection, modelLog } from './utils.js';
4
4
  /**
@@ -29,17 +29,22 @@ const indexes = new WeakMap();
29
29
  export function choice(pattern,
30
30
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
31
31
  childNodes, rules, specs, options, depth, mode) {
32
- const choiceLog = cmLog.extend(`choice#${depth}`);
32
+ const choiceLog = depthLog('choice', depth);
33
+ const choiceEnabled = choiceLog.enabled;
33
34
  const collection = new Collection(childNodes);
34
35
  const unmatchedResults = [];
35
36
  let i = 0;
36
37
  for (const some of pattern.choice) {
37
- choiceLog('Patterns[%s]: %s', i, modelLog(some, ''));
38
+ if (choiceEnabled) {
39
+ choiceLog('Patterns[%s]: %s', i, modelLog(some, ''));
40
+ }
38
41
  const result = order(some, collection.unmatched, rules, specs, options, depth + 1, mode);
39
42
  if (result.type === 'MATCHED' ||
40
43
  result.type === 'MATCHED_ZERO' ||
41
44
  (result.type === 'UNEXPECTED_EXTRA_NODE' && result.matched.length > 0)) {
42
- choiceLog('Results[%s]: %s', i, choiceLogString(pattern.choice, i));
45
+ if (choiceEnabled) {
46
+ choiceLog('Results[%s]: %s', i, choiceLogString(pattern.choice, i));
47
+ }
43
48
  return {
44
49
  type: result.type,
45
50
  matched: result.matched,
@@ -51,7 +56,11 @@ childNodes, rules, specs, options, depth, mode) {
51
56
  }
52
57
  unmatchedResults.push(result);
53
58
  collection.addMatched(result.matched);
54
- indexes.set(result, i);
59
+ // Log-only bookkeeping. The `indexes.get()` below is guarded by the same `choiceEnabled`
60
+ // snapshot, so the two stay in step.
61
+ if (choiceEnabled) {
62
+ indexes.set(result, i);
63
+ }
55
64
  i++;
56
65
  }
57
66
  const barelyMatchedResult = unmatchedResults.toSorted((a, b) => {
@@ -75,9 +84,11 @@ childNodes, rules, specs, options, depth, mode) {
75
84
  if (!barelyMatchedResult) {
76
85
  throw new Error('Unreachable code');
77
86
  }
78
- const index = indexes.get(barelyMatchedResult);
79
- if (index != null) {
80
- choiceLog('Results[%s]: %s', index, choiceLogString(pattern.choice, index, true));
87
+ if (choiceEnabled) {
88
+ const index = indexes.get(barelyMatchedResult);
89
+ if (index != null) {
90
+ choiceLog('Results[%s]: %s', index, choiceLogString(pattern.choice, index, true));
91
+ }
81
92
  }
82
93
  return {
83
94
  type: barelyMatchedResult.type,
@@ -1,5 +1,5 @@
1
1
  import type { ContentModelResult, Element, Mode, Options, Specs, TagRule } from './types.js';
2
- import type { ContentModel } from '@markuplint/ml-spec';
2
+ import type { ContentModel, MLMLSpec } from '@markuplint/ml-spec';
3
3
  import type { ReadonlyDeep } from 'type-fest';
4
4
  /**
5
5
  * Top-level entry point for content model validation of a single element.
@@ -30,3 +30,26 @@ export declare function contentModel(el: Element, rules: readonly TagRule[], opt
30
30
  * resolution logic when recursing into child content models.
31
31
  */
32
32
  export declare function resolveContentModel(el: Element, rules: readonly TagRule[], specs: Specs, mode: Mode): ReadonlyDeep<ContentModel['contents']> | null;
33
+ /**
34
+ * Whether the user's tag rule is written for the element: the name of the rule is the name
35
+ * the element is written with, or another spelling of it that the document resolves to the
36
+ * same component (see `MLElement#matchesTagNameVariant`).
37
+ *
38
+ * @param el - The element
39
+ * @param rule - The user's tag rule
40
+ * @returns Whether the rule is written for the element
41
+ */
42
+ export declare function hasTagRule(el: Element, rule: TagRule): boolean;
43
+ /**
44
+ * Returns a Specs object that merges the base ML spec with user-defined
45
+ * tag rules. Results are cached by the specs of the document and the serialized rules to avoid redundant
46
+ * merging: the specs differ by document (the `specs` of the config is by file), so what is merged
47
+ * for one must not be given to another.
48
+ *
49
+ * Exported for the spec of the cache.
50
+ *
51
+ * @param specs - The base ML spec from the document.
52
+ * @param rules - User-defined tag rules to merge.
53
+ * @returns The merged Specs, either from cache or freshly computed.
54
+ */
55
+ export declare function cachedSpecs(specs: MLMLSpec, rules: readonly TagRule[]): Specs;