@markuplint/astro-parser 5.1.1 → 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,30 @@
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
+ * **astro-parser:** detect a frontmatter fence with trailing spaces and pin the guard boundaries ([23e6e0c](https://github.com/markuplint/markuplint/commit/23e6e0c3628ed3c9b3bad20d9381a505919146b1))
11
+ * **astro-parser:** do not report a root that is neither a component nor an element ([fdf141b](https://github.com/markuplint/markuplint/commit/fdf141b02eb0c9bb26812c4be748feac1c1584d6))
12
+ * **astro-parser:** migrate to astro-eslint-parser v3 ([f76792a](https://github.com/markuplint/markuplint/commit/f76792a01f823bb4e29103d1b4ce7ef6e33c8bda))
13
+ * 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)
14
+
15
+ ### Features
16
+
17
+ * **astro-parser:** answer what a component name is by the rule of Astro ([5399070](https://github.com/markuplint/markuplint/commit/5399070fa1797cbd502379e9c469aba51693b92b))
18
+
19
+
20
+ # [5.2.0](https://github.com/markuplint/markuplint/compare/v5.1.1...v5.2.0) (2026-10-13)
21
+
22
+ ### Bug Fixes
23
+
24
+ - use caret ranges for third-party dependencies of published packages ([5fb5d2e](https://github.com/markuplint/markuplint/commit/5fb5d2ee81eaeea45fe7256a40dd41c95ff0d672))
25
+
26
+ ### Features
27
+
28
+ - **astro-parser:** report the namespace of a slot wrapper ([798c429](https://github.com/markuplint/markuplint/commit/798c429eceecf7bc2b2498c568116d91bcc79178))
29
+
6
30
  ## [5.1.1](https://github.com/markuplint/markuplint/compare/v5.1.0...v5.1.1) (2026-10-09)
7
31
 
8
32
  **Note:** Version bump only for package @markuplint/astro-parser
@@ -1,12 +1,4 @@
1
- import type { RootNode } from '@astrojs/compiler/types';
2
- /**
3
- * `@astrojs/compiler` is a dev dependency used only for these type
4
- * definitions; the runtime AST actually comes from `astro-eslint-parser`.
5
- * When upgrading `astro-eslint-parser`, also update `@astrojs/compiler` to
6
- * the version it uses internally, otherwise the types drift from the
7
- * runtime AST shape.
8
- */
9
- export type { RootNode, ElementNode, CustomElementNode, ComponentNode, FragmentNode, AttributeNode, Node, } from '@astrojs/compiler/types';
1
+ import type { AstroRoot } from './to-nodes.js';
10
2
  /**
11
3
  * ## Diagnostic handling policy
12
4
  *
@@ -25,8 +17,8 @@ export type { RootNode, ElementNode, CustomElementNode, ComponentNode, FragmentN
25
17
  *
26
18
  * `parseTemplate()` itself raises an `astro-eslint-parser` `ParseError`
27
19
  * (extending `SyntaxError`) on severity=Error diagnostics and on raw
28
- * template syntax errors (unterminated comments, unclosed expressions,
29
- * etc.). Those — and only those — are normalized to `ParserError`, so the
20
+ * template syntax errors (unclosed expressions, mismatched end tags, etc.).
21
+ * Those — and only those — are normalized to `ParserError`, so the
30
22
  * caller sees a single Tier-3 (per-file violation) error type for every
31
23
  * parse failure. Any other throw, including a genuine `SyntaxError` from a
32
24
  * markuplint invariant break, is allowed to propagate so the standard
@@ -36,10 +28,15 @@ export type { RootNode, ElementNode, CustomElementNode, ComponentNode, FragmentN
36
28
  * code today (upstream gates internally) but is retained as a safety net
37
29
  * if a future `astro-eslint-parser` ever stops gating severity=Error.
38
30
  *
31
+ * Two malformed inputs that v3 accepts without any signal — an unterminated
32
+ * frontmatter and an unclosed HTML comment — are detected here and raised as
33
+ * `ParserError` too (see `assertFrontmatterTerminated` and
34
+ * `assertParsedToTheEnd`), so no template is silently skipped.
35
+ *
39
36
  * @see https://github.com/markuplint/markuplint/issues/3823
40
37
  * @see https://docs.astro.build/en/reference/directives-reference/#isinline
41
38
  * @see https://docs.astro.build/en/guides/client-side-scripts/#script-processing
42
39
  *
43
40
  * @throws {ParserError} on severity=Error Astro diagnostics or on raw upstream syntax errors
44
41
  */
45
- export declare function astroParse(code: string): RootNode;
42
+ export declare function astroParse(code: string): AstroRoot;
@@ -1,14 +1,11 @@
1
1
  import { ParserError } from '@markuplint/parser-utils';
2
2
  import { parseTemplate } from 'astro-eslint-parser';
3
3
  /**
4
- * Astro tags each diagnostic with a VS Code-style severity
5
- * (1=Error, 2=Warning, 3=Information, 4=Hint). The compiler exposes the
6
- * enum as types-only via `@astrojs/compiler/types` — there is no runtime
7
- * value to import — so the fatal level is mirrored as a literal here.
8
- *
9
- * @see https://github.com/withastro/compiler/blob/main/packages/compiler/shared/types.ts
4
+ * Astro tags each diagnostic with a severity of `'error'`, `'warning'`,
5
+ * `'information'` or `'hint'` (`DiagnosticSeverity` of `@astrojs/compiler-binding`,
6
+ * a `const enum`, i.e. types-only), so the fatal level is mirrored as a literal here.
10
7
  */
11
- const ASTRO_DIAGNOSTIC_SEVERITY_ERROR = 1;
8
+ const ASTRO_DIAGNOSTIC_SEVERITY_ERROR = 'error';
12
9
  /**
13
10
  * Type guard for `astro-eslint-parser`'s `ParseError` class. The class
14
11
  * extends `SyntaxError` and carries a unique `originalAST` instance
@@ -44,8 +41,8 @@ function isAstroEslintParseError(error) {
44
41
  *
45
42
  * `parseTemplate()` itself raises an `astro-eslint-parser` `ParseError`
46
43
  * (extending `SyntaxError`) on severity=Error diagnostics and on raw
47
- * template syntax errors (unterminated comments, unclosed expressions,
48
- * etc.). Those — and only those — are normalized to `ParserError`, so the
44
+ * template syntax errors (unclosed expressions, mismatched end tags, etc.).
45
+ * Those — and only those — are normalized to `ParserError`, so the
49
46
  * caller sees a single Tier-3 (per-file violation) error type for every
50
47
  * parse failure. Any other throw, including a genuine `SyntaxError` from a
51
48
  * markuplint invariant break, is allowed to propagate so the standard
@@ -55,6 +52,11 @@ function isAstroEslintParseError(error) {
55
52
  * code today (upstream gates internally) but is retained as a safety net
56
53
  * if a future `astro-eslint-parser` ever stops gating severity=Error.
57
54
  *
55
+ * Two malformed inputs that v3 accepts without any signal — an unterminated
56
+ * frontmatter and an unclosed HTML comment — are detected here and raised as
57
+ * `ParserError` too (see `assertFrontmatterTerminated` and
58
+ * `assertParsedToTheEnd`), so no template is silently skipped.
59
+ *
58
60
  * @see https://github.com/markuplint/markuplint/issues/3823
59
61
  * @see https://docs.astro.build/en/reference/directives-reference/#isinline
60
62
  * @see https://docs.astro.build/en/guides/client-side-scripts/#script-processing
@@ -80,10 +82,57 @@ export function astroParse(code) {
80
82
  // past the wrapper as a non-fatal pass-through.
81
83
  const fatal = result.diagnostics.find(d => d.severity === ASTRO_DIAGNOSTIC_SEVERITY_ERROR);
82
84
  if (fatal) {
85
+ // A label's column is 0-based; `ParserError` takes a 1-based one.
86
+ const label = fatal.labels[0];
83
87
  throw new ParserError(fatal.text, {
84
- line: fatal.location.line,
85
- col: fatal.location.column,
88
+ line: label?.line ?? 1,
89
+ col: label ? label.column + 1 : 1,
86
90
  });
87
91
  }
92
+ assertFrontmatterTerminated(code, result.ast);
93
+ assertParsedToTheEnd(code, result.ast);
88
94
  return result.ast;
89
95
  }
96
+ /**
97
+ * A leading `---` line, which opens a frontmatter block. Spaces may follow the fence
98
+ * (`astro-eslint-parser` accepts `--- ` as one), and the source may end right after it.
99
+ */
100
+ const RE_FRONTMATTER_FENCE = /^(?:\s*\n)?---[ \t]*(?:\r?\n|$)/;
101
+ /**
102
+ * `astro-eslint-parser` v3 reports an unterminated frontmatter (for example,
103
+ * a closing `---` hidden inside an unterminated block comment) neither
104
+ * as an error nor as a diagnostic: the fence and everything after it silently
105
+ * becomes template text. Every earlier version, and Astro itself, rejects it.
106
+ *
107
+ * @throws {ParserError} when the source starts with a frontmatter fence that no frontmatter was parsed from
108
+ */
109
+ function assertFrontmatterTerminated(code, ast) {
110
+ const { frontmatter } = ast;
111
+ if (frontmatter && frontmatter.end === frontmatter.start && RE_FRONTMATTER_FENCE.test(code)) {
112
+ throw new ParserError('Unterminated frontmatter', locate(code, 0));
113
+ }
114
+ }
115
+ /**
116
+ * `astro-eslint-parser` v3 reports an unclosed HTML comment (`<!-- ...`)
117
+ * neither as an error nor as a diagnostic: it drops the comment and
118
+ * everything after it, so a template that is not checked at all would pass.
119
+ * A node list that stops before the end of the source is that situation.
120
+ *
121
+ * Whitespace after the last node is not content: the newline that follows a
122
+ * closing frontmatter fence belongs to no node.
123
+ *
124
+ * @throws {ParserError} when the AST does not reach the end of the source
125
+ */
126
+ function assertParsedToTheEnd(code, ast) {
127
+ const parsedEnd = ast.body.at(-1)?.end ?? ast.frontmatter?.end ?? 0;
128
+ if (parsedEnd < code.length && code.slice(parsedEnd).trim() !== '') {
129
+ throw new ParserError('Unterminated syntax: the template is not parsed to its end', locate(code, parsedEnd));
130
+ }
131
+ }
132
+ /**
133
+ * The 1-based line and column of an offset.
134
+ */
135
+ function locate(code, offset) {
136
+ const lines = code.slice(0, offset).split('\n');
137
+ return { line: lines.length, col: (lines.at(-1)?.length ?? 0) + 1 };
138
+ }
@@ -1,3 +1,4 @@
1
+ import type { NamespaceURI } from '@markuplint/ml-ast';
1
2
  /**
2
3
  * Result of scanning a single component file for its root element information.
3
4
  */
@@ -47,6 +48,8 @@ export interface ComponentScanSlotWrapper {
47
48
  readonly isRoot: boolean;
48
49
  readonly attrs: readonly ComponentScanAttr[];
49
50
  readonly contents: readonly ComponentScanContent[];
51
+ /** The namespace of the element when the elements between it and the root element decide it. */
52
+ readonly namespace?: NamespaceURI;
50
53
  }
51
54
  /**
52
55
  * A script/ESM source block extracted from a component file.
@@ -1,9 +1,18 @@
1
+ import { getNamespaceBelow } from '@markuplint/parser-utils';
1
2
  import { parser } from './parser.js';
2
3
  function extractComponentInfo(doc) {
3
4
  const root = findRoot(doc);
4
5
  if (!root) {
5
6
  return null;
6
7
  }
8
+ // The parser says the root is authored, yet its name is not the one of a component
9
+ // (`component`, `slot` of Vue): it renders what it is given, so there is no element
10
+ // to pretend to be. `pretenders` would otherwise emit that name as the target, and
11
+ // nothing tells it from the name of an element. What a component's name is, is the
12
+ // parser's rule (`Parser#isComponentName`).
13
+ if (root.elementType === 'authored' && !parser.isComponentName(root.nodeName)) {
14
+ return null;
15
+ }
7
16
  const spreads = extractSpreads(root);
8
17
  return {
9
18
  rootElement: root.nodeName,
@@ -109,6 +118,24 @@ function findWrapper(n, nodes) {
109
118
  }
110
119
  return null;
111
120
  }
121
+ /**
122
+ * The names of the elements from the child of `root` down to `el`, for the namespace of `el`
123
+ * (`getNamespaceBelow`). A component is `null`, as it does not enter a namespace by its name.
124
+ */
125
+ function getNamesBelow(el, root, nodes) {
126
+ const names = [];
127
+ let current = el;
128
+ while (current && current.uuid !== root.uuid) {
129
+ if (isElement(current) && !current.isFragment && current.nodeName !== 'template') {
130
+ names.unshift(getNamespaceSubject(current));
131
+ }
132
+ current = current.parentNodeUuid ? nodes.get(current.parentNodeUuid) : undefined;
133
+ }
134
+ return names;
135
+ }
136
+ function getNamespaceSubject(el) {
137
+ return el.elementType === 'authored' ? null : el.nodeName;
138
+ }
112
139
  function isWithin(n, root, nodes) {
113
140
  let current = n;
114
141
  while (current) {
@@ -188,11 +215,15 @@ function scanSlotContent(doc) {
188
215
  return {
189
216
  slotWrappers: [...wrappers.values()].map(wrapper => {
190
217
  const isNative = wrapper.elementType === 'html';
218
+ const namespace = isNative
219
+ ? getNamespaceBelow(getNamespaceSubject(root), getNamesBelow(wrapper, root, nodes))
220
+ : null;
191
221
  return {
192
222
  element: wrapper.nodeName,
193
223
  isRoot: wrapper.uuid === root.uuid,
194
224
  attrs: isNative ? extractAttrs(wrapper) : [],
195
225
  contents: isNative ? extractContents(wrapper) : [],
226
+ ...(namespace ? { namespace } : {}),
196
227
  };
197
228
  }),
198
229
  rootContents: extractContents(root),
package/lib/parser.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Node } from './astro-parser.js';
1
+ import type { Node } from './to-nodes.js';
2
2
  import type { MLASTNodeTreeItem, MLASTParentNode } from '@markuplint/ml-ast';
3
3
  import type { ChildToken, Token } from '@markuplint/parser-utils';
4
4
  import { Parser } from '@markuplint/parser-utils';
@@ -106,6 +106,15 @@ declare class AstroParser extends Parser<Node> {
106
106
  * @param defaultPattern
107
107
  */
108
108
  detectElementType(nodeName: string): import("@markuplint/ml-ast").ElementType;
109
+ /**
110
+ * A variable used as a tag must be capitalized in Astro, as `detectElementType` takes it. Unlike the
111
+ * default of the base class, a `.` alone does not make a name a component: `<ui.Item>` is an
112
+ * HTML element there.
113
+ *
114
+ * @param name - A tag name as written, case kept
115
+ * @returns Whether the name is a component's
116
+ */
117
+ isComponentName(name: string): boolean;
109
118
  }
110
119
  export declare const parser: AstroParser;
111
120
  export {};
package/lib/parser.js CHANGED
@@ -1,7 +1,8 @@
1
- import { AttrState, Parser, ParserError } from '@markuplint/parser-utils';
1
+ import { AttrState, Parser, ParserError, isPotentialCustomElementName } from '@markuplint/parser-utils';
2
2
  import { astroParse } from './astro-parser.js';
3
3
  import { detectBlockBehavior } from './detect-block-behavior.js';
4
4
  import { extractSpreadAttribute } from './spread-attr.js';
5
+ import { toNodes } from './to-nodes.js';
5
6
  /**
6
7
  * Parser implementation for Astro component templates.
7
8
  * Extends the base Parser to handle Astro-specific syntax including frontmatter blocks,
@@ -26,7 +27,7 @@ class AstroParser extends Parser {
26
27
  }
27
28
  tokenize() {
28
29
  return {
29
- ast: astroParse(this.rawCode).children,
30
+ ast: toNodes(astroParse(this.rawCode)),
30
31
  isFragment: true,
31
32
  };
32
33
  }
@@ -43,11 +44,8 @@ class AstroParser extends Parser {
43
44
  nodeize(
44
45
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
45
46
  originNode, parentNode, depth) {
46
- if (!originNode.position) {
47
- throw new TypeError("Node doesn't have position");
48
- }
49
- const offset = originNode.position.start.offset;
50
- const endOffset = originNode.position.end?.offset;
47
+ const offset = originNode.start;
48
+ const endOffset = originNode.end;
51
49
  const token = this.sliceFragment(offset, endOffset);
52
50
  switch (originNode.type) {
53
51
  case 'frontmatter': {
@@ -86,13 +84,9 @@ class AstroParser extends Parser {
86
84
  parentNode,
87
85
  });
88
86
  }
89
- case 'component':
90
- case 'custom-element':
91
- case 'fragment':
92
87
  case 'element': {
93
- const tagToken = token.raw ? token : this.sliceFragment(0);
94
88
  return this.visitElement({
95
- ...tagToken,
89
+ ...token,
96
90
  depth,
97
91
  parentNode,
98
92
  }, originNode.children);
@@ -106,12 +100,12 @@ class AstroParser extends Parser {
106
100
  const nodes = [];
107
101
  let closeExpressionLocation = null;
108
102
  if (firstChild && lastChild && firstChild !== lastChild) {
109
- const startExpressionEndOffset = firstChild.position?.end?.offset ?? endOffset ?? offset;
103
+ const startExpressionEndOffset = firstChild.end;
110
104
  const startExpressionLocation = this.sliceFragment(offset, startExpressionEndOffset);
111
105
  startExpressionRaw = startExpressionLocation.raw;
112
106
  startExpressionStartLine = startExpressionLocation.line;
113
107
  startExpressionStartCol = startExpressionLocation.col;
114
- closeExpressionLocation = this.sliceFragment(lastChild.position?.start.offset ?? offset, endOffset);
108
+ closeExpressionLocation = this.sliceFragment(lastChild.start, endOffset);
115
109
  }
116
110
  const blockBehavior = detectBlockBehavior(startExpressionRaw);
117
111
  nodes.push(...this.visitPsBlock({
@@ -316,5 +310,16 @@ class AstroParser extends Parser {
316
310
  detectElementType(nodeName) {
317
311
  return super.detectElementType(nodeName, /^[A-Z]/);
318
312
  }
313
+ /**
314
+ * A variable used as a tag must be capitalized in Astro, as `detectElementType` takes it. Unlike the
315
+ * default of the base class, a `.` alone does not make a name a component: `<ui.Item>` is an
316
+ * HTML element there.
317
+ *
318
+ * @param name - A tag name as written, case kept
319
+ * @returns Whether the name is a component's
320
+ */
321
+ isComponentName(name) {
322
+ return /^[A-Z]/.test(name) && !isPotentialCustomElementName(name);
323
+ }
319
324
  }
320
325
  export const parser = new AstroParser();
@@ -0,0 +1,52 @@
1
+ import type { ParseTemplateResult } from 'astro-eslint-parser';
2
+ /**
3
+ * The root `astro-eslint-parser` produces for a template.
4
+ */
5
+ export type AstroRoot = ParseTemplateResult['result']['ast'];
6
+ /**
7
+ * A node of the Astro template reduced to what {@link import('./parser.js')} needs:
8
+ * its kind, its source range (UTF-16 offsets, `end` exclusive) and the nodes it contains.
9
+ *
10
+ * Attributes are deliberately absent: the parser re-tokenizes the start tag from the
11
+ * source text, so no attribute information is read from the Astro AST.
12
+ */
13
+ export type Node = (Range & {
14
+ readonly type: 'frontmatter';
15
+ }) | (Range & {
16
+ readonly type: 'doctype';
17
+ readonly value: string;
18
+ }) | (Range & {
19
+ readonly type: 'text';
20
+ }) | (Range & {
21
+ readonly type: 'comment';
22
+ }) | (Range & {
23
+ readonly type: 'element';
24
+ readonly children: readonly Node[];
25
+ }) | (Range & {
26
+ readonly type: 'expression';
27
+ readonly children: readonly Node[];
28
+ });
29
+ interface Range {
30
+ readonly start: number;
31
+ readonly end: number;
32
+ }
33
+ /**
34
+ * The nodes of the Astro AST as a flat list of top-level {@link Node}s: the frontmatter
35
+ * (when there is one) followed by the body.
36
+ *
37
+ * ## Why a normalization step exists
38
+ *
39
+ * `astro-eslint-parser` v3 reports a JSX-style AST (`JSXElement`, `JSXText`, ...) where the
40
+ * former compiler reported `element` / `component` / `text` nodes. Three of its shapes
41
+ * differ in a way the parser must not see:
42
+ *
43
+ * - The range of an `AstroScript` child is that of the whole `<script>` element, not of its
44
+ * body, so the body range is taken from the surrounding start and end tags.
45
+ * - A JSX node inside an expression (`{items.map(i => <li>{i}</li>)}`) is not a child of the
46
+ * expression container but a descendant of its ESTree expression. The outermost ones are
47
+ * collected, and the code between them is reported as `text` nodes, which is the layout the
48
+ * parser relies on to split an expression into its opening and closing parts.
49
+ * - An absent frontmatter is reported as an empty one.
50
+ */
51
+ export declare function toNodes(root: AstroRoot): Node[];
52
+ export {};
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The nodes of the Astro AST as a flat list of top-level {@link Node}s: the frontmatter
3
+ * (when there is one) followed by the body.
4
+ *
5
+ * ## Why a normalization step exists
6
+ *
7
+ * `astro-eslint-parser` v3 reports a JSX-style AST (`JSXElement`, `JSXText`, ...) where the
8
+ * former compiler reported `element` / `component` / `text` nodes. Three of its shapes
9
+ * differ in a way the parser must not see:
10
+ *
11
+ * - The range of an `AstroScript` child is that of the whole `<script>` element, not of its
12
+ * body, so the body range is taken from the surrounding start and end tags.
13
+ * - A JSX node inside an expression (`{items.map(i => <li>{i}</li>)}`) is not a child of the
14
+ * expression container but a descendant of its ESTree expression. The outermost ones are
15
+ * collected, and the code between them is reported as `text` nodes, which is the layout the
16
+ * parser relies on to split an expression into its opening and closing parts.
17
+ * - An absent frontmatter is reported as an empty one.
18
+ */
19
+ export function toNodes(root) {
20
+ const nodes = [];
21
+ const { frontmatter } = root;
22
+ if (frontmatter && frontmatter.end > frontmatter.start) {
23
+ nodes.push({ type: 'frontmatter', start: frontmatter.start, end: frontmatter.end });
24
+ }
25
+ for (const node of root.body) {
26
+ nodes.push(...convert(node));
27
+ }
28
+ return nodes;
29
+ }
30
+ function convert(node) {
31
+ const { start, end } = node;
32
+ switch (node.type) {
33
+ case 'AstroDoctype': {
34
+ return [{ type: 'doctype', start, end, value: node.value }];
35
+ }
36
+ case 'AstroComment': {
37
+ return [{ type: 'comment', start, end }];
38
+ }
39
+ case 'JSXText': {
40
+ return [{ type: 'text', start, end }];
41
+ }
42
+ case 'JSXElement': {
43
+ return [
44
+ {
45
+ type: 'element',
46
+ start,
47
+ end,
48
+ children: node.children.flatMap(child => {
49
+ if (child.type !== 'AstroScript') {
50
+ return convert(child);
51
+ }
52
+ // An empty body (`<script />`) is still reported as a script: it has no text.
53
+ const bodyStart = node.openingElement.end;
54
+ const bodyEnd = node.closingElement?.start ?? end;
55
+ return bodyEnd > bodyStart ? [{ type: 'text', start: bodyStart, end: bodyEnd }] : [];
56
+ }),
57
+ },
58
+ ];
59
+ }
60
+ case 'JSXFragment': {
61
+ return [{ type: 'element', start, end, children: node.children.flatMap(child => convert(child)) }];
62
+ }
63
+ case 'JSXExpressionContainer': {
64
+ return [{ type: 'expression', start, end, children: expressionChildren(node) }];
65
+ }
66
+ default: {
67
+ // Exhaustive: a node type that `astro-eslint-parser` adds fails the build here
68
+ // instead of silently vanishing from the document.
69
+ node;
70
+ return [];
71
+ }
72
+ }
73
+ }
74
+ /**
75
+ * The children of an expression container: the outermost JSX nodes of its expression, with
76
+ * the (non-empty) code between them as `text`. The braces are not part of any child.
77
+ *
78
+ * Empty gaps produce no `text`, as the former compiler did. The parser cuts the opening part of
79
+ * the expression at the end of the first child and the closing part at the start of the last
80
+ * one, so an expression that ends with an element has that element's markup in its closing
81
+ * part (`<input type={type} />}`). That layout is pinned by the `#739` spec in `parser.spec.ts`.
82
+ */
83
+ function expressionChildren(container) {
84
+ const jsxNodes = collectJsx(container.expression).toSorted((a, b) => a.start - b.start);
85
+ if (jsxNodes.length === 0) {
86
+ return [];
87
+ }
88
+ const children = [];
89
+ // Skip the opening `{`
90
+ let cursor = container.start + 1;
91
+ for (const jsx of jsxNodes) {
92
+ if (jsx.start > cursor) {
93
+ children.push({ type: 'text', start: cursor, end: jsx.start });
94
+ }
95
+ children.push(...convert(jsx));
96
+ cursor = jsx.end;
97
+ }
98
+ // Stop before the closing `}`
99
+ const innerEnd = container.end - 1;
100
+ if (innerEnd > cursor) {
101
+ children.push({ type: 'text', start: cursor, end: innerEnd });
102
+ }
103
+ return children;
104
+ }
105
+ /**
106
+ * Finds the outermost `JSXElement` / `JSXFragment` nodes in an ESTree subtree. A node found
107
+ * is not descended into: its own descendants are converted when the node itself is.
108
+ */
109
+ function collectJsx(value) {
110
+ if (typeof value !== 'object' || value === null) {
111
+ return [];
112
+ }
113
+ if (Array.isArray(value)) {
114
+ return value.flatMap(item => collectJsx(item));
115
+ }
116
+ if ('type' in value && (value.type === 'JSXElement' || value.type === 'JSXFragment')) {
117
+ return [value];
118
+ }
119
+ return Object.entries(value).flatMap(([key, child]) => (key === 'parent' ? [] : collectJsx(child)));
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markuplint/astro-parser",
3
- "version": "5.1.1",
3
+ "version": "5.3.0",
4
4
  "description": "astro parser for markuplint",
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
  "./component-scanner": {
22
- "import": "./lib/component-scanner.js",
23
- "types": "./lib/component-scanner.d.ts"
22
+ "types": "./lib/component-scanner.d.ts",
23
+ "default": "./lib/component-scanner.js"
24
24
  }
25
25
  },
26
26
  "publishConfig": {
@@ -32,12 +32,9 @@
32
32
  "clean": "tsc --build --clean tsconfig.build.json"
33
33
  },
34
34
  "dependencies": {
35
- "@markuplint/ml-ast": "5.1.1",
36
- "@markuplint/parser-utils": "5.1.1",
37
- "astro-eslint-parser": "1.4.0"
35
+ "@markuplint/ml-ast": "5.3.0",
36
+ "@markuplint/parser-utils": "5.3.0",
37
+ "astro-eslint-parser": "^3.2.0"
38
38
  },
39
- "devDependencies": {
40
- "@astrojs/compiler": "3.0.1"
41
- },
42
- "gitHead": "a940c8526e971d590d97395ce924910221208820"
39
+ "gitHead": "f587cf0206c8426e57369a48d6d5fb22306eafde"
43
40
  }