@markuplint/parser-utils 5.0.0-rc.4 → 5.0.0-rc.5

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,17 @@
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.0.0-rc.5](https://github.com/markuplint/markuplint/compare/v5.0.0-rc.4...v5.0.0-rc.5) (2026-08-28)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **parser-utils:** handle raw-text element body in parseCodeFragment per HTML LS §13.2.5.1 ([4a1b0f7](https://github.com/markuplint/markuplint/commit/4a1b0f701c5c0eec91b325c4f3a9f9fb773766e8)), closes [#3825](https://github.com/markuplint/markuplint/issues/3825)
11
+
12
+ ### Features
13
+
14
+ - **parser-utils:** introduce accumulateParseErrors() for embedded parser delegation ([49e15d6](https://github.com/markuplint/markuplint/commit/49e15d6f528282ca145e77baf03ae8f6561b2b09)), closes [#3844](https://github.com/markuplint/markuplint/issues/3844)
15
+ - **parser-utils:** propagate Tokenized.parseErrors onto MLASTDocument ([37c8430](https://github.com/markuplint/markuplint/commit/37c8430f454d24d691e093fda9e9fb71b658b4d2)), closes [#3844](https://github.com/markuplint/markuplint/issues/3844)
16
+
6
17
  # [5.0.0-rc.4](https://github.com/markuplint/markuplint/compare/v5.0.0-rc.3...v5.0.0-rc.4) (2026-04-19)
7
18
 
8
19
  **Note:** Version bump only for package @markuplint/parser-utils
package/README.md CHANGED
@@ -16,9 +16,3 @@ $ yarn add @markuplint/parser-utils
16
16
  ```
17
17
 
18
18
  </details>
19
-
20
- ## Documentation
21
-
22
- - [Architecture](ARCHITECTURE.md) ([日本語](ARCHITECTURE.ja.md)) — Package overview, module relationships, and integration points
23
- - [Parser Class Reference](docs/parser-class.md) ([日本語](docs/parser-class.ja.md)) — Complete reference for the abstract `Parser` class
24
- - [Maintenance Guide](docs/maintenance.md) ([日本語](docs/maintenance.ja.md)) — Commands, recipes, and troubleshooting
package/lib/const.d.ts CHANGED
@@ -1,17 +1,8 @@
1
1
  export declare const MASK_CHAR = "\uE000";
2
2
  /**
3
- * SVG Element list
4
- *
5
3
  * @see https://developer.mozilla.org/en-US/docs/Web/SVG/Element
6
4
  */
7
5
  export declare const svgElementList: string[];
8
6
  export declare const reTagName: RegExp;
9
7
  export declare const reSplitterTag: RegExp;
10
- /**
11
- * - U+0009 CHARACTER TABULATION (tab) => `\t`
12
- * - U+000A LINE FEED (LF) => `\n`
13
- * - U+000C FORM FEED (FF) => `\f`
14
- * - U+000D CARRIAGE RETURN (CR) => `\r`
15
- * - U+0020 SPACE => ` `
16
- */
17
8
  export declare const defaultSpaces: readonly ["\t", "\n", "\f", "\r", " "];
package/lib/const.js CHANGED
@@ -1,7 +1,5 @@
1
1
  export const MASK_CHAR = '\uE000';
2
2
  /**
3
- * SVG Element list
4
- *
5
3
  * @see https://developer.mozilla.org/en-US/docs/Web/SVG/Element
6
4
  */
7
5
  export const svgElementList = [
@@ -97,11 +95,4 @@ export const svgElementList = [
97
95
  // eslint-disable-next-line no-control-regex -- WHATWG HTML spec requires matching NULL character
98
96
  export const reTagName = /^[a-z][^\0\t\n\f />]*/i;
99
97
  export const reSplitterTag = /<[^>]+>/g;
100
- /**
101
- * - U+0009 CHARACTER TABULATION (tab) => `\t`
102
- * - U+000A LINE FEED (LF) => `\n`
103
- * - U+000C FORM FEED (FF) => `\f`
104
- * - U+000D CARRIAGE RETURN (CR) => `\r`
105
- * - U+0020 SPACE => ` `
106
- */
107
98
  export const defaultSpaces = ['\t', '\n', '\f', '\r', ' '];
package/lib/decision.d.ts CHANGED
@@ -1,8 +1,2 @@
1
- /**
2
- *
3
- *
4
- * @param nodeName
5
- * @returns
6
- */
7
1
  export declare function isSVGElement(nodeName: string): boolean;
8
2
  export declare function isPotentialCustomElementName(tagName: string): boolean;
package/lib/decision.js CHANGED
@@ -1,11 +1,5 @@
1
1
  import { isCustomElementName } from '@markuplint/types';
2
2
  import { svgElementList } from './const.js';
3
- /**
4
- *
5
- *
6
- * @param nodeName
7
- * @returns
8
- */
9
3
  export function isSVGElement(nodeName) {
10
4
  return svgElementList.includes(nodeName);
11
5
  }
@@ -1,5 +1,14 @@
1
1
  import type { Parser } from './parser.js';
2
2
  import type { IgnoreBlock, IgnoreTag } from './types.js';
3
3
  import type { MLASTNodeTreeItem } from '@markuplint/ml-ast';
4
+ /**
5
+ * Masks the source regions that match the given tag patterns so that
6
+ * template expressions do not interfere with HTML tokenization.
7
+ *
8
+ * Invariant: the masked output must keep exactly the same character count
9
+ * and line breaks as the original source — `restoreNode` and all position
10
+ * reporting depend on offsets and line numbers in the masked code matching
11
+ * the original.
12
+ */
4
13
  export declare function ignoreBlock(source: string, tags: readonly IgnoreTag[], maskChar?: string): IgnoreBlock;
5
14
  export declare function restoreNode(parser: Parser<any, any>, nodeList: readonly MLASTNodeTreeItem[], ignoreBlock: IgnoreBlock, throwErrorWhenTagHasUnresolved?: boolean): MLASTNodeTreeItem[];
@@ -1,6 +1,15 @@
1
1
  import { MASK_CHAR } from './const.js';
2
2
  import { getPosition } from './get-location.js';
3
3
  import { ParserError } from './parser-error.js';
4
+ /**
5
+ * Masks the source regions that match the given tag patterns so that
6
+ * template expressions do not interfere with HTML tokenization.
7
+ *
8
+ * Invariant: the masked output must keep exactly the same character count
9
+ * and line breaks as the original source — `restoreNode` and all position
10
+ * reporting depend on offsets and line numbers in the masked code matching
11
+ * the original.
12
+ */
4
13
  export function ignoreBlock(source, tags, maskChar = MASK_CHAR) {
5
14
  let replaced = source;
6
15
  const stack = [];
@@ -9,6 +18,11 @@ export function ignoreBlock(source, tags, maskChar = MASK_CHAR) {
9
18
  const mask = maskChar.repeat(startTag.length) +
10
19
  taggedCode.replaceAll(/[^\n]/g, maskChar) +
11
20
  maskChar.repeat((endTag ?? '').length);
21
+ // Wrap in `<!` ... `>` (bogus comment syntax) so the HTML tokenizer
22
+ // consumes the masked region as a single bogus comment node instead of
23
+ // interpreting it as text or markup. The slices drop three mask
24
+ // characters to compensate for the three wrapper characters,
25
+ // preserving the total length.
12
26
  const taggedMask = `<!${mask.slice(2).slice(0, -1)}>`;
13
27
  return taggedMask;
14
28
  });
@@ -165,7 +179,6 @@ ignoreBlock, throwErrorWhenTagHasUnresolved = true) {
165
179
  attr.value.raw +
166
180
  attr.endQuote.raw);
167
181
  }
168
- // Update node raw
169
182
  const length = attr.raw.length;
170
183
  const offset = attr.offset - node.offset;
171
184
  const above = node.raw.slice(0, offset);
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 } from '@markuplint/ml-ast';
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';
3
3
  import { AttrState } from './enums.js';
4
4
  import { ParserError } from './parser-error.js';
5
5
  /**
@@ -8,6 +8,24 @@ import { ParserError } from './parser-error.js';
8
8
  * Subclasses must implement `nodeize` to convert language-specific AST nodes
9
9
  * into the markuplint AST format.
10
10
  *
11
+ * When adding framework support, choose the lightest extension that fits:
12
+ *
13
+ * - **Spec-only package** (`ExtendedSpec`, no parser) when the framework is
14
+ * valid HTML plus extra attributes.
15
+ * - **`HtmlParser` subclass** (from `@markuplint/html-parser`) configuring
16
+ * only `ignoreTags` when the syntax is HTML with embedded template
17
+ * expressions (EJS, ERB, Liquid, Mustache, Nunjucks, PHP, Smarty) — the
18
+ * expressions are masked before HTML parsing and restored as `psblock`
19
+ * nodes, so no external parsing library is needed.
20
+ * - **Direct `Parser` subclass** only when the document structure itself
21
+ * diverges from HTML (JSX, Vue SFC, Svelte, Pug, Astro).
22
+ *
23
+ * Direct subclasses should delegate tokenization to the framework's
24
+ * established parser library rather than hand-rolling one, and those
25
+ * libraries are chosen to span multiple major framework versions (e.g.
26
+ * `vue-eslint-parser` covers both Vue 2 and Vue 3 template syntax) so the
27
+ * parsers keep working across version ranges without frequent updates.
28
+ *
11
29
  * @template Node - The language-specific AST node type produced by the tokenizer
12
30
  * @template State - An optional parser state type that persists across tokenization
13
31
  */
@@ -68,6 +86,11 @@ export declare abstract class Parser<Node extends {} = {}, State extends unknown
68
86
  * the raw source code. The default implementation prepends offset spaces
69
87
  * based on the parse options.
70
88
  *
89
+ * Overrides must call `super.beforeParse()` first: the offset spaces
90
+ * prepended here are removed again by {@link Parser.afterParse}, and the
91
+ * two hooks must stay symmetric or position reporting for embedded code
92
+ * fragments (e.g., a `<template>` block inside a `.vue` file) breaks.
93
+ *
71
94
  * @param rawCode - The raw source code about to be parsed
72
95
  * @param options - Parse options that may specify offset positioning
73
96
  * @returns The preprocessed source code to be used for tokenization
@@ -88,6 +111,9 @@ export declare abstract class Parser<Node extends {} = {}, State extends unknown
88
111
  * to perform final transformations on the node list. The default implementation
89
112
  * removes any offset spaces that were prepended during preprocessing.
90
113
  *
114
+ * Overrides must call `super.afterParse()` first: it is the counterpart of
115
+ * {@link Parser.beforeParse} and removes the offset spaces prepended there.
116
+ *
91
117
  * @param nodeList - The fully parsed and flattened node list
92
118
  * @param options - The parse options used for this parse invocation
93
119
  * @returns The post-processed node list
@@ -259,6 +285,10 @@ export declare abstract class Parser<Node extends {} = {}, State extends unknown
259
285
  * Also detects spread attributes. If there is leftover text after the attribute,
260
286
  * it is returned in the `__rightText` property for further processing.
261
287
  *
288
+ * Subclass contract: overrides must call `super.visitAttr()` first — the base
289
+ * implementation owns the token decomposition; subclasses only post-process
290
+ * the returned node (e.g. directive detection).
291
+ *
262
292
  * @param token - The token containing the raw attribute text and position
263
293
  * @param options - Controls quoting behavior, value types, and the initial parser state
264
294
  * @returns The parsed attribute AST node with an optional `__rightText` for remaining unparsed content
@@ -274,11 +304,27 @@ export declare abstract class Parser<Node extends {} = {}, State extends unknown
274
304
  /**
275
305
  * Re-parses a text token to discover embedded HTML/XML tags within it,
276
306
  * splitting the content into a sequence of tag and text AST nodes.
277
- * Handles self-closing detection, depth tracking, and void element recognition.
307
+ * Handles self-closing detection, depth tracking, void element recognition,
308
+ * and the raw-text element body short-circuit (HTML LS §13.2.5.1).
309
+ *
310
+ * Raw-text element handling only covers the elements listed in
311
+ * `rawTextElements` (default `['style', 'script']`). HTML LS *escapable* raw
312
+ * text elements — `<title>` and `<textarea>` — are intentionally NOT in the
313
+ * default. They allow character references (`&amp;` etc.) in their body, and
314
+ * decoding is not implemented in this short-circuit. A subclass that adds
315
+ * them via the `rawTextElements` option will see character references passed
316
+ * through verbatim.
317
+ *
318
+ * `@markuplint/astro-parser` is the only downstream caller that hands a full
319
+ * element raw (start tag + body + end tag) to this method, so it is the only
320
+ * package that exercises the raw-text branch — when changing that branch,
321
+ * its tests are the most sensitive regression signal.
278
322
  *
279
323
  * @param token - The child token containing the code fragment to re-parse
280
324
  * @param options - Controls whether nameless fragments (JSX `<>`) are recognized
281
325
  * @returns An array of tag and text AST nodes discovered in the code fragment
326
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#cdata-rcdata-restrictions
327
+ * @see https://github.com/markuplint/markuplint/issues/3825
282
328
  */
283
329
  parseCodeFragment(token: ChildToken, options?: {
284
330
  readonly namelessFragment?: boolean;
@@ -388,4 +434,24 @@ export declare abstract class Parser<Node extends {} = {}, State extends unknown
388
434
  * @param replacementChildNodes - The replacement nodes to insert at the old child's position
389
435
  */
390
436
  replaceChild(parentNode: MLASTParentNode, oldChildNode: MLASTChildNode, ...replacementChildNodes: readonly MLASTChildNode[]): void;
437
+ /**
438
+ * Subclass hook for parsers that delegate to an embedded parse() call —
439
+ * e.g., `@markuplint/markdown-parser` parsing inline HTML blocks via a
440
+ * private `HtmlParser` instance, or `@markuplint/pug-parser` re-running
441
+ * each raw HTML line through `HtmlInPugParser`. Push the embedded
442
+ * document's `parseErrors` here so the top-level `parse()` can merge
443
+ * them into the outer `MLASTDocument.parseErrors`.
444
+ *
445
+ * If the embedded document has no `parseErrors`, this is a no-op.
446
+ *
447
+ * @param parseErrors - Parse errors collected by the embedded parser. May be `undefined`.
448
+ */
449
+ protected accumulateParseErrors(parseErrors: readonly MLASTParseError[] | undefined): void;
450
+ /**
451
+ * @internal Read-only snapshot of accumulated embedded parse errors.
452
+ * Used by {@link Parser.parse} to merge them with the top-level tokenize
453
+ * result. Subclasses should not call this directly — push via
454
+ * {@link Parser.accumulateParseErrors} instead.
455
+ */
456
+ get embeddedParseErrors(): readonly MLASTParseError[];
391
457
  }
package/lib/parser.js CHANGED
@@ -18,6 +18,24 @@ const timer = new PerformanceTimer();
18
18
  * Subclasses must implement `nodeize` to convert language-specific AST nodes
19
19
  * into the markuplint AST format.
20
20
  *
21
+ * When adding framework support, choose the lightest extension that fits:
22
+ *
23
+ * - **Spec-only package** (`ExtendedSpec`, no parser) when the framework is
24
+ * valid HTML plus extra attributes.
25
+ * - **`HtmlParser` subclass** (from `@markuplint/html-parser`) configuring
26
+ * only `ignoreTags` when the syntax is HTML with embedded template
27
+ * expressions (EJS, ERB, Liquid, Mustache, Nunjucks, PHP, Smarty) — the
28
+ * expressions are masked before HTML parsing and restored as `psblock`
29
+ * nodes, so no external parsing library is needed.
30
+ * - **Direct `Parser` subclass** only when the document structure itself
31
+ * diverges from HTML (JSX, Vue SFC, Svelte, Pug, Astro).
32
+ *
33
+ * Direct subclasses should delegate tokenization to the framework's
34
+ * established parser library rather than hand-rolling one, and those
35
+ * libraries are chosen to span multiple major framework versions (e.g.
36
+ * `vue-eslint-parser` covers both Vue 2 and Vue 3 template syntax) so the
37
+ * parsers keep working across version ranges without frequent updates.
38
+ *
21
39
  * @template Node - The language-specific AST node type produced by the tokenizer
22
40
  * @template State - An optional parser state type that persists across tokenization
23
41
  */
@@ -31,6 +49,25 @@ export class Parser {
31
49
  #selfCloseType = 'html';
32
50
  #spaceChars = defaultSpaces;
33
51
  #rawTextElements = ['style', 'script'];
52
+ /**
53
+ * Buffer for parse errors collected from **embedded** parse() calls
54
+ * (e.g., Markdown's inline HTML blocks, Pug's raw HTML lines — these
55
+ * invoke a separate HtmlParser instance from inside `nodeize()`).
56
+ *
57
+ * Subclasses that delegate to an internal parser should push the
58
+ * resulting `parseErrors` onto this array via {@link Parser.accumulateParseErrors}.
59
+ * The base `parse()` merges them with the top-level tokenize result so
60
+ * the final `MLASTDocument.parseErrors` is complete.
61
+ *
62
+ * Reset on every `parse()` invocation.
63
+ */
64
+ #embeddedParseErrors = [];
65
+ /**
66
+ * Keyed by the original tag name (as authored in source) so a
67
+ * `tagNameCaseSensitive` parser that preserves casing reuses the same
68
+ * `RegExp` for every occurrence.
69
+ */
70
+ #rawTextCloseTagPatternCache = new Map();
34
71
  #authoredElementName;
35
72
  #originalRawCode = '';
36
73
  #rawCode = '';
@@ -117,6 +154,11 @@ export class Parser {
117
154
  * the raw source code. The default implementation prepends offset spaces
118
155
  * based on the parse options.
119
156
  *
157
+ * Overrides must call `super.beforeParse()` first: the offset spaces
158
+ * prepended here are removed again by {@link Parser.afterParse}, and the
159
+ * two hooks must stay symmetric or position reporting for embedded code
160
+ * fragments (e.g., a `<template>` block inside a `.vue` file) breaks.
161
+ *
120
162
  * @param rawCode - The raw source code about to be parsed
121
163
  * @param options - Parse options that may specify offset positioning
122
164
  * @returns The preprocessed source code to be used for tokenization
@@ -157,6 +199,7 @@ export class Parser {
157
199
  const tokenized = this.tokenize(options);
158
200
  const ast = tokenized.ast;
159
201
  const isFragment = tokenized.isFragment;
202
+ const parseErrors = tokenized.parseErrors;
160
203
  this.#defaultDepth = options?.depth ?? this.#defaultDepth;
161
204
  timer.push('traverse');
162
205
  const traversed = this.traverse(ast, null, this.#defaultDepth);
@@ -205,11 +248,17 @@ export class Parser {
205
248
  }
206
249
  timer.log();
207
250
  domLog(nodeList);
251
+ // Merge top-level tokenizer parseErrors with any parseErrors
252
+ // pushed by embedded parser delegations (Markdown / Pug HTML
253
+ // regions). Snapshot before #reset() clears the buffer.
254
+ const embeddedErrors = this.embeddedParseErrors;
255
+ const mergedParseErrors = parseErrors || embeddedErrors.length > 0 ? [...(parseErrors ?? []), ...embeddedErrors] : undefined;
208
256
  this.#reset();
209
257
  return {
210
258
  raw: rawCode,
211
259
  nodeList,
212
260
  isFragment,
261
+ ...(mergedParseErrors && mergedParseErrors.length > 0 ? { parseErrors: mergedParseErrors } : {}),
213
262
  };
214
263
  }
215
264
  catch (error) {
@@ -221,6 +270,9 @@ export class Parser {
221
270
  * to perform final transformations on the node list. The default implementation
222
271
  * removes any offset spaces that were prepended during preprocessing.
223
272
  *
273
+ * Overrides must call `super.afterParse()` first: it is the counterpart of
274
+ * {@link Parser.beforeParse} and removes the offset spaces prepended there.
275
+ *
224
276
  * @param nodeList - The fully parsed and flattened node list
225
277
  * @param options - The parse options used for this parse invocation
226
278
  * @returns The post-processed node list
@@ -574,6 +626,10 @@ export class Parser {
574
626
  * Also detects spread attributes. If there is leftover text after the attribute,
575
627
  * it is returned in the `__rightText` property for further processing.
576
628
  *
629
+ * Subclass contract: overrides must call `super.visitAttr()` first — the base
630
+ * implementation owns the token decomposition; subclasses only post-process
631
+ * the returned node (e.g. directive detection).
632
+ *
577
633
  * @param token - The token containing the raw attribute text and position
578
634
  * @param options - Controls quoting behavior, value types, and the initial parser state
579
635
  * @returns The parsed attribute AST node with an optional `__rightText` for remaining unparsed content
@@ -646,11 +702,27 @@ export class Parser {
646
702
  /**
647
703
  * Re-parses a text token to discover embedded HTML/XML tags within it,
648
704
  * splitting the content into a sequence of tag and text AST nodes.
649
- * Handles self-closing detection, depth tracking, and void element recognition.
705
+ * Handles self-closing detection, depth tracking, void element recognition,
706
+ * and the raw-text element body short-circuit (HTML LS §13.2.5.1).
707
+ *
708
+ * Raw-text element handling only covers the elements listed in
709
+ * `rawTextElements` (default `['style', 'script']`). HTML LS *escapable* raw
710
+ * text elements — `<title>` and `<textarea>` — are intentionally NOT in the
711
+ * default. They allow character references (`&amp;` etc.) in their body, and
712
+ * decoding is not implemented in this short-circuit. A subclass that adds
713
+ * them via the `rawTextElements` option will see character references passed
714
+ * through verbatim.
715
+ *
716
+ * `@markuplint/astro-parser` is the only downstream caller that hands a full
717
+ * element raw (start tag + body + end tag) to this method, so it is the only
718
+ * package that exercises the raw-text branch — when changing that branch,
719
+ * its tests are the most sensitive regression signal.
650
720
  *
651
721
  * @param token - The child token containing the code fragment to re-parse
652
722
  * @param options - Controls whether nameless fragments (JSX `<>`) are recognized
653
723
  * @returns An array of tag and text AST nodes discovered in the code fragment
724
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#cdata-rcdata-restrictions
725
+ * @see https://github.com/markuplint/markuplint/issues/3825
654
726
  */
655
727
  parseCodeFragment(token, options) {
656
728
  const nodes = [];
@@ -732,6 +804,51 @@ export class Parser {
732
804
  }
733
805
  nodes.push(tag);
734
806
  }
807
+ /**
808
+ * Raw-text element body short-circuit (HTML Living Standard §13.2.5.1
809
+ * — "Restrictions on the contents of raw text and escapable raw text
810
+ * elements". The spec section anchor is `cdata-rcdata-restrictions`,
811
+ * where RCDATA stands for "Raw text + Character REF data" — the broader
812
+ * class that covers escapable raw text such as `<title>` / `<textarea>`).
813
+ *
814
+ * Per spec, the contents of `<script>` / `<style>` (and any caller-supplied
815
+ * raw-text element) are NOT re-tokenized as HTML — the only thing that
816
+ * terminates the body is `</tagName` followed by a tab/LF/FF/CR/space/`>`
817
+ * /`/`. Without this guard, fragments like `<script>const t = s.replace(
818
+ * /<br\s*\/?>/gi, " ");</script>` would feed the regex to `#parseTag`,
819
+ * which would try to parse `<br\s*\/?>` as a tag and throw on the
820
+ * backslash (#3825).
821
+ *
822
+ * This is dormant for `jsx-parser` and `mdx-parser` because their upstream
823
+ * tokenizers reject bare `<` in element body before reaching here, but the
824
+ * fix keeps `parseCodeFragment` honest for any future caller.
825
+ *
826
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#cdata-rcdata-restrictions
827
+ * @see https://github.com/markuplint/markuplint/issues/3825
828
+ */
829
+ if (tag.type === 'starttag' && !isSelfClose && this.#rawTextElements.includes(tag.nodeName.toLowerCase())) {
830
+ const closeTagPattern = this.#getRawTextCloseTagPattern(tag.nodeName);
831
+ const match = closeTagPattern.exec(raw);
832
+ if (match) {
833
+ const bodyRaw = raw.slice(0, match.index);
834
+ if (bodyRaw) {
835
+ const bodyToken = this.createToken(bodyRaw, curOffset, curLine, curCol);
836
+ const bodyNode = {
837
+ ...bodyToken,
838
+ type: 'text',
839
+ depth,
840
+ nodeName: '#text',
841
+ parentNode: null,
842
+ parentNodeUuid: null,
843
+ };
844
+ nodes.push(bodyNode);
845
+ ({ offset: curOffset, line: curLine, col: curCol } = this.#getEndLocation(bodyToken));
846
+ }
847
+ raw = raw.slice(match.index);
848
+ }
849
+ // If no matching close tag is found, fall through to the generic loop
850
+ // so the existing "unclosed tag" handling remains in effect.
851
+ }
735
852
  }
736
853
  return nodes;
737
854
  }
@@ -1026,13 +1143,24 @@ export class Parser {
1026
1143
  };
1027
1144
  }
1028
1145
  /**
1029
- * Checks whether a node is a descendant of another node by walking up
1030
- * the parent chain.
1031
- *
1032
- * @param node - The node to test.
1033
- * @param potentialAncestor - The node that may be an ancestor.
1034
- * @returns `true` if `node` is a descendant of `potentialAncestor`.
1146
+ * The pattern matches `</tagName` followed by a tab/LF/FF/CR/space/`>`/`/`
1147
+ * (HTML LS §13.2.5.1) ASCII-case-insensitively. Caching avoids recompiling the
1148
+ * `RegExp` for every `<script>` / `<style>` start tag in large documents.
1035
1149
  */
1150
+ #getRawTextCloseTagPattern(tagName) {
1151
+ const cached = this.#rawTextCloseTagPatternCache.get(tagName);
1152
+ if (cached) {
1153
+ return cached;
1154
+ }
1155
+ const escapedName = tagName.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&');
1156
+ // `regexp/strict` cannot statically verify a pattern built from the
1157
+ // dynamic `escapedName` interpolation; the escape above is the contract
1158
+ // that makes this safe for any caller-supplied `rawTextElements` value.
1159
+ // eslint-disable-next-line regexp/strict
1160
+ const pattern = new RegExp(`</${escapedName}(?=[\\t\\n\\f\\r >/])`, 'i');
1161
+ this.#rawTextCloseTagPatternCache.set(tagName, pattern);
1162
+ return pattern;
1163
+ }
1036
1164
  #isDescendantOf(node, potentialAncestor) {
1037
1165
  let current = 'parentNode' in node ? (node.parentNode ?? null) : null;
1038
1166
  while (current) {
@@ -1282,13 +1410,7 @@ export class Parser {
1282
1410
  * @param nodeOrders [Disruptive change]
1283
1411
  */
1284
1412
  #removeDeprecatedNode(nodeOrders) {
1285
- /**
1286
- * sorting
1287
- */
1288
1413
  const sorted = nodeOrders.toSorted(sortNodes);
1289
- /**
1290
- * remove duplicated node
1291
- */
1292
1414
  const stack = {};
1293
1415
  const removeIndexes = [];
1294
1416
  for (const [i, node] of sorted.entries()) {
@@ -1330,34 +1452,54 @@ export class Parser {
1330
1452
  return nodeList;
1331
1453
  }
1332
1454
  #reset() {
1333
- // Reset state
1334
1455
  this.state = structuredClone(this.#defaultState);
1335
1456
  this.#defaultDepth = 0;
1457
+ this.#embeddedParseErrors = [];
1458
+ }
1459
+ /**
1460
+ * Subclass hook for parsers that delegate to an embedded parse() call —
1461
+ * e.g., `@markuplint/markdown-parser` parsing inline HTML blocks via a
1462
+ * private `HtmlParser` instance, or `@markuplint/pug-parser` re-running
1463
+ * each raw HTML line through `HtmlInPugParser`. Push the embedded
1464
+ * document's `parseErrors` here so the top-level `parse()` can merge
1465
+ * them into the outer `MLASTDocument.parseErrors`.
1466
+ *
1467
+ * If the embedded document has no `parseErrors`, this is a no-op.
1468
+ *
1469
+ * @param parseErrors - Parse errors collected by the embedded parser. May be `undefined`.
1470
+ */
1471
+ accumulateParseErrors(parseErrors) {
1472
+ if (parseErrors && parseErrors.length > 0) {
1473
+ this.#embeddedParseErrors.push(...parseErrors);
1474
+ }
1475
+ }
1476
+ /**
1477
+ * @internal Read-only snapshot of accumulated embedded parse errors.
1478
+ * Used by {@link Parser.parse} to merge them with the top-level tokenize
1479
+ * result. Subclasses should not call this directly — push via
1480
+ * {@link Parser.accumulateParseErrors} instead.
1481
+ */
1482
+ get embeddedParseErrors() {
1483
+ return this.#embeddedParseErrors;
1336
1484
  }
1337
1485
  #setRawCode(rawCode, originalRawCode) {
1338
1486
  this.#rawCode = rawCode;
1339
1487
  this.#originalRawCode = originalRawCode ?? this.#originalRawCode;
1340
1488
  }
1341
1489
  /**
1342
- * Trims text nodes whose source range overlaps with the next node in
1343
- * the flat list. This prevents text content from bleeding into adjacent
1344
- * elements that occupy a later (or overlapping) source range.
1490
+ * Prevents text content from bleeding into adjacent elements that
1491
+ * occupy a later (or overlapping) source range.
1345
1492
  *
1346
1493
  * Skips trimming when the text node is a descendant of the next node
1347
1494
  * in the tree hierarchy, because synthetic parsers (e.g., Markdown)
1348
1495
  * can produce child elements that share the same source range as their
1349
1496
  * parent and therefore appear before the parent in offset-sorted order.
1350
- *
1351
- * @param nodeList - The flat, offset-sorted node list to process.
1352
- * @returns A new node list with overlapping text nodes trimmed.
1353
1497
  */
1354
1498
  #trimText(nodeList) {
1355
1499
  const newNodeList = [];
1356
1500
  let prevNode = null;
1357
1501
  for (const node of nodeList) {
1358
- if (prevNode?.type === 'text' &&
1359
- // Empty node
1360
- node.raw.length > 0) {
1502
+ if (prevNode?.type === 'text' && node.raw.length > 0) {
1361
1503
  const prevNodeEndOffset = prevNode.offset + prevNode.raw.length;
1362
1504
  const nodeStartOffset = node.offset;
1363
1505
  if (prevNodeEndOffset > nodeStartOffset && !this.#isDescendantOf(prevNode, node)) {
@@ -1,10 +1,2 @@
1
1
  import type { MLASTNodeTreeItem } from '@markuplint/ml-ast';
2
- /**
3
- * Comparator function for sorting AST nodes by their source position.
4
- * Sorts primarily by offset, then by end offset for nodes at the same position.
5
- *
6
- * @param a - The first node to compare
7
- * @param b - The second node to compare
8
- * @returns A negative, zero, or positive number for sort ordering
9
- */
10
2
  export declare function sortNodes(a: MLASTNodeTreeItem, b: MLASTNodeTreeItem): number;
package/lib/sort-nodes.js CHANGED
@@ -1,11 +1,3 @@
1
- /**
2
- * Comparator function for sorting AST nodes by their source position.
3
- * Sorts primarily by offset, then by end offset for nodes at the same position.
4
- *
5
- * @param a - The first node to compare
6
- * @param b - The second node to compare
7
- * @returns A negative, zero, or positive number for sort ordering
8
- */
9
1
  export function sortNodes(a, b) {
10
2
  if (a.offset === b.offset) {
11
3
  return sort(a.offset + a.raw.length, b.offset + b.raw.length);
package/lib/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { EndTagType, MLASTParentNode, ParserOptions as ConfigParserOptions } from '@markuplint/ml-ast';
1
+ import type { 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.
@@ -34,6 +34,13 @@ export type Tokenized<N extends {} = {}, State extends unknown = null> = {
34
34
  readonly ast: N[];
35
35
  readonly isFragment: boolean;
36
36
  readonly state?: State;
37
+ /**
38
+ * Non-fatal parser conformance errors collected during tokenisation.
39
+ * Optional — parsers that have no equivalent surface (e.g., raw regex
40
+ * tokenizers) simply omit it. Propagated unchanged onto
41
+ * `MLASTDocument.parseErrors` by {@link Parser.parse}.
42
+ */
43
+ readonly parseErrors?: readonly MLASTParseError[];
37
44
  };
38
45
  /**
39
46
  * A minimal source token representing a raw string fragment
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markuplint/parser-utils",
3
- "version": "5.0.0-rc.4",
3
+ "version": "5.0.0-rc.5",
4
4
  "description": "Utility module for markuplint parser plugin",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,7 +10,7 @@
10
10
  "author": "Yusuke Hirao <yusukehirao@me.com>",
11
11
  "license": "MIT",
12
12
  "engines": {
13
- "node": ">=22"
13
+ "node": ">=24"
14
14
  },
15
15
  "type": "module",
16
16
  "exports": {
@@ -35,16 +35,16 @@
35
35
  "clean": "tsc --build --clean tsconfig.build.json"
36
36
  },
37
37
  "dependencies": {
38
- "@markuplint/ml-ast": "5.0.0-rc.4",
39
- "@markuplint/ml-spec": "5.0.0-rc.4",
40
- "@markuplint/shared": "5.0.0-rc.4",
41
- "@markuplint/types": "5.0.0-rc.4",
38
+ "@markuplint/ml-ast": "5.0.0-rc.5",
39
+ "@markuplint/ml-spec": "5.0.0-rc.5",
40
+ "@markuplint/shared": "5.0.0-rc.5",
41
+ "@markuplint/types": "5.0.0-rc.5",
42
42
  "debug": "4.4.3",
43
43
  "espree": "11.2.0",
44
44
  "type-fest": "5.6.0"
45
45
  },
46
46
  "devDependencies": {
47
- "@typescript-eslint/typescript-estree": "8.58.2"
47
+ "@typescript-eslint/typescript-estree": "8.59.0"
48
48
  },
49
- "gitHead": "97a6339bbae23f556de5d307b3ce2ef7cfd9402d"
49
+ "gitHead": "8d87463af2ff3f1b83fb28da20f1819362cf3555"
50
50
  }