@markuplint/ml-spec 5.5.0 → 5.6.1

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 CHANGED
@@ -3,6 +3,21 @@
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.6.1](https://github.com/markuplint/markuplint/compare/v5.6.0...v5.6.1) (2026-11-13)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **ml-spec:** let a directive pattern declare the key of its repeat ([df95b4c](https://github.com/markuplint/markuplint/commit/df95b4cd56753fa4c665991f6bc091e4c0620225))
11
+
12
+
13
+ # [5.6.0](https://github.com/markuplint/markuplint/compare/v5.5.0...v5.6.0) (2026-11-12)
14
+
15
+ ### Bug Fixes
16
+
17
+ * do not report an area element that has no parent node in the file ([916a166](https://github.com/markuplint/markuplint/commit/916a166916e4efd5292402abcd293927605ff799)), closes [#4528](https://github.com/markuplint/markuplint/issues/4528)
18
+ * **ml-spec:** follow a light child to the slot that takes it ([5269f1f](https://github.com/markuplint/markuplint/commit/5269f1f3892df6e383a9f08de6ccb07f71b5cfb9))
19
+
20
+
6
21
  # [5.5.0](https://github.com/markuplint/markuplint/compare/v5.4.0...v5.5.0) (2026-11-10)
7
22
 
8
23
  ### Bug Fixes
@@ -3,7 +3,7 @@ import { isPresentational } from './is-presentational.js';
3
3
  import { SVG_NAMESPACE } from '../../const/index.js';
4
4
  import { hasSvgAccessibleNameSource } from './accname/svg-helpers.js';
5
5
  import { getAttrSpecs } from '../../utils/get-attr-specs.js';
6
- import { getComposedParentElement } from '../dom/tree-boundary.js';
6
+ import { getAccessibilityParentElement, getComposedParentElement } from '../dom/tree-boundary.js';
7
7
  import { getExplicitRole } from './get-explicit-role.js';
8
8
  import { getImplicitRole } from './get-implicit-role.js';
9
9
  import { getNonPresentationalAncestor } from './get-non-presentational-ancestor.js';
@@ -325,7 +325,7 @@ el, specs, version) {
325
325
  if (!computedRole.role?.isImplicit) {
326
326
  return false;
327
327
  }
328
- const parent = getComposedParentElement(el);
328
+ const parent = getAccessibilityParentElement(el);
329
329
  if (!parent) {
330
330
  return false;
331
331
  }
@@ -3,7 +3,7 @@ import type { ARIAVersion, MLMLSpec } from '../../types/index.js';
3
3
  * In ARIA 1.3, `generic` role elements are additionally transparent for
4
4
  * ownership traversal.
5
5
  *
6
- * The ancestors are those of the accessibility tree: see {@link getComposedParentElement}.
6
+ * The ancestors are those of the accessibility tree: see {@link getAccessibilityParentElement}.
7
7
  */
8
8
  export declare function getNonPresentationalAncestor(el: Element, specs: MLMLSpec, version: ARIAVersion): import("../../types/index.js").ComputedRole | {
9
9
  el: null;
@@ -1,16 +1,16 @@
1
- import { getComposedParentElement } from '../dom/tree-boundary.js';
1
+ import { getAccessibilityParentElement } from '../dom/tree-boundary.js';
2
2
  import { isTransparentForOwnership } from './is-presentational.js';
3
3
  import { getComputedRole } from './get-computed-role.js';
4
4
  /**
5
5
  * In ARIA 1.3, `generic` role elements are additionally transparent for
6
6
  * ownership traversal.
7
7
  *
8
- * The ancestors are those of the accessibility tree: see {@link getComposedParentElement}.
8
+ * The ancestors are those of the accessibility tree: see {@link getAccessibilityParentElement}.
9
9
  */
10
10
  export function getNonPresentationalAncestor(
11
11
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
12
12
  el, specs, version) {
13
- let ancestor = getComposedParentElement(el);
13
+ let ancestor = getAccessibilityParentElement(el);
14
14
  /**
15
15
  * In ARIA 1.1 and 1.2, the role of an element is not affected by the role of its ancestor elements.
16
16
  *
@@ -22,7 +22,7 @@ el, specs, version) {
22
22
  if (!isTransparentForOwnership(ancestorRole.role?.name, version)) {
23
23
  return ancestorRole;
24
24
  }
25
- ancestor = getComposedParentElement(ancestor);
25
+ ancestor = getAccessibilityParentElement(ancestor);
26
26
  }
27
27
  return {
28
28
  el: ancestor,
@@ -1,5 +1,5 @@
1
1
  import { ariaSpecs } from './aria-specs.js';
2
- import { getComposedParentElement } from '../dom/tree-boundary.js';
2
+ import { getComposedParentElement, isNotRendered } from '../dom/tree-boundary.js';
3
3
  import { getSpecByTagName } from '../../utils/get-spec-by-tag-name.js';
4
4
  import { isPresentational } from './is-presentational.js';
5
5
  import { resolveNamespace } from '../../utils/resolve-namespace.js';
@@ -66,11 +66,16 @@ el, specs, version) {
66
66
  * that have host language semantics specifying
67
67
  * that the element is not displayed, such as CSS display:none,
68
68
  * visibility:hidden, or the HTML hidden attribute.
69
+ *
70
+ * An element that is not in the flat tree is not displayed either: a light child of a shadow host
71
+ * that no slot takes.
69
72
  */
70
73
  {
71
74
  let currentEl = el;
72
75
  while (currentEl) {
73
- if (hasDisplayNodeOrVisibilityHidden(currentEl) || currentEl.hasAttribute('hidden')) {
76
+ if (isNotRendered(currentEl) ||
77
+ hasDisplayNodeOrVisibilityHidden(currentEl) ||
78
+ currentEl.hasAttribute('hidden')) {
74
79
  return true;
75
80
  }
76
81
  currentEl = getComposedParentElement(currentEl);
@@ -11,6 +11,7 @@ import type { ARIAVersion, MLMLSpec } from '../../types/index.js';
11
11
  * `aria-owns` attributes, which is a separate architectural concern.
12
12
  * See also: `has-required-owned-elements.ts` has a similar limitation.
13
13
  *
14
- * The chain is the one the accessibility tree is built from ({@link getComposedParentElement}).
14
+ * The chain is the one the accessibility tree is built from ({@link getAccessibilityParentElement}), which
15
+ * does not have a `slot`.
15
16
  */
16
17
  export declare function matchesContextRole(conditions: readonly string[], ownedEl: Element, specs: MLMLSpec, version: ARIAVersion): boolean;
@@ -1,4 +1,4 @@
1
- import { getComposedParentElement } from '../dom/tree-boundary.js';
1
+ import { getAccessibilityParentElement } from '../dom/tree-boundary.js';
2
2
  import { isTransparentForOwnership } from './is-presentational.js';
3
3
  import { getComputedRole } from './get-computed-role.js';
4
4
  /**
@@ -13,12 +13,13 @@ import { getComputedRole } from './get-computed-role.js';
13
13
  * `aria-owns` attributes, which is a separate architectural concern.
14
14
  * See also: `has-required-owned-elements.ts` has a similar limitation.
15
15
  *
16
- * The chain is the one the accessibility tree is built from ({@link getComposedParentElement}).
16
+ * The chain is the one the accessibility tree is built from ({@link getAccessibilityParentElement}), which
17
+ * does not have a `slot`.
17
18
  */
18
19
  export function matchesContextRole(conditions,
19
20
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
20
21
  ownedEl, specs, version) {
21
- return conditions.some(condition => matchesCondition(condition, getComposedParentElement(ownedEl), specs, version));
22
+ return conditions.some(condition => matchesCondition(condition, getAccessibilityParentElement(ownedEl), specs, version));
22
23
  }
23
24
  function matchesCondition(condition,
24
25
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
@@ -50,14 +51,14 @@ parentEl, specs, version) {
50
51
  * @see https://w3c.github.io/aria/#scope
51
52
  */
52
53
  if (isTransparentForOwnership(parentRole?.name, version)) {
53
- parentEl = getComposedParentElement(parentEl);
54
+ parentEl = getAccessibilityParentElement(parentEl);
54
55
  continue;
55
56
  }
56
57
  const condition = conditions.shift();
57
58
  if (condition !== parentRole?.name) {
58
59
  return false;
59
60
  }
60
- parentEl = getComposedParentElement(parentEl);
61
+ parentEl = getAccessibilityParentElement(parentEl);
61
62
  }
62
63
  return true;
63
64
  }
@@ -1,47 +1,3 @@
1
- /**
2
- * @module
3
- *
4
- * The parent of an element as the DOM sees it, for the algorithms that walk up from an element.
5
- *
6
- * The algorithms of this package are written against the DOM `Element`, where `parentElement`
7
- * stops at the boundary of a tree: the parent element of a node whose parent is not an element
8
- * (a document fragment, such as the content of a `template` element or a shadow root) is `null`.
9
- * `@markuplint/ml-core` hands them its own elements, whose `parentElement` is the syntactical
10
- * parent: the syntax tree has no other place for the content of a `template` element than its
11
- * children, so the top of the content has the `template` element as its parent element. A walk
12
- * that reads `parentElement` there leaves the content and enters the tree that holds the
13
- * `template`. `getRootNode()` is where `@markuplint/ml-core` puts the boundary, and in a real DOM
14
- * a parent is always in the tree of its child, so comparing the two roots gives the DOM's answer
15
- * to both.
16
- *
17
- * This package cannot use `MLNode#treeParentElement` of `@markuplint/ml-core`, which depends on it,
18
- * so the helpers derive the same answer from `getRootNode()`. The two are pinned to agree by
19
- * `tree-scope.spec.ts` of `@markuplint/ml-core`: a change to the boundary in one belongs to both.
20
- *
21
- * Which of the two helpers a walk takes depends on what it follows:
22
- *
23
- * - {@link getTreeParentElement} follows a relation that the DOM defines in the tree, such as
24
- * the ancestor `label` element of a labelable element (`findAncestorLabel`, the one walk of
25
- * this package that does not follow the accessibility tree).
26
- * - {@link getComposedParentElement} follows what the accessibility tree is built from. Its
27
- * content is the flat tree: the content of a shadow root is rendered in its host, so the
28
- * elements above it are the host and the ancestors of the host. The content of an ordinary
29
- * `template` element is not rendered, and where it is inserted is not known from the markup,
30
- * so a walk ends at its top.
31
- *
32
- * A `template` element that may have a declarative shadow root but whose attachment is not known
33
- * from the markup (a host that is not in the file, a component, or a dynamic `shadowrootmode`),
34
- * or that fails to attach one, is treated as an ordinary `template` element: a walk ends at the
35
- * top of its content.
36
- *
37
- * Known limitation: the flat tree places the content of a shadow root where the `slot` element
38
- * is, between the host and the nodes the host has as children. A walk goes from the top of the
39
- * shadow root to the host and does not model the position of the `slot`.
40
- *
41
- * @see https://dom.spec.whatwg.org/#parent-element
42
- * @see https://dom.spec.whatwg.org/#concept-shadow-including-ancestor
43
- * @see https://html.spec.whatwg.org/multipage/scripting.html#the-template-element
44
- */
45
1
  /**
46
2
  * What the helpers read of an element: the DOM `Element`, and `MLElement` of `@markuplint/ml-core`.
47
3
  * An element without `getRootNode` (an adapter that has no notion of a tree boundary) is in one
@@ -51,6 +7,18 @@ type TreeElement = {
51
7
  readonly parentElement: unknown;
52
8
  getRootNode?(): unknown;
53
9
  };
10
+ /**
11
+ * What {@link getComposedParentElement} reads besides {@link TreeElement}: the slot that places the
12
+ * element in the flat tree. `null` is an element of a shadow host that no slot takes (not rendered),
13
+ * `undefined` an element that is not one or whose assignment is not known.
14
+ *
15
+ * @template P - The type of the parent element the element reads
16
+ */
17
+ type FlatTreeElement<P extends TreeElement> = {
18
+ readonly parentElement: P | null;
19
+ getRootNode?(): unknown;
20
+ getFlatTreeSlot?(): P | null | undefined;
21
+ };
54
22
  /**
55
23
  * The parent element of an element in its own tree, which is what the DOM calls `parentElement`:
56
24
  * `null` at the top of the content of a `template` element or of a shadow root, even where the
@@ -68,16 +36,13 @@ export declare function getTreeParentElement<P extends TreeElement>(el: {
68
36
  * The parent element of an element as the accessibility tree is built: the parent element in its own
69
37
  * tree, or, at the top of a shadow root, the host of the shadow root. `null` at the top of the content
70
38
  * of an ordinary `template` element, and at the top of a document or of any other root that is not a
71
- * shadow root.
39
+ * shadow root. For a light child of a shadow host, the slot that takes it, or `null` when none does.
72
40
  *
73
41
  * @template P - The type of the parent element the element reads
74
42
  * @param el - The element to start from
75
43
  * @returns The parent element or the shadow host, or `null` when the walk ends
76
44
  */
77
- export declare function getComposedParentElement<P extends TreeElement>(el: {
78
- readonly parentElement: P | null;
79
- getRootNode?(): unknown;
80
- }): P | null;
45
+ export declare function getComposedParentElement<P extends TreeElement>(el: FlatTreeElement<P>): P | null;
81
46
  /**
82
47
  * Whether a root is a shadow root: a document fragment that has a host.
83
48
  * The shadow root of `@markuplint/ml-core` is not a `ShadowRoot` of the DOM by class, and the
@@ -90,4 +55,54 @@ export declare function getComposedParentElement<P extends TreeElement>(el: {
90
55
  export declare function isShadowRoot<P = unknown>(root: unknown): root is {
91
56
  readonly host: P;
92
57
  };
58
+ /**
59
+ * Whether an element is a `slot` element of the HTML namespace, which the accessibility tree does not
60
+ * map: it has no role of its own, and what it renders (the nodes assigned to it, or its children)
61
+ * belongs to the parent of the `slot`. A walk up the ancestors of the accessibility tree that reads
62
+ * roles passes through it. The walks that read an attribute of the ancestors (`hidden`, `aria-hidden`)
63
+ * do not skip it: what a `slot` that has them renders is hidden as well.
64
+ *
65
+ * It does not look at `MLParser#slotElement` on purpose: the `slot` of a framework (Vue, Svelte, Astro)
66
+ * is replaced with what it is given, so it is no element of the accessibility tree either, and the
67
+ * pass-through is right for it too. Which `slot` has an assignment is `MLElement#getAssignedNodes` of
68
+ * `@markuplint/ml-core`, which does look at it.
69
+ *
70
+ * > No corresponding role
71
+ *
72
+ * @param el - An element
73
+ * @returns `true` for the `slot` element of the HTML namespace
74
+ * @see https://w3c.github.io/html-aam/#el-slot
75
+ * @see https://dom.spec.whatwg.org/#find-flattened-slotables
76
+ */
77
+ export declare function isSlotElement(el: {
78
+ readonly localName: string;
79
+ readonly namespaceURI?: string | null;
80
+ }): boolean;
81
+ /**
82
+ * Whether an element is not rendered: it is a light child of a shadow host, and no slot of the shadow
83
+ * root takes it (`getFlatTreeSlot()` is `null`). The flat tree has no place for it, so it is not in the
84
+ * accessibility tree either, whatever its host is.
85
+ *
86
+ * @param el - An element
87
+ * @returns `true` for an element that is known not to be rendered
88
+ * @see https://dom.spec.whatwg.org/#find-flattened-slotables
89
+ */
90
+ export declare function isNotRendered(el: object): boolean;
91
+ /**
92
+ * The parent element of an element in the accessibility tree: the composed parent element
93
+ * ({@link getComposedParentElement}) with the `slot` elements passed through ({@link isSlotElement}). The walks
94
+ * that read the roles of the ancestors take it; the walks that read an attribute of the ancestors take
95
+ * {@link getComposedParentElement}, as what a `slot` that has `hidden` renders is hidden.
96
+ *
97
+ * @template E - The type of the element
98
+ * @param el - The element to start from
99
+ * @returns The parent element, or `null` when the walk ends
100
+ */
101
+ export declare function getAccessibilityParentElement<E extends {
102
+ readonly parentElement: E | null;
103
+ readonly localName: string;
104
+ readonly namespaceURI?: string | null;
105
+ getRootNode?(): unknown;
106
+ getFlatTreeSlot?(): E | null | undefined;
107
+ }>(el: E): E | null;
93
108
  export {};
@@ -1,3 +1,4 @@
1
+ import { XHTML_NAMESPACE } from '../../const/dom.js';
1
2
  /**
2
3
  * @module
3
4
  *
@@ -34,10 +35,22 @@
34
35
  * or that fails to attach one, is treated as an ordinary `template` element: a walk ends at the
35
36
  * top of its content.
36
37
  *
37
- * Known limitation: the flat tree places the content of a shadow root where the `slot` element
38
- * is, between the host and the nodes the host has as children. A walk goes from the top of the
39
- * shadow root to the host and does not model the position of the `slot`.
38
+ * The flat tree renders a light child of a shadow host where the `slot` element that takes it is,
39
+ * so the parent of the child is that `slot`: `getFlatTreeSlot()` of `@markuplint/ml-core` tells it
40
+ * (the named slot assignment of the shadow root). A child that no slot takes is not rendered, so a
41
+ * walk ends there as it does at the top of the content of an ordinary `template` element. Where the
42
+ * assignment is not known (`undefined`: manual assignment, a name written as code, a slot of a
43
+ * framework), the walk goes to the host as it does for any other child. The `slot` element itself
44
+ * is an element that is not mapped to the accessibility tree, which the algorithms of this package
45
+ * pass through (`isSlotElement`).
40
46
  *
47
+ * Known limitation: a walk from the top of the shadow root goes to the host and does not model where
48
+ * the `slot` sits in the shadow root, so the elements of the shadow root that are above the `slot` are
49
+ * not between the host and a light child the `slot` takes. The fallback content of a slot that takes
50
+ * nodes is not rendered either, and a walk from it still goes through the `slot`.
51
+ *
52
+ * @see https://dom.spec.whatwg.org/#find-a-slot
53
+ * @see https://dom.spec.whatwg.org/#find-flattened-slotables
41
54
  * @see https://dom.spec.whatwg.org/#parent-element
42
55
  * @see https://dom.spec.whatwg.org/#concept-shadow-including-ancestor
43
56
  * @see https://html.spec.whatwg.org/multipage/scripting.html#the-template-element
@@ -68,13 +81,17 @@ export function getTreeParentElement(el) {
68
81
  * The parent element of an element as the accessibility tree is built: the parent element in its own
69
82
  * tree, or, at the top of a shadow root, the host of the shadow root. `null` at the top of the content
70
83
  * of an ordinary `template` element, and at the top of a document or of any other root that is not a
71
- * shadow root.
84
+ * shadow root. For a light child of a shadow host, the slot that takes it, or `null` when none does.
72
85
  *
73
86
  * @template P - The type of the parent element the element reads
74
87
  * @param el - The element to start from
75
88
  * @returns The parent element or the shadow host, or `null` when the walk ends
76
89
  */
77
90
  export function getComposedParentElement(el) {
91
+ const slot = el.getFlatTreeSlot?.();
92
+ if (slot !== undefined) {
93
+ return slot;
94
+ }
78
95
  const parent = getTreeParentElement(el);
79
96
  if (parent) {
80
97
  return parent;
@@ -101,3 +118,55 @@ export function isShadowRoot(root) {
101
118
  root.nodeType === DOCUMENT_FRAGMENT_NODE &&
102
119
  'host' in root);
103
120
  }
121
+ /**
122
+ * Whether an element is a `slot` element of the HTML namespace, which the accessibility tree does not
123
+ * map: it has no role of its own, and what it renders (the nodes assigned to it, or its children)
124
+ * belongs to the parent of the `slot`. A walk up the ancestors of the accessibility tree that reads
125
+ * roles passes through it. The walks that read an attribute of the ancestors (`hidden`, `aria-hidden`)
126
+ * do not skip it: what a `slot` that has them renders is hidden as well.
127
+ *
128
+ * It does not look at `MLParser#slotElement` on purpose: the `slot` of a framework (Vue, Svelte, Astro)
129
+ * is replaced with what it is given, so it is no element of the accessibility tree either, and the
130
+ * pass-through is right for it too. Which `slot` has an assignment is `MLElement#getAssignedNodes` of
131
+ * `@markuplint/ml-core`, which does look at it.
132
+ *
133
+ * > No corresponding role
134
+ *
135
+ * @param el - An element
136
+ * @returns `true` for the `slot` element of the HTML namespace
137
+ * @see https://w3c.github.io/html-aam/#el-slot
138
+ * @see https://dom.spec.whatwg.org/#find-flattened-slotables
139
+ */
140
+ export function isSlotElement(el) {
141
+ return el.localName === 'slot' && el.namespaceURI === XHTML_NAMESPACE;
142
+ }
143
+ /**
144
+ * Whether an element is not rendered: it is a light child of a shadow host, and no slot of the shadow
145
+ * root takes it (`getFlatTreeSlot()` is `null`). The flat tree has no place for it, so it is not in the
146
+ * accessibility tree either, whatever its host is.
147
+ *
148
+ * @param el - An element
149
+ * @returns `true` for an element that is known not to be rendered
150
+ * @see https://dom.spec.whatwg.org/#find-flattened-slotables
151
+ */
152
+ export function isNotRendered(el) {
153
+ // The DOM `Element` has no `getFlatTreeSlot`: only the element of `@markuplint/ml-core` knows it
154
+ return el.getFlatTreeSlot?.() === null;
155
+ }
156
+ /**
157
+ * The parent element of an element in the accessibility tree: the composed parent element
158
+ * ({@link getComposedParentElement}) with the `slot` elements passed through ({@link isSlotElement}). The walks
159
+ * that read the roles of the ancestors take it; the walks that read an attribute of the ancestors take
160
+ * {@link getComposedParentElement}, as what a `slot` that has `hidden` renders is hidden.
161
+ *
162
+ * @template E - The type of the element
163
+ * @param el - The element to start from
164
+ * @returns The parent element, or `null` when the walk ends
165
+ */
166
+ export function getAccessibilityParentElement(el) {
167
+ let parent = getComposedParentElement(el);
168
+ while (parent && isSlotElement(parent)) {
169
+ parent = getComposedParentElement(parent);
170
+ }
171
+ return parent;
172
+ }
@@ -155,11 +155,58 @@ export type DirectivePattern = {
155
155
  */
156
156
  readonly valueType?: 'string' | 'number' | 'boolean' | 'code';
157
157
  /**
158
- * If true, the attribute can appear multiple times on the same element.
159
- * Can also be a string array of potentialName values for which
160
- * duplication is allowed (e.g., ['class', 'style']).
158
+ * If true, the attribute is not a repeat of an attribute of the same name (`:class` beside `class`,
159
+ * which Vue merges). Can also be a string array of potentialName values for which this holds
160
+ * (e.g., ['class', 'style']).
161
+ *
162
+ * It is not whether the attribute may be written twice: that is told by {@link DirectivePattern.duplicateKey}
163
+ * (without it, the same spelling of a name that is not `true` is a repeat, and `true` is never one).
161
164
  */
162
165
  readonly isDuplicatable?: true | readonly string[];
166
+ /**
167
+ * The key under which the framework counts this directive as written twice, as a template that takes the
168
+ * captures of `pattern` (`$1`, `$2`, …) as they are spelled. Two attributes of an element with the same key
169
+ * are a repeat, whatever name the directive resolves to (`potentialName`): a framework tells a repeat by
170
+ * what it keeps of the name, and a directive is not an attribute of the DOM.
171
+ *
172
+ * Without it, a pattern is keyed by the name as it is written (how a framework that rejects only the
173
+ * same spelling of a directive tells a repeat), except a pattern with `isDuplicatable: true`, which is
174
+ * never a repeat (a framework that takes any number of the same directive).
175
+ *
176
+ * @example "$1" -- the key of `:view-box.camel` is its argument, `view-box` (with `duplicateKeyModifiers`, `viewBox`)
177
+ */
178
+ readonly duplicateKey?: string;
179
+ /**
180
+ * Modifiers of the directive that change {@link DirectivePattern.duplicateKey}: the modifiers are those
181
+ * written after the name, and a framework gives two spellings of a name that differ in them the same key
182
+ * (the order of them, a modifier that it does not know) or a different one (a modifier that changes the
183
+ * name that is bound). Only the modifiers listed here count, and a modifier is matched as it is spelled.
184
+ */
185
+ readonly duplicateKeyModifiers?: {
186
+ /**
187
+ * The capture group of `pattern` that holds the modifiers, joined with `.` and starting with it (`.prop.camel`).
188
+ */
189
+ readonly group: number;
190
+ /**
191
+ * The modifiers that turn the template into camelCase before the prefixes are put in front of it.
192
+ */
193
+ readonly camelize?: readonly string[];
194
+ /**
195
+ * The prefix that a modifier puts in front of the key. They are put in the order listed, so that
196
+ * the first of the listed modifiers that is written is the outermost.
197
+ */
198
+ readonly prefixes?: readonly {
199
+ readonly modifier: string;
200
+ readonly prefix: string;
201
+ }[];
202
+ /**
203
+ * When no prefix is put in front of the key, it is the name of an attribute (`:id.foo` binds `id`), so
204
+ * the element folds its case as it does for any attribute and it is a repeat of that attribute
205
+ * written as such (`:id`). A key with a prefix is a property or a namespaced name, which
206
+ * keeps its case (`.id` and `.ID` are two).
207
+ */
208
+ readonly foldedWithoutPrefix?: true;
209
+ };
163
210
  };
164
211
  /**
165
212
  * Reference URLs
@@ -12,7 +12,14 @@ export interface ContentModelsSchema {
12
12
  }
13
13
  export interface ContentModel {
14
14
  contents: PermittedContentPattern[] | boolean;
15
+ /**
16
+ * A CSS selector of the ancestor that this element must have. The ancestor is looked for in the tree of the element: the walk up the tree stops at the content of a `template` element.
17
+ */
15
18
  descendantOf?: string;
19
+ /**
20
+ * Whether `descendantOf` is required only of an element that has a parent node, as the HTML Standard says of the `area` element ("An area element with a parent node must have a map element ancestor"). The top of a partial or of a component has none in the file, so the element there is not reported.
21
+ */
22
+ descendantOfWithParentNode?: boolean;
16
23
  /**
17
24
  * Elements that this element must NOT appear as a descendant of. Each entry is a CSS selector matching forbidden ancestor elements. The walk up the tree stops at the content of a `template` element, which has no ancestors.
18
25
  */
@@ -22,6 +22,17 @@ export type DirectiveResolution = {
22
22
  readonly expressionLanguage?: ExpressionLanguage;
23
23
  readonly valueType?: 'string' | 'number' | 'boolean' | 'code';
24
24
  readonly isDuplicatable?: boolean;
25
+ /**
26
+ * Present only when the matched pattern decides it ({@link DirectivePattern.duplicateKey}): the key
27
+ * under which the directive counts as written twice, or `null` when it never does (a pattern that is
28
+ * `isDuplicatable: true` and declares no key). Absent, the key is the name as it is written.
29
+ */
30
+ readonly duplicateKey?: string | null;
31
+ /**
32
+ * Present only when the key is the name of an attribute that the element folds
33
+ * ({@link DirectivePattern.duplicateKeyModifiers}'s `foldedWithoutPrefix`) and no prefix is in front of it.
34
+ */
35
+ readonly duplicateKeyFolds?: true;
25
36
  };
26
37
  /**
27
38
  * Resolves an attribute name against a list of compiled directive patterns.
@@ -1,3 +1,4 @@
1
+ import { camelize } from '@markuplint/shared';
1
2
  const cache = new WeakMap();
2
3
  /**
3
4
  * Compiles directive patterns into RegExp objects, caching them
@@ -14,6 +15,33 @@ export function compileDirectivePatterns(patterns) {
14
15
  }
15
16
  return compiled;
16
17
  }
18
+ /**
19
+ * The key of an attribute that a pattern gives, as {@link DirectivePattern.duplicateKey} says.
20
+ */
21
+ function resolveDuplicateKey(
22
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
23
+ pattern, match) {
24
+ if (pattern.duplicateKey == null) {
25
+ return { key: pattern.isDuplicatable === true ? null : undefined, folds: false };
26
+ }
27
+ const modifiers = pattern.duplicateKeyModifiers;
28
+ const written = new Set((modifiers ? (match[modifiers.group] ?? '') : '').split('.').filter(Boolean));
29
+ // Only what the directive writes is camelized (`transformBind` of Vue does so to the argument), not the
30
+ // text that the template puts around it
31
+ const camelized = modifiers?.camelize?.some(modifier => written.has(modifier)) === true;
32
+ const key = pattern.duplicateKey.replaceAll(/\$(\d+)/g, (_, idx) => {
33
+ const capture = match[Number(idx)] ?? '';
34
+ return camelized ? camelize(capture) : capture;
35
+ });
36
+ if (!modifiers) {
37
+ return { key, folds: false };
38
+ }
39
+ const prefix = (modifiers.prefixes ?? [])
40
+ .filter(({ modifier }) => written.has(modifier))
41
+ .map(({ prefix }) => prefix)
42
+ .join('');
43
+ return { key: prefix + key, folds: modifiers.foldedWithoutPrefix === true && prefix === '' };
44
+ }
17
45
  /**
18
46
  * Resolves an attribute name against a list of compiled directive patterns.
19
47
  * Returns the first match's resolution, or null if no pattern matches.
@@ -38,6 +66,7 @@ compiledPatterns) {
38
66
  else if (Array.isArray(pattern.isDuplicatable) && potentialName) {
39
67
  isDuplicatable = pattern.isDuplicatable.includes(potentialName);
40
68
  }
69
+ const duplicateKey = resolveDuplicateKey(pattern, match);
41
70
  return {
42
71
  potentialName,
43
72
  isDirective: pattern.isDirective,
@@ -45,6 +74,8 @@ compiledPatterns) {
45
74
  valueType: pattern.valueType,
46
75
  isDuplicatable,
47
76
  ...(pattern.expressionLanguage ? { expressionLanguage: pattern.expressionLanguage } : {}),
77
+ ...(duplicateKey.key === undefined ? {} : { duplicateKey: duplicateKey.key }),
78
+ ...(duplicateKey.folds ? { duplicateKeyFolds: true } : {}),
48
79
  };
49
80
  }
50
81
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markuplint/ml-spec",
3
- "version": "5.5.0",
3
+ "version": "5.6.1",
4
4
  "description": "Types and schema that specs of the Markup languages for markuplint",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,9 +22,6 @@
22
22
  "publishConfig": {
23
23
  "access": "public"
24
24
  },
25
- "typedoc": {
26
- "entryPoint": "./src/index.ts"
27
- },
28
25
  "scripts": {
29
26
  "build": "tsc --project tsconfig.build.json",
30
27
  "dev": "tsc --watch --project tsconfig.build.json",
@@ -36,15 +33,15 @@
36
33
  "schema:aria": "npx json2ts ./schemas/aria.schema.json --cwd ./schemas > ./src/types/aria.ts"
37
34
  },
38
35
  "dependencies": {
39
- "@markuplint/ml-ast": "5.5.0",
40
- "@markuplint/shared": "5.5.0",
41
- "@markuplint/types": "5.5.0",
36
+ "@markuplint/ml-ast": "5.6.0",
37
+ "@markuplint/shared": "5.6.0",
38
+ "@markuplint/types": "5.6.0",
42
39
  "is-plain-object": "^5.1.0",
43
40
  "type-fest": "^5.10.0"
44
41
  },
45
42
  "devDependencies": {
46
- "@markuplint/test-tools": "5.5.0",
43
+ "@markuplint/test-tools": "5.6.1",
47
44
  "json-schema-to-typescript": "16.0.0"
48
45
  },
49
- "gitHead": "fedebcfd56eaec4642e68643b2f9e10750d810e1"
46
+ "gitHead": "a9245162df3699c4880469f493aaaf6103343d83"
50
47
  }