@markuplint/astro-parser 4.6.23 → 4.18.3

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.
@@ -191,20 +191,48 @@ Astro テンプレートディレクティブは `name:modifier` 構文を使用
191
191
  - ショートハンド属性: `{prop}`
192
192
  - ネストされた式: `style={{ a: b }}`
193
193
 
194
+ ### スプレッド属性
195
+
196
+ スプレッド属性(`{...EXPR}`)は、基底の `Parser.visitAttr()` に委譲する **前**に、`visitAttr()` 内の波括弧対応プリパス(`src/spread-attr.ts` 参照)で抽出されます。プリパスは生のトークンを 1 文字ずつ走査し、以下を考慮します:
197
+
198
+ - 文字列リテラル(`'`、`"`)
199
+ - `${}` 補間付きテンプレートリテラル
200
+ - 行コメント(`//`)とブロックコメント(`/* */`)
201
+ - バックスラッシュでエスケープされたクォート(連続するバックスラッシュの偶奇判定)
202
+
203
+ スプレッドトークンに対しては上流の `safeScriptParser`(espree ベース)を意図的に回避します。理由:
204
+
205
+ 1. espree は `{...x as any}` のような TypeScript 構文を理解せず、`as` の手前でスプレッドを誤って終端させる。
206
+ 2. espree は「valid な JS プレフィックス」をスプレッドの閉じ `}` を超えて貪欲に伸ばすことがあり、例えば `{...props}>{label}` を二項 `>` 式として解釈してしまい、次の兄弟ノードを呑み込み `Invalid tag syntax` エラーになる。
207
+
208
+ 両方とも [#3824](https://github.com/markuplint/markuplint/issues/3824) で報告された症状です。プリパスは `{...}` 境界を純粋な波括弧マッチング問題として扱うことで両方を解消します。
209
+
210
+ **波括弧マッチャの既知の制限**:
211
+
212
+ - 波括弧を含む正規表現リテラル(例: `{...x.match(/}/) ? a : b}`)は認識しません。`/` は常に除算演算子として扱われます。遭遇した場合は変数に切り出して回避してください。
213
+
214
+ **撤去条件**: `parser-utils/script-parser.ts` が TypeScript 構文を理解し、かつスプレッドの `}` を超えて伸びないように改善された場合、本パッケージの `src/spread-attr.ts` と `visitAttr()` のプリパスは削除し、基底パーサーのパスに戻すことが可能です。
215
+
216
+ ### Raw-text 要素の本文(`<script>`、`<style>`)
217
+
218
+ `AstroParser.visitElement()` は **要素全体(本文・終了タグを含む)の raw 文字列**を `parser-utils` の `parseCodeFragment()` に渡し、最初のノードを開始タグ、最後のノードを終了タグとして使います。`<script>` や `<style>` の本文をそのまま再トークナイズすると、`/<br\s*\/?>/gi` のような正規表現がタグとして誤読され `Invalid tag syntax` で失敗します([#3860](https://github.com/markuplint/markuplint/issues/3860)、[#3825](https://github.com/markuplint/markuplint/issues/3825) の v4 backport)。
219
+
220
+ raw-text の安全性は `parser-utils` 側に委譲しています。`parseCodeFragment()` は `rawTextElements`(既定 `['style', 'script']`)を認識し、本文を次に現れる ASCII 大文字小文字非依存の `</tagName`(タブ/LF/FF/CR/空白/`>` /`/` のいずれかが続く)まで丸ごと消費します([HTML LS §13.2.5.1](https://html.spec.whatwg.org/multipage/syntax.html#cdata-rcdata-restrictions))。`astro-parser` 側に専用分岐は不要で、`parsedNodes[0]` と `parsedNodes[-1]` がそれぞれ開始タグ・終了タグになる既存フローのまま動作します。
221
+
194
222
  ## jsx-parser との比較
195
223
 
196
- | 機能 | `astro-parser` | `jsx-parser` |
197
- | ------------------------------ | -------------------------------------- | ------------------------------------------------- |
198
- | **トークナイザ** | `astro-eslint-parser` | TypeScript ESTree(`@typescript-eslint/parser`) |
199
- | **フロントマター** | サポート(`---...---` psblock) | 該当なし |
200
- | **式の構文** | `{expr}` を MustacheTag psblock として | `{expr}` を JSXExpressionContainer psblock として |
201
- | **テンプレートディレクティブ** | `class:list`、`set:html` 等 | 該当なし |
202
- | **名前空間管理** | `#updateScopeNS()` で手動管理 | html-parser の `getNamespace()` に委譲 |
203
- | **コンポーネント検出** | `/^[A-Z]/` パターン | `/^[A-Z]/` パターン |
204
- | **自己閉じタイプ** | `html+xml` | デフォルト(XML のみ) |
205
- | **booleanish 属性** | 未設定 | `booleanish: true` |
206
- | **名前なしフラグメント** | `<>...</>` サポート | `<>...</>` サポート |
207
- | **スプレッド属性** | 基底パーサーで処理 | カスタム `visitSpreadAttr()` で IDL ルックアップ |
224
+ | 機能 | `astro-parser` | `jsx-parser` |
225
+ | ------------------------------ | ----------------------------------------------- | ------------------------------------------------- |
226
+ | **トークナイザ** | `astro-eslint-parser` | TypeScript ESTree(`@typescript-eslint/parser`) |
227
+ | **フロントマター** | サポート(`---...---` psblock) | 該当なし |
228
+ | **式の構文** | `{expr}` を MustacheTag psblock として | `{expr}` を JSXExpressionContainer psblock として |
229
+ | **テンプレートディレクティブ** | `class:list`、`set:html` 等 | 該当なし |
230
+ | **名前空間管理** | `#updateScopeNS()` で手動管理 | html-parser の `getNamespace()` に委譲 |
231
+ | **コンポーネント検出** | `/^[A-Z]/` パターン | `/^[A-Z]/` パターン |
232
+ | **自己閉じタイプ** | `html+xml` | デフォルト(XML のみ) |
233
+ | **booleanish 属性** | 未設定 | `booleanish: true` |
234
+ | **名前なしフラグメント** | `<>...</>` サポート | `<>...</>` サポート |
235
+ | **スプレッド属性** | `visitAttr()` 内の波括弧対応プリパス(TS 対応) | カスタム `visitSpreadAttr()` で IDL ルックアップ |
208
236
 
209
237
  ## バージョン互換性
210
238
 
@@ -218,11 +246,12 @@ astro-eslint-parser → @astrojs/compiler → Astro 構文サポート
218
246
 
219
247
  ## 主要ソースファイル
220
248
 
221
- | ファイル | 用途 |
222
- | ----------------- | ------------------------------------------------------------------------------------- |
223
- | `parser.ts` | `AstroParser` クラス — 全オーバーライドメソッドと名前空間スコーピング |
224
- | `astro-parser.ts` | `astroParse()` ラッパー — `astro-eslint-parser` に委譲し、診断を `ParserError` に変換 |
225
- | `index.ts` | 公開 API — シングルトン `parser` インスタンスを再エクスポート |
249
+ | ファイル | 用途 |
250
+ | ----------------- | -------------------------------------------------------------------------------------- |
251
+ | `parser.ts` | `AstroParser` クラス — 全オーバーライドメソッドと名前空間スコーピング |
252
+ | `astro-parser.ts` | `astroParse()` ラッパー — `astro-eslint-parser` に委譲し、診断を `ParserError` に変換 |
253
+ | `spread-attr.ts` | `visitAttr()` が使用する波括弧対応スプレッド属性抽出器(上記「スプレッド属性」を参照) |
254
+ | `index.ts` | 公開 API — シングルトン `parser` インスタンスを再エクスポート |
226
255
 
227
256
  ## ドキュメントマップ
228
257
 
package/ARCHITECTURE.md CHANGED
@@ -191,20 +191,48 @@ Any attribute whose start quote is `{` gets `isDynamicValue: true`. This applies
191
191
  - Shorthand attributes: `{prop}`
192
192
  - Nested expressions: `style={{ a: b }}`
193
193
 
194
+ ### Spread Attributes
195
+
196
+ Spread attributes (`{...EXPR}`) are extracted by a brace-aware pre-pass in `visitAttr()` (see `src/spread-attr.ts`) **before** delegating to the base `Parser.visitAttr()`. The pre-pass walks the raw token character by character with awareness of:
197
+
198
+ - string literals (`'`, `"`)
199
+ - template literals with `${}` interpolation
200
+ - line (`//`) and block (`/* */`) comments
201
+ - backslash-escaped quotes (counting consecutive backslashes for parity)
202
+
203
+ This bypasses the upstream `safeScriptParser` (espree-based) for spread tokens because espree:
204
+
205
+ 1. Does not understand TypeScript syntax such as `{...x as any}` and would terminate the spread early at `as`.
206
+ 2. May greedily extend a "valid JS prefix" past the spread's closing `}` into surrounding HTML — for example `{...props}>{label}` is interpreted as a binary `>` expression, swallowing the next sibling and producing `Invalid tag syntax`.
207
+
208
+ Both failure modes were reported in [#3824](https://github.com/markuplint/markuplint/issues/3824). The pre-pass solves them by treating the `{...}` boundary as a pure brace-matching problem.
209
+
210
+ **Known limitations** of the brace matcher:
211
+
212
+ - Regular-expression literals containing braces (e.g. `{...x.match(/}/) ? a : b}`) are not recognised — `/` is always treated as a division operator. Rewrite via a variable indirection if encountered.
213
+
214
+ **Retraction condition**: if `parser-utils/script-parser.ts` is upgraded to handle TypeScript syntax and to stop extending past the spread's `}`, this package's `src/spread-attr.ts` and the `visitAttr()` pre-pass can be removed and the base parser path restored.
215
+
216
+ ### Raw-text Element Bodies (`<script>`, `<style>`)
217
+
218
+ `AstroParser.visitElement()` hands the **entire element source** (including body and end tag) to `parser-utils`'s `parseCodeFragment()` and uses the first parsed node as the start tag and the last as the end tag. For `<script>` and `<style>`, the body would otherwise be re-tokenized as HTML — and a regex such as `/<br\s*\/?>/gi` inside a script body would be misread as a tag, causing `Invalid tag syntax` ([#3860](https://github.com/markuplint/markuplint/issues/3860), v4 backport of [#3825](https://github.com/markuplint/markuplint/issues/3825)).
219
+
220
+ Raw-text safety is delegated to `parser-utils`: `parseCodeFragment()` recognizes `rawTextElements` (default `['style', 'script']`) and consumes the body verbatim until the next ASCII-case-insensitive `</tagName` followed by a tab/LF/FF/CR/space/`>` /`/`, per [HTML LS §13.2.5.1](https://html.spec.whatwg.org/multipage/syntax.html#cdata-rcdata-restrictions). `astro-parser` requires no special-case branch — the existing `visitElement()` flow continues to work because the start tag and end tag still occupy `parsedNodes[0]` and `parsedNodes[-1]` respectively.
221
+
194
222
  ## Comparison with jsx-parser
195
223
 
196
- | Feature | `astro-parser` | `jsx-parser` |
197
- | ------------------------- | ------------------------------- | ----------------------------------------------- |
198
- | **Tokenizer** | `astro-eslint-parser` | TypeScript ESTree (`@typescript-eslint/parser`) |
199
- | **Frontmatter** | Supported (`---...---` psblock) | Not applicable |
200
- | **Expression syntax** | `{expr}` as MustacheTag psblock | `{expr}` as JSXExpressionContainer psblock |
201
- | **Template directives** | `class:list`, `set:html`, etc. | Not applicable |
202
- | **Namespace management** | Manual via `#updateScopeNS()` | Delegates to `getNamespace()` from html-parser |
203
- | **Component detection** | `/^[A-Z]/` pattern | `/^[A-Z]/` pattern |
204
- | **Self-close type** | `html+xml` | Default (XML-only) |
205
- | **Booleanish attributes** | Not configured | `booleanish: true` |
206
- | **Nameless fragments** | `<>...</>` supported | `<>...</>` supported |
207
- | **Spread attributes** | Handled by base parser | Custom `visitSpreadAttr()` with IDL lookup |
224
+ | Feature | `astro-parser` | `jsx-parser` |
225
+ | ------------------------- | ------------------------------------------------ | ----------------------------------------------- |
226
+ | **Tokenizer** | `astro-eslint-parser` | TypeScript ESTree (`@typescript-eslint/parser`) |
227
+ | **Frontmatter** | Supported (`---...---` psblock) | Not applicable |
228
+ | **Expression syntax** | `{expr}` as MustacheTag psblock | `{expr}` as JSXExpressionContainer psblock |
229
+ | **Template directives** | `class:list`, `set:html`, etc. | Not applicable |
230
+ | **Namespace management** | Manual via `#updateScopeNS()` | Delegates to `getNamespace()` from html-parser |
231
+ | **Component detection** | `/^[A-Z]/` pattern | `/^[A-Z]/` pattern |
232
+ | **Self-close type** | `html+xml` | Default (XML-only) |
233
+ | **Booleanish attributes** | Not configured | `booleanish: true` |
234
+ | **Nameless fragments** | `<>...</>` supported | `<>...</>` supported |
235
+ | **Spread attributes** | Brace-aware pre-pass in `visitAttr()` (TS-aware) | Custom `visitSpreadAttr()` with IDL lookup |
208
236
 
209
237
  ## Version Compatibility
210
238
 
@@ -222,6 +250,7 @@ astro-eslint-parser → @astrojs/compiler → Astro syntax support
222
250
  | ----------------- | -------------------------------------------------------------------------------------------------- |
223
251
  | `parser.ts` | `AstroParser` class — all override methods and namespace scoping |
224
252
  | `astro-parser.ts` | `astroParse()` wrapper — delegates to `astro-eslint-parser`, converts diagnostics to `ParserError` |
253
+ | `spread-attr.ts` | Brace-aware spread-attribute extractor used by `visitAttr()` (see Spread Attributes above) |
225
254
  | `index.ts` | Public API — re-exports the singleton `parser` instance |
226
255
 
227
256
  ## Documentation Map
package/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
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
+ ## [4.18.3](https://github.com/markuplint/markuplint/compare/v4.18.2...v4.18.3) (2026-05-10)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **astro-parser:** preserve spread attributes containing TypeScript and expression-child siblings ([fe76c34](https://github.com/markuplint/markuplint/commit/fe76c34f04b6ce2559c8fbfad01df525781ef34d)), closes [#3824](https://github.com/markuplint/markuplint/issues/3824)
11
+ - **astro-parser:** stop surfacing non-fatal Astro diagnostics as parse errors ([3b51f6c](https://github.com/markuplint/markuplint/commit/3b51f6c34d200c04006c283a62becb5733673fac)), closes [#3823](https://github.com/markuplint/markuplint/issues/3823)
12
+
13
+ # [4.18.0](https://github.com/markuplint/markuplint/compare/v4.14.1...v4.18.0) (2026-04-22)
14
+
15
+ **Note:** Version bump only for package @markuplint/astro-parser
16
+
17
+ ## [4.6.24](https://github.com/markuplint/markuplint/compare/@markuplint/astro-parser@4.6.23...@markuplint/astro-parser@4.6.24) (2026-04-21)
18
+
19
+ **Note:** Version bump only for package @markuplint/astro-parser
20
+
6
21
  ## [4.6.23](https://github.com/markuplint/markuplint/compare/@markuplint/astro-parser@4.6.22...@markuplint/astro-parser@4.6.23) (2026-02-10)
7
22
 
8
23
  **Note:** Version bump only for package @markuplint/astro-parser
@@ -112,11 +112,12 @@ expect(debugMaps).toStrictEqual([
112
112
 
113
113
  上流パッケージの変更がこのパーサーに影響を与える可能性があります:
114
114
 
115
- | パッケージ | 影響 |
116
- | -------------------------- | ----------------------------------------------------------- |
117
- | `@markuplint/parser-utils` | 基底 `Parser` クラスの変更は全オーバーライドメソッドに影響 |
118
- | `@markuplint/ml-ast` | AST 型の変更は `nodeize()` の戻り値の型に影響 |
119
- | `astro-eslint-parser` | パーサー出力形式の変更は `tokenize()` `nodeize()` に影響 |
115
+ | パッケージ | 影響 |
116
+ | ---------------------------------------- | ----------------------------------------------------------------------------------------------------- |
117
+ | `@markuplint/parser-utils` | 基底 `Parser` クラスの変更は全オーバーライドメソッドに影響 |
118
+ | `@markuplint/parser-utils/script-parser` | TS 対応と非貪欲なパースが上流に入った場合、`src/spread-attr.ts` `visitAttr()` のプリパスは削除可能 |
119
+ | `@markuplint/ml-ast` | AST 型の変更は `nodeize()` の戻り値の型に影響 |
120
+ | `astro-eslint-parser` | パーサー出力形式の変更は `tokenize()` と `nodeize()` に影響 |
120
121
 
121
122
  `astro-eslint-parser` を更新する場合:
122
123
 
@@ -131,6 +132,23 @@ yarn upgrade @astrojs/compiler --scope @markuplint/astro-parser --dev
131
132
  yarn build --scope @markuplint/astro-parser && yarn test --scope @markuplint/astro-parser
132
133
  ```
133
134
 
135
+ ## dev (v5 RC) からの fix port 手順
136
+
137
+ `dev` と `v4` は自動マージ不可 — 両方に当てる必要がある fix は手動で port する。最も頻発する port hazard は `Token` のフィールド名差:
138
+
139
+ | v4 (本ブランチ) | dev (v5 RC) |
140
+ | ---------------------------------------------- | ----------------------------------------------------------- |
141
+ | `token.startOffset` / `startLine` / `startCol` | `token.offset` / `line` / `col` |
142
+ | `token.endOffset` / `endLine` / `endCol` | `#getEndLocation()` ヘルパが `{ offset, line, col }` を返す |
143
+ | `MLASTText` は `parentNode` のみ | `MLASTText` は `parentNodeUuid` も持つ |
144
+
145
+ dev fix を port する手順:
146
+
147
+ 1. dev のソース変更とテストを両方読む。`token.line` / `token.col` / `token.offset` で assert しているテストは v4 では `startLine` / `startCol` / `startOffset` に書き換える。
148
+ 2. v4 のフィールド名でソース変更を適用。`yarn build` で `TS2339` が出れば見落としを fail-fast に検出できる。
149
+ 3. dev と同じ `#NNNN` describe id を v4 でも使う — id を揃えると後から両ブランチを grep で対比しやすい。
150
+ 4. JSDoc / `parser-class.md` / 該当パッケージの `maintenance.md` Troubleshooting に dev / v4 の Issue・PR を相互リンク。次に port する人が関係を再発見しなくて済む。
151
+
134
152
  ## トラブルシューティング
135
153
 
136
154
  ### フロントマターが認識されない
@@ -181,3 +199,34 @@ yarn build --scope @markuplint/astro-parser && yarn test --scope @markuplint/ast
181
199
  1. 属性名の形式を確認 — 正規表現はコロンが1つだけで、両側に空でない部分が必要
182
200
  2. `switch (lowerCaseDirectiveName)` を確認 — ディレクティブプレフィックスがケースにマッチする必要がある
183
201
  3. 新しいディレクティブプレフィックスの場合は、新しいケースを追加(レシピ #1 参照)
202
+
203
+ ### スプレッド属性が途中で切れる、または `{...EXPR}` の直後に式の子があると `Invalid tag syntax` が発生する
204
+
205
+ **症状:** 次のいずれか:
206
+
207
+ - `{...{ command: 'close' } as any}` が部分的なスプレッドと不正な `as` / `any}` 属性に分割される。
208
+ - `<div {...props}>{label}</div>`(または `{label}` のような式の子をスプレッド属性の直後に持つ要素)が `SyntaxError: Invalid tag syntax: ...` をスローする。
209
+
210
+ **原因:** 要素の生トークンが、`src/spread-attr.ts` の波括弧対応抽出器ではなく `parser-utils/safeScriptParser`(espree ベース)にルーティングされている。`safeScriptParser` は TypeScript 構文(`as`)を理解せず、また「valid な JS プレフィックス」をスプレッドの `}` を超えて周囲の HTML まで伸ばすことがある(`{...props}>{label}` を二項 `>` 式として解釈)。
211
+
212
+ **解決策:**
213
+
214
+ 1. `src/parser.ts` の `visitAttr()` が `super.visitAttr()` の **前**に `./spread-attr.js` の `extractSpreadAttribute()` を呼んでいることを確認。順序が重要 — 基底パスにフォールスルーするとバグが発動する。
215
+ 2. 新しいエッジケースが報告されたら、`src/spread-attr.spec.ts` で `findMatchingBrace()` のユニットテストとして再現し、波括弧マッチャに追加のエスケープルール(文字列 / テンプレート / コメント / バックスラッシュ)が必要かを判断。
216
+ 3. 既知の制限は `src/spread-attr.ts` の JSDoc に記載 — 波括弧を含む正規表現リテラルは扱えない。新たな制限が見つかったらそこに追記。
217
+
218
+ オリジナルの報告とスプレッド属性を `parser-utils` ではなく本パッケージで処理する根拠については [#3824](https://github.com/markuplint/markuplint/issues/3824) を参照。
219
+
220
+ ### `<script>` または `<style>` の本文で `Invalid tag syntax` が throw する
221
+
222
+ **症状:** `<script>` 本文の JS/TS に HTML 風の部分文字列(典型的には `/<br\s*\/?>/gi` や `/<\/?p>/gi` のような正規表現)が含まれると `ParserError: SyntaxError: Invalid tag syntax: "..."` で落ちる。`<style>` 本文中の `/* <br = */` のような CSS コメントでも同種の症状が出る。
223
+
224
+ **原因:** `AstroParser.visitElement()` は要素全体(本文を含む)の raw 文字列を `parser-utils` の `parseCodeFragment()` に渡している。raw-text 認識がないと `parseCodeFragment()` は本文を HTML として再トークナイズし、正規表現中の `<br...>` をタグ開始と解釈、続く `\s`(リテラルなバックスラッシュ)を属性名とみなして throw する。
225
+
226
+ **解決策:** raw-text の安全性は `parser-utils/parser.ts` の `parseCodeFragment()` 内に実装されている。自閉でない開始タグの `nodeName.toLowerCase()` が `rawTextElements` に含まれる場合、本文は次に現れる ASCII 大文字小文字非依存の `</tagName`(タブ/LF/FF/CR/空白/`>` /`/` のいずれかが続く)まで丸ごと消費される(HTML LS §13.2.5.1)。リグレッションが疑われたら:
227
+
228
+ 1. `npx vitest run packages/@markuplint/astro-parser/src/parser.spec.ts -t "#3860"` で失敗フィクスチャがまだ再現するかを確認。
229
+ 2. 修正は `astro-parser` 内ではない。`packages/@markuplint/parser-utils/src/parser.ts` の `parseCodeFragment()` を見て raw-text 分岐が無傷か、close-tag 正規表現が仕様の文字クラスにまだ一致するかを確認する。
230
+ 3. 別の上流 parser が `astro-parser` と同様に要素全体の raw を `parseCodeFragment` に渡すケースが追加された場合、追加配線は不要 — 同じ parser-utils 分岐がそのままカバーする。
231
+
232
+ オリジナルの報告は [#3860](https://github.com/markuplint/markuplint/issues/3860)([#3825](https://github.com/markuplint/markuplint/issues/3825) の v4 backport)を参照。dev (v5 RC) には [#3859](https://github.com/markuplint/markuplint/pull/3859) が先行マージされている。今後 raw-text 周りの変更を port する際は両 PR を diff 比較すること。
@@ -112,11 +112,12 @@ The second argument `true` to `nodeListToDebugMaps` includes attribute details i
112
112
 
113
113
  Changes to upstream packages can affect this parser:
114
114
 
115
- | Package | Impact |
116
- | -------------------------- | ---------------------------------------------------------------- |
117
- | `@markuplint/parser-utils` | Base `Parser` class changes affect all override methods |
118
- | `@markuplint/ml-ast` | AST type changes affect `nodeize()` return types |
119
- | `astro-eslint-parser` | Parser output format changes affect `tokenize()` and `nodeize()` |
115
+ | Package | Impact |
116
+ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
117
+ | `@markuplint/parser-utils` | Base `Parser` class changes affect all override methods |
118
+ | `@markuplint/parser-utils/script-parser` | If TS support and non-greedy parsing land here, `src/spread-attr.ts` and the `visitAttr()` pre-pass can be removed |
119
+ | `@markuplint/ml-ast` | AST type changes affect `nodeize()` return types |
120
+ | `astro-eslint-parser` | Parser output format changes affect `tokenize()` and `nodeize()` |
120
121
 
121
122
  When updating `astro-eslint-parser`:
122
123
 
@@ -131,6 +132,23 @@ yarn upgrade @astrojs/compiler --scope @markuplint/astro-parser --dev
131
132
  yarn build --scope @markuplint/astro-parser && yarn test --scope @markuplint/astro-parser
132
133
  ```
133
134
 
135
+ ## Porting Fixes from dev (v5 RC)
136
+
137
+ `dev` and `v4` cannot be merged automatically — fixes that need to land on both branches are ported manually. The most common port hazard is the `Token` field rename:
138
+
139
+ | v4 (this branch) | dev (v5 RC) |
140
+ | ---------------------------------------------- | ---------------------------------------------------------------------------- |
141
+ | `token.startOffset` / `startLine` / `startCol` | `token.offset` / `line` / `col` |
142
+ | `token.endOffset` / `endLine` / `endCol` | computed via `#getEndLocation()` helper that returns `{ offset, line, col }` |
143
+ | `MLASTText` has only `parentNode` | `MLASTText` adds `parentNodeUuid` |
144
+
145
+ Procedure when porting a dev fix:
146
+
147
+ 1. Read both the dev source change and the dev tests, including any test that asserts on `token.line` / `token.col` / `token.offset` — those need to become `startLine` / `startCol` / `startOffset` on v4.
148
+ 2. Apply the source change with the v4 field names. `yarn build` should fail fast with `TS2339` on any field you missed.
149
+ 3. Re-run the v4 test fixtures against the same `#NNNN` describe id used on dev — keeping the id stable lets future maintainers grep across both branches.
150
+ 4. Cross-link both Issues / PRs in the JSDoc, `parser-class.md`, and the package's `maintenance.md` Troubleshooting entry so the next porter doesn't have to re-discover the relationship.
151
+
134
152
  ## Troubleshooting
135
153
 
136
154
  ### Frontmatter is not recognized
@@ -181,3 +199,34 @@ yarn build --scope @markuplint/astro-parser && yarn test --scope @markuplint/ast
181
199
  1. Verify the attribute name format — the regex requires exactly one colon with non-empty parts on both sides
182
200
  2. Check the `switch (lowerCaseDirectiveName)` — the directive prefix must match a case
183
201
  3. If it is a new directive prefix, add a new case (see Recipe #1)
202
+
203
+ ### Spread attribute is truncated, or `Invalid tag syntax` is thrown for `{...EXPR}` followed by an expression child
204
+
205
+ **Symptom:** Either of the following:
206
+
207
+ - `{...{ command: 'close' } as any}` is split into a partial spread plus bogus `as` / `any}` attributes.
208
+ - `<div {...props}>{label}</div>` (or any element with a spread attribute followed immediately by an expression child like `{label}`) throws `SyntaxError: Invalid tag syntax: ...`.
209
+
210
+ **Cause:** The element's raw token is being routed through `parser-utils/safeScriptParser` (espree-based) instead of the brace-aware extractor in `src/spread-attr.ts`. `safeScriptParser` does not understand TypeScript syntax (`as`) and may extend a "valid JS prefix" past the spread's `}` into the surrounding HTML (interpreting `{...props}>{label}` as a binary `>` expression).
211
+
212
+ **Solution:**
213
+
214
+ 1. Confirm `src/parser.ts` `visitAttr()` calls `extractSpreadAttribute()` from `./spread-attr.js` _before_ `super.visitAttr()`. The order matters — falling through to the base path is what triggers the bug.
215
+ 2. If a new edge case is reported, reproduce it as a unit test in `src/spread-attr.spec.ts` with `findMatchingBrace()` and decide whether the brace matcher needs an additional escape rule (string / template / comment / backslash).
216
+ 3. Known limitations are listed in `src/spread-attr.ts` JSDoc — regular-expression literals containing braces are not handled. Document any additional limitations there.
217
+
218
+ See [#3824](https://github.com/markuplint/markuplint/issues/3824) for the original report and the rationale for handling spread attributes locally instead of in `parser-utils`.
219
+
220
+ ### `<script>` or `<style>` body throws `Invalid tag syntax`
221
+
222
+ **Symptom:** A `<script>` body containing JavaScript / TypeScript with HTML-like substrings (most commonly a regex such as `/<br\s*\/?>/gi` or `/<\/?p>/gi`) throws `ParserError: SyntaxError: Invalid tag syntax: "..."`. Same for `<style>` bodies that include CSS comments such as `/* <br = */`.
223
+
224
+ **Cause:** `AstroParser.visitElement()` passes the entire element source (including body) to `parser-utils`'s `parseCodeFragment()`. Without raw-text awareness, `parseCodeFragment()` would treat the body as HTML and try to tokenize the regex's `<br...>` as a start tag, hitting `\s` (literal backslash) where an attribute name is expected.
225
+
226
+ **Solution:** Raw-text safety lives in `parser-utils/parser.ts` `parseCodeFragment()` — after a non-self-closing start tag whose `nodeName.toLowerCase()` matches `rawTextElements`, the body is consumed verbatim until the next ASCII-case-insensitive `</tagName` followed by a tab/LF/FF/CR/space/`>` /`/` (HTML LS §13.2.5.1). If a regression appears here:
227
+
228
+ 1. Verify the failing fixture still reproduces by running `npx vitest run packages/@markuplint/astro-parser/src/parser.spec.ts -t "#3860"`.
229
+ 2. The fix is _not_ inside `astro-parser` — inspect `packages/@markuplint/parser-utils/src/parser.ts` `parseCodeFragment()` to confirm the raw-text branch is intact and the close-tag regex still matches the spec character class.
230
+ 3. If a different upstream parser is added that hands the full element raw to `parseCodeFragment` (similar to `astro-parser`), no extra wiring is needed — the same parser-utils branch covers it.
231
+
232
+ See [#3860](https://github.com/markuplint/markuplint/issues/3860) (v4 backport of [#3825](https://github.com/markuplint/markuplint/issues/3825)) for the original report. The dev (v5 RC) implementation landed first via [#3859](https://github.com/markuplint/markuplint/pull/3859) — diff-compare both PRs when porting future raw-text changes.
@@ -2,9 +2,39 @@ import type { RootNode } from '@astrojs/compiler/types';
2
2
  export type { RootNode, ElementNode, CustomElementNode, ComponentNode, FragmentNode, AttributeNode, Node, } from '@astrojs/compiler/types';
3
3
  /**
4
4
  * Parses an Astro component source string into the Astro compiler's root AST node.
5
- * Delegates to astro-eslint-parser and converts any diagnostics into ParserErrors.
5
+ *
6
+ * ## Diagnostic handling policy
7
+ *
8
+ * Only **severity=Error** Astro diagnostics surface as a `ParserError`.
9
+ * Warning / Information / Hint diagnostics are passed through silently
10
+ * because the AST is still fully populated for those levels and Astro's own
11
+ * tooling (the language server, `astro check`) owns the user-facing message
12
+ * — markuplint must not surface them as fatal `parse-error`s (#3823).
13
+ *
14
+ * Concretely, the `is:inline` Hint that Astro emits for a `<script>` tag
15
+ * carrying any non-`src` attribute, and the `set:html` Warning for
16
+ * `set:html` overwriting children, both reach this wrapper but do not
17
+ * abort parsing.
18
+ *
19
+ * ## Error normalization
20
+ *
21
+ * `parseTemplate()` itself throws on severity=Error diagnostics and on raw
22
+ * template syntax errors (unterminated comments, unclosed expressions,
23
+ * etc.) by raising a `SyntaxError`-derived `ParseError`. Those throws are
24
+ * caught and normalized to `ParserError`, so the caller sees a single
25
+ * Tier-3 (per-file violation) error type for every parse failure rather
26
+ * than a Tier-1 fatal `SyntaxError` that would abort the whole lint run.
27
+ *
28
+ * The defensive `find(severity === Error)` after `parseTemplate()` is dead
29
+ * code today (upstream gates internally) but is retained as a safety net
30
+ * if a future `astro-eslint-parser` ever stops gating severity=Error.
31
+ *
32
+ * @see https://github.com/markuplint/markuplint/issues/3823
33
+ * @see https://docs.astro.build/en/reference/directives-reference/#isinline
34
+ * @see https://docs.astro.build/en/guides/client-side-scripts/#script-processing
6
35
  *
7
36
  * @param code - The raw Astro component source code
8
37
  * @returns The root AST node produced by the Astro compiler
38
+ * @throws {ParserError} on severity=Error Astro diagnostics or on raw upstream syntax errors
9
39
  */
10
40
  export declare function astroParse(code: string): RootNode;
@@ -1,19 +1,74 @@
1
1
  import { ParserError } from '@markuplint/parser-utils';
2
2
  import { parseTemplate } from 'astro-eslint-parser';
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
10
+ */
11
+ const ASTRO_DIAGNOSTIC_SEVERITY_ERROR = 1;
3
12
  /**
4
13
  * Parses an Astro component source string into the Astro compiler's root AST node.
5
- * Delegates to astro-eslint-parser and converts any diagnostics into ParserErrors.
14
+ *
15
+ * ## Diagnostic handling policy
16
+ *
17
+ * Only **severity=Error** Astro diagnostics surface as a `ParserError`.
18
+ * Warning / Information / Hint diagnostics are passed through silently
19
+ * because the AST is still fully populated for those levels and Astro's own
20
+ * tooling (the language server, `astro check`) owns the user-facing message
21
+ * — markuplint must not surface them as fatal `parse-error`s (#3823).
22
+ *
23
+ * Concretely, the `is:inline` Hint that Astro emits for a `<script>` tag
24
+ * carrying any non-`src` attribute, and the `set:html` Warning for
25
+ * `set:html` overwriting children, both reach this wrapper but do not
26
+ * abort parsing.
27
+ *
28
+ * ## Error normalization
29
+ *
30
+ * `parseTemplate()` itself throws on severity=Error diagnostics and on raw
31
+ * template syntax errors (unterminated comments, unclosed expressions,
32
+ * etc.) by raising a `SyntaxError`-derived `ParseError`. Those throws are
33
+ * caught and normalized to `ParserError`, so the caller sees a single
34
+ * Tier-3 (per-file violation) error type for every parse failure rather
35
+ * than a Tier-1 fatal `SyntaxError` that would abort the whole lint run.
36
+ *
37
+ * The defensive `find(severity === Error)` after `parseTemplate()` is dead
38
+ * code today (upstream gates internally) but is retained as a safety net
39
+ * if a future `astro-eslint-parser` ever stops gating severity=Error.
40
+ *
41
+ * @see https://github.com/markuplint/markuplint/issues/3823
42
+ * @see https://docs.astro.build/en/reference/directives-reference/#isinline
43
+ * @see https://docs.astro.build/en/guides/client-side-scripts/#script-processing
6
44
  *
7
45
  * @param code - The raw Astro component source code
8
46
  * @returns The root AST node produced by the Astro compiler
47
+ * @throws {ParserError} on severity=Error Astro diagnostics or on raw upstream syntax errors
9
48
  */
10
49
  export function astroParse(code) {
11
- const { result } = parseTemplate(code);
12
- if (result.diagnostics[0]) {
13
- const error = result.diagnostics[0];
14
- throw new ParserError(error.text, {
15
- line: error.location.line,
16
- col: error.location.column,
50
+ let result;
51
+ try {
52
+ ({ result } = parseTemplate(code));
53
+ }
54
+ catch (error) {
55
+ if (!(error instanceof SyntaxError)) {
56
+ throw error;
57
+ }
58
+ const { lineNumber, column } = error;
59
+ throw new ParserError(error.message, {
60
+ line: lineNumber ?? 1,
61
+ col: column ?? 0,
62
+ });
63
+ }
64
+ // Defensive: if a future `astro-eslint-parser` stops gating severity=Error
65
+ // diagnostics internally, surface them here so they never silently leak
66
+ // past the wrapper as a non-fatal pass-through.
67
+ const fatal = result.diagnostics.find(d => d.severity === ASTRO_DIAGNOSTIC_SEVERITY_ERROR);
68
+ if (fatal) {
69
+ throw new ParserError(fatal.text, {
70
+ line: fatal.location.line,
71
+ col: fatal.location.column,
17
72
  });
18
73
  }
19
74
  return result.ast;
package/lib/parser.d.ts CHANGED
@@ -54,14 +54,14 @@ declare class AstroParser extends Parser<Node, State> {
54
54
  /**
55
55
  * Visits an attribute token, handling Astro-specific syntax including
56
56
  * curly-brace expression values, shorthand attributes (`{name}`),
57
- * and template directives (e.g., `class:list`, `set:html`).
57
+ * spread attributes (`{...expr}`, including TypeScript and nested
58
+ * expressions, see #3824), and template directives (e.g., `class:list`,
59
+ * `set:html`).
58
60
  *
59
61
  * @param token - The token representing the attribute
60
62
  * @returns The parsed attribute node with Astro-specific metadata
61
63
  */
62
- visitAttr(token: Token): (import("@markuplint/ml-ast").MLASTSpreadAttr & {
63
- __rightText?: string;
64
- }) | {
64
+ visitAttr(token: Token): import("@markuplint/ml-ast").MLASTSpreadAttr | {
65
65
  isDynamicValue: true | undefined;
66
66
  isDirective: true | undefined;
67
67
  potentialName: string | undefined;
package/lib/parser.js CHANGED
@@ -6,6 +6,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
6
6
  var _AstroParser_instances, _AstroParser_updateScopeNS;
7
7
  import { AttrState, Parser, ParserError } from '@markuplint/parser-utils';
8
8
  import { astroParse } from './astro-parser.js';
9
+ import { extractSpreadAttribute } from './spread-attr.js';
9
10
  /**
10
11
  * Parser implementation for Astro component templates.
11
12
  * Extends the base Parser to handle Astro-specific syntax including frontmatter blocks,
@@ -201,12 +202,41 @@ class AstroParser extends Parser {
201
202
  /**
202
203
  * Visits an attribute token, handling Astro-specific syntax including
203
204
  * curly-brace expression values, shorthand attributes (`{name}`),
204
- * and template directives (e.g., `class:list`, `set:html`).
205
+ * spread attributes (`{...expr}`, including TypeScript and nested
206
+ * expressions, see #3824), and template directives (e.g., `class:list`,
207
+ * `set:html`).
205
208
  *
206
209
  * @param token - The token representing the attribute
207
210
  * @returns The parsed attribute node with Astro-specific metadata
208
211
  */
209
212
  visitAttr(token) {
213
+ const spreadHit = extractSpreadAttribute(token.raw);
214
+ if (spreadHit) {
215
+ let spreadStartLine = token.startLine;
216
+ let spreadStartCol = token.startCol;
217
+ for (const c of spreadHit.leadingSpace) {
218
+ if (c === '\n') {
219
+ spreadStartLine++;
220
+ spreadStartCol = 1;
221
+ }
222
+ else {
223
+ spreadStartCol++;
224
+ }
225
+ }
226
+ const spread = super.visitSpreadAttr({
227
+ raw: spreadHit.spreadRaw,
228
+ startOffset: token.startOffset + spreadHit.leadingSpace.length,
229
+ startLine: spreadStartLine,
230
+ startCol: spreadStartCol,
231
+ });
232
+ // `extractSpreadAttribute` already validates the `{...EXPR}` shape
233
+ // so `super.visitSpreadAttr` is expected to return a node here.
234
+ // Falling through to the generic attr path is a defensive safeguard
235
+ // against future shape changes in the parent class.
236
+ if (spread) {
237
+ return spreadHit.leftover ? { ...spread, __rightText: spreadHit.leftover } : spread;
238
+ }
239
+ }
210
240
  const attr = super.visitAttr(token, {
211
241
  quoteSet: [
212
242
  { start: '"', end: '"', type: 'string' },
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Locates the matching closing brace for a JS-like expression that starts at
3
+ * `raw[start]` (which must be `{`), respecting string literals (`'`, `"`),
4
+ * template literals with `${}` interpolation, and line/block comments.
5
+ *
6
+ * Returns the index of the matching `}`, or -1 if no match is found.
7
+ *
8
+ * Why this exists: `safeScriptParser` (espree-based) does not understand
9
+ * TypeScript syntax such as `{...x as any}` and may also extend a "valid JS
10
+ * prefix" past the spread's closing brace into surrounding HTML
11
+ * (e.g. `{...props}>{label}` is parsed as a binary `>` expression),
12
+ * misclassifying both the spread end and any expression-child siblings.
13
+ * See https://github.com/markuplint/markuplint/issues/3824.
14
+ *
15
+ * Known limitation: regular-expression literals containing braces
16
+ * (e.g. `{...x.match(/}/) ? a : b}`) are not recognised — `/` is always
17
+ * treated as a division operator. Such patterns are vanishingly rare in
18
+ * Astro spread attributes; rewrite via a variable indirection if needed.
19
+ */
20
+ export declare function findMatchingBrace(raw: string, start: number): number;
21
+ /**
22
+ * Detects whether the given attribute token starts with an Astro spread
23
+ * attribute (`{...EXPR}`) and, if so, returns the spread's exact slice plus
24
+ * the leading whitespace and any leftover trailing text after the spread.
25
+ *
26
+ * Returns null when the token is not a spread attribute.
27
+ *
28
+ * Exported so the brace-matching logic can be unit-tested independently of
29
+ * the parser pipeline.
30
+ */
31
+ export declare function extractSpreadAttribute(raw: string): {
32
+ leadingSpace: string;
33
+ spreadRaw: string;
34
+ leftover: string;
35
+ } | null;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Counts consecutive backslashes immediately before position `i` in `raw`.
3
+ * An odd count means the character at `i` is escaped; an even count means
4
+ * the preceding backslashes are themselves paired escapes.
5
+ */
6
+ function countPrecedingBackslashes(raw, i) {
7
+ let n = 0;
8
+ let j = i - 1;
9
+ while (j >= 0 && raw[j] === '\\') {
10
+ n++;
11
+ j--;
12
+ }
13
+ return n;
14
+ }
15
+ /**
16
+ * Locates the matching closing brace for a JS-like expression that starts at
17
+ * `raw[start]` (which must be `{`), respecting string literals (`'`, `"`),
18
+ * template literals with `${}` interpolation, and line/block comments.
19
+ *
20
+ * Returns the index of the matching `}`, or -1 if no match is found.
21
+ *
22
+ * Why this exists: `safeScriptParser` (espree-based) does not understand
23
+ * TypeScript syntax such as `{...x as any}` and may also extend a "valid JS
24
+ * prefix" past the spread's closing brace into surrounding HTML
25
+ * (e.g. `{...props}>{label}` is parsed as a binary `>` expression),
26
+ * misclassifying both the spread end and any expression-child siblings.
27
+ * See https://github.com/markuplint/markuplint/issues/3824.
28
+ *
29
+ * Known limitation: regular-expression literals containing braces
30
+ * (e.g. `{...x.match(/}/) ? a : b}`) are not recognised — `/` is always
31
+ * treated as a division operator. Such patterns are vanishingly rare in
32
+ * Astro spread attributes; rewrite via a variable indirection if needed.
33
+ */
34
+ export function findMatchingBrace(raw, start) {
35
+ if (raw[start] !== '{')
36
+ return -1;
37
+ let depth = 0;
38
+ let inString = null;
39
+ const templateBraceStack = [];
40
+ for (let i = start; i < raw.length; i++) {
41
+ const c = raw[i];
42
+ if (inString) {
43
+ if (inString === '`') {
44
+ if (c === '`' && countPrecedingBackslashes(raw, i) % 2 === 0) {
45
+ inString = null;
46
+ }
47
+ else if (c === '$' && raw[i + 1] === '{') {
48
+ templateBraceStack.push(depth);
49
+ inString = null;
50
+ depth++;
51
+ i++;
52
+ }
53
+ }
54
+ else if (c === inString && countPrecedingBackslashes(raw, i) % 2 === 0) {
55
+ inString = null;
56
+ }
57
+ continue;
58
+ }
59
+ if (c === '/' && raw[i + 1] === '/') {
60
+ while (i < raw.length && raw[i] !== '\n')
61
+ i++;
62
+ continue;
63
+ }
64
+ if (c === '/' && raw[i + 1] === '*') {
65
+ i += 2;
66
+ while (i < raw.length - 1 && !(raw[i] === '*' && raw[i + 1] === '/'))
67
+ i++;
68
+ i++;
69
+ continue;
70
+ }
71
+ if (c === '"' || c === "'" || c === '`') {
72
+ inString = c;
73
+ continue;
74
+ }
75
+ if (c === '{') {
76
+ depth++;
77
+ }
78
+ else if (c === '}') {
79
+ depth--;
80
+ if (depth === 0)
81
+ return i;
82
+ if (templateBraceStack.length > 0 && depth === templateBraceStack.at(-1)) {
83
+ templateBraceStack.pop();
84
+ inString = '`';
85
+ }
86
+ }
87
+ }
88
+ return -1;
89
+ }
90
+ /**
91
+ * Detects whether the given attribute token starts with an Astro spread
92
+ * attribute (`{...EXPR}`) and, if so, returns the spread's exact slice plus
93
+ * the leading whitespace and any leftover trailing text after the spread.
94
+ *
95
+ * Returns null when the token is not a spread attribute.
96
+ *
97
+ * Exported so the brace-matching logic can be unit-tested independently of
98
+ * the parser pipeline.
99
+ */
100
+ export function extractSpreadAttribute(raw) {
101
+ const leadingMatch = /^\s*/.exec(raw);
102
+ const leadingSpace = leadingMatch?.[0] ?? '';
103
+ const remaining = raw.slice(leadingSpace.length);
104
+ // eslint-disable-next-line regexp/strict
105
+ if (!/^{\s*\.{3}[^.]/.test(remaining)) {
106
+ return null;
107
+ }
108
+ const end = findMatchingBrace(remaining, 0);
109
+ if (end < 0)
110
+ return null;
111
+ return {
112
+ leadingSpace,
113
+ spreadRaw: remaining.slice(0, end + 1),
114
+ leftover: remaining.slice(end + 1),
115
+ };
116
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markuplint/astro-parser",
3
- "version": "4.6.23",
3
+ "version": "4.18.3",
4
4
  "description": "astro parser for markuplint",
5
5
  "repository": "git@github.com:markuplint/markuplint.git",
6
6
  "author": "Yusuke Hirao <yusukehirao@me.com>",
@@ -21,12 +21,12 @@
21
21
  "clean": "tsc --build --clean tsconfig.build.json"
22
22
  },
23
23
  "dependencies": {
24
- "@markuplint/ml-ast": "4.4.11",
25
- "@markuplint/parser-utils": "4.8.11",
26
- "astro-eslint-parser": "1.2.2"
24
+ "@markuplint/ml-ast": "4.18.0",
25
+ "@markuplint/parser-utils": "4.18.3",
26
+ "astro-eslint-parser": "1.4.0"
27
27
  },
28
28
  "devDependencies": {
29
- "@astrojs/compiler": "2.13.1"
29
+ "@astrojs/compiler": "3.0.1"
30
30
  },
31
- "gitHead": "193ee7c1262bbed95424e38efdf1a8e56ff049f4"
31
+ "gitHead": "c53026c2c600bb304f2088589f67303479e92e62"
32
32
  }
@@ -1,2 +0,0 @@
1
- import type { MLASTBlockBehavior } from '@markuplint/ml-ast';
2
- export declare function detectBlockBehavior(raw: string): MLASTBlockBehavior | null;
@@ -1,12 +0,0 @@
1
- export function detectBlockBehavior(raw) {
2
- const re = /\.+\s*(?<type>map|filter)\s*\((?:function\s*\(.[^\n\r{\u2028\u2029]*\{.*return\s*$|.+=>\s*\(?\s*)/;
3
- const match = raw.match(re);
4
- if (!match) {
5
- return null;
6
- }
7
- const type = match.groups?.type === 'map' ? 'each' : 'if';
8
- return {
9
- type,
10
- expression: raw,
11
- };
12
- }