@markuplint/ml-core 5.2.0 → 5.3.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,33 @@
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.3.0](https://github.com/markuplint/markuplint/compare/v5.2.0...v5.3.0) (2026-10-21)
7
+
8
+ ### Bug Fixes
9
+
10
+ * make published packages loadable from require() and put types first ([723106f](https://github.com/markuplint/markuplint/commit/723106f51aac4e60dc02db2c1a894617368b0253)), closes [#4198](https://github.com/markuplint/markuplint/issues/4198)
11
+ * **ml-core:** add MLElement.isCustomElement so :model() matches #custom ([befdf3b](https://github.com/markuplint/markuplint/commit/befdf3be6623331b4fc5dca773df6ba745ea10ee)), closes [#custom](https://github.com/markuplint/markuplint/issues/custom)
12
+ * **ml-core:** decide by the name that the DOM gives an attribute ([68f732c](https://github.com/markuplint/markuplint/commit/68f732ce3eec1203fbed479af1f1488e5f272a47))
13
+ * **ml-core:** make the type selector and the pretender guard read `isNativeTag` of the parser ([9f7877f](https://github.com/markuplint/markuplint/commit/9f7877f10a02d86e5efe5254b414ef232f08c2a6))
14
+ * **ml-core:** match attribute names case-insensitively in getAttributeToken ([418e627](https://github.com/markuplint/markuplint/commit/418e627d6f7402136654ed6f1007b06ad6da3d86)), closes [#4180](https://github.com/markuplint/markuplint/issues/4180)
15
+ * **ml-core:** read a prefixed name as the HTML parser does and match Vue props by either spelling ([fe6cd8f](https://github.com/markuplint/markuplint/commit/fe6cd8f949fd28212e2123e4129c8dc3d7b0404f))
16
+ * **ml-core:** tell the selector whether the document tells the case of a tag name apart ([f0a9c17](https://github.com/markuplint/markuplint/commit/f0a9c1783bd237f307647f0b3cb978edeb814037))
17
+ * **ml-core:** treat a pretender target that is a component name as a component ([f20dc1b](https://github.com/markuplint/markuplint/commit/f20dc1be225e2f2e90f2c721bc2c10e8847b9809))
18
+ * **rules:** compare attribute names as the DOM does in the rules that list attributes ([51faee6](https://github.com/markuplint/markuplint/commit/51faee6f13a8f09f136438873991f0581071012a))
19
+ * **selector:** match a prefixed attribute by its local name as well ([a9beb11](https://github.com/markuplint/markuplint/commit/a9beb112b87bf9ade372ede0fe0d7958d05027fc))
20
+
21
+ ### Features
22
+
23
+ * **ml-core:** ask whether a name is a prefix of an attribute, and keep suggesting the property ([bd195ef](https://github.com/markuplint/markuplint/commit/bd195ef6ededb80482c642a86360c5f932f8b91b))
24
+ * **ml-core:** expose attrNameCaseSensitive on MLDocument ([d73a2fd](https://github.com/markuplint/markuplint/commit/d73a2fdc337425e6203da696abe785172f7b277d)), closes [#4229](https://github.com/markuplint/markuplint/issues/4229)
25
+ * **ml-core:** read the parser's name rules through the document ([b4f03ea](https://github.com/markuplint/markuplint/commit/b4f03eaff13f52421993bbec490330a4039ee850)), closes [MLElement#tagNameKind](https://github.com/MLElement/issues/tagNameKind)
26
+ * **parser-utils:** let a parser say how an attribute name gets into the DOM ([abd81e5](https://github.com/markuplint/markuplint/commit/abd81e5d20f4ffeb0ba878e77fd4122c1a803a9f))
27
+
28
+ ### Performance Improvements
29
+
30
+ * **ml-core:** guard debug logging on the rule mapper and verify paths ([1a6249f](https://github.com/markuplint/markuplint/commit/1a6249f333e2dc75d65607b5059f9d8d647e7b3b))
31
+
32
+
6
33
  # [5.2.0](https://github.com/markuplint/markuplint/compare/v5.1.1...v5.2.0) (2026-10-13)
7
34
 
8
35
  ### Bug Fixes
package/CLAUDE.md ADDED
@@ -0,0 +1,11 @@
1
+ # @markuplint/ml-core
2
+
3
+ ## Keeping the MLDOM classes in step with `lib.dom`
4
+
5
+ `MLElement`, `MLNode`, `MLAttr` and the other MLDOM classes `implements` the DOM interfaces, so a TypeScript release that adds a property or makes one nullable breaks the build with a long list of "is missing the following properties" errors. Fix that by adding the missing stubs and widening types to `| null` where `lib.dom` did, one stub at a time, rebuilding between rounds.
6
+
7
+ Do not change a parameter or return type from an MLDOM class to the plain DOM type (`MLElement` → `Element`, `MLNode` → `Node`) to make the errors go away. That drops the markuplint-specific type information for every caller, and the error cascade it seems to cure is only the missing stubs. Adding a method overload (for example on `matches()`) counts as a stub and is fine.
8
+
9
+ ## Consumer reports of a type error after a TypeScript release
10
+
11
+ The `implements` clauses land in the published `.d.ts`, so a consumer whose `lib.dom` is newer than ours (a newer TypeScript, or `libReplacement` with third-party DOM types) gets the same "is missing the following properties" errors from `node_modules/@markuplint/ml-core` without changing anything on their side; `skipLibCheck: false` is enough. Adding the stubs here fixes our build, and the consumer only once they upgrade markuplint. The structural fix — drop `implements` from the public classes and move DOM conformance to a typecheck spec that is not published (`yarn test:types` now type-checks the specs) — is tracked in #4076; check its state before closing another such report with a stub-only release.
package/lib/debug.d.ts CHANGED
@@ -1,4 +1,10 @@
1
1
  import debug from 'debug';
2
+ /**
3
+ * Read `.enabled` at call time; never cache it at module load. `debug.enable()` (including via `verbosely()` /
4
+ * `enableDebug()`) can switch a namespace on after import. A verify or fix run reads it once on entry to decide
5
+ * whether to build log arguments. Enabling debug mid-run is not seen until the next run; disabling takes effect at
6
+ * once because the logger itself re-checks.
7
+ */
2
8
  export declare const log: debug.Debugger;
3
9
  /**
4
10
  * Enables debug logging for the `ml-core` namespace and the CLI namespace.
package/lib/debug.js CHANGED
@@ -1,5 +1,11 @@
1
1
  import debug from 'debug';
2
2
  const CLI_NS = 'markuplint-cli';
3
+ /**
4
+ * Read `.enabled` at call time; never cache it at module load. `debug.enable()` (including via `verbosely()` /
5
+ * `enableDebug()`) can switch a namespace on after import. A verify or fix run reads it once on entry to decide
6
+ * whether to build log arguments. Enabling debug mid-run is not seen until the next run; disabling takes effect at
7
+ * once because the logger itself re-checks.
8
+ */
3
9
  export const log = debug('ml-core');
4
10
  /**
5
11
  * Enables debug logging for the `ml-core` namespace and the CLI namespace.
package/lib/ml-core.js CHANGED
@@ -191,7 +191,10 @@ export class MLCore {
191
191
  async verify(fixOrOptions) {
192
192
  const options = typeof fixOrOptions === 'boolean' ? { fix: fixOrOptions } : (fixOrOptions ?? {});
193
193
  const fix = options.fix ?? false;
194
- log('verify: start');
194
+ const logEnabled = log.enabled;
195
+ if (logEnabled) {
196
+ log('verify: start');
197
+ }
195
198
  const violations = [];
196
199
  if (this.#document instanceof ParserError) {
197
200
  const parseError = this.#createParseError(this.#document.message, this.#document.line, this.#document.col, this.#document.raw);
@@ -199,7 +202,9 @@ export class MLCore {
199
202
  return { violations: [], fixedCode: fix ? this.#sourceCode : undefined };
200
203
  }
201
204
  violations.push(parseError);
202
- log('verify: error %o', this.#document.message);
205
+ if (logEnabled) {
206
+ log('verify: error %o', this.#document.message);
207
+ }
203
208
  return { violations, fixedCode: fix ? this.#sourceCode : undefined };
204
209
  }
205
210
  this.#pushNonFatalParseErrors(violations);
@@ -321,7 +326,9 @@ export class MLCore {
321
326
  };
322
327
  }
323
328
  }
324
- log('verify: end');
329
+ if (logEnabled) {
330
+ log('verify: end');
331
+ }
325
332
  return { violations, fixedCode, fixSummary };
326
333
  }
327
334
  #createDocument() {
@@ -338,6 +345,12 @@ export class MLCore {
338
345
  endTag: this.#parser.endTag,
339
346
  booleanish: this.#parser.booleanish,
340
347
  tagNameCaseSensitive: this.#parser.tagNameCaseSensitive,
348
+ componentNameVariants: this.#parser.componentNameVariants,
349
+ // The parser's own rule, called on the parser: a method of a class that reads its own state
350
+ isComponentName: this.#parser.isComponentName?.bind(this.#parser),
351
+ isNativeTag: this.#parser.isNativeTag?.bind(this.#parser),
352
+ attrNameCaseSensitive: this.#parser.attrNameCaseSensitive,
353
+ attrNameRoute: this.#parser.attrNameRoute,
341
354
  pretenders: this.#pretenders,
342
355
  });
343
356
  // Collect errors from rule mapping (e.g., invalid wildcard usage).
@@ -494,6 +507,7 @@ export class MLCore {
494
507
  async #multiPassFix(initialViolations) {
495
508
  // Same safety cap as ESLint's SourceCodeFixer (10 passes).
496
509
  const MAX_FIX_PASSES = 10;
510
+ const logEnabled = log.enabled;
497
511
  let currentCode = this.#sourceCode;
498
512
  let fixes = extractFixes(initialViolations);
499
513
  let totalApplied = 0;
@@ -511,7 +525,9 @@ export class MLCore {
511
525
  let lastParsableCode = currentCode;
512
526
  let pass = 0;
513
527
  for (; pass < MAX_FIX_PASSES; pass++) {
514
- log('fix pass %d: %d fixes', pass, fixes.length);
528
+ if (logEnabled) {
529
+ log('fix pass %d: %d fixes', pass, fixes.length);
530
+ }
515
531
  const result = applyFixes(currentCode, fixes);
516
532
  totalApplied += result.applied.length;
517
533
  totalSkipped += result.skipped.length;
@@ -519,18 +535,24 @@ export class MLCore {
519
535
  firstPassEdits = result.appliedEdits;
520
536
  }
521
537
  if (result.applied.length === 0) {
522
- log('fix pass %d: no fixes applied, stopping', pass);
538
+ if (logEnabled) {
539
+ log('fix pass %d: no fixes applied, stopping', pass);
540
+ }
523
541
  break;
524
542
  }
525
543
  if (result.output === currentCode) {
526
- log('fix pass %d: output unchanged, stopping', pass);
544
+ if (logEnabled) {
545
+ log('fix pass %d: output unchanged, stopping', pass);
546
+ }
527
547
  break;
528
548
  }
529
549
  // Cycle detection: if the output matches the input of any earlier pass,
530
550
  // fixes are oscillating (A → B → A, A → B → C → A, ...) and will never converge.
531
551
  const cycleStart = codeHistory.get(result.output);
532
552
  if (cycleStart !== undefined) {
533
- log('fix pass %d: cycle detected (output matches the input of pass %d, cycle length %d), stopping', pass, cycleStart, pass + 1 - cycleStart);
553
+ if (logEnabled) {
554
+ log('fix pass %d: cycle detected (output matches the input of pass %d, cycle length %d), stopping', pass, cycleStart, pass + 1 - cycleStart);
555
+ }
534
556
  currentCode = result.output;
535
557
  latestViolations = undefined;
536
558
  break;
@@ -545,7 +567,9 @@ export class MLCore {
545
567
  this.#parse();
546
568
  this.#createDocument();
547
569
  if (this.#document instanceof ParserError) {
548
- log('fix pass %d: produced unparsable code, reverting to the previous parsable code', pass);
570
+ if (logEnabled) {
571
+ log('fix pass %d: produced unparsable code, reverting to the previous parsable code', pass);
572
+ }
549
573
  currentCode = lastParsableCode;
550
574
  totalApplied -= result.applied.length;
551
575
  if (pass === 0) {
@@ -555,21 +579,27 @@ export class MLCore {
555
579
  }
556
580
  lastParsableCode = currentCode;
557
581
  if (result.skipped.length === 0) {
558
- log('fix pass %d: all fixes applied, stopping', pass);
582
+ if (logEnabled) {
583
+ log('fix pass %d: all fixes applied, stopping', pass);
584
+ }
559
585
  break;
560
586
  }
561
587
  // --- Multi-pass path (only when overlapping fixes exist) ---
562
- log('fix pass %d: %d skipped, re-running rules for next pass', pass, result.skipped.length);
588
+ if (logEnabled) {
589
+ log('fix pass %d: %d skipped, re-running rules for next pass', pass, result.skipped.length);
590
+ }
563
591
  const newViolations = await this.#runAllRules(true);
564
592
  latestViolations = newViolations;
565
593
  fixes = extractFixes(newViolations);
566
594
  if (fixes.length === 0) {
567
- log('fix pass %d: no more fixable violations, stopping', pass);
595
+ if (logEnabled) {
596
+ log('fix pass %d: no more fixable violations, stopping', pass);
597
+ }
568
598
  break;
569
599
  }
570
600
  }
571
601
  const reachedMaxPasses = pass === MAX_FIX_PASSES;
572
- if (reachedMaxPasses) {
602
+ if (reachedMaxPasses && logEnabled) {
573
603
  log('fix: reached maximum number of passes (%d), some fixes may not have been applied', MAX_FIX_PASSES);
574
604
  }
575
605
  // Final verification (#3890): compute the violations that remain in the
@@ -608,6 +638,7 @@ export class MLCore {
608
638
  };
609
639
  }
610
640
  async #runAllRules(fix) {
641
+ const logEnabled = log.enabled;
611
642
  const violations = [];
612
643
  if (this.#document instanceof ParserError) {
613
644
  return violations;
@@ -629,7 +660,9 @@ export class MLCore {
629
660
  if (ruleInfo.disabled && ruleInfo.nodeRules.length === 0 && ruleInfo.childNodeRules.length === 0) {
630
661
  continue;
631
662
  }
632
- log('%s Rule: verify', rule.name);
663
+ if (logEnabled) {
664
+ log('%s Rule: verify', rule.name);
665
+ }
633
666
  const results = await rule.verify(this.#document, this.#locale, fix).catch(error => {
634
667
  if (error instanceof ParserError) {
635
668
  return error;
@@ -639,14 +672,18 @@ export class MLCore {
639
672
  if (results instanceof ParserError) {
640
673
  const parseError = this.#createParseError(results.message, results.line, results.col, results.raw);
641
674
  if (parseError) {
642
- log('%s Rule: verify error %o', rule.name, results.message);
675
+ if (logEnabled) {
676
+ log('%s Rule: verify error %o', rule.name, results.message);
677
+ }
643
678
  violations.push(parseError);
644
679
  }
645
680
  }
646
681
  else {
647
682
  violations.push(...results);
648
683
  }
649
- log('%s Rule: verify end', rule.name);
684
+ if (logEnabled) {
685
+ log('%s Rule: verify end', rule.name);
686
+ }
650
687
  }
651
688
  return violations;
652
689
  }
@@ -655,7 +692,9 @@ export class MLCore {
655
692
  this.#ast = this.#parser.parse(this.#sourceCode, this.#parserOptions);
656
693
  }
657
694
  catch (error) {
658
- log('Caught the parse error: %O', error);
695
+ if (log.enabled) {
696
+ log('Caught the parse error: %O', error);
697
+ }
659
698
  this.#ast = null;
660
699
  if (error instanceof ParserError) {
661
700
  this.#document = error;
@@ -23,8 +23,10 @@ el, version) {
23
23
  return result.name;
24
24
  }
25
25
  catch (error) {
26
- accnameLog('Raw: %s', el.raw);
27
- accnameLog('Error: %O', error);
26
+ if (accnameLog.enabled) {
27
+ accnameLog('Raw: %s', el.raw);
28
+ accnameLog('Error: %O', error);
29
+ }
28
30
  return '';
29
31
  }
30
32
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The name that the HTML parser gives an attribute of an element in a foreign namespace: the name
3
+ * lower-cased, with the case restored for the names of SVG (`viewbox` is `viewBox`) and MathML
4
+ * (`definitionurl` is `definitionURL`).
5
+ *
6
+ * @see https://html.spec.whatwg.org/multipage/parsing.html#adjust-svg-attributes
7
+ * @see https://html.spec.whatwg.org/multipage/parsing.html#adjust-mathml-attributes
8
+ *
9
+ * @param lowerCaseName - The name with its ASCII letters lower-cased
10
+ * @param namespaceURI - The namespace of the element that the attribute is on
11
+ * @returns The name in the DOM
12
+ */
13
+ export declare function adjustForeignAttrName(lowerCaseName: string, namespaceURI: string | null): string;
@@ -0,0 +1,89 @@
1
+ const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
2
+ const MATHML_NAMESPACE = 'http://www.w3.org/1998/Math/MathML';
3
+ /**
4
+ * The names of SVG attributes that are not all lower case, the second column of the table of
5
+ * "adjust SVG attributes". The HTML parser lower-cases a name it reads and then restores these.
6
+ *
7
+ * @see https://html.spec.whatwg.org/multipage/parsing.html#adjust-svg-attributes
8
+ */
9
+ const SVG_ATTR_NAMES = new Map([
10
+ 'attributeName',
11
+ 'attributeType',
12
+ 'baseFrequency',
13
+ 'baseProfile',
14
+ 'calcMode',
15
+ 'clipPathUnits',
16
+ 'diffuseConstant',
17
+ 'edgeMode',
18
+ 'filterUnits',
19
+ 'glyphRef',
20
+ 'gradientTransform',
21
+ 'gradientUnits',
22
+ 'kernelMatrix',
23
+ 'kernelUnitLength',
24
+ 'keyPoints',
25
+ 'keySplines',
26
+ 'keyTimes',
27
+ 'lengthAdjust',
28
+ 'limitingConeAngle',
29
+ 'markerHeight',
30
+ 'markerUnits',
31
+ 'markerWidth',
32
+ 'maskContentUnits',
33
+ 'maskUnits',
34
+ 'numOctaves',
35
+ 'pathLength',
36
+ 'patternContentUnits',
37
+ 'patternTransform',
38
+ 'patternUnits',
39
+ 'pointsAtX',
40
+ 'pointsAtY',
41
+ 'pointsAtZ',
42
+ 'preserveAlpha',
43
+ 'preserveAspectRatio',
44
+ 'primitiveUnits',
45
+ 'refX',
46
+ 'refY',
47
+ 'repeatCount',
48
+ 'repeatDur',
49
+ 'requiredExtensions',
50
+ 'requiredFeatures',
51
+ 'specularConstant',
52
+ 'specularExponent',
53
+ 'spreadMethod',
54
+ 'startOffset',
55
+ 'stdDeviation',
56
+ 'stitchTiles',
57
+ 'surfaceScale',
58
+ 'systemLanguage',
59
+ 'tableValues',
60
+ 'targetX',
61
+ 'targetY',
62
+ 'textLength',
63
+ 'viewBox',
64
+ 'viewTarget',
65
+ 'xChannelSelector',
66
+ 'yChannelSelector',
67
+ 'zoomAndPan',
68
+ ].map(name => [name.toLowerCase(), name]));
69
+ /**
70
+ * The name that the HTML parser gives an attribute of an element in a foreign namespace: the name
71
+ * lower-cased, with the case restored for the names of SVG (`viewbox` is `viewBox`) and MathML
72
+ * (`definitionurl` is `definitionURL`).
73
+ *
74
+ * @see https://html.spec.whatwg.org/multipage/parsing.html#adjust-svg-attributes
75
+ * @see https://html.spec.whatwg.org/multipage/parsing.html#adjust-mathml-attributes
76
+ *
77
+ * @param lowerCaseName - The name with its ASCII letters lower-cased
78
+ * @param namespaceURI - The namespace of the element that the attribute is on
79
+ * @returns The name in the DOM
80
+ */
81
+ export function adjustForeignAttrName(lowerCaseName, namespaceURI) {
82
+ if (namespaceURI === SVG_NAMESPACE) {
83
+ return SVG_ATTR_NAMES.get(lowerCaseName) ?? lowerCaseName;
84
+ }
85
+ if (namespaceURI === MATHML_NAMESPACE && lowerCaseName === 'definitionurl') {
86
+ return 'definitionURL';
87
+ }
88
+ return lowerCaseName;
89
+ }
@@ -97,6 +97,83 @@ export declare class MLAttr<T extends RuleConfigValue, O extends PlainData = und
97
97
  * @see https://dom.spec.whatwg.org/#dom-attr-name
98
98
  */
99
99
  get name(): string;
100
+ /**
101
+ * The name that the DOM gives this attribute, which two spellings of a name share when they are
102
+ * one attribute (`ID` and `id` on a `<div>`, `VIEWBOX` and `viewbox` on an `<svg>` that the
103
+ * HTML parser reads). `name` keeps the spelling of the source for a message, and for
104
+ * `case-sensitive-attr-name`.
105
+ *
106
+ * It depends on how the name gets into the DOM (`MLDocument#attrNameRoute`) and on the element:
107
+ * the HTML parser lower-cases a name, and restores the case of the names of SVG and MathML;
108
+ * `setAttribute` lower-cases a name only on an element of the HTML namespace, so `ROLE` on an
109
+ * `<svg>` that code sets is `ROLE`, which is not `role`. The attribute of a component is a prop,
110
+ * which keeps its spelling, except in Vue where `my-label` and `myLabel` are one prop.
111
+ *
112
+ * @see https://dom.spec.whatwg.org/#dom-element-setattribute
113
+ * @see https://html.spec.whatwg.org/multipage/parsing.html#attribute-name-state
114
+ */
115
+ get nameKey(): string;
116
+ /**
117
+ * The key of `name`, a name of a spec, of an option or of a lookup, as this attribute is keyed:
118
+ * `name` is this attribute when its key is {@link MLAttr.nameKey}. Only the ASCII letters fold,
119
+ * as the HTML syntax says (`toLowerCase` also turns U+212A KELVIN SIGN into `k`).
120
+ *
121
+ * It is computed when asked, not stored by the constructor: it reads the owner element (its
122
+ * type and namespace) and the document, which are complete only after the attribute is built.
123
+ *
124
+ * @param name - A name as written
125
+ * @returns The key that two spellings of one attribute share
126
+ */
127
+ keyOf(name: string): string;
128
+ /**
129
+ * Whether `name` is the name of this attribute: the spelling it is written in, or another that
130
+ * the DOM makes the same attribute ({@link MLAttr.keyOf}). A spec, an option and the lookups of
131
+ * the element ask this, so the case of a name is decided here and not in each of them.
132
+ *
133
+ * A name with a capital letter that code sets on an element of the HTML namespace is a prop of
134
+ * the framework (`defaultValue` of React), not an attribute of the DOM: only its exact spelling
135
+ * is it, and `defaultvalue` is not (see {@link MLAttr.isPrefixedBy} for a prefix).
136
+ *
137
+ * @param name - A name of a spec, an option or a lookup
138
+ * @returns `true` if `name` is this attribute
139
+ */
140
+ isNamedBy(name: string): boolean;
141
+ /**
142
+ * Whether the name of this attribute starts with `prefix`, a prefix of an option
143
+ * (`ignoreAttrNamePrefix`): as it is written, or as the DOM gives the name ({@link MLAttr.keyOf}).
144
+ * A prefix with a capital letter that code sets on an element of the HTML namespace is the prefix
145
+ * of a name of the framework, and only its exact spelling is it, as for {@link MLAttr.isNamedBy}.
146
+ *
147
+ * @param prefix - A prefix of an option
148
+ * @returns `true` if the name of this attribute starts with `prefix`
149
+ */
150
+ isPrefixedBy(prefix: string): boolean;
151
+ /**
152
+ * Whether the name of this attribute reaches the DOM through the DOM API (`setAttribute`) and
153
+ * not through the HTML parser of the browser: what the parser says of its language
154
+ * (`MLDocument#attrNameRoute`), and for a parser that says it of a dynamic attribute only
155
+ * (Svelte), whether this one is dynamic.
156
+ */
157
+ get isSetThroughDOMAPI(): boolean;
158
+ /**
159
+ * Whether a selector matches the name of this attribute without regard to ASCII case: the DOM
160
+ * does so for an element of the HTML namespace and not for one of SVG or MathML, nor for a prop.
161
+ *
162
+ * @implements `SelectorAttr#nameCaseInsensitive` of `@markuplint/selector`
163
+ */
164
+ get nameCaseInsensitive(): boolean;
165
+ /**
166
+ * The name that a selector is matched with.
167
+ *
168
+ * @implements `SelectorAttr#domName` of `@markuplint/selector`
169
+ */
170
+ get domName(): string;
171
+ /**
172
+ * Whether a name of a selector is this prop once it is camelized: the props of a Vue component.
173
+ *
174
+ * @implements `SelectorAttr#nameIsCamelized` of `@markuplint/selector`
175
+ */
176
+ get nameIsCamelized(): boolean;
100
177
  /**
101
178
  * Returns the namespace URI of this attribute: that of its prefix when the name has a known
102
179
  * one (e.g. `xlink:href`), otherwise `null`. An attribute without a prefix has no namespace
@@ -1,9 +1,10 @@
1
1
  import { resolveNamespace, compileDirectivePatterns, resolveDirective } from '@markuplint/ml-spec';
2
- import { searchIDLAttribute } from '@markuplint/parser-utils';
2
+ import { searchIDLAttribute, suggestIDLAttribute } from '@markuplint/parser-utils';
3
+ import { adjustForeignAttrName } from '../helper/foreign-attr-names.js';
3
4
  import { MLToken } from '../token/token.js';
4
5
  import { MLDomTokenList } from './dom-token-list.js';
5
6
  import { MLNode } from './node.js';
6
- import { UnexpectedCallError } from '@markuplint/shared';
7
+ import { UnexpectedCallError, asciiLowerCase, vuePropKey } from '@markuplint/shared';
7
8
  /**
8
9
  * Represents a DOM Attr (attribute) node wrapper in the markuplint DOM tree.
9
10
  * Wraps an AST attribute token and provides access to the attribute's name, value,
@@ -141,10 +142,12 @@ export class MLAttr extends MLNode {
141
142
  }
142
143
  // Set candidate for IDL naming suggestions (e.g., tabindex → tabIndex in JSX).
143
144
  // Only in 'idl' mode (React). In 'both' mode (Svelte), both content and IDL names are accepted.
144
- if (!resolution && idlPropName && ownElement.ownerMLDocument.specs.acceptedAttrNames === 'idl') {
145
+ if (!resolution && ownElement.ownerMLDocument.specs.acceptedAttrNames === 'idl') {
146
+ // The property of a spelling that is not its own (`TABINDEX`) is suggested as well
147
+ const suggested = idlPropName ?? suggestIDLAttribute(this.#potentialName);
145
148
  const rawName = this.nameNode?.raw ?? '';
146
- if (rawName !== idlPropName) {
147
- this.candidate = idlPropName;
149
+ if (suggested && rawName !== suggested) {
150
+ this.candidate = suggested;
148
151
  }
149
152
  }
150
153
  }
@@ -156,13 +159,17 @@ export class MLAttr extends MLNode {
156
159
  this.isDirective = this._astToken.isDirective;
157
160
  this.isDuplicatable = this._astToken.isDuplicatable;
158
161
  }
159
- const ns = resolveNamespace(this.#potentialName, ownElement.namespaceURI);
162
+ // The HTML parser lower-cases the name before it adjusts a foreign attribute, so `XLINK:HREF`
163
+ // is `xlink:href` there; a name that code sets keeps the case of its prefix.
164
+ const prefixed = this.#potentialName.includes(':') && !this.isSetThroughDOMAPI;
165
+ const nameWithPrefix = prefixed ? asciiLowerCase(this.#potentialName) : this.#potentialName;
166
+ const ns = resolveNamespace(nameWithPrefix, ownElement.namespaceURI);
160
167
  this.#localName = ns.localName;
161
168
  // An attribute does not inherit the namespace of its element, so only a known prefix
162
169
  // gives it one. `ns.namespace` equals the prefix exactly when the prefix is a known
163
170
  // one; for an unknown prefix it falls back to the element's namespace.
164
- const hasPrefix = ns.localName !== this.#potentialName;
165
- const prefix = this.#potentialName.split(':')[0];
171
+ const hasPrefix = ns.localName !== nameWithPrefix;
172
+ const prefix = nameWithPrefix.split(':')[0];
166
173
  this.#namespaceURI = hasPrefix && ns.namespace === prefix ? ns.namespaceURI : null;
167
174
  }
168
175
  /**
@@ -183,6 +190,133 @@ export class MLAttr extends MLNode {
183
190
  get name() {
184
191
  return this.#potentialName;
185
192
  }
193
+ /**
194
+ * The name that the DOM gives this attribute, which two spellings of a name share when they are
195
+ * one attribute (`ID` and `id` on a `<div>`, `VIEWBOX` and `viewbox` on an `<svg>` that the
196
+ * HTML parser reads). `name` keeps the spelling of the source for a message, and for
197
+ * `case-sensitive-attr-name`.
198
+ *
199
+ * It depends on how the name gets into the DOM (`MLDocument#attrNameRoute`) and on the element:
200
+ * the HTML parser lower-cases a name, and restores the case of the names of SVG and MathML;
201
+ * `setAttribute` lower-cases a name only on an element of the HTML namespace, so `ROLE` on an
202
+ * `<svg>` that code sets is `ROLE`, which is not `role`. The attribute of a component is a prop,
203
+ * which keeps its spelling, except in Vue where `my-label` and `myLabel` are one prop.
204
+ *
205
+ * @see https://dom.spec.whatwg.org/#dom-element-setattribute
206
+ * @see https://html.spec.whatwg.org/multipage/parsing.html#attribute-name-state
207
+ */
208
+ get nameKey() {
209
+ return this.keyOf(this.#potentialName);
210
+ }
211
+ /**
212
+ * The key of `name`, a name of a spec, of an option or of a lookup, as this attribute is keyed:
213
+ * `name` is this attribute when its key is {@link MLAttr.nameKey}. Only the ASCII letters fold,
214
+ * as the HTML syntax says (`toLowerCase` also turns U+212A KELVIN SIGN into `k`).
215
+ *
216
+ * It is computed when asked, not stored by the constructor: it reads the owner element (its
217
+ * type and namespace) and the document, which are complete only after the attribute is built.
218
+ *
219
+ * @param name - A name as written
220
+ * @returns The key that two spellings of one attribute share
221
+ */
222
+ keyOf(name) {
223
+ const el = this.ownerElement;
224
+ if (el.elementType === 'authored') {
225
+ return el.ownerMLDocument.componentNameVariants ? vuePropKey(name) : name;
226
+ }
227
+ if (!el.isForeignElement) {
228
+ return asciiLowerCase(name);
229
+ }
230
+ if (this.isSetThroughDOMAPI) {
231
+ return name;
232
+ }
233
+ return adjustForeignAttrName(asciiLowerCase(name), el.namespaceURI);
234
+ }
235
+ /**
236
+ * Whether `name` is the name of this attribute: the spelling it is written in, or another that
237
+ * the DOM makes the same attribute ({@link MLAttr.keyOf}). A spec, an option and the lookups of
238
+ * the element ask this, so the case of a name is decided here and not in each of them.
239
+ *
240
+ * A name with a capital letter that code sets on an element of the HTML namespace is a prop of
241
+ * the framework (`defaultValue` of React), not an attribute of the DOM: only its exact spelling
242
+ * is it, and `defaultvalue` is not (see {@link MLAttr.isPrefixedBy} for a prefix).
243
+ *
244
+ * @param name - A name of a spec, an option or a lookup
245
+ * @returns `true` if `name` is this attribute
246
+ */
247
+ isNamedBy(name) {
248
+ if (name === this.#potentialName) {
249
+ return true;
250
+ }
251
+ if (this.#isFrameworkPropName(name)) {
252
+ return false;
253
+ }
254
+ return this.keyOf(name) === this.nameKey;
255
+ }
256
+ /**
257
+ * Whether the name of this attribute starts with `prefix`, a prefix of an option
258
+ * (`ignoreAttrNamePrefix`): as it is written, or as the DOM gives the name ({@link MLAttr.keyOf}).
259
+ * A prefix with a capital letter that code sets on an element of the HTML namespace is the prefix
260
+ * of a name of the framework, and only its exact spelling is it, as for {@link MLAttr.isNamedBy}.
261
+ *
262
+ * @param prefix - A prefix of an option
263
+ * @returns `true` if the name of this attribute starts with `prefix`
264
+ */
265
+ isPrefixedBy(prefix) {
266
+ if (this.#potentialName.startsWith(prefix)) {
267
+ return true;
268
+ }
269
+ if (this.#isFrameworkPropName(prefix)) {
270
+ return false;
271
+ }
272
+ return this.nameKey.startsWith(this.keyOf(prefix));
273
+ }
274
+ /**
275
+ * A name with a capital letter that code sets on an element of the HTML namespace is a prop of the
276
+ * framework (`defaultValue` of React), not an attribute of the DOM: the DOM folds what `setAttribute`
277
+ * is given, and a prop is read as it is written.
278
+ */
279
+ #isFrameworkPropName(name) {
280
+ const el = this.ownerElement;
281
+ return el.elementType !== 'authored' && !el.isForeignElement && this.isSetThroughDOMAPI && /[A-Z]/.test(name);
282
+ }
283
+ /**
284
+ * Whether the name of this attribute reaches the DOM through the DOM API (`setAttribute`) and
285
+ * not through the HTML parser of the browser: what the parser says of its language
286
+ * (`MLDocument#attrNameRoute`), and for a parser that says it of a dynamic attribute only
287
+ * (Svelte), whether this one is dynamic.
288
+ */
289
+ get isSetThroughDOMAPI() {
290
+ const route = this.ownerElement.ownerMLDocument.attrNameRoute;
291
+ return route === 'dom-api' || (route === 'dom-api-when-dynamic' && this.isDynamicValue === true);
292
+ }
293
+ /**
294
+ * Whether a selector matches the name of this attribute without regard to ASCII case: the DOM
295
+ * does so for an element of the HTML namespace and not for one of SVG or MathML, nor for a prop.
296
+ *
297
+ * @implements `SelectorAttr#nameCaseInsensitive` of `@markuplint/selector`
298
+ */
299
+ get nameCaseInsensitive() {
300
+ const el = this.ownerElement;
301
+ return el.elementType !== 'authored' && !el.isForeignElement;
302
+ }
303
+ /**
304
+ * The name that a selector is matched with.
305
+ *
306
+ * @implements `SelectorAttr#domName` of `@markuplint/selector`
307
+ */
308
+ get domName() {
309
+ return this.nameKey;
310
+ }
311
+ /**
312
+ * Whether a name of a selector is this prop once it is camelized: the props of a Vue component.
313
+ *
314
+ * @implements `SelectorAttr#nameIsCamelized` of `@markuplint/selector`
315
+ */
316
+ get nameIsCamelized() {
317
+ const el = this.ownerElement;
318
+ return el.elementType === 'authored' && el.ownerMLDocument.componentNameVariants;
319
+ }
186
320
  /**
187
321
  * Returns the namespace URI of this attribute: that of its prefix when the name has a known
188
322
  * one (e.g. `xlink:href`), otherwise `null`. An attribute without a prefix has no namespace