fix(extract): parse only Astro frontmatter and scripts in the AST pass

extract_astro fed the whole .astro file to the JS grammar. The template is
not JS, so every page reported a syntax error (usually at line 1-2) and
symbols tree-sitter could not recover past the template were dropped.

Blank everything outside the frontmatter and JS <script> bodies (keeping
newlines so locations still match, as _vue_mask_non_script does) and parse
with the TS grammar, Astro's default. Scripts with a non-JS type
(application/ld+json, ...) are blanked too. The existing regex import
rescue is unchanged.

Fixes #2788. Covers the unmasked-script half of #3337.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
KevinandClaude Opus 5.5 committed 2026-09-28 21:00:53 +02:00
1 parent d6eaa8aae8
commit fe07657fe1
2 files changed
+104 -1

No files matched your search

+48 -1
View File
@@ -2405,6 +2405,42 @@ def extract_svelte(path: Path) -> dict:
return result
_ASTRO_FRONTMATTER_RE = re.compile(r"\A\s*---[^\S\r\n]*\r?\n([\s\S]*?)\r?\n---")
_ASTRO_SCRIPT_RE = re.compile(
r"<script\b((?:\"[^\"]*\"|'[^']*'|[^>\"'])*)>([\s\S]*?)</script\s*>",
re.IGNORECASE,
)
_ASTRO_NON_JS_TYPE_RE = re.compile(
r"""\btype\s*=\s*["']?(?!module\b|text/javascript\b|application/javascript\b)""",
re.IGNORECASE,
)
def _astro_mask_non_script(src: str) -> str:
"""Blank everything in a ``.astro`` file except frontmatter and JS ``<script>`` bodies.
Every character outside those regions becomes a space (``\\r``/``\\n`` are kept),
so AST locations match the original file. Scripts with a non-JS ``type``
(``application/ld+json`` and the like) are blanked too: their bodies are not
statements and would only add parse errors.
"""
keep: list[tuple[int, int]] = []
fm = _ASTRO_FRONTMATTER_RE.match(src)
if fm:
keep.append((fm.start(1), fm.end(1)))
for m in _ASTRO_SCRIPT_RE.finditer(src, fm.end() if fm else 0):
if not _ASTRO_NON_JS_TYPE_RE.search(m.group(1)):
keep.append((m.start(2), m.end(2)))
chars = [c if c in "\r\n" else " " for c in src]
for start, end in keep:
chars[start:end] = src[start:end]
# Terminate the region in place of the following `<`, so two scripts on
# one line don't run together into a single statement.
if end < len(chars) and chars[end] == " ":
chars[end] = ";"
return "".join(chars)
def extract_astro(path: Path) -> dict:
"""Extract imports from .astro files: frontmatter (TS) + template regex fallback.
@@ -2417,8 +2453,19 @@ def extract_astro(path: Path) -> dict:
silently dropped (#850). Mirrors :func:`extract_svelte` — same regex-rescue
approach, scanning the frontmatter block and any client-side ``<script>`` blocks
for static and dynamic imports.
The AST pass parses only the frontmatter and JS ``<script>`` bodies with the TS
grammar (Astro's default), blanking everything else so line numbers still
line up — the same masking as :func:`_vue_mask_non_script`. Parsing the whole
file flagged every template as a syntax error and dropped frontmatter symbols.
"""
result = _extract_generic(path, _JS_CONFIG)
try:
src = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return {"nodes": [], "edges": []}
masked = _astro_mask_non_script(src).encode("utf-8")
masked = _normalize_ts_import_types(masked) or masked
result = _extract_generic(path, _TS_CONFIG, source_override=masked)
try:
import re as _re
src = path.read_text(encoding="utf-8", errors="replace")
+56
View File
@@ -141,3 +141,59 @@ import Hero from '@components/Hero.astro';
result = extract_astro(page)
targets = _import_targets(result, relation="imports_from")
assert _make_id(str(hero)) in targets
def _labels(result: dict) -> dict[str, str]:
"""Label -> source_location for every node except the file node."""
return {
str(n.get("label")): str(n.get("source_location"))
for n in result.get("nodes", [])
if n.get("id") != result.get("nodes", [{}])[0].get("id")
}
def test_extract_astro_template_is_not_a_parse_error(tmp_path):
"""The template is not TS; only frontmatter and <script> bodies are parsed (#2788).
Parsing the whole file as JS reported every page as a syntax error, and
symbols the parser could not recover past the template were dropped.
"""
page = _write(
tmp_path / "src/pages/results.astro",
"""---
import Layout from '../layouts/Layout.astro';
interface Props { year: number }
const { year } = Astro.props;
function rank(scores: number[]): number[] {
return [...scores].sort((a, b) => b - a);
}
---
<Layout title={`Results ${year}`}>
<ol>{rank([3, 1, 2]).map((s) => <li class="score">{s}</li>)}</ol>
</Layout>
<script type="application/ld+json">{ "@type": "Event", "name": "x" }</script>
<script>
function toggle(el: HTMLElement) { el.classList.toggle('open'); }
document.querySelectorAll('ol').forEach((el) => toggle(el));
</script>
""",
)
result = extract_astro(page)
assert result.get("parse_errors") is None
labels = _labels(result)
assert "rank()" in labels and "toggle()" in labels
# Masking keeps offsets, so locations still point at the original lines.
assert labels["rank()"] == "L5"
assert labels["toggle()"] == "L15"
def test_extract_astro_scripts_on_one_line_do_not_merge(tmp_path):
"""Two script bodies on one line must not parse as a single statement."""
page = _write(
tmp_path / "src/pages/inline.astro",
"<script>const a = 1</script><script>function b() {}</script>\n",
)
result = extract_astro(page)
assert result.get("parse_errors") is None
assert "b()" in _labels(result)