@markuplint/rules 5.3.0 → 5.5.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.
- package/CHANGELOG.md +28 -0
- package/CLAUDE.md +4 -3
- package/lib/aria-prop-requires-role/index.js +2 -2
- package/lib/attr-eligibility.d.ts +21 -0
- package/lib/attr-eligibility.js +86 -6
- package/lib/attr-order/index.js +2 -1
- package/lib/attr-value-quotes/index.js +1 -1
- package/lib/consistent-table-row-length/index.js +1 -1
- package/lib/element-supports-aria-prop/index.js +2 -2
- package/lib/form-attr-references-form/index.js +2 -2
- package/lib/helpers.d.ts +43 -5
- package/lib/helpers.js +88 -26
- package/lib/input-list-references-datalist/index.js +2 -2
- package/lib/itemprop-requires-itemscope/index.js +27 -10
- package/lib/label-for-references-labelable/index.js +2 -2
- package/lib/label-has-control/index.d.ts +17 -0
- package/lib/label-has-control/index.js +28 -1
- package/lib/label-no-multiple-controls/index.d.ts +4 -5
- package/lib/label-no-multiple-controls/index.js +6 -6
- package/lib/landmark-roles/roles.d.ts +13 -1
- package/lib/landmark-roles/roles.js +22 -0
- package/lib/no-ambiguous-navigable-target-names/index.js +4 -0
- package/lib/no-aria-on-presentational-children/index.js +7 -1
- package/lib/no-aria-on-unsupported-element/index.js +2 -2
- package/lib/no-boolean-attr-value/index.js +2 -1
- package/lib/no-broken-fragment-link/index.d.ts +5 -0
- package/lib/no-broken-fragment-link/index.js +8 -2
- package/lib/no-consecutive-br/index.js +1 -1
- package/lib/no-contradictory-aria-prop/index.js +2 -2
- package/lib/no-default-aria-value/index.js +2 -2
- package/lib/no-default-value/index.js +4 -0
- package/lib/no-deprecated-aria-prop/index.js +2 -2
- package/lib/no-disallowed-ancestor/index.d.ts +16 -1
- package/lib/no-disallowed-ancestor/index.js +78 -4
- package/lib/no-disallowed-attr/index.js +4 -0
- package/lib/no-duplicate-dt/index.js +1 -1
- package/lib/no-duplicate-h1/index.js +18 -14
- package/lib/no-duplicate-id/index.d.ts +9 -0
- package/lib/no-duplicate-id/index.js +23 -4
- package/lib/no-empty-table-track/index.js +1 -1
- package/lib/no-ineffective-attr/index.js +4 -0
- package/lib/no-invalid-aria-prop-value/index.js +3 -3
- package/lib/no-invalid-attr-value/index.js +12 -0
- package/lib/no-nested-top-level-landmark/index.js +20 -17
- package/lib/no-prohibited-naming/index.js +2 -2
- package/lib/no-redundant-accessible-name/index.js +8 -7
- package/lib/no-redundant-aria-prop/index.js +2 -2
- package/lib/no-refer-to-non-existent-id/index.d.ts +15 -0
- package/lib/no-refer-to-non-existent-id/index.js +47 -16
- package/lib/no-restricted-element/index.js +4 -2
- package/lib/no-skipped-heading-level/index.js +9 -4
- package/lib/no-stray-head-or-body-tag/index.d.ts +2 -1
- package/lib/no-stray-head-or-body-tag/index.js +2 -1
- package/lib/no-table-cell-overlap/index.js +1 -1
- package/lib/no-table-span-overflow/index.js +1 -1
- package/lib/no-unescaped-char/index.js +1 -1
- package/lib/permitted-contents/index.js +2 -4
- package/lib/permitted-contents/matches-selector.js +28 -5
- package/lib/permitted-contents/represent-transparent-nodes.js +100 -68
- package/lib/permitted-contents/slot-content.d.ts +2 -1
- package/lib/permitted-contents/slot-content.js +28 -2
- package/lib/permitted-contents/start.js +4 -2
- package/lib/permitted-contents/utils.d.ts +11 -0
- package/lib/permitted-contents/utils.js +15 -0
- package/lib/placeholder-label-option/index.js +4 -26
- package/lib/require-accessible-name/index.js +2 -2
- package/lib/require-adjacent-popover/index.js +4 -2
- package/lib/require-ancestor/index.d.ts +20 -0
- package/lib/require-ancestor/index.js +33 -7
- package/lib/require-datetime/index.js +1 -1
- package/lib/require-dialog-autofocus/index.js +8 -6
- package/lib/require-h1/index.js +4 -8
- package/lib/require-landmark-label/index.js +17 -15
- package/lib/role-supports-aria-prop/index.js +2 -2
- package/lib/sizes-auto-requires-lazy-loading/index.d.ts +4 -2
- package/lib/sizes-auto-requires-lazy-loading/index.js +4 -2
- package/lib/usemap-references-map/index.js +11 -2
- package/lib/valid-speculation-rules/index.js +1 -1
- package/lib/wai-aria/checkings/interaction-in-hidden.js +6 -2
- package/lib/wai-aria/checkings/presentational-children.d.ts +17 -6
- package/lib/wai-aria/checkings/presentational-children.js +259 -25
- package/lib/wai-aria/checkings/required-accessibility-parent-role.js +2 -1
- package/lib/wai-aria/checkings/required-prop.js +2 -1
- package/lib/wai-aria/checkings/tab-requires-tabpanel.js +10 -6
- package/lib/wai-aria/checkings/value.d.ts +1 -4
- package/lib/wai-aria/checkings/value.js +5 -17
- package/package.json +11 -11
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,34 @@
|
|
|
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.5.0](https://github.com/markuplint/markuplint/compare/v5.4.0...v5.5.0) (2026-11-10)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **html-spec:** allow script-supporting elements in table sections, colgroup and dl ([197176a](https://github.com/markuplint/markuplint/commit/197176a7aae805e2949f9b96c6a27f39d478d261)), closes [#3591](https://github.com/markuplint/markuplint/issues/3591) [#4492](https://github.com/markuplint/markuplint/issues/4492)
|
|
11
|
+
* report a main element that is not hierarchically correct ([b44c936](https://github.com/markuplint/markuplint/commit/b44c936cf2e40c8142f88fe6b503af6f437cd341))
|
|
12
|
+
* **rules:** accept style as the first child in permitted-contents ([0523872](https://github.com/markuplint/markuplint/commit/05238722e9501b7758035d55293a178ab766001a)), closes [#x20](https://github.com/markuplint/markuplint/issues/x20)
|
|
13
|
+
* **rules:** accept the interactive content that canvas allows ([095eb3a](https://github.com/markuplint/markuplint/commit/095eb3aeb16186b0f5cb3f4a3bd342360dae1c2c)), closes [#4491](https://github.com/markuplint/markuplint/issues/4491)
|
|
14
|
+
* **rules:** count an element inside a conditional block as a label's descendant ([6ca1da5](https://github.com/markuplint/markuplint/commit/6ca1da545a903bec024f4418c3595f16d72365bc))
|
|
15
|
+
* **rules:** do not judge an element that the framework moves to the head by its parent ([db28a20](https://github.com/markuplint/markuplint/commit/db28a20bc562233f0c55594077a15276ec9c113a)), closes [#4484](https://github.com/markuplint/markuplint/issues/4484) [#4484](https://github.com/markuplint/markuplint/issues/4484)
|
|
16
|
+
* **rules:** do not look for an ancestor across the boundary of template content ([b660c8b](https://github.com/markuplint/markuplint/commit/b660c8bc5d2b4cbcc16f592ad536db9a2b8bdaaa))
|
|
17
|
+
* **rules:** do not treat template content as an accessible name ([0d004ef](https://github.com/markuplint/markuplint/commit/0d004efd84e6cd201cdcdc66163b89c4b0a714d2))
|
|
18
|
+
* **rules:** ignore comment nodes in permitted-contents ([38881eb](https://github.com/markuplint/markuplint/commit/38881eb71e80bfef45832a8f2378d336e5bfbe09)), closes [#4488](https://github.com/markuplint/markuplint/issues/4488)
|
|
19
|
+
* **rules:** judge an id and a structure in the tree that it is in ([#4489](https://github.com/markuplint/markuplint/issues/4489)) ([b17db6d](https://github.com/markuplint/markuplint/commit/b17db6d813012e127beb2c2b9e3fc21a393e2d4d))
|
|
20
|
+
* **rules:** require as to satisfy every matched link destination ([b4dbea2](https://github.com/markuplint/markuplint/commit/b4dbea2a08d761c90c98098102d7af40cf485849))
|
|
21
|
+
|
|
22
|
+
### Features
|
|
23
|
+
|
|
24
|
+
* **rules:** judge an attribute by what the framework renders ([3beee5a](https://github.com/markuplint/markuplint/commit/3beee5ad51e96fc3033b7930fec2889ea5bf18e6))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# [5.4.0](https://github.com/markuplint/markuplint/compare/v5.3.0...v5.4.0) (2026-10-29)
|
|
28
|
+
|
|
29
|
+
### Bug Fixes
|
|
30
|
+
|
|
31
|
+
* **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)
|
|
32
|
+
|
|
33
|
+
|
|
6
34
|
# [5.3.0](https://github.com/markuplint/markuplint/compare/v5.2.0...v5.3.0) (2026-10-21)
|
|
7
35
|
|
|
8
36
|
### Bug Fixes
|
package/CLAUDE.md
CHANGED
|
@@ -65,10 +65,11 @@ broken description and detonate in someone else's PR. Run `yarn site:build` befo
|
|
|
65
65
|
|
|
66
66
|
## Citing a specification
|
|
67
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.
|
|
68
|
+
A README, a source comment, `meta.ts` and `schema.json` cite the passage that justifies a rule; so do the comments in the `src` of every package (the algorithms of `ml-spec`, the value grammars of `types`, the DOM of `ml-core`, the declarations of how a framework renders a literal in `*-spec` and `*-parser`), the `html-spec` data and the website's pages. The monitor reads the whole `src` of each package, so a citation that moves with its code to another package stays watched. `yarn spec-citations` checks every citation against the live specification, the documentation or the source of a framework (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
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.
|
|
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. A `>` is read only at the start of a comment line (not after a list marker), and each quote is a paragraph of its own. Never quote inline in double quotes: the monitor cannot check it. `[…]` marks what the quote leaves out, and a quote is looked up in the sections the comment cites, in the order that it writes it in.
|
|
71
|
+
- Cite a section by its fragment. A `§4.10.7` beside the link is compared with the section the fragment is in (one before the link belongs to the link that follows it, one after a link on the same line, as in `@see <url> — Name §4.3.2`, to that link); the number of an ancestor section is accepted. Prefer the id of a heading to a text fragment (`#:~:text=`) alone: a text fragment with no `#fragment` is searched in the whole page, and stops matching when the passage is reworded (HTML-AAM's tables were rebuilt into one section per element, and `#:~:text=Comments-,article,-aside` went with them).
|
|
72
|
+
- A source file of a framework is cited by `https://github.com/<owner>/<repo>/blob/<default branch>/<path>#<name>`, where `<name>` is a top-level declaration of the file (a function, a variable, a class, a type, or `exports.<name> =`). It is compared as the declaration alone, so an edit elsewhere in the file reports nothing and a renamed declaration is `anchor-missing`. Only the repositories listed in `scripts/spec-citations/collect.ts` are monitored, and a link without a name or with a line range (`#L11-L52`) is not checked.
|
|
72
73
|
- `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
74
|
- 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
|
|
|
@@ -2,7 +2,7 @@ import { createRule, getComputedRole, ariaSpecs, getSpec } from '@markuplint/ml-
|
|
|
2
2
|
import { ARIA_RECOMMENDED_VERSION } from '@markuplint/ml-spec';
|
|
3
3
|
import { checkingNoGlobalProp } from '../wai-aria/checkings/no-global-prop.js';
|
|
4
4
|
import { defaultOptions } from '../wai-aria/default-options.js';
|
|
5
|
-
import {
|
|
5
|
+
import { getAriaAttrs } from '../helpers.js';
|
|
6
6
|
import meta from './meta.js';
|
|
7
7
|
export default createRule({
|
|
8
8
|
meta,
|
|
@@ -14,7 +14,7 @@ export default createRule({
|
|
|
14
14
|
return;
|
|
15
15
|
if (!elSpec.globalAttrs['#ARIAAttrs'])
|
|
16
16
|
return;
|
|
17
|
-
const propAttrs = el
|
|
17
|
+
const propAttrs = getAriaAttrs(el);
|
|
18
18
|
if (propAttrs.length === 0)
|
|
19
19
|
return;
|
|
20
20
|
const ariaVersion = el.rule.options?.version ?? document.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
|
|
@@ -41,8 +41,29 @@ export type AttrEligibility = {
|
|
|
41
41
|
* @param attr - The attribute that is being resolved
|
|
42
42
|
*/
|
|
43
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;
|
|
44
|
+
type PlainEnum = {
|
|
45
|
+
readonly enum: readonly [string, ...string[]];
|
|
46
|
+
readonly caseInsensitive?: boolean;
|
|
47
|
+
readonly disallowToSurroundBySpaces?: boolean;
|
|
48
|
+
readonly invalidValueDefault?: string;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* `invalidValueDefault` is not carried over: a fallback of one set may be a
|
|
52
|
+
* value another set forbids. A case-sensitive spelling is kept when an earlier
|
|
53
|
+
* entry only has another case. Case and surrounding spaces follow the stricter
|
|
54
|
+
* side, because the value has to satisfy every entry.
|
|
55
|
+
*
|
|
56
|
+
* An empty intersection cannot be an enum (`enum` has at least one token).
|
|
57
|
+
* A pattern that matches nothing keeps every value invalid. That pattern is
|
|
58
|
+
* not a keyword list an author is meant to satisfy.
|
|
59
|
+
*
|
|
60
|
+
* @param enums - The enum entries that matched, in spec order. At least two
|
|
61
|
+
* @returns The intersection, or a pattern that matches nothing when the sets are disjoint
|
|
62
|
+
*/
|
|
63
|
+
export declare function intersectEnums(enums: readonly PlainEnum[]): Attribute['type'];
|
|
44
64
|
/**
|
|
45
65
|
* Formats the "did you mean" suffix shared by `no-unknown-attr`'s two typo
|
|
46
66
|
* paths (spec-driven candidate, case-sensitivity mismatch).
|
|
47
67
|
*/
|
|
48
68
|
export declare function suggestionSuffix(t: Translator, candidate: string | undefined): string;
|
|
69
|
+
export {};
|
package/lib/attr-eligibility.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isConditionalAttributeTypeArray } from '@markuplint/ml-spec';
|
|
2
|
-
import { getCandidate } from '@markuplint/types';
|
|
2
|
+
import { check, getCandidate } from '@markuplint/types';
|
|
3
3
|
import { attrMatches, conditionDependsOnDynamicAttr, findAttrSpec, namespaceKeyOf } from './helpers.js';
|
|
4
4
|
/**
|
|
5
5
|
* Attribute namespaces that are exempt from spec-based name/eligibility
|
|
@@ -43,14 +43,11 @@ attr) {
|
|
|
43
43
|
}
|
|
44
44
|
// Resolve ConditionalAttributeType[] to a concrete type based on element matching (#3598).
|
|
45
45
|
if (isConditionalAttributeTypeArray(spec.type)) {
|
|
46
|
-
const matched = spec.type.
|
|
46
|
+
const matched = spec.type.filter(entry => {
|
|
47
47
|
const cond = typeof entry.condition === 'string' ? entry.condition : entry.condition.join(',');
|
|
48
48
|
return node.matches(cond);
|
|
49
49
|
});
|
|
50
|
-
|
|
51
|
-
// entry (text, search, tel, password, hidden, checkbox, radio, file, submit,
|
|
52
|
-
// image, reset, button) have no value constraints per the HTML spec.
|
|
53
|
-
spec = { ...spec, type: matched ? matched.type : 'Any' };
|
|
50
|
+
spec = { ...spec, type: combineMatchedConditionalTypes(matched) };
|
|
54
51
|
}
|
|
55
52
|
if (spec.condition != null &&
|
|
56
53
|
!node.hasSpreadAttr &&
|
|
@@ -60,6 +57,89 @@ attr) {
|
|
|
60
57
|
}
|
|
61
58
|
return { status: 'ok', spec };
|
|
62
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Enum entries that match together are simultaneous constraints: `link[as]`
|
|
62
|
+
* has to be a preload destination and a module preload destination when `rel`
|
|
63
|
+
* contains both keywords. The message names that intersection and does not
|
|
64
|
+
* offer a keyword another entry forbids. Token order follows the entries:
|
|
65
|
+
* a spelling is kept from the earliest entry whose own token satisfies every
|
|
66
|
+
* entry, which is the first entry's order when that spelling is valid there.
|
|
67
|
+
*
|
|
68
|
+
* A non-enum overlap stays on the first match. Those entries are ordered
|
|
69
|
+
* alternatives: an empty `input[value]` other than `type=range` is
|
|
70
|
+
* unconstrained even though a later type-specific entry also matches, and
|
|
71
|
+
* `email` with `multiple` matches both the address-list type and the
|
|
72
|
+
* single-address type, of which the list is the requirement.
|
|
73
|
+
*/
|
|
74
|
+
function combineMatchedConditionalTypes(matched) {
|
|
75
|
+
const first = matched[0];
|
|
76
|
+
// No condition matched. Input types without an explicit entry (text, search,
|
|
77
|
+
// tel, password, hidden, checkbox, radio, file, submit, image, reset, button)
|
|
78
|
+
// have no value constraints per the HTML spec.
|
|
79
|
+
if (!first) {
|
|
80
|
+
return 'Any';
|
|
81
|
+
}
|
|
82
|
+
const enums = [];
|
|
83
|
+
for (const entry of matched) {
|
|
84
|
+
if (!isPlainEnum(entry.type)) {
|
|
85
|
+
return first.type;
|
|
86
|
+
}
|
|
87
|
+
enums.push(entry.type);
|
|
88
|
+
}
|
|
89
|
+
if (enums.length === 1) {
|
|
90
|
+
return first.type;
|
|
91
|
+
}
|
|
92
|
+
return intersectEnums(enums);
|
|
93
|
+
}
|
|
94
|
+
function isPlainEnum(type) {
|
|
95
|
+
if (typeof type === 'string' || Array.isArray(type)) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
return 'enum' in type && Array.isArray(type.enum) && type.enum.length > 0;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* `invalidValueDefault` is not carried over: a fallback of one set may be a
|
|
102
|
+
* value another set forbids. A case-sensitive spelling is kept when an earlier
|
|
103
|
+
* entry only has another case. Case and surrounding spaces follow the stricter
|
|
104
|
+
* side, because the value has to satisfy every entry.
|
|
105
|
+
*
|
|
106
|
+
* An empty intersection cannot be an enum (`enum` has at least one token).
|
|
107
|
+
* A pattern that matches nothing keeps every value invalid. That pattern is
|
|
108
|
+
* not a keyword list an author is meant to satisfy.
|
|
109
|
+
*
|
|
110
|
+
* @param enums - The enum entries that matched, in spec order. At least two
|
|
111
|
+
* @returns The intersection, or a pattern that matches nothing when the sets are disjoint
|
|
112
|
+
*/
|
|
113
|
+
export function intersectEnums(enums) {
|
|
114
|
+
const caseInsensitive = enums.every(item => item.caseInsensitive !== false);
|
|
115
|
+
const tokens = [];
|
|
116
|
+
for (const item of enums) {
|
|
117
|
+
for (const token of item.enum) {
|
|
118
|
+
if (!enums.every(other => check(token, other).matched)) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (tokens.some(existing => sameToken(existing, token, caseInsensitive))) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
tokens.push(token);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const [head, ...rest] = tokens;
|
|
128
|
+
if (head === undefined) {
|
|
129
|
+
return { pattern: '/(?!)/' };
|
|
130
|
+
}
|
|
131
|
+
const result = {
|
|
132
|
+
enum: [head, ...rest],
|
|
133
|
+
...(caseInsensitive ? {} : { caseInsensitive: false }),
|
|
134
|
+
...(enums.every(item => item.disallowToSurroundBySpaces === false)
|
|
135
|
+
? { disallowToSurroundBySpaces: false }
|
|
136
|
+
: {}),
|
|
137
|
+
};
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
function sameToken(a, b, caseInsensitive) {
|
|
141
|
+
return caseInsensitive ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
142
|
+
}
|
|
63
143
|
/**
|
|
64
144
|
* Formats the "did you mean" suffix shared by `no-unknown-attr`'s two typo
|
|
65
145
|
* paths (spec-driven candidate, case-sensitivity mismatch).
|
package/lib/attr-order/index.js
CHANGED
|
@@ -229,7 +229,8 @@ export default createRule({
|
|
|
229
229
|
let cachedValue;
|
|
230
230
|
let cachedCompiled;
|
|
231
231
|
await document.walkOn('Element', node => {
|
|
232
|
-
|
|
232
|
+
// An attribute that a pretender made is not in the source, so it has no place in the order of it
|
|
233
|
+
const attrs = node.getAttributeTokens().filter(attr => !attr.isPretended);
|
|
233
234
|
if (attrs.length <= 1) {
|
|
234
235
|
return;
|
|
235
236
|
}
|
|
@@ -14,7 +14,7 @@ export default createRule({
|
|
|
14
14
|
async verify({ document, report, t }) {
|
|
15
15
|
await document.walkOn('Attr', attr => {
|
|
16
16
|
const message = t('{0} is must {1} on {2}', 'Attribute value', 'quote', `${attr.rule.value} quotation mark`);
|
|
17
|
-
if (attr.
|
|
17
|
+
if (attr.isExpressionValue || attr.isDirective || !attr.equal || attr.equal?.raw === '') {
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
20
|
const quote = attr.startQuote?.raw;
|
|
@@ -18,7 +18,7 @@ export default createRule({
|
|
|
18
18
|
meta,
|
|
19
19
|
defaultSeverity: 'warning',
|
|
20
20
|
verify({ document, report, t }) {
|
|
21
|
-
const tables = document.
|
|
21
|
+
const tables = document.deepQuerySelectorAll('table');
|
|
22
22
|
for (const table of tables) {
|
|
23
23
|
const grid = new Grid(table);
|
|
24
24
|
if (grid.hasOverlapped()) {
|
|
@@ -2,7 +2,7 @@ import { createRule, getComputedRole, ariaSpecs, getSpec } from '@markuplint/ml-
|
|
|
2
2
|
import { ARIA_RECOMMENDED_VERSION } from '@markuplint/ml-spec';
|
|
3
3
|
import { checkingElementSupportsAriaProp } from '../wai-aria/checkings/element-supports-aria-prop.js';
|
|
4
4
|
import { defaultOptions } from '../wai-aria/default-options.js';
|
|
5
|
-
import {
|
|
5
|
+
import { getAriaAttrs } from '../helpers.js';
|
|
6
6
|
import meta from './meta.js';
|
|
7
7
|
/**
|
|
8
8
|
* Split from the former `wai-aria-disallowed-props` rule (#3989): the ARIA in
|
|
@@ -25,7 +25,7 @@ export default createRule({
|
|
|
25
25
|
return;
|
|
26
26
|
if (!elSpec.globalAttrs['#ARIAAttrs'])
|
|
27
27
|
return;
|
|
28
|
-
const propAttrs = el
|
|
28
|
+
const propAttrs = getAriaAttrs(el);
|
|
29
29
|
if (propAttrs.length === 0)
|
|
30
30
|
return;
|
|
31
31
|
const ariaVersion = el.rule.options?.version ?? document.ruleCommonSettings?.ariaVersion ?? ARIA_RECOMMENDED_VERSION;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createRule } from '@markuplint/ml-core';
|
|
2
|
+
import { getElementByIdInTree } from '../helpers.js';
|
|
2
3
|
import meta from './meta.js';
|
|
3
4
|
/**
|
|
4
5
|
* Form-associated elements (HTML LS §4.10.2). Listed elements may carry
|
|
@@ -47,8 +48,7 @@ export default createRule({
|
|
|
47
48
|
return;
|
|
48
49
|
if (formAttr.isDynamicValue)
|
|
49
50
|
return;
|
|
50
|
-
const
|
|
51
|
-
const target = [...document.querySelectorAll('[id]')].find(candidate => candidate.getAttribute('id') === targetId);
|
|
51
|
+
const target = getElementByIdInTree(el, formAttr.value, formAttr.isExpressionValue);
|
|
52
52
|
// Existence is checked by `no-refer-to-non-existent-id`. If the ID does not resolve
|
|
53
53
|
// here, leave that diagnostic to the dedicated rule and stay silent.
|
|
54
54
|
if (!target)
|
package/lib/helpers.d.ts
CHANGED
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
import type { FixToken, IRuleFixer, PlainData, TextEdit } from '@markuplint/ml-config';
|
|
2
|
-
import type { Attr, Element, RuleConfigValue
|
|
2
|
+
import type { Attr, Element, RuleConfigValue } from '@markuplint/ml-core';
|
|
3
3
|
import type { Attribute } from '@markuplint/ml-spec';
|
|
4
4
|
import type { WritableDeep } from 'type-fest';
|
|
5
|
+
import { getDOMValue } from '@markuplint/ml-core';
|
|
6
|
+
export { getDOMValue };
|
|
7
|
+
/**
|
|
8
|
+
* The element that an id reference written in an attribute of `el` resolves to: the first element
|
|
9
|
+
* of the tree `el` is in whose id is the reference. The content of a `template` element and a
|
|
10
|
+
* declarative shadow root are trees of their own, so a reference there does not reach an id of the
|
|
11
|
+
* document, nor the other way around.
|
|
12
|
+
*
|
|
13
|
+
* The reference is compared with the character references in the markup resolved, as the id is.
|
|
14
|
+
*
|
|
15
|
+
* @template T - The rule configuration value type
|
|
16
|
+
* @template O - The rule options type
|
|
17
|
+
* @param el - The element that has the attribute
|
|
18
|
+
* @param reference - The value of the attribute, or one token of it, as written in the markup
|
|
19
|
+
* @param isExpression - Whether the value is written as code, which has no character reference in it
|
|
20
|
+
* @returns The element, or `null` when the tree has none, or `el` is in no tree
|
|
21
|
+
* @see https://html.spec.whatwg.org/multipage/dom.html#the-id-attribute
|
|
22
|
+
*/
|
|
23
|
+
export declare function getElementByIdInTree<T extends RuleConfigValue, O extends PlainData>(el: Element<T, O>, reference: string, isExpression?: boolean): Element<T, O> | null;
|
|
24
|
+
/**
|
|
25
|
+
* The elements that match the selector in the tree `el` is in (see {@link getElementByIdInTree}).
|
|
26
|
+
*
|
|
27
|
+
* @template T - The rule configuration value type
|
|
28
|
+
* @template O - The rule options type
|
|
29
|
+
* @param el - An element of the tree
|
|
30
|
+
* @param selectors - A selector
|
|
31
|
+
* @returns The elements in the order of the markup, none when `el` is in no tree
|
|
32
|
+
*/
|
|
33
|
+
export declare function querySelectorAllInTree<T extends RuleConfigValue, O extends PlainData>(el: Element<T, O>, selectors: string): readonly Element<T, O>[];
|
|
5
34
|
/** A `null`/`undefined` condition means the attribute applies unconditionally. */
|
|
6
35
|
export declare function attrMatches<T extends RuleConfigValue, O extends PlainData>(node: Element<T, O>, condition: Attribute['condition']): boolean;
|
|
7
36
|
/**
|
|
@@ -60,6 +89,16 @@ export declare function namespaceKeyOf(attr: Pick<Attr<RuleConfigValue, PlainDat
|
|
|
60
89
|
readonly elementType: string;
|
|
61
90
|
};
|
|
62
91
|
}): string;
|
|
92
|
+
/**
|
|
93
|
+
* The `aria-*` attributes that the element has. An attribute that the framework does not output
|
|
94
|
+
* (`aria-label={null}`, `MLAttr#isAbsent`) is not one of them: the DOM does not have it, so what a rule
|
|
95
|
+
* says of an attribute of the element (it is unsupported, deprecated, redundant, or its value is
|
|
96
|
+
* invalid) is not said of it.
|
|
97
|
+
*
|
|
98
|
+
* @param el - The element
|
|
99
|
+
* @returns The `aria-*` attributes that are output
|
|
100
|
+
*/
|
|
101
|
+
export declare function getAriaAttrs<T extends RuleConfigValue, O extends PlainData>(el: Element<T, O>): Attr<T, O>[];
|
|
63
102
|
/**
|
|
64
103
|
* Whether the attribute is an `aria-*` one, told by {@link namespaceKeyOf}.
|
|
65
104
|
*
|
|
@@ -117,7 +156,7 @@ export declare function match(needle: string, pattern: string): boolean;
|
|
|
117
156
|
*/
|
|
118
157
|
export declare const rePCENChar: string;
|
|
119
158
|
export declare function toNormalizedValue(value: string, spec: Attribute): string;
|
|
120
|
-
export declare function accnameMayBeMutable(el: Element<any, any
|
|
159
|
+
export declare function accnameMayBeMutable(el: Element<any, any>): boolean;
|
|
121
160
|
/**
|
|
122
161
|
* Whether the accessible name of `el` may be rendered by content the pretender
|
|
123
162
|
* does not know: an unknown entry (`{ dynamic: true }`) in the `contents` of
|
|
@@ -129,9 +168,9 @@ export declare function accnameMayBeMutable(el: Element<any, any>, document: Doc
|
|
|
129
168
|
* must stay checked (and reported as redundant by `no-redundant-accessible-name`).
|
|
130
169
|
* Call it only once the static name has been computed and found empty.
|
|
131
170
|
*/
|
|
132
|
-
export declare function accnameMayComeFromUnknownContent(el: Element<any, any
|
|
171
|
+
export declare function accnameMayComeFromUnknownContent(el: Element<any, any>): boolean;
|
|
133
172
|
export declare const labelable: string[];
|
|
134
|
-
export declare function getOwnedLabel<V extends RuleConfigValue, O extends PlainData>(el: Element<V, O
|
|
173
|
+
export declare function getOwnedLabel<V extends RuleConfigValue, O extends PlainData>(el: Element<V, O>): Element<V, O> | null;
|
|
135
174
|
/** `null`/`undefined` items are silently dropped on add. */
|
|
136
175
|
export declare class Collection<T> {
|
|
137
176
|
#private;
|
|
@@ -181,4 +220,3 @@ export declare function createIgnoreFeaturesLookup(tagNameCaseSensitive: boolean
|
|
|
181
220
|
/** Whether the attribute of the element is ignored. */
|
|
182
221
|
isAttrIgnored<T extends RuleConfigValue, O extends PlainData>(elementOption: readonly string[] | undefined, elementName: string, attr: Attr<T, O>): boolean;
|
|
183
222
|
};
|
|
184
|
-
export {};
|
package/lib/helpers.js
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { asciiLowerCase } from '@markuplint/shared';
|
|
1
|
+
import { getDOMValue, getRenderedChildNodes } from '@markuplint/ml-core';
|
|
2
|
+
import { asciiLowerCase, decodeEntities } from '@markuplint/shared';
|
|
3
|
+
export { getDOMValue };
|
|
4
|
+
/**
|
|
5
|
+
* The element that an id reference written in an attribute of `el` resolves to: the first element
|
|
6
|
+
* of the tree `el` is in whose id is the reference. The content of a `template` element and a
|
|
7
|
+
* declarative shadow root are trees of their own, so a reference there does not reach an id of the
|
|
8
|
+
* document, nor the other way around.
|
|
9
|
+
*
|
|
10
|
+
* The reference is compared with the character references in the markup resolved, as the id is.
|
|
11
|
+
*
|
|
12
|
+
* @template T - The rule configuration value type
|
|
13
|
+
* @template O - The rule options type
|
|
14
|
+
* @param el - The element that has the attribute
|
|
15
|
+
* @param reference - The value of the attribute, or one token of it, as written in the markup
|
|
16
|
+
* @param isExpression - Whether the value is written as code, which has no character reference in it
|
|
17
|
+
* @returns The element, or `null` when the tree has none, or `el` is in no tree
|
|
18
|
+
* @see https://html.spec.whatwg.org/multipage/dom.html#the-id-attribute
|
|
19
|
+
*/
|
|
20
|
+
export function getElementByIdInTree(
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
22
|
+
el, reference, isExpression = false) {
|
|
23
|
+
const root = el.getRootNode();
|
|
24
|
+
if (root.is(root.DOCUMENT_NODE) || root.is(root.DOCUMENT_FRAGMENT_NODE)) {
|
|
25
|
+
return root.getElementById(isExpression ? reference : decodeEntities(reference));
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The elements that match the selector in the tree `el` is in (see {@link getElementByIdInTree}).
|
|
31
|
+
*
|
|
32
|
+
* @template T - The rule configuration value type
|
|
33
|
+
* @template O - The rule options type
|
|
34
|
+
* @param el - An element of the tree
|
|
35
|
+
* @param selectors - A selector
|
|
36
|
+
* @returns The elements in the order of the markup, none when `el` is in no tree
|
|
37
|
+
*/
|
|
38
|
+
export function querySelectorAllInTree(
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
40
|
+
el, selectors) {
|
|
41
|
+
const root = el.getRootNode();
|
|
42
|
+
if (root.is(root.DOCUMENT_NODE) || root.is(root.DOCUMENT_FRAGMENT_NODE)) {
|
|
43
|
+
return [...root.querySelectorAll(selectors)];
|
|
44
|
+
}
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
3
47
|
/** A `null`/`undefined` condition means the attribute applies unconditionally. */
|
|
4
48
|
export function attrMatches(
|
|
5
49
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
@@ -30,9 +74,11 @@ node, condition) {
|
|
|
30
74
|
}
|
|
31
75
|
}
|
|
32
76
|
}
|
|
33
|
-
// Whether the attribute is the one that the selector names is up to the attribute (`isNamedBy`)
|
|
77
|
+
// Whether the attribute is the one that the selector names is up to the attribute (`isNamedBy`).
|
|
78
|
+
// An attribute that the framework does not output (`type={null}`) is known not to be there, so the
|
|
79
|
+
// condition is decided: it is not met.
|
|
34
80
|
const names = [...referencedNames];
|
|
35
|
-
return node.attributes.some(attr => attr.isDynamicValue && names.some(name => attr.isNamedBy(name)));
|
|
81
|
+
return node.attributes.some(attr => attr.isDynamicValue && !attr.isAbsent && names.some(name => attr.isNamedBy(name)));
|
|
36
82
|
}
|
|
37
83
|
/**
|
|
38
84
|
* Memoizes `fn` by the identity of its argument. Use it for an object that is one object for every
|
|
@@ -118,6 +164,20 @@ attr) {
|
|
|
118
164
|
export function namespaceKeyOf(attr) {
|
|
119
165
|
return attr.ownerElement.elementType === 'authored' ? asciiLowerCase(attr.name) : attr.nameKey;
|
|
120
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* The `aria-*` attributes that the element has. An attribute that the framework does not output
|
|
169
|
+
* (`aria-label={null}`, `MLAttr#isAbsent`) is not one of them: the DOM does not have it, so what a rule
|
|
170
|
+
* says of an attribute of the element (it is unsupported, deprecated, redundant, or its value is
|
|
171
|
+
* invalid) is not said of it.
|
|
172
|
+
*
|
|
173
|
+
* @param el - The element
|
|
174
|
+
* @returns The `aria-*` attributes that are output
|
|
175
|
+
*/
|
|
176
|
+
export function getAriaAttrs(
|
|
177
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
178
|
+
el) {
|
|
179
|
+
return el.attributes.filter(attr => !attr.isAbsent && isAriaAttr(attr));
|
|
180
|
+
}
|
|
121
181
|
/**
|
|
122
182
|
* Whether the attribute is an `aria-*` one, told by {@link namespaceKeyOf}.
|
|
123
183
|
*
|
|
@@ -261,13 +321,11 @@ export function toNormalizedValue(value, spec) {
|
|
|
261
321
|
}
|
|
262
322
|
export function accnameMayBeMutable(
|
|
263
323
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
264
|
-
el
|
|
265
|
-
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
266
|
-
document) {
|
|
324
|
+
el) {
|
|
267
325
|
if (el.hasMutableAttributes() || el.hasMutableChildren(true)) {
|
|
268
326
|
return true;
|
|
269
327
|
}
|
|
270
|
-
const ownedLabel = getOwnedLabel(el
|
|
328
|
+
const ownedLabel = getOwnedLabel(el);
|
|
271
329
|
if (ownedLabel && (ownedLabel.hasMutableAttributes() || ownedLabel.hasMutableChildren(true))) {
|
|
272
330
|
return true;
|
|
273
331
|
}
|
|
@@ -286,31 +344,33 @@ document) {
|
|
|
286
344
|
*/
|
|
287
345
|
export function accnameMayComeFromUnknownContent(
|
|
288
346
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
289
|
-
el
|
|
290
|
-
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
291
|
-
document) {
|
|
347
|
+
el) {
|
|
292
348
|
if (hasUnknownContent(el)) {
|
|
293
349
|
return true;
|
|
294
350
|
}
|
|
295
|
-
const
|
|
351
|
+
const labelledbyAttr = el.getAttributeNode('aria-labelledby');
|
|
352
|
+
const labelledby = labelledbyAttr?.value;
|
|
296
353
|
if (labelledby) {
|
|
297
354
|
const referenced = labelledby
|
|
298
355
|
.trim()
|
|
299
356
|
.split(/\s+/)
|
|
300
|
-
.map(id =>
|
|
357
|
+
.map(id => getElementByIdInTree(el, id, labelledbyAttr.isExpressionValue));
|
|
301
358
|
if (referenced.some(target => target && hasUnknownContent(target))) {
|
|
302
359
|
return true;
|
|
303
360
|
}
|
|
304
361
|
}
|
|
305
|
-
const ownedLabel = getOwnedLabel(el
|
|
362
|
+
const ownedLabel = getOwnedLabel(el);
|
|
306
363
|
return !!ownedLabel && hasUnknownContent(ownedLabel);
|
|
307
364
|
}
|
|
308
365
|
/**
|
|
309
|
-
* Walks the
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
* `
|
|
313
|
-
*
|
|
366
|
+
* Walks the children {@link getRenderedChildNodes} returns. A hidden child is
|
|
367
|
+
* skipped because the name computation never reads it (`isHidden` in that resolver).
|
|
368
|
+
*
|
|
369
|
+
* A `template` whose shadow root is only maybe attached returns `true` before
|
|
370
|
+
* that walk. Its content is omitted from the computed name, so an ancestor
|
|
371
|
+
* whose computed name is empty is not reported: the rule calls this only after
|
|
372
|
+
* the computed name is empty. Light DOM text that remains in the computed name
|
|
373
|
+
* is still a name, and the rule does not reach this function.
|
|
314
374
|
*/
|
|
315
375
|
function hasUnknownContent(
|
|
316
376
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
@@ -319,8 +379,10 @@ el) {
|
|
|
319
379
|
if (context?.type === 'pretender' && context.slotContent?.mutable) {
|
|
320
380
|
return true;
|
|
321
381
|
}
|
|
322
|
-
|
|
323
|
-
|
|
382
|
+
if (context?.type !== 'pretender' && el.isHTMLTemplateElement && el.declarativeShadowRootAttachment === 'unknown') {
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
const children = [...getRenderedChildNodes(el)];
|
|
324
386
|
return children.some(child => child.is(child.ELEMENT_NODE) &&
|
|
325
387
|
child.getAttribute('aria-hidden') !== 'true' &&
|
|
326
388
|
!child.hasAttribute('hidden') &&
|
|
@@ -329,17 +391,17 @@ el) {
|
|
|
329
391
|
export const labelable = ['button', 'input:not([type=hidden])', 'meter', 'output', 'progress', 'select', 'textarea'];
|
|
330
392
|
export function getOwnedLabel(
|
|
331
393
|
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
332
|
-
el
|
|
333
|
-
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
334
|
-
document) {
|
|
394
|
+
el) {
|
|
335
395
|
if (!labelable.some(cond => el.matches(cond))) {
|
|
336
396
|
return null;
|
|
337
397
|
}
|
|
338
398
|
let ownedLabel = el.closest('label');
|
|
339
399
|
if (!ownedLabel) {
|
|
340
|
-
const id = el.
|
|
400
|
+
const id = getDOMValue(el.getAttributeNode('id'));
|
|
341
401
|
if (id) {
|
|
342
|
-
|
|
402
|
+
// The label is looked for in the tree of the element, and the two values are compared as the DOM has them
|
|
403
|
+
ownedLabel =
|
|
404
|
+
querySelectorAllInTree(el, 'label[for]').find(label => getDOMValue(label.getAttributeNode('for')) === id) ?? null;
|
|
343
405
|
}
|
|
344
406
|
}
|
|
345
407
|
return ownedLabel;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createRule } from '@markuplint/ml-core';
|
|
2
|
+
import { getElementByIdInTree } from '../helpers.js';
|
|
2
3
|
import meta from './meta.js';
|
|
3
4
|
/**
|
|
4
5
|
* Require the `list` attribute on an `<input>` element to reference an
|
|
@@ -28,8 +29,7 @@ export default createRule({
|
|
|
28
29
|
return;
|
|
29
30
|
if (listAttr.isDynamicValue)
|
|
30
31
|
return;
|
|
31
|
-
const
|
|
32
|
-
const target = [...document.querySelectorAll('[id]')].find(candidate => candidate.getAttribute('id') === targetId);
|
|
32
|
+
const target = getElementByIdInTree(el, listAttr.value, listAttr.isExpressionValue);
|
|
33
33
|
// Existence is checked by `no-refer-to-non-existent-id`. If the ID does not resolve
|
|
34
34
|
// here, leave that diagnostic to the dedicated rule and stay silent.
|
|
35
35
|
if (!target)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createRule } from '@markuplint/ml-core';
|
|
2
|
+
import { getDOMValue } from '../helpers.js';
|
|
2
3
|
import meta from './meta.js';
|
|
3
4
|
/**
|
|
4
5
|
* Rule that requires every `itemprop` to belong to an item.
|
|
@@ -28,21 +29,37 @@ export default createRule({
|
|
|
28
29
|
// once and then check each `[itemprop]` against the union set.
|
|
29
30
|
// `itemref=" "` (whitespace-only) yields zero tokens and contributes
|
|
30
31
|
// no entries to the set; orphans referencing such a host stay orphans.
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
// The ids are collected by tree: the content of a `template` element and a declarative shadow
|
|
33
|
+
// root are trees of their own, which an `itemref` does not reach out of, and the elements of
|
|
34
|
+
// every tree are checked.
|
|
35
|
+
// An `itemref` that is not known until it is rendered may reach any element of its tree, so
|
|
36
|
+
// that tree is not checked.
|
|
37
|
+
const itemrefIdsOfTree = new Map();
|
|
38
|
+
const treesWithDynamicItemref = new Set();
|
|
39
|
+
const itemrefHosts = document.deepQuerySelectorAll('[itemscope][itemref]');
|
|
33
40
|
for (const host of itemrefHosts) {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
const root = host.getRootNode();
|
|
42
|
+
let itemrefIds = itemrefIdsOfTree.get(root);
|
|
43
|
+
if (!itemrefIds) {
|
|
44
|
+
itemrefIds = new Set();
|
|
45
|
+
itemrefIdsOfTree.set(root, itemrefIds);
|
|
46
|
+
}
|
|
47
|
+
const itemref = host.getAttributeNode('itemref');
|
|
48
|
+
if (itemref?.isDynamicValue) {
|
|
49
|
+
treesWithDynamicItemref.add(root);
|
|
50
|
+
}
|
|
51
|
+
for (const token of itemref?.value.split(/\s+/) ?? []) {
|
|
52
|
+
const id = token ? getDOMValue(itemref, token) : null;
|
|
53
|
+
if (id != null)
|
|
54
|
+
itemrefIds.add(id);
|
|
38
55
|
}
|
|
39
56
|
}
|
|
40
|
-
const itempropEls = document.
|
|
57
|
+
const itempropEls = document.deepQuerySelectorAll('[itemprop]');
|
|
41
58
|
for (const el of itempropEls) {
|
|
42
59
|
const hasAncestorScope = el.closest('[itemscope]') != null;
|
|
43
|
-
const id = el.
|
|
44
|
-
const isItemrefTarget = id != null && id !== '' &&
|
|
45
|
-
if (!hasAncestorScope && !isItemrefTarget) {
|
|
60
|
+
const id = getDOMValue(el.getAttributeNode('id'));
|
|
61
|
+
const isItemrefTarget = id != null && id !== '' && !!itemrefIdsOfTree.get(el.getRootNode())?.has(id);
|
|
62
|
+
if (!hasAncestorScope && !isItemrefTarget && !treesWithDynamicItemref.has(el.getRootNode())) {
|
|
46
63
|
report({
|
|
47
64
|
scope: el,
|
|
48
65
|
message: t('{0} must {1}', t('the {0}', 'element'), 'belong to an item via an ancestor `itemscope` or be referenced by an `itemref`'),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createRule } from '@markuplint/ml-core';
|
|
2
|
+
import { getElementByIdInTree } from '../helpers.js';
|
|
2
3
|
import meta from './meta.js';
|
|
3
4
|
/**
|
|
4
5
|
* Labelable elements per HTML LS §4.10.2 *Categories: Labelable elements*.
|
|
@@ -51,8 +52,7 @@ export default createRule({
|
|
|
51
52
|
return;
|
|
52
53
|
if (forAttr.isDynamicValue)
|
|
53
54
|
return;
|
|
54
|
-
const
|
|
55
|
-
const target = [...document.querySelectorAll('[id]')].find(candidate => candidate.getAttribute('id') === targetId);
|
|
55
|
+
const target = getElementByIdInTree(el, forAttr.value, forAttr.isExpressionValue);
|
|
56
56
|
// Existence is checked by `no-refer-to-non-existent-id`. If the ID does not resolve
|
|
57
57
|
// here, leave that diagnostic to the dedicated rule and stay silent.
|
|
58
58
|
if (!target)
|