@markuplint/ml-spec 5.0.0 → 5.1.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 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.1.0](https://github.com/markuplint/markuplint/compare/v5.0.1...v5.1.0) (2026-10-08)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **ml-spec:** read option, SVG title and control text through the accname resolver ([4c66d1f](https://github.com/markuplint/markuplint/commit/4c66d1fb960e746dc9f3c6cec0c8b3c9284a657e))
11
+ - **ml-spec:** read the SVG title and select options through the accname resolver ([0a7c162](https://github.com/markuplint/markuplint/commit/0a7c162a83a553d7872388184ea0020735def6fb)), closes [#4067](https://github.com/markuplint/markuplint/issues/4067)
12
+
13
+ ### Features
14
+
15
+ - **ml-spec:** let the accname resolver supply the child nodes to traverse ([caa8263](https://github.com/markuplint/markuplint/commit/caa82633dcecb604b32e8e9438b1096c116f059c)), closes [#4056](https://github.com/markuplint/markuplint/issues/4056)
16
+
17
+ ## [5.0.1](https://github.com/markuplint/markuplint/compare/v5.0.0...v5.0.1) (2026-09-22)
18
+
19
+ **Note:** Version bump only for package @markuplint/ml-spec
20
+
6
21
  # [5.0.0](https://github.com/markuplint/markuplint/compare/v5.0.0-rc.7...v5.0.0) (2026-09-11)
7
22
 
8
23
  **Note:** Version bump only for package @markuplint/ml-spec
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/prefer-readonly-parameter-types -- AccnameElement wraps mutable DOM types */
2
2
  import { DEFAULT_IMAGE_LABEL, DEFAULT_RESET_LABEL, DEFAULT_SUBMIT_LABEL, TEXT_INPUT_TYPES, } from '../../../const/index.js';
3
- import { findChildByLocalName, getInputType, isSvgElement, makeResult, resolveNameFromContent } from './helpers.js';
3
+ import { findChildByLocalName, getInputType, getTextContent, isSvgElement, makeResult, resolveNameFromContent, } from './helpers.js';
4
4
  import { resolveLabelText } from './label-steps.js';
5
5
  /**
6
6
  * Implements AccName 1.2 §4.3.2 Step 2E: for elements that have a native
@@ -15,7 +15,7 @@ import { resolveLabelText } from './label-steps.js';
15
15
  */
16
16
  export function getElementSpecificName(el, resolver, visited, computeFn, inLabelledbyTraversal) {
17
17
  if (isSvgElement(el)) {
18
- return handleSvgElement(el);
18
+ return handleSvgElement(el, resolver);
19
19
  }
20
20
  const { localName } = el;
21
21
  switch (localName) {
@@ -185,7 +185,7 @@ function handleButton(el, resolver, visited, computeFn, inLabelledbyTraversal) {
185
185
  * @see https://www.w3.org/TR/html-aam-1.0/#el-fieldset
186
186
  */
187
187
  function handleFieldset(el, resolver, visited, computeFn, inLabelledbyTraversal) {
188
- const legend = findChildByLocalName(el, 'legend');
188
+ const legend = findChildByLocalName(el, 'legend', resolver);
189
189
  if (legend) {
190
190
  const content = resolveNameFromContent(legend, resolver, visited, computeFn, inLabelledbyTraversal);
191
191
  if (content.trim()) {
@@ -204,7 +204,7 @@ function handleFieldset(el, resolver, visited, computeFn, inLabelledbyTraversal)
204
204
  * @see https://www.w3.org/TR/html-aam-1.0/#el-table
205
205
  */
206
206
  function handleTable(el, resolver, visited, computeFn, inLabelledbyTraversal) {
207
- const caption = findChildByLocalName(el, 'caption');
207
+ const caption = findChildByLocalName(el, 'caption', resolver);
208
208
  if (caption) {
209
209
  const content = resolveNameFromContent(caption, resolver, visited, computeFn, inLabelledbyTraversal);
210
210
  if (content.trim()) {
@@ -310,10 +310,11 @@ function handleTitleOnly(el) {
310
310
  * SVG-AAM: SVG elements get name from `<title>` child element.
311
311
  * @see https://www.w3.org/TR/svg-aam-1.0/#mapping_additional_nd — SVG-AAM §8.1
312
312
  */
313
- function handleSvgElement(el) {
314
- const titleEl = findChildByLocalName(el, 'title');
315
- if (titleEl?.textContent?.trim()) {
316
- return makeResult(titleEl.textContent, 'svg-title');
313
+ function handleSvgElement(el, resolver) {
314
+ const titleEl = findChildByLocalName(el, 'title', resolver);
315
+ const title = titleEl ? getTextContent(titleEl, resolver) : '';
316
+ if (title.trim()) {
317
+ return makeResult(title, 'svg-title');
317
318
  }
318
319
  return null;
319
320
  }
@@ -1,4 +1,4 @@
1
- import type { AccnameElement, AccnameResolver, AccnameResult, AccnameSource } from './types.js';
1
+ import type { AccnameElement, AccnameNode, AccnameResolver, AccnameResult, AccnameSource } from './types.js';
2
2
  export { EMBEDDED_CONTROL_ROLES } from '../../../const/index.js';
3
3
  export declare function makeResult(name: string, source: AccnameSource | null): AccnameResult;
4
4
  export declare function flattenText(text: string): string;
@@ -22,7 +22,18 @@ export declare function flattenText(text: string): string;
22
22
  * @see https://www.w3.org/TR/accname-1.2/#comp_embedded_control — AccName 1.2 §4.3.2 Step 2C
23
23
  */
24
24
  export declare function resolveNameFromContent(el: AccnameElement, resolver: AccnameResolver, visited: ReadonlySet<string>, computeFn: (el: AccnameElement, resolver: AccnameResolver, inLabelledbyTraversal: boolean, visited: ReadonlySet<string>) => AccnameResult, inLabelledbyTraversal: boolean): string;
25
- export declare function findChildByLocalName(el: AccnameElement, localName: string): AccnameElement | null;
25
+ /**
26
+ * The child nodes to traverse for `el`: what the resolver says it renders,
27
+ * or `el.childNodes` when it says nothing.
28
+ */
29
+ export declare function getChildNodes(el: AccnameElement, resolver: AccnameResolver): Iterable<AccnameNode>;
30
+ /**
31
+ * `el.textContent` as the resolver sees the element: the text of the nodes it
32
+ * says the element renders, at any depth. Text written inside a pretended
33
+ * component that never renders its children is not among them.
34
+ */
35
+ export declare function getTextContent(el: AccnameElement, resolver: AccnameResolver): string;
36
+ export declare function findChildByLocalName(el: AccnameElement, localName: string, resolver?: AccnameResolver): AccnameElement | null;
26
37
  export declare function findAncestorLabel(el: AccnameElement): AccnameElement | null;
27
38
  export declare function resolveLabel(el: AccnameElement, resolver: AccnameResolver): readonly AccnameElement[];
28
39
  export declare function getInputType(el: AccnameElement): string;
@@ -32,7 +32,7 @@ export function flattenText(text) {
32
32
  */
33
33
  export function resolveNameFromContent(el, resolver, visited, computeFn, inLabelledbyTraversal) {
34
34
  const parts = [];
35
- for (const child of el.childNodes) {
35
+ for (const child of getChildNodes(el, resolver)) {
36
36
  if (child.nodeType === TEXT_NODE) {
37
37
  parts.push(child.textContent ?? '');
38
38
  }
@@ -40,7 +40,7 @@ export function resolveNameFromContent(el, resolver, visited, computeFn, inLabel
40
40
  const childEl = child;
41
41
  if (resolver.isEmbeddedControl(childEl)) {
42
42
  // Step 2C: Embedded controls — use value directly, aria-label is ignored
43
- parts.push(getEmbeddedControlValue(childEl));
43
+ parts.push(getEmbeddedControlValue(childEl, resolver));
44
44
  }
45
45
  else {
46
46
  const result = computeFn(childEl, resolver, inLabelledbyTraversal, visited);
@@ -78,14 +78,14 @@ function collectTextContent(el, resolver, visited, computeFn, inLabelledbyTraver
78
78
  return '';
79
79
  }
80
80
  const parts = [];
81
- for (const child of el.childNodes) {
81
+ for (const child of getChildNodes(el, resolver)) {
82
82
  if (child.nodeType === TEXT_NODE) {
83
83
  parts.push(child.textContent ?? '');
84
84
  }
85
85
  else if (child.nodeType === ELEMENT_NODE) {
86
86
  const childEl = child;
87
87
  if (resolver.isEmbeddedControl(childEl)) {
88
- parts.push(getEmbeddedControlValue(childEl));
88
+ parts.push(getEmbeddedControlValue(childEl, resolver));
89
89
  }
90
90
  else {
91
91
  const result = computeFn(childEl, resolver, inLabelledbyTraversal, visited);
@@ -110,7 +110,7 @@ function collectTextContent(el, resolver, visited, computeFn, inLabelledbyTraver
110
110
  *
111
111
  * @see https://www.w3.org/TR/accname-1.2/#comp_embedded_control — AccName 1.2 §4.3.2 Step 2C
112
112
  */
113
- function getEmbeddedControlValue(el) {
113
+ function getEmbeddedControlValue(el, resolver) {
114
114
  const role = el.getAttribute('role')?.trim().split(/\s+/)[0];
115
115
  // Range controls: slider, spinbutton, input[type=range]
116
116
  if (role === 'slider' || role === 'spinbutton' || (el.localName === 'input' && getInputType(el) === 'range')) {
@@ -126,7 +126,7 @@ function getEmbeddedControlValue(el) {
126
126
  if (value?.trim()) {
127
127
  return value;
128
128
  }
129
- return el.textContent ?? '';
129
+ return getTextContent(el, resolver);
130
130
  }
131
131
  // Textbox/combobox: value attr -> textContent
132
132
  if (role === 'textbox' ||
@@ -138,13 +138,13 @@ function getEmbeddedControlValue(el) {
138
138
  if (value != null) {
139
139
  return value;
140
140
  }
141
- return el.textContent ?? '';
141
+ return getTextContent(el, resolver);
142
142
  }
143
143
  // Listbox/select: selected option text
144
144
  if (el.localName === 'select') {
145
- return getSelectedOptionText(el);
145
+ return getSelectedOptionText(el, resolver);
146
146
  }
147
- return el.textContent ?? '';
147
+ return getTextContent(el, resolver);
148
148
  }
149
149
  /**
150
150
  * Gets the text content of the selected `<option>` elements within a `<select>`.
@@ -170,16 +170,16 @@ function getEmbeddedControlValue(el) {
170
170
  *
171
171
  * @see https://github.com/markuplint/markuplint/issues/2069 — `<select>` and `<selectedcontent>` support
172
172
  */
173
- function getSelectedOptionText(el) {
174
- const options = collectOptions(el);
173
+ function getSelectedOptionText(el, resolver) {
174
+ const options = collectOptions(el, resolver);
175
175
  const selected = options.filter(opt => opt.hasAttribute('selected'));
176
176
  if (selected.length > 0) {
177
- return selected.map(opt => opt.textContent?.trim() ?? '').join(' ');
177
+ return selected.map(opt => getTextContent(opt, resolver).trim()).join(' ');
178
178
  }
179
179
  // No explicit selected attr: HTML spec says first non-disabled option is selected
180
180
  // (only for non-multiple selects, but we approximate for all)
181
181
  const first = options.find(opt => !opt.hasAttribute('disabled'));
182
- return first?.textContent?.trim() ?? '';
182
+ return first ? getTextContent(first, resolver).trim() : '';
183
183
  }
184
184
  /**
185
185
  * **Note (#2069):** Customizable `<select>` allows non-option children
@@ -189,14 +189,14 @@ function getSelectedOptionText(el) {
189
189
  *
190
190
  * @see https://github.com/markuplint/markuplint/issues/2069
191
191
  */
192
- function collectOptions(el) {
192
+ function collectOptions(el, resolver) {
193
193
  const result = [];
194
- for (const child of el.children) {
194
+ for (const child of getChildElements(el, resolver)) {
195
195
  if (child.localName === 'option') {
196
196
  result.push(child);
197
197
  }
198
198
  else if (child.localName === 'optgroup') {
199
- for (const grandchild of child.children) {
199
+ for (const grandchild of getChildElements(child, resolver)) {
200
200
  if (grandchild.localName === 'option') {
201
201
  result.push(grandchild);
202
202
  }
@@ -208,9 +208,47 @@ function collectOptions(el) {
208
208
  function isTextLikeInput(el) {
209
209
  return TEXT_LIKE_INPUT_TYPES.has(getInputType(el));
210
210
  }
211
- export function findChildByLocalName(el, localName) {
212
- for (const child of el.children) {
213
- if (child.localName === localName) {
211
+ /**
212
+ * The child nodes to traverse for `el`: what the resolver says it renders,
213
+ * or `el.childNodes` when it says nothing.
214
+ */
215
+ export function getChildNodes(el, resolver) {
216
+ return resolver.getChildNodes?.(el) ?? el.childNodes;
217
+ }
218
+ /**
219
+ * `el.textContent` as the resolver sees the element: the text of the nodes it
220
+ * says the element renders, at any depth. Text written inside a pretended
221
+ * component that never renders its children is not among them.
222
+ */
223
+ export function getTextContent(el, resolver) {
224
+ if (!resolver.getChildNodes) {
225
+ return el.textContent ?? '';
226
+ }
227
+ let text = '';
228
+ for (const child of resolver.getChildNodes(el)) {
229
+ if (child.nodeType === TEXT_NODE) {
230
+ text += child.textContent ?? '';
231
+ }
232
+ else if (child.nodeType === ELEMENT_NODE) {
233
+ text += getTextContent(child, resolver);
234
+ }
235
+ }
236
+ return text;
237
+ }
238
+ function getChildElements(el, resolver) {
239
+ return [...getChildNodes(el, resolver)].filter((child) => child.nodeType === ELEMENT_NODE);
240
+ }
241
+ export function findChildByLocalName(el, localName, resolver) {
242
+ if (!resolver?.getChildNodes) {
243
+ for (const child of el.children) {
244
+ if (child.localName === localName) {
245
+ return child;
246
+ }
247
+ }
248
+ return null;
249
+ }
250
+ for (const child of resolver.getChildNodes(el)) {
251
+ if (child.nodeType === ELEMENT_NODE && child.localName === localName) {
214
252
  return child;
215
253
  }
216
254
  }
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/prefer-readonly-parameter-types -- AccnameElement wraps mutable DOM types */
2
2
  import { ELEMENT_NODE, TEXT_NODE } from '../../../const/index.js';
3
- import { flattenText, makeResult, resolveLabel } from './helpers.js';
3
+ import { flattenText, getChildNodes, makeResult, resolveLabel } from './helpers.js';
4
4
  /**
5
5
  * Part of AccName 1.2 §4.3.2 Step 2E — for labelable elements, the HTML label
6
6
  * association is checked before other element-specific rules.
@@ -33,7 +33,7 @@ export function resolveLabelText(el, resolver, visited, computeFn, inLabelledbyT
33
33
  */
34
34
  function collectLabelText(label, labeledElement, resolver, visited, computeFn, inLabelledbyTraversal) {
35
35
  const parts = [];
36
- for (const child of label.childNodes) {
36
+ for (const child of getChildNodes(label, resolver)) {
37
37
  if (child.nodeType === TEXT_NODE) {
38
38
  parts.push(child.textContent ?? '');
39
39
  }
@@ -10,6 +10,13 @@ import type { AccnameElement } from './types.js';
10
10
  *
11
11
  * Checks (in order): `aria-label`, `aria-labelledby`, `<title>` child, `<desc>` child.
12
12
  *
13
+ * Known limitation: this reads `el.children`, not `AccnameResolver.getChildNodes`,
14
+ * because `getComputedRole` has no resolver to pass. For a pretended component it
15
+ * therefore answers from the children written at the usage site, while the name
16
+ * computation reads the ones the component renders. A `<title>` that comes only
17
+ * from the `contents` of a pretender names nothing in either: those entries are
18
+ * childless, so it has no text.
19
+ *
13
20
  * @param el - The SVG element to check for accessible name sources
14
21
  * @returns True if the element has aria-label, aria-labelledby, or a title/desc child
15
22
  * @see https://www.w3.org/TR/svg-aam-1.0/#include_elements — SVG-AAM §5.1.1
@@ -10,6 +10,13 @@
10
10
  *
11
11
  * Checks (in order): `aria-label`, `aria-labelledby`, `<title>` child, `<desc>` child.
12
12
  *
13
+ * Known limitation: this reads `el.children`, not `AccnameResolver.getChildNodes`,
14
+ * because `getComputedRole` has no resolver to pass. For a pretended component it
15
+ * therefore answers from the children written at the usage site, while the name
16
+ * computation reads the ones the component renders. A `<title>` that comes only
17
+ * from the `contents` of a pretender names nothing in either: those entries are
18
+ * childless, so it has no text.
19
+ *
13
20
  * @param el - The SVG element to check for accessible name sources
14
21
  * @returns True if the element has aria-label, aria-labelledby, or a title/desc child
15
22
  * @see https://www.w3.org/TR/svg-aam-1.0/#include_elements — SVG-AAM §5.1.1
@@ -48,6 +48,17 @@ export interface AccnameResolver {
48
48
  * accessible names through configuration.
49
49
  */
50
50
  getPrecomputedName?(el: AccnameElement): string | null;
51
+ /**
52
+ * Returns the child nodes to traverse for `el`, in place of `el.childNodes`.
53
+ * Used by ml-core for a pretended component: it renders its own elements around
54
+ * the children given at the usage site (`contents` of the pretender), and those
55
+ * can carry the accessible name (`<button><img alt="Save"></button>`).
56
+ * Consulted by name-from-content, label text, the legend/caption/SVG `title`
57
+ * lookups, the `<select>` options, and wherever the computation reads an
58
+ * element's text (`getTextContent`): an option, an SVG `title`, the value of
59
+ * an embedded control. `hasSvgAccessibleNameSource` is the exception.
60
+ */
61
+ getChildNodes?(el: AccnameElement): Iterable<AccnameNode>;
51
62
  }
52
63
  /**
53
64
  * Identifies the source of an element's accessible name per HTML-AAM §4.1.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markuplint/ml-spec",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "Types and schema that specs of the Markup languages for markuplint",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,14 +36,14 @@
36
36
  "schema:aria": "npx json2ts ./schemas/aria.schema.json --cwd ./schemas > ./src/types/aria.ts"
37
37
  },
38
38
  "dependencies": {
39
- "@markuplint/ml-ast": "5.0.0",
40
- "@markuplint/types": "5.0.0",
39
+ "@markuplint/ml-ast": "5.1.0",
40
+ "@markuplint/types": "5.1.0",
41
41
  "is-plain-object": "5.0.0",
42
42
  "type-fest": "5.6.0"
43
43
  },
44
44
  "devDependencies": {
45
- "@markuplint/test-tools": "5.0.0",
45
+ "@markuplint/test-tools": "5.1.0",
46
46
  "json-schema-to-typescript": "15.0.4"
47
47
  },
48
- "gitHead": "c99706fda4c186aeb87b521eaabc5208ac144bbd"
48
+ "gitHead": "3a061784a8211034ad0c1cbd64798c0213414f65"
49
49
  }