@markuplint/ml-spec 4.10.1 → 5.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/ARCHITECTURE.ja.md +267 -0
  2. package/ARCHITECTURE.md +267 -0
  3. package/CHANGELOG.md +20 -2
  4. package/README.md +6 -188
  5. package/SKILL.md +116 -0
  6. package/docs/aria-algorithms.ja.md +798 -0
  7. package/docs/aria-algorithms.md +800 -0
  8. package/docs/html-algorithms.ja.md +469 -0
  9. package/docs/html-algorithms.md +469 -0
  10. package/docs/maintenance.ja.md +331 -0
  11. package/docs/maintenance.md +331 -0
  12. package/docs/spec-resolution.ja.md +568 -0
  13. package/docs/spec-resolution.md +580 -0
  14. package/docs/type-definitions.ja.md +565 -0
  15. package/docs/type-definitions.md +565 -0
  16. package/lib/algorithm/aria/accname/__tests__/test-helpers.d.ts +45 -0
  17. package/lib/algorithm/aria/accname/__tests__/test-helpers.js +120 -0
  18. package/lib/algorithm/aria/accname/aria-steps.d.ts +51 -0
  19. package/lib/algorithm/aria/accname/aria-steps.js +104 -0
  20. package/lib/algorithm/aria/accname/compute.d.ts +51 -0
  21. package/lib/algorithm/aria/accname/compute.js +101 -0
  22. package/lib/algorithm/aria/accname/element-names.d.ts +36 -0
  23. package/lib/algorithm/aria/accname/element-names.js +342 -0
  24. package/lib/algorithm/aria/accname/helpers.d.ts +98 -0
  25. package/lib/algorithm/aria/accname/helpers.js +330 -0
  26. package/lib/algorithm/aria/accname/index.d.ts +4 -0
  27. package/lib/algorithm/aria/accname/index.js +3 -0
  28. package/lib/algorithm/aria/accname/label-steps.d.ts +25 -0
  29. package/lib/algorithm/aria/accname/label-steps.js +66 -0
  30. package/lib/algorithm/aria/accname/svg-helpers.d.ts +17 -0
  31. package/lib/algorithm/aria/accname/svg-helpers.js +30 -0
  32. package/lib/algorithm/aria/accname/types.d.ts +70 -0
  33. package/lib/algorithm/aria/accname/types.js +2 -0
  34. package/lib/algorithm/aria/accname-computation.d.ts +19 -1
  35. package/lib/algorithm/aria/accname-computation.js +136 -6
  36. package/lib/algorithm/aria/aria-specs.d.ts +8 -0
  37. package/lib/algorithm/aria/aria-specs.js +7 -0
  38. package/lib/algorithm/aria/get-aria.d.ts +12 -0
  39. package/lib/algorithm/aria/get-aria.js +42 -4
  40. package/lib/algorithm/aria/get-computed-aria-props.d.ts +22 -0
  41. package/lib/algorithm/aria/get-computed-aria-props.js +10 -0
  42. package/lib/algorithm/aria/get-computed-role.d.ts +12 -0
  43. package/lib/algorithm/aria/get-computed-role.js +118 -26
  44. package/lib/algorithm/aria/get-explicit-role.d.ts +12 -0
  45. package/lib/algorithm/aria/get-explicit-role.js +12 -0
  46. package/lib/algorithm/aria/get-implicit-role.d.ts +18 -0
  47. package/lib/algorithm/aria/get-implicit-role.js +18 -0
  48. package/lib/algorithm/aria/get-non-presentational-ancestor.d.ts +12 -0
  49. package/lib/algorithm/aria/get-non-presentational-ancestor.js +14 -2
  50. package/lib/algorithm/aria/get-permitted-roles-spec.d.ts +8 -4
  51. package/lib/algorithm/aria/get-permitted-roles-spec.js +19 -6
  52. package/lib/algorithm/aria/get-permitted-roles.d.ts +9 -0
  53. package/lib/algorithm/aria/get-permitted-roles.js +9 -0
  54. package/lib/algorithm/aria/get-role-spec.d.ts +11 -0
  55. package/lib/algorithm/aria/get-role-spec.js +21 -3
  56. package/lib/algorithm/aria/has-required-owned-elements.d.ts +24 -0
  57. package/lib/algorithm/aria/has-required-owned-elements.js +40 -14
  58. package/lib/algorithm/aria/is-exposed.d.ts +7 -4
  59. package/lib/algorithm/aria/is-exposed.js +7 -4
  60. package/lib/algorithm/aria/is-presentational.d.ts +32 -0
  61. package/lib/algorithm/aria/is-presentational.js +39 -0
  62. package/lib/algorithm/aria/matches-context-role.d.ts +19 -0
  63. package/lib/algorithm/aria/matches-context-role.js +46 -2
  64. package/lib/algorithm/html/content-model-category-to-tag-names.d.ts +9 -0
  65. package/lib/algorithm/html/content-model-category-to-tag-names.js +10 -1
  66. package/lib/algorithm/html/get-content-model.d.ts +12 -1
  67. package/lib/algorithm/html/get-content-model.js +14 -6
  68. package/lib/algorithm/html/get-selectors-by-content-model-category.d.ts +8 -0
  69. package/lib/algorithm/html/get-selectors-by-content-model-category.js +8 -0
  70. package/lib/algorithm/html/is-nothing-content-model.d.ts +7 -0
  71. package/lib/algorithm/html/is-nothing-content-model.js +7 -0
  72. package/lib/algorithm/html/is-palpable-elements.d.ts +13 -0
  73. package/lib/algorithm/html/is-palpable-elements.js +13 -0
  74. package/lib/algorithm/html/is-void-element.d.ts +9 -0
  75. package/lib/algorithm/html/is-void-element.js +9 -0
  76. package/lib/algorithm/html/may-be-focusable.d.ts +10 -0
  77. package/lib/algorithm/html/may-be-focusable.js +10 -0
  78. package/lib/const/accname.d.ts +29 -0
  79. package/lib/const/accname.js +76 -0
  80. package/lib/const/dom.d.ts +8 -0
  81. package/lib/const/dom.js +8 -0
  82. package/lib/const/index.d.ts +2 -0
  83. package/lib/const/index.js +2 -0
  84. package/lib/index.d.ts +3 -0
  85. package/lib/index.js +4 -0
  86. package/lib/types/index.d.ts +119 -0
  87. package/lib/utils/aria-version.d.ts +6 -0
  88. package/lib/utils/aria-version.js +6 -0
  89. package/lib/utils/directive-resolver.d.ts +23 -0
  90. package/lib/utils/directive-resolver.js +50 -0
  91. package/lib/utils/get-attr-specs-spec.d.ts +18 -0
  92. package/lib/utils/get-attr-specs-spec.js +22 -3
  93. package/lib/utils/get-attr-specs.d.ts +9 -0
  94. package/lib/utils/get-attr-specs.js +9 -0
  95. package/lib/utils/get-ns.d.ts +7 -0
  96. package/lib/utils/get-ns.js +7 -0
  97. package/lib/utils/get-spec-by-tag-name.d.ts +12 -1
  98. package/lib/utils/get-spec-by-tag-name.js +11 -0
  99. package/lib/utils/get-spec.d.ts +11 -1
  100. package/lib/utils/get-spec.js +10 -0
  101. package/lib/utils/merge-array.d.ts +10 -0
  102. package/lib/utils/merge-array.js +10 -0
  103. package/lib/utils/resolve-namespace.d.ts +13 -0
  104. package/lib/utils/resolve-namespace.js +10 -0
  105. package/lib/utils/resolve-version.d.ts +11 -0
  106. package/lib/utils/resolve-version.js +11 -0
  107. package/lib/utils/schema-to-spec.d.ts +7 -2
  108. package/lib/utils/schema-to-spec.js +20 -6
  109. package/lib/utils/validate-aria-version.d.ts +7 -0
  110. package/lib/utils/validate-aria-version.js +7 -0
  111. package/package.json +9 -7
@@ -0,0 +1,580 @@
1
+ # Spec Resolution Pipeline
2
+
3
+ This document describes how `@markuplint/ml-spec` merges a base HTML specification with framework-specific extensions, resolves element and attribute lookups, handles ARIA versioning, and manages caching throughout the pipeline.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Overview](#overview)
8
+ - [Schema Merging (`schemaToSpec`)](#schema-merging-schematospec)
9
+ - [Element Spec Lookup](#element-spec-lookup)
10
+ - [Namespace Resolution](#namespace-resolution)
11
+ - [Attribute Spec Resolution](#attribute-spec-resolution)
12
+ - [ARIA Version Resolution](#aria-version-resolution)
13
+ - [Array Merging (`mergeArray`)](#array-merging-mergearray)
14
+ - [Caching Strategy](#caching-strategy)
15
+
16
+ ---
17
+
18
+ ## Overview
19
+
20
+ The spec resolution pipeline is the mechanism by which markuplint constructs a
21
+ single, unified specification from multiple sources. The overall flow is:
22
+
23
+ ```
24
+ @markuplint/html-spec (MLMLSpec)
25
+ +
26
+ framework specs (ExtendedSpec[]) e.g., vue-spec, react-spec, svelte-spec
27
+ |
28
+ v
29
+ schemaToSpec()
30
+ |
31
+ v
32
+ merged MLMLSpec (used by all downstream algorithms)
33
+ ```
34
+
35
+ A user's configuration file may reference one or more framework plugins. Each
36
+ plugin provides an `ExtendedSpec` that adds, overrides, or extends the base
37
+ HTML specification with framework-specific elements, attributes, ARIA
38
+ definitions, and content model categories.
39
+
40
+ All downstream APIs -- element lookup, attribute resolution, ARIA queries,
41
+ content model checks -- operate on the merged `MLMLSpec` returned by
42
+ `schemaToSpec`. This design means that resolution logic does not need to know
43
+ whether a particular definition came from the base spec or an extension.
44
+
45
+ ### Key Types
46
+
47
+ | Type | Role |
48
+ | -------------- | ---------------------------------------------------------------------------------------------------- |
49
+ | `MLMLSpec` | The full specification object: cites, defs (globalAttrs, aria, contentModels), and per-element specs |
50
+ | `ExtendedSpec` | A partial overlay that may contribute to any section of `MLMLSpec` |
51
+ | `ElementSpec` | Per-element definition: name, categories, attributes, globalAttrs, aria, contentModel |
52
+ | `Attribute` | Single attribute definition: name, type, description, and other metadata |
53
+
54
+ ---
55
+
56
+ ## Schema Merging (`schemaToSpec`)
57
+
58
+ **File:** `src/utils/schema-to-spec.ts`
59
+
60
+ ```ts
61
+ function schemaToSpec(schemas: readonly [MLMLSpec, ...ExtendedSpec[]]): MLMLSpec;
62
+ ```
63
+
64
+ The function takes a tuple whose first element is always the base `MLMLSpec`
65
+ (typically `@markuplint/html-spec`) and whose remaining elements are zero or
66
+ more `ExtendedSpec` objects. It returns a new `MLMLSpec` containing the merged
67
+ result.
68
+
69
+ ### Step-by-Step Merge Process
70
+
71
+ The merge is performed iteratively. Each `ExtendedSpec` in order is folded into
72
+ the accumulating result:
73
+
74
+ ```ts
75
+ const [main, ...extendedSpecs] = schemas;
76
+ const result = { ...main };
77
+
78
+ for (const extendedSpec of extendedSpecs) {
79
+ // merge each section...
80
+ }
81
+ ```
82
+
83
+ #### 1. Cites
84
+
85
+ If the extended spec provides `cites`, they are concatenated onto the existing
86
+ array:
87
+
88
+ ```ts
89
+ result.cites = [...result.cites, ...extendedSpec.cites];
90
+ ```
91
+
92
+ This is simple array concatenation -- no deduplication is performed because
93
+ citation sources are expected to be distinct across specs.
94
+
95
+ #### 2. Global Attributes
96
+
97
+ The extended spec's `#extends` property is spread into the base spec's
98
+ `#HTMLGlobalAttrs` category:
99
+
100
+ ```ts
101
+ gAttrs['#HTMLGlobalAttrs'] = {
102
+ ...def['#globalAttrs']?.['#HTMLGlobalAttrs'],
103
+ ...extendedSpec.def['#globalAttrs']?.['#extends'],
104
+ };
105
+ ```
106
+
107
+ This means a framework can introduce new global attributes (e.g., Vue's `v-if`,
108
+ `v-for`) and they become part of `#HTMLGlobalAttrs` for all elements that
109
+ reference that category. Existing attributes with the same key are overridden by
110
+ the extension.
111
+
112
+ #### 3. ARIA Definitions
113
+
114
+ For each ARIA version (`1.1`, `1.2`, `1.3`), the four arrays -- `roles`,
115
+ `props`, `graphicsRoles`, and `dpubRoles` -- are merged using `mergeArray`:
116
+
117
+ ```ts
118
+ def['#aria'] = {
119
+ '1.1': {
120
+ roles: mergeArray(def['#aria']['1.1'].roles, extendedSpec.def['#aria']['1.1'].roles),
121
+ props: mergeArray(def['#aria']['1.1'].props, extendedSpec.def['#aria']['1.1'].props),
122
+ graphicsRoles: mergeArray(def['#aria']['1.1'].graphicsRoles, extendedSpec.def['#aria']['1.1'].graphicsRoles),
123
+ dpubRoles: mergeArray(def['#aria']['1.1'].dpubRoles, extendedSpec.def['#aria']['1.1'].dpubRoles),
124
+ },
125
+ // same for 1.2 and 1.3
126
+ };
127
+ ```
128
+
129
+ `mergeArray` uses name-based matching (see [Array Merging](#array-merging-mergearray)),
130
+ so an extension can both add new ARIA roles/properties and override existing
131
+ definitions.
132
+
133
+ #### 4. Content Models
134
+
135
+ All content model categories are unioned. For each category key across both the
136
+ base and extension, the selector arrays are concatenated:
137
+
138
+ ```ts
139
+ const keys = new Set([...Object.keys(def['#contentModels']), ...Object.keys(extendedSpec.def['#contentModels'])]);
140
+
141
+ for (const modelName of keys) {
142
+ models[modelName] = [...(mainModel ?? []), ...(exModel ?? [])];
143
+ }
144
+ ```
145
+
146
+ This means a framework can add its custom elements to existing categories
147
+ (e.g., adding `<router-link>` to `#phrasing`) or define entirely new
148
+ categories.
149
+
150
+ #### 5. Directive Patterns
151
+
152
+ If the extended spec provides `directivePatterns`, they are concatenated onto the
153
+ existing array:
154
+
155
+ ```ts
156
+ result.directivePatterns = [...(result.directivePatterns ?? []), ...extendedSpec.directivePatterns];
157
+ ```
158
+
159
+ This is simple array concatenation. The core engine evaluates patterns in order
160
+ (first match wins), so framework specs should define their patterns from most
161
+ specific to most general.
162
+
163
+ #### 6. `useIDLAttributeNames`
164
+
165
+ If the extended spec explicitly sets `useIDLAttributeNames` (to `true` or
166
+ `false`), it overrides the current value:
167
+
168
+ ```ts
169
+ if (extendedSpec.useIDLAttributeNames != null) {
170
+ result.useIDLAttributeNames = extendedSpec.useIDLAttributeNames;
171
+ }
172
+ ```
173
+
174
+ This is a last-write-wins semantic with explicit `null` guard -- omitting the
175
+ property does not reset a previously set value. This flag is consumed by
176
+ `@markuplint/ml-core`'s `MLAttr` constructor to enable IDL-to-content attribute
177
+ name resolution (e.g., `className` -> `class`).
178
+
179
+ #### 7. Element Specs
180
+
181
+ Elements are matched by name (case-insensitive comparison). For each element in
182
+ the base spec:
183
+
184
+ - If no matching extension element exists, the base element is kept as-is.
185
+ - If a match is found, the specs are merged:
186
+
187
+ ```ts
188
+ specs.push({
189
+ ...elSpec, // base element spread
190
+ ...exSpec, // extension overrides top-level properties
191
+ globalAttrs: {
192
+ ...elSpec.globalAttrs,
193
+ ...exSpec?.globalAttrs,
194
+ },
195
+ attributes: mergeAttrSpec(elSpec.attributes, exSpec?.attributes),
196
+ categories: mergeArray(elSpec.categories, exSpec?.categories),
197
+ });
198
+ ```
199
+
200
+ The helper `mergeAttrSpec` unions all attribute keys and spreads the extension
201
+ attribute onto the base attribute for each key, allowing partial overrides of
202
+ individual attribute definitions.
203
+
204
+ ---
205
+
206
+ ## Element Spec Lookup
207
+
208
+ **Files:** `src/utils/get-spec.ts`, `src/utils/get-spec-by-tag-name.ts`
209
+
210
+ The element lookup API has two layers:
211
+
212
+ ### DOM Wrapper: `getSpec`
213
+
214
+ ```ts
215
+ function getSpec<K extends keyof ElementSpec>(
216
+ el: Element,
217
+ specs: readonly Pick<ElementSpec, 'name' | K>[],
218
+ ): Pick<ElementSpec, 'name' | K> | null;
219
+ ```
220
+
221
+ A convenience wrapper that extracts `el.localName` and `el.namespaceURI` from a
222
+ DOM `Element` and delegates to `getSpecByTagName`.
223
+
224
+ ### Core Lookup: `getSpecByTagName`
225
+
226
+ ```ts
227
+ function getSpecByTagName<K extends keyof ElementSpec>(
228
+ specs: readonly Pick<ElementSpec, 'name' | K>[],
229
+ localName: string,
230
+ namespace: string | null,
231
+ ): Pick<ElementSpec, 'name' | K> | null;
232
+ ```
233
+
234
+ Steps:
235
+
236
+ 1. Call `resolveNamespace(localName, namespace)` to get the namespace-qualified
237
+ name (e.g., `"svg:circle"` for an SVG circle element, or `"div"` for an HTML div).
238
+ 2. Check the module-level `Map<string, ElementSpec | null>` cache using the
239
+ qualified name as key.
240
+ 3. If not cached, perform a linear search through `specs` matching by `name`.
241
+ 4. Store the result (including `null` for miss) in the cache and return.
242
+
243
+ The generic parameter `K` allows callers to request only specific keys from
244
+ `ElementSpec`, reducing the amount of data carried through the type system while
245
+ still preserving type safety.
246
+
247
+ ---
248
+
249
+ ## Namespace Resolution
250
+
251
+ **Files:** `src/utils/resolve-namespace.ts`, `src/utils/get-ns.ts`
252
+
253
+ ### `resolveNamespace`
254
+
255
+ ```ts
256
+ function resolveNamespace(
257
+ name: string,
258
+ namespaceURI: string | null = 'http://www.w3.org/1999/xhtml',
259
+ ): NamespacedElementName;
260
+ ```
261
+
262
+ Resolves an element name and optional namespace URI into a fully normalized
263
+ form:
264
+
265
+ ```ts
266
+ type NamespacedElementName = {
267
+ localNameWithNS: string; // e.g., "svg:circle" or "div"
268
+ localName: string; // e.g., "circle" or "div"
269
+ namespace: Namespace; // "html" | "svg" | "mml" | "xlink"
270
+ namespaceURI: NamespaceURI; // full URI string
271
+ };
272
+ ```
273
+
274
+ Resolution logic:
275
+
276
+ 1. **Split on colon** -- If `name` contains a colon (e.g., `"svg:circle"`), the
277
+ prefix is treated as an explicit namespace hint and the suffix as the local
278
+ name.
279
+ 2. **Determine namespace** -- The namespace is resolved from the explicit prefix
280
+ or by calling `getNS(namespaceURI)`. If neither yields a recognized
281
+ namespace, it defaults to `'html'`.
282
+ 3. **Build qualified name** -- For HTML namespace, the qualified name is just the
283
+ local name (no prefix). For all other namespaces, the shorthand is prepended:
284
+ `"svg:circle"`, `"mml:math"`, etc.
285
+ 4. **Cache** -- Results are cached in a `Map<string, NamespacedElementName>`
286
+ keyed by the concatenation `name + namespaceURI`.
287
+
288
+ ### Namespace URI Mapping
289
+
290
+ | Namespace URI | Shorthand |
291
+ | ------------------------------------ | --------- |
292
+ | `http://www.w3.org/1999/xhtml` | `html` |
293
+ | `http://www.w3.org/2000/svg` | `svg` |
294
+ | `http://www.w3.org/1998/Math/MathML` | `mml` |
295
+ | `http://www.w3.org/1999/xlink` | `xlink` |
296
+
297
+ ### `getNS` Helper
298
+
299
+ ```ts
300
+ function getNS(namespaceURI: string | null): Namespace;
301
+ ```
302
+
303
+ A simple switch-case that maps a namespace URI string to its shorthand. Any
304
+ unrecognized URI (including `null`) returns `'html'`.
305
+
306
+ ---
307
+
308
+ ## Attribute Spec Resolution
309
+
310
+ **Files:** `src/utils/get-attr-specs.ts` (DOM wrapper), `src/utils/get-attr-specs-spec.ts` (core)
311
+
312
+ ### DOM Wrapper: `getAttrSpecs` (from `get-attr-specs.ts`)
313
+
314
+ ```ts
315
+ function getAttrSpecs(el: Element, schema: MLMLSpec): readonly Attribute[] | null;
316
+ ```
317
+
318
+ Extracts `el.localName` and `el.namespaceURI`, then delegates to the core
319
+ function.
320
+
321
+ ### Core Function: `getAttrSpecs` (from `get-attr-specs-spec.ts`)
322
+
323
+ ```ts
324
+ function getAttrSpecs(localName: string, namespace: NamespaceURI | null, schema: MLMLSpec): readonly Attribute[] | null;
325
+ ```
326
+
327
+ Resolution steps:
328
+
329
+ 1. **Schema invalidation** -- If the `schema` reference has changed (checked via
330
+ a `WeakSet<MLMLSpec>`), the entire attribute cache is cleared. This ensures
331
+ correctness when specs are re-merged.
332
+
333
+ 2. **Cache check** -- Look up the namespace-qualified name in
334
+ `Map<string, readonly Attribute[] | null>`.
335
+
336
+ 3. **Find element spec** -- Search `schema.specs` for a matching element by
337
+ namespace-qualified name. Return `null` (and cache it) if not found.
338
+
339
+ 4. **Collect global attributes** -- Iterate over the element's `globalAttrs`
340
+ selection map. For each category:
341
+ - `false` -- skip the category entirely
342
+ - `true` -- include all attributes from that global category
343
+ - `string[]` -- include only the named attributes from that category
344
+
345
+ ```ts
346
+ for (const catName in elSpec.globalAttrs) {
347
+ const catAttrs = elSpec.globalAttrs[catName];
348
+ if (catAttrs === false) continue;
349
+ if (typeof catAttrs === 'boolean') {
350
+ attrs = { ...attrs, ...global };
351
+ }
352
+ if (Array.isArray(catAttrs)) {
353
+ for (const selectedName of catAttrs) {
354
+ attrs[selectedName] = { ...attrs[selectedName], ...global[selectedName] };
355
+ }
356
+ }
357
+ }
358
+ ```
359
+
360
+ 5. **Merge element-specific attributes** -- The element's own `attributes` are
361
+ spread on top of the collected globals, so element-specific definitions
362
+ override globals:
363
+
364
+ ```ts
365
+ attrs[attrName] = {
366
+ description: '',
367
+ ...current, // from globals
368
+ ...attr, // from element spec
369
+ };
370
+ ```
371
+
372
+ 6. **Convert to sorted array** -- The attribute map is converted to an
373
+ `Attribute[]`, giving each entry a default `type: 'Any'` if none was
374
+ provided, then sorted alphabetically (case-insensitive) using `nameCompare`.
375
+
376
+ 7. **Cache and return** -- The sorted array is stored in the cache and returned.
377
+
378
+ ### `nameCompare`
379
+
380
+ ```ts
381
+ function nameCompare(a: HasName | string, b: HasName | string): number;
382
+ ```
383
+
384
+ Case-insensitive sort comparator. Extracts the `name` property (or uses the
385
+ string directly), converts to uppercase, and performs standard lexicographic
386
+ comparison.
387
+
388
+ ---
389
+
390
+ ## ARIA Version Resolution
391
+
392
+ **Files:** `src/utils/resolve-version.ts`, `src/utils/aria-version.ts`,
393
+ `src/utils/validate-aria-version.ts`, `src/algorithm/aria/get-aria.ts`
394
+
395
+ ### `resolveVersion`
396
+
397
+ ```ts
398
+ function resolveVersion(aria: ReadonlyDeep<ARIA>, version: ARIAVersion): Omit<ReadonlyDeep<ARIA>, ARIAVersion>;
399
+ ```
400
+
401
+ Extracts a version-specific ARIA definition from a multi-version `ARIA` object.
402
+ For each property, the version-specific value is used if present; otherwise the
403
+ base (version-agnostic) value applies:
404
+
405
+ ```ts
406
+ const implicitRole = aria[version]?.implicitRole ?? aria.implicitRole;
407
+ const permittedRoles = aria[version]?.permittedRoles ?? aria.permittedRoles;
408
+ // ...etc
409
+ ```
410
+
411
+ Special case: `namingProhibited` for version `'1.1'` always uses the base
412
+ value, because the naming prohibition concept was not formalized until ARIA 1.2:
413
+
414
+ ```ts
415
+ const namingProhibited =
416
+ version === '1.1' ? aria.namingProhibited : (aria[version]?.namingProhibited ?? aria.namingProhibited);
417
+ ```
418
+
419
+ The returned object contains only the resolved properties -- the version keys
420
+ (`'1.1'`, `'1.2'`, `'1.3'`) are stripped from the type.
421
+
422
+ ### `getARIA`
423
+
424
+ ```ts
425
+ function getARIA(
426
+ specs: MLMLSpec,
427
+ localName: string,
428
+ namespace: string | null,
429
+ version: ARIAVersion,
430
+ matches: Matches,
431
+ ): Omit<ReadonlyDeep<ARIA>, ARIAVersion | 'conditions'> | null;
432
+ ```
433
+
434
+ The high-level ARIA resolver. It:
435
+
436
+ 1. Calls `getVersionResolvedARIA` (internal) to get the version-resolved spec.
437
+ 2. If the spec has `conditions` (CSS-selector-keyed overrides), iterates through
438
+ them and applies the first matching condition's properties. This handles
439
+ cases like `<input type="checkbox">` having different ARIA semantics than
440
+ `<input type="text">`.
441
+ 3. Optimizes `permittedRoles` -- if both `"presentation"` and `"none"` are in
442
+ the list, ensures both synonyms are present (per WAI-ARIA 1.2 note).
443
+
444
+ The internal `getVersionResolvedARIA` function caches results in a
445
+ `Map<string, ARIA | null>` keyed by `localName + namespace + version`.
446
+
447
+ ### Version Constants and Validation
448
+
449
+ ```ts
450
+ // aria-version.ts
451
+ const ariaVersions = ['1.1', '1.2', '1.3'] as const;
452
+ const ARIA_RECOMMENDED_VERSION = '1.2';
453
+
454
+ // validate-aria-version.ts
455
+ function validateAriaVersion(version: string): version is ARIAVersion;
456
+ ```
457
+
458
+ `validateAriaVersion` is a type guard that checks whether a string is a member
459
+ of the `ariaVersions` tuple. It is used at configuration boundaries to validate
460
+ user-supplied version strings before they enter the typed pipeline.
461
+
462
+ ---
463
+
464
+ ## Array Merging (`mergeArray`)
465
+
466
+ **File:** `src/utils/merge-array.ts`
467
+
468
+ ```ts
469
+ function mergeArray<T extends NamedDefinition>(a: readonly T[], b: readonly T[] | null | undefined): readonly T[];
470
+ ```
471
+
472
+ Where `NamedDefinition = string | { readonly name: string }`.
473
+
474
+ This is the core merge utility used throughout the spec merging pipeline. It
475
+ performs **name-based merging** rather than simple concatenation:
476
+
477
+ ### Algorithm
478
+
479
+ 1. If `b` is `null` or `undefined`, return `a` unchanged.
480
+ 2. Start with a copy of `a`.
481
+ 3. For each item in `b`:
482
+ - Extract the name using `getName()` (case-insensitive, trimmed).
483
+ - Search for an item with the same name in the result.
484
+ - **No match found:** append the extension item.
485
+ - **Match found (both are strings):** the string is a simple identifier with
486
+ no additional data, so the base item is kept (the splice removes it, then
487
+ the loop continues without pushing a replacement, effectively keeping the
488
+ base version).
489
+ - **Match found (base is string, extension is object):** replace with the
490
+ richer object form from the extension.
491
+ - **Match found (both are objects):** spread-merge the two objects, with the
492
+ extension's properties taking precedence:
493
+ ```ts
494
+ const exItem = { ...aItem, ...bItem };
495
+ ```
496
+
497
+ ### `getName` Helper
498
+
499
+ ```ts
500
+ function getName(def: NamedDefinition): string {
501
+ const result = typeof def === 'string' ? def : def.name;
502
+ return result.toLowerCase().trim();
503
+ }
504
+ ```
505
+
506
+ Names are normalized to lowercase and trimmed before comparison, ensuring
507
+ case-insensitive matching.
508
+
509
+ ---
510
+
511
+ ## Caching Strategy
512
+
513
+ The package uses multiple cache layers to avoid redundant computation. Since
514
+ specs are typically loaded once and reused for the lifetime of a lint run, these
515
+ caches provide significant performance benefits.
516
+
517
+ ### Cache Inventory
518
+
519
+ | # | Location | Cache Type | Key | Value | Invalidation |
520
+ | --- | ----------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------- |
521
+ | 1 | `getSpecByTagName` | `Map<string, any>` | Namespace-qualified name (e.g., `"svg:circle"`) | `ElementSpec \| null` | Module lifetime (never cleared) |
522
+ | 2 | `getVersionResolvedARIA` (inside `get-aria.ts`) | `Map<string, ARIA \| null>` | `localName + namespace + version` (string concatenation) | Version-resolved ARIA spec or null | Module lifetime (never cleared) |
523
+ | 3 | `getContentModel` | `WeakMap<Element, ...>` | DOM Element reference | `PermittedContentPattern[] \| boolean \| null` | Entries automatically removed when Element is GC'd |
524
+ | 4 | `contentModelCategoryToTagNames` | `Map<Category, ReadonlyArray<string>>` | Category string (e.g., `"#flow"`) | Frozen sorted array of tag names | Module lifetime (never cleared) |
525
+ | 5 | `resolveNamespace` | `Map<string, NamespacedElementName>` | `name + namespaceURI` (string concatenation) | Resolved namespace object | Module lifetime (never cleared) |
526
+ | 6 | `getAttrSpecs` (in `get-attr-specs-spec.ts`) | `Map<string, readonly Attribute[] \| null>` + `WeakSet<MLMLSpec>` | Namespace-qualified name | Sorted attribute array or null | Cleared when schema reference changes (via WeakSet check) |
527
+
528
+ ### Cache Characteristics
529
+
530
+ **No explicit eviction:** Most caches are module-level `Map` instances that
531
+ persist for the entire process lifetime. This is appropriate because:
532
+
533
+ - Spec data is immutable after merging.
534
+ - The number of unique elements/attributes is bounded (HTML has ~120 elements).
535
+ - A lint run typically processes one configuration.
536
+
537
+ **Schema-aware invalidation:** The `getAttrSpecs` cache (item 6) is the
538
+ exception. It uses a `WeakSet<MLMLSpec>` to detect when the schema reference
539
+ changes. If a new schema is passed (e.g., when running different file patterns
540
+ with different framework specs), the entire `cacheMap` is cleared before
541
+ proceeding:
542
+
543
+ ```ts
544
+ if (!schemaCache.has(schema)) {
545
+ cacheMap.clear();
546
+ }
547
+ ```
548
+
549
+ **WeakMap caching:** The `getContentModel` cache (item 3) uses a
550
+ `WeakMap<Element, ...>` keyed by DOM Element reference. Re-querying the same
551
+ element is O(1). When elements are garbage-collected (e.g., after a re-parse),
552
+ their cache entries are automatically removed, preventing memory leaks.
553
+
554
+ **Deterministic keys:** Caches in items 1, 2, and 5 use string concatenation
555
+ for keys. Since `resolveNamespace` produces deterministic output for the same
556
+ inputs, and spec data does not mutate, these concatenated keys are stable.
557
+
558
+ ### Data Flow with Caching
559
+
560
+ ```
561
+ schemaToSpec() --> merged MLMLSpec (no cache; called once at startup)
562
+ |
563
+ +-----------+-----------+
564
+ | | |
565
+ getSpecByTagName getAttrSpecs getARIA
566
+ (cache 1) (cache 6) (cache 2)
567
+ | |
568
+ resolveNamespace resolveVersion
569
+ (cache 5) (pure; no cache)
570
+ |
571
+ getContentModel
572
+ (cache 3)
573
+ |
574
+ contentModelCategoryToTagNames
575
+ (cache 4)
576
+ ```
577
+
578
+ Each arrow represents a function call. Caches intercept repeated calls at each
579
+ layer, so a second lookup for the same element hits cached results at every
580
+ level of the call chain.