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