@markuplint/parser-utils 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,29 @@
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
+ * **parser-utils:** map an IDL name by its exact spelling ([3eac033](https://github.com/markuplint/markuplint/commit/3eac033da5d20884bf00f4defeccb3be9c7d44bf))
12
+ * **rules:** tell the namespace of a component's prop by the name as written ([157f434](https://github.com/markuplint/markuplint/commit/157f434b9dc4bb0f51c3fb77b219f38ef4035dd8))
13
+
14
+ ### Features
15
+
16
+ * **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))
17
+ * **parser-utils:** add isComponentName to tell a component name from an element name ([3385e5b](https://github.com/markuplint/markuplint/commit/3385e5b874290ec6c9d23c769d50e8741a0ce8af))
18
+ * **parser-utils:** add the attrNameCaseSensitive parser option ([d494501](https://github.com/markuplint/markuplint/commit/d49450161f5de6dfcd8e035db5b39eeb3815d396)), closes [#4229](https://github.com/markuplint/markuplint/issues/4229)
19
+ * **parser-utils:** let a parser decide which character starts a tag name ([720022f](https://github.com/markuplint/markuplint/commit/720022ff33bd103c20827e232acb16a0da8230bc))
20
+ * **parser-utils:** let a parser own the rule of its component names ([92d5048](https://github.com/markuplint/markuplint/commit/92d5048a174bfa39e0624ece4ec71e0b00a2bae0)), closes [Parser#isComponentName](https://github.com/Parser/issues/isComponentName)
21
+ * **parser-utils:** let a parser say how an attribute name gets into the DOM ([abd81e5](https://github.com/markuplint/markuplint/commit/abd81e5d20f4ffeb0ba878e77fd4122c1a803a9f))
22
+
23
+ ### Performance Improvements
24
+
25
+ * **parser-utils:** skip debug maps when logging is disabled ([47466b0](https://github.com/markuplint/markuplint/commit/47466b00d5d4c4fc121ed55d22e11540cd1ed0cd))
26
+ * **selector:** read the debug flag once per match and pass it down ([1e99fc0](https://github.com/markuplint/markuplint/commit/1e99fc095aa57c9cadf08a55e109a1f75c0b31ca))
27
+
28
+
6
29
  # [5.2.0](https://github.com/markuplint/markuplint/compare/v5.1.1...v5.2.0) (2026-10-13)
7
30
 
8
31
  ### Bug Fixes
package/lib/debug.d.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import type { MLASTNode } from '@markuplint/ml-ast';
2
2
  import debug from 'debug';
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.
6
+ */
3
7
  export declare const log: debug.Debugger;
4
8
  export declare function domLog(nodeList: readonly (MLASTNode | null)[]): void;
5
9
  export declare class PerformanceTimer {
package/lib/debug.js CHANGED
@@ -1,7 +1,14 @@
1
1
  import debug from 'debug';
2
2
  import { nodeListToDebugMaps } from './debugger.js';
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.
6
+ */
3
7
  export const log = debug('ml-parser');
4
8
  export function domLog(nodeList) {
9
+ if (!log.enabled) {
10
+ return;
11
+ }
5
12
  log('Parse result: %O', nodeListToDebugMaps(nodeList, true));
6
13
  }
7
14
  export class PerformanceTimer {
package/lib/decision.d.ts CHANGED
@@ -1,2 +1,27 @@
1
1
  export declare function isSVGElement(nodeName: string): boolean;
2
2
  export declare function isPotentialCustomElementName(tagName: string): boolean;
3
+ /**
4
+ * Whether `name` is a reference to a component, not the name of an element, judged by
5
+ * its form alone.
6
+ *
7
+ * The scanners of `pretenders.auto` / `pretenders.scan` follow what a component
8
+ * renders until they reach a name they cannot resolve (a component of a package, a
9
+ * member expression such as `Tabs.Root`, a local variable, a namespace import, a
10
+ * component past the depth limit), and emit that name unchanged. Such a name is never
11
+ * an element: the names of HTML, SVG and MathML elements start with a lowercase
12
+ * letter and have no `.` (the camelCase of `foreignObject` starts with one), and the
13
+ * `.` is valid only in a custom element name, which a hyphen and a lowercase start
14
+ * give away. So the form of the name tells, however the chain stopped.
15
+ *
16
+ * This is the form that most languages share, and the default of `Parser#isComponentName`.
17
+ * A parser of a language that names its components otherwise overrides that method, and what asks
18
+ * (the scanners, `ml-core`, `pretenders`) asks the parser, not this function. It is called by name for a
19
+ * parser that says nothing and for a file of a language that has no parser to ask.
20
+ *
21
+ * The case sensitivity of the document is not part of the criterion, nor is the user's pattern for
22
+ * authored names (`authoredElementName`): the scanners and ml-core must agree on the rule of the language.
23
+ *
24
+ * @param name - A tag name as written, case kept
25
+ * @returns Whether the name is a component's
26
+ */
27
+ export declare function isComponentName(name: string): boolean;
package/lib/decision.js CHANGED
@@ -7,3 +7,30 @@ const isCEN = isCustomElementName();
7
7
  export function isPotentialCustomElementName(tagName) {
8
8
  return isCEN(tagName);
9
9
  }
10
+ /**
11
+ * Whether `name` is a reference to a component, not the name of an element, judged by
12
+ * its form alone.
13
+ *
14
+ * The scanners of `pretenders.auto` / `pretenders.scan` follow what a component
15
+ * renders until they reach a name they cannot resolve (a component of a package, a
16
+ * member expression such as `Tabs.Root`, a local variable, a namespace import, a
17
+ * component past the depth limit), and emit that name unchanged. Such a name is never
18
+ * an element: the names of HTML, SVG and MathML elements start with a lowercase
19
+ * letter and have no `.` (the camelCase of `foreignObject` starts with one), and the
20
+ * `.` is valid only in a custom element name, which a hyphen and a lowercase start
21
+ * give away. So the form of the name tells, however the chain stopped.
22
+ *
23
+ * This is the form that most languages share, and the default of `Parser#isComponentName`.
24
+ * A parser of a language that names its components otherwise overrides that method, and what asks
25
+ * (the scanners, `ml-core`, `pretenders`) asks the parser, not this function. It is called by name for a
26
+ * parser that says nothing and for a file of a language that has no parser to ask.
27
+ *
28
+ * The case sensitivity of the document is not part of the criterion, nor is the user's pattern for
29
+ * authored names (`authoredElementName`): the scanners and ml-core must agree on the rule of the language.
30
+ *
31
+ * @param name - A tag name as written, case kept
32
+ * @returns Whether the name is a component's
33
+ */
34
+ export function isComponentName(name) {
35
+ return /^[A-Z]|\./.test(name) && !isPotentialCustomElementName(name);
36
+ }
@@ -20,14 +20,25 @@ function _distinguishAuthoredName(name, patterns) {
20
20
  if (typeof pattern === 'function') {
21
21
  return pattern(name);
22
22
  }
23
- const regex = typeof pattern === 'string' ? toRegexp(pattern) : pattern;
24
- return regex.test(name);
23
+ if (typeof pattern === 'string') {
24
+ const regex = toRegexp(pattern);
25
+ return regex ? regex.test(name) : name === pattern;
26
+ }
27
+ return pattern.test(name);
25
28
  });
26
29
  }
30
+ /**
31
+ * A string in the form of a regular expression literal (`/^[A-Z]/i`, with the flags `g` and `i` at most) is that regular
32
+ * expression. Any other string is a name, and stands for the whole name only: `component`
33
+ * is `<component>`, not `<my-component>` nor `<components>`, and a character such as `.`
34
+ * is that character. So a name is written as it is, as in the examples of `authoredElementName`.
35
+ *
36
+ * @returns The regular expression, or `null` for a name
37
+ */
27
38
  function toRegexp(pattern) {
28
39
  const matched = pattern.match(/^\/(.+)\/([gi]*)$/i);
29
40
  if (matched && matched[1]) {
30
41
  return new RegExp(matched[1], matched[2]);
31
42
  }
32
- return new RegExp(pattern);
43
+ return null;
33
44
  }
@@ -1,13 +1,28 @@
1
1
  /**
2
2
  * Searches the IDL-to-content-attribute mapping to find the corresponding
3
3
  * IDL property name and content attribute name for a given attribute name.
4
- * Handles camelCase IDL names, lowercase content attribute names, hyphenated names,
5
- * and event handler attributes (e.g., `onclick`).
6
4
  *
7
- * @param name - The attribute name to look up (can be IDL, content, or hyphenated form)
5
+ * A name is mapped by its exact spelling: the IDL property name (`tabIndex`, `strokeWidth`) or the
6
+ * content attribute name (`tabindex`, `stroke-width`). A spelling in another case (`TABINDEX`) is
7
+ * another name here, because a framework reads its own property names as they are written (JSX
8
+ * `onClick` is a JavaScript identifier). Whether such a name is still the same attribute in the DOM is
9
+ * decided by `MLAttr#isNamedBy` of `@markuplint/ml-core`, not here. An event handler
10
+ * (`onclick`) is its own name in lower case.
11
+ *
12
+ * @param name - The attribute name to look up (the IDL or the content spelling, exactly)
8
13
  * @returns An object with the matching `idlPropName` and `contentAttrName`, both undefined if no match is found
9
14
  */
10
15
  export declare function searchIDLAttribute(name: string): {
11
16
  idlPropName: string | undefined;
12
17
  contentAttrName: string | undefined;
13
18
  };
19
+ /**
20
+ * Finds the IDL property that a name suggests whatever the case it is written in: `TABINDEX`,
21
+ * `tab-index` and `tabindex` suggest `tabIndex`. It is for a message that says how the framework
22
+ * spells the name (a suggestion), not for the name of an attribute: that is
23
+ * {@link searchIDLAttribute}, which reads a spelling exactly.
24
+ *
25
+ * @param name - The attribute name, in any case
26
+ * @returns The IDL property name, undefined if no name of the mapping is like it
27
+ */
28
+ export declare function suggestIDLAttribute(name: string): string | undefined;
@@ -416,30 +416,64 @@ const idlContentMap = {
416
416
  credentialless: 'credentialless',
417
417
  };
418
418
  const list = Object.entries(idlContentMap);
419
+ // The first entry of a name wins. They are built once: a name is looked up for every attribute of a document.
420
+ const byIdlPropName = new Map();
421
+ const byContentAttrName = new Map();
422
+ for (const entry of list) {
423
+ if (!byIdlPropName.has(entry[0])) {
424
+ byIdlPropName.set(entry[0], entry);
425
+ }
426
+ if (!byContentAttrName.has(entry[1])) {
427
+ byContentAttrName.set(entry[1], entry);
428
+ }
429
+ }
419
430
  /**
420
431
  * Searches the IDL-to-content-attribute mapping to find the corresponding
421
432
  * IDL property name and content attribute name for a given attribute name.
422
- * Handles camelCase IDL names, lowercase content attribute names, hyphenated names,
423
- * and event handler attributes (e.g., `onclick`).
424
433
  *
425
- * @param name - The attribute name to look up (can be IDL, content, or hyphenated form)
434
+ * A name is mapped by its exact spelling: the IDL property name (`tabIndex`, `strokeWidth`) or the
435
+ * content attribute name (`tabindex`, `stroke-width`). A spelling in another case (`TABINDEX`) is
436
+ * another name here, because a framework reads its own property names as they are written (JSX
437
+ * `onClick` is a JavaScript identifier). Whether such a name is still the same attribute in the DOM is
438
+ * decided by `MLAttr#isNamedBy` of `@markuplint/ml-core`, not here. An event handler
439
+ * (`onclick`) is its own name in lower case.
440
+ *
441
+ * @param name - The attribute name to look up (the IDL or the content spelling, exactly)
426
442
  * @returns An object with the matching `idlPropName` and `contentAttrName`, both undefined if no match is found
427
443
  */
428
444
  export function searchIDLAttribute(name) {
429
- const camelizedName = camelize(name);
430
445
  const [idlPropName, contentAttrName] = /^on[a-z]/.test(name)
431
446
  ? [name.toLowerCase(), name.toLowerCase()]
432
- : (list.find(([idlPropName, contentAttrName]) => idlPropName.toLowerCase() === camelizedName.toLowerCase() ||
433
- contentAttrName.toLowerCase() === name.toLowerCase() ||
434
- hyphenize(idlPropName) === name.toLowerCase()) ?? []);
447
+ : (byIdlPropName.get(name) ?? byContentAttrName.get(name) ?? []);
435
448
  return {
436
449
  idlPropName,
437
450
  contentAttrName,
438
451
  };
439
452
  }
440
- function camelize(str) {
441
- return str.replaceAll(/[:-][a-z]/g, $0 => $0[1]?.toUpperCase() ?? '');
453
+ // The IDL property that a name of another spelling suggests, by the lower-case of its IDL name, of its
454
+ // content name, and of its hyphenated form. The first entry of the list wins.
455
+ const suggestions = new Map();
456
+ for (const [idlPropName, contentAttrName] of list) {
457
+ for (const key of [
458
+ idlPropName.toLowerCase(),
459
+ contentAttrName.toLowerCase(),
460
+ idlPropName.replaceAll(/[A-Z]/g, c => `-${c.toLowerCase()}`),
461
+ ]) {
462
+ if (!suggestions.has(key)) {
463
+ suggestions.set(key, idlPropName);
464
+ }
465
+ }
442
466
  }
443
- function hyphenize(str) {
444
- return str.replaceAll(/[A-Z]/g, $0 => `-${$0.toLowerCase()}`);
467
+ /**
468
+ * Finds the IDL property that a name suggests whatever the case it is written in: `TABINDEX`,
469
+ * `tab-index` and `tabindex` suggest `tabIndex`. It is for a message that says how the framework
470
+ * spells the name (a suggestion), not for the name of an attribute: that is
471
+ * {@link searchIDLAttribute}, which reads a spelling exactly.
472
+ *
473
+ * @param name - The attribute name, in any case
474
+ * @returns The IDL property name, undefined if no name of the mapping is like it
475
+ */
476
+ export function suggestIDLAttribute(name) {
477
+ const lower = name.toLowerCase();
478
+ return suggestions.get(lower) ?? suggestions.get(lower.replaceAll(/[:-]/g, ''));
445
479
  }
package/lib/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './debugger.js';
2
+ export * from './decision.js';
2
3
  export * from './enums.js';
3
4
  export * from './get-namespace.js';
4
5
  export * from './idl-attributes.js';
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './debugger.js';
2
+ export * from './decision.js';
2
3
  export * from './enums.js';
3
4
  export * from './get-namespace.js';
4
5
  export * from './idl-attributes.js';
package/lib/parser.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Token, ChildToken, QuoteSet, ParseOptions, ParserOptions, Tokenized, ValueType } from './types.js';
2
- import type { EndTagType, MLASTDocument, MLASTParentNode, MLParser, ParserAuthoredElementNameDistinguishing, MLASTElement, MLASTElementCloseTag, MLASTToken, MLASTNodeTreeItem, MLASTTag, MLASTText, MLASTAttr, MLASTChildNode, MLASTSpreadAttr, ElementType, Walker, MLASTHTMLAttr, MLASTBlockBehavior, MLASTParseError } from '@markuplint/ml-ast';
2
+ import type { AttrNameRoute, EndTagType, MLASTDocument, MLASTParentNode, MLParser, ParserAuthoredElementNameDistinguishing, MLASTElement, MLASTElementCloseTag, MLASTToken, MLASTNodeTreeItem, MLASTTag, MLASTText, MLASTAttr, MLASTChildNode, MLASTSpreadAttr, ElementType, Walker, MLASTHTMLAttr, MLASTBlockBehavior, MLASTParseError } from '@markuplint/ml-ast';
3
3
  import { AttrState } from './enums.js';
4
4
  import { ParserError } from './parser-error.js';
5
5
  /**
@@ -73,6 +73,27 @@ export declare abstract class Parser<Node extends {} = {}, State extends unknown
73
73
  * When false (the default), tag name comparisons are case-insensitive (HTML behavior).
74
74
  */
75
75
  get tagNameCaseSensitive(): boolean;
76
+ /**
77
+ * Whether a component is referred to by any spelling of its name (`<FooBar>`, `<fooBar>`
78
+ * and `<foo-bar>` are one component). Only Vue resolves a tag so; see
79
+ * {@link MLParser.componentNameVariants} in `@markuplint/ml-ast`.
80
+ */
81
+ get componentNameVariants(): boolean;
82
+ /**
83
+ * Whether attribute names are case-sensitive in this language (JSX props such as
84
+ * `onClick`). Independent of {@link Parser.tagNameCaseSensitive}: Svelte and Astro
85
+ * distinguish components by tag-name case but lowercase or pass through HTML
86
+ * attribute names, so they leave this `false`.
87
+ */
88
+ get attrNameCaseSensitive(): boolean;
89
+ /**
90
+ * How the name of an attribute gets into the DOM of the browser (see `AttrNameRoute`), which decides
91
+ * whether another spelling of the name is the same attribute. Independent of
92
+ * {@link Parser.attrNameCaseSensitive}, which is about the style rules: Vue sets its attributes through
93
+ * the DOM API, but its names are not JavaScript identifiers, so a template is still checked for the
94
+ * case of a name. The HTML parser of the browser by default.
95
+ */
96
+ get attrNameRoute(): AttrNameRoute;
76
97
  /**
77
98
  * Tokenizes the raw source code into language-specific AST nodes.
78
99
  * Subclasses should override this method to provide actual tokenization logic.
@@ -264,7 +285,14 @@ export declare abstract class Parser<Node extends {} = {}, State extends unknown
264
285
  /**
265
286
  * Traverses a list of child nodes under the given parent, appending the resulting
266
287
  * child AST nodes to the parent and returning any sibling nodes that belong
267
- * to ancestor levels. Skips traversal for raw text elements (e.g., `<script>`, `<style>`).
288
+ * to ancestor levels.
289
+ *
290
+ * Children of a raw-text element (`rawTextElements`; `style` and `script` by
291
+ * default) are dropped on purpose. Their body is CSS or JavaScript, not markup,
292
+ * so it gets no text node and the element reads as empty downstream
293
+ * (`textContent === ''`, no `childNodes`, no `Text` visit). A rule that needs the
294
+ * body reads it from the document source between the element's `endOffset` and
295
+ * its `closeTag.startOffset`, when a close tag is present.
268
296
  *
269
297
  * @param children - The language-specific child AST nodes to traverse
270
298
  * @param parentNode - The parent markuplint AST node to which children will be appended
@@ -366,10 +394,42 @@ export declare abstract class Parser<Node extends {} = {}, State extends unknown
366
394
  * given tag name, using the parser's authored element name distinguishing pattern.
367
395
  *
368
396
  * @param nodeName - The tag name to classify
369
- * @param defaultPattern - A fallback pattern if no authored element name pattern is set
397
+ * @param defaultPattern - A fallback pattern if no authored element name pattern is set: a regular
398
+ * expression, a name, or a function (a parser of a language that decides by a rule passes
399
+ * `name => this.isComponentName(name)`)
370
400
  * @returns The element type classification
371
401
  */
372
402
  detectElementType(nodeName: string, defaultPattern?: ParserAuthoredElementNameDistinguishing): ElementType;
403
+ /**
404
+ * Whether a character can be the first of a tag name, where the tag scanner reads a `<` followed by it as a
405
+ * start tag. Anything else after a `<` leaves it as text (`a < b`, `<3`).
406
+ *
407
+ * The default is an ASCII letter, the rule of the HTML tokenizer ("tag open state") that most
408
+ * languages share. A parser of a language whose element names start otherwise overrides this
409
+ * (JSX: `$`, `_` and a letter of any script).
410
+ *
411
+ * @param char - The character that follows `<`
412
+ * @returns Whether it starts a tag name
413
+ * @see https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state
414
+ */
415
+ isTagNameStart(char: string): boolean;
416
+ /**
417
+ * Whether a name is a reference to a component, not the name of an element, judged by its form
418
+ * alone in the language this parser reads. The scanners of `pretenders` end a chain of what a
419
+ * component renders at a name they cannot resolve (`Tabs.Root`, a variable) and emit it as it is,
420
+ * and a pretender's target (`as.element`) is typed by it, so what the scanners and `ml-core` take
421
+ * for a component has to be the parser's own answer.
422
+ *
423
+ * The default is the form that most languages share: a capital initial or a `.` in a name that
424
+ * is not that of a custom element (the `isComponentName` function of `./decision.ts`). A parser of a language that
425
+ * names its components otherwise overrides this, as it overrides {@link detectElementType}; this
426
+ * class names no language. The user's `authoredElementName` is not part of the answer: it is
427
+ * the user's choice of what to call an authored element in a document, not the language's rule.
428
+ *
429
+ * @param name - A tag name as written, case kept
430
+ * @returns Whether the name is a component's
431
+ */
432
+ isComponentName(name: string): boolean;
373
433
  /**
374
434
  * Creates a new MLASTToken with a generated UUID.
375
435
  * Accepts either a Token object or a raw string with explicit start coordinates.
package/lib/parser.js CHANGED
@@ -3,6 +3,7 @@ import { isVoidElement as detectVoidElement } from '@markuplint/ml-spec';
3
3
  import { attrTokenizer } from './attr-tokenizer.js';
4
4
  import { defaultSpaces } from './const.js';
5
5
  import { domLog, PerformanceTimer } from './debug.js';
6
+ import { isComponentName } from './decision.js';
6
7
  import { detectElementType } from './detect-element-type.js';
7
8
  import { AttrState, TagState } from './enums.js';
8
9
  import { getEndCol, getEndLine, getOffsetsFromCode, getPosition } from './get-location.js';
@@ -46,6 +47,9 @@ export class Parser {
46
47
  #ignoreTags = [];
47
48
  #maskChar;
48
49
  #tagNameCaseSensitive = false;
50
+ #componentNameVariants = false;
51
+ #attrNameCaseSensitive = false;
52
+ #attrNameRoute = 'html-parser';
49
53
  #selfCloseType = 'html';
50
54
  #spaceChars = defaultSpaces;
51
55
  #rawTextElements = ['style', 'script'];
@@ -86,6 +90,9 @@ export class Parser {
86
90
  this.#ignoreTags = options?.ignoreTags ?? this.#ignoreTags;
87
91
  this.#maskChar = options?.maskChar ?? this.#maskChar;
88
92
  this.#tagNameCaseSensitive = options?.tagNameCaseSensitive ?? this.#tagNameCaseSensitive;
93
+ this.#componentNameVariants = options?.componentNameVariants ?? this.#componentNameVariants;
94
+ this.#attrNameCaseSensitive = options?.attrNameCaseSensitive ?? this.#attrNameCaseSensitive;
95
+ this.#attrNameRoute = options?.attrNameRoute ?? this.#attrNameRoute;
89
96
  this.#selfCloseType = options?.selfCloseType ?? this.#selfCloseType;
90
97
  this.#spaceChars = options?.spaceChars ?? this.#spaceChars;
91
98
  this.#rawTextElements = options?.rawTextElements ?? this.#rawTextElements;
@@ -136,6 +143,33 @@ export class Parser {
136
143
  get tagNameCaseSensitive() {
137
144
  return this.#tagNameCaseSensitive;
138
145
  }
146
+ /**
147
+ * Whether a component is referred to by any spelling of its name (`<FooBar>`, `<fooBar>`
148
+ * and `<foo-bar>` are one component). Only Vue resolves a tag so; see
149
+ * {@link MLParser.componentNameVariants} in `@markuplint/ml-ast`.
150
+ */
151
+ get componentNameVariants() {
152
+ return this.#componentNameVariants;
153
+ }
154
+ /**
155
+ * Whether attribute names are case-sensitive in this language (JSX props such as
156
+ * `onClick`). Independent of {@link Parser.tagNameCaseSensitive}: Svelte and Astro
157
+ * distinguish components by tag-name case but lowercase or pass through HTML
158
+ * attribute names, so they leave this `false`.
159
+ */
160
+ get attrNameCaseSensitive() {
161
+ return this.#attrNameCaseSensitive;
162
+ }
163
+ /**
164
+ * How the name of an attribute gets into the DOM of the browser (see `AttrNameRoute`), which decides
165
+ * whether another spelling of the name is the same attribute. Independent of
166
+ * {@link Parser.attrNameCaseSensitive}, which is about the style rules: Vue sets its attributes through
167
+ * the DOM API, but its names are not JavaScript identifiers, so a template is still checked for the
168
+ * case of a name. The HTML parser of the browser by default.
169
+ */
170
+ get attrNameRoute() {
171
+ return this.#attrNameRoute;
172
+ }
139
173
  /**
140
174
  * Tokenizes the raw source code into language-specific AST nodes.
141
175
  * Subclasses should override this method to provide actual tokenization logic.
@@ -576,7 +610,14 @@ export class Parser {
576
610
  /**
577
611
  * Traverses a list of child nodes under the given parent, appending the resulting
578
612
  * child AST nodes to the parent and returning any sibling nodes that belong
579
- * to ancestor levels. Skips traversal for raw text elements (e.g., `<script>`, `<style>`).
613
+ * to ancestor levels.
614
+ *
615
+ * Children of a raw-text element (`rawTextElements`; `style` and `script` by
616
+ * default) are dropped on purpose. Their body is CSS or JavaScript, not markup,
617
+ * so it gets no text node and the element reads as empty downstream
618
+ * (`textContent === ''`, no `childNodes`, no `Text` visit). A rule that needs the
619
+ * body reads it from the document source between the element's `endOffset` and
620
+ * its `closeTag.startOffset`, when a close tag is present.
580
621
  *
581
622
  * @param children - The language-specific child AST nodes to traverse
582
623
  * @param parentNode - The parent markuplint AST node to which children will be appended
@@ -896,12 +937,48 @@ export class Parser {
896
937
  * given tag name, using the parser's authored element name distinguishing pattern.
897
938
  *
898
939
  * @param nodeName - The tag name to classify
899
- * @param defaultPattern - A fallback pattern if no authored element name pattern is set
940
+ * @param defaultPattern - A fallback pattern if no authored element name pattern is set: a regular
941
+ * expression, a name, or a function (a parser of a language that decides by a rule passes
942
+ * `name => this.isComponentName(name)`)
900
943
  * @returns The element type classification
901
944
  */
902
945
  detectElementType(nodeName, defaultPattern) {
903
946
  return detectElementType(nodeName, this.#authoredElementName, defaultPattern);
904
947
  }
948
+ /**
949
+ * Whether a character can be the first of a tag name, where the tag scanner reads a `<` followed by it as a
950
+ * start tag. Anything else after a `<` leaves it as text (`a < b`, `<3`).
951
+ *
952
+ * The default is an ASCII letter, the rule of the HTML tokenizer ("tag open state") that most
953
+ * languages share. A parser of a language whose element names start otherwise overrides this
954
+ * (JSX: `$`, `_` and a letter of any script).
955
+ *
956
+ * @param char - The character that follows `<`
957
+ * @returns Whether it starts a tag name
958
+ * @see https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state
959
+ */
960
+ isTagNameStart(char) {
961
+ return /[a-z]/i.test(char);
962
+ }
963
+ /**
964
+ * Whether a name is a reference to a component, not the name of an element, judged by its form
965
+ * alone in the language this parser reads. The scanners of `pretenders` end a chain of what a
966
+ * component renders at a name they cannot resolve (`Tabs.Root`, a variable) and emit it as it is,
967
+ * and a pretender's target (`as.element`) is typed by it, so what the scanners and `ml-core` take
968
+ * for a component has to be the parser's own answer.
969
+ *
970
+ * The default is the form that most languages share: a capital initial or a `.` in a name that
971
+ * is not that of a custom element (the `isComponentName` function of `./decision.ts`). A parser of a language that
972
+ * names its components otherwise overrides this, as it overrides {@link detectElementType}; this
973
+ * class names no language. The user's `authoredElementName` is not part of the answer: it is
974
+ * the user's choice of what to call an authored element in a document, not the language's rule.
975
+ *
976
+ * @param name - A tag name as written, case kept
977
+ * @returns Whether the name is a component's
978
+ */
979
+ isComponentName(name) {
980
+ return isComponentName(name);
981
+ }
905
982
  createToken(token, offset, line, col) {
906
983
  const props = typeof token === 'string'
907
984
  ? {
@@ -1251,7 +1328,7 @@ export class Parser {
1251
1328
  break;
1252
1329
  }
1253
1330
  case TagState.FirstCharOfTagName: {
1254
- if (/[a-z]/i.test(char)) {
1331
+ if (this.isTagNameStart(char)) {
1255
1332
  tagName += char;
1256
1333
  state = TagState.TagName;
1257
1334
  break;
package/lib/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { EndTagType, MLASTParentNode, MLASTParseError, ParserOptions as ConfigParserOptions } from '@markuplint/ml-ast';
1
+ import type { AttrNameRoute, EndTagType, MLASTParentNode, MLASTParseError, ParserOptions as ConfigParserOptions } from '@markuplint/ml-ast';
2
2
  /**
3
3
  * Configuration options for initializing a Parser instance,
4
4
  * controlling how the parser handles tags, attributes, and whitespace.
@@ -9,6 +9,9 @@ export type ParserOptions = {
9
9
  readonly ignoreTags?: readonly IgnoreTag[];
10
10
  readonly maskChar?: string;
11
11
  readonly tagNameCaseSensitive?: boolean;
12
+ readonly componentNameVariants?: boolean;
13
+ readonly attrNameCaseSensitive?: boolean;
14
+ readonly attrNameRoute?: AttrNameRoute;
12
15
  readonly selfCloseType?: SelfCloseType;
13
16
  readonly spaceChars?: readonly string[];
14
17
  readonly rawTextElements?: readonly string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markuplint/parser-utils",
3
- "version": "5.2.0",
3
+ "version": "5.3.0",
4
4
  "description": "Utility module for markuplint parser plugin",
5
5
  "repository": {
6
6
  "type": "git",
@@ -15,12 +15,12 @@
15
15
  "type": "module",
16
16
  "exports": {
17
17
  ".": {
18
- "import": "./lib/index.js",
19
- "types": "./lib/index.d.ts"
18
+ "types": "./lib/index.d.ts",
19
+ "default": "./lib/index.js"
20
20
  },
21
21
  "./location": {
22
- "import": "./lib/get-location.js",
23
- "types": "./lib/get-location.d.ts"
22
+ "types": "./lib/get-location.d.ts",
23
+ "default": "./lib/get-location.js"
24
24
  }
25
25
  },
26
26
  "publishConfig": {
@@ -35,16 +35,16 @@
35
35
  "clean": "tsc --build --clean tsconfig.build.json"
36
36
  },
37
37
  "dependencies": {
38
- "@markuplint/ml-ast": "5.1.1",
39
- "@markuplint/ml-spec": "5.2.0",
40
- "@markuplint/shared": "5.2.0",
41
- "@markuplint/types": "5.2.0",
38
+ "@markuplint/ml-ast": "5.3.0",
39
+ "@markuplint/ml-spec": "5.3.0",
40
+ "@markuplint/shared": "5.3.0",
41
+ "@markuplint/types": "5.3.0",
42
42
  "debug": "^4.4.3",
43
43
  "espree": "^11.2.0",
44
44
  "type-fest": "^5.10.0"
45
45
  },
46
46
  "devDependencies": {
47
- "@typescript-eslint/typescript-estree": "8.71.1"
47
+ "@typescript-eslint/typescript-estree": "8.71.2"
48
48
  },
49
- "gitHead": "d8b2feab77c9c862513615bea6b80d2817431878"
49
+ "gitHead": "f587cf0206c8426e57369a48d6d5fb22306eafde"
50
50
  }