fix(vue): a self-closing <script /> no longer swallows the next script (#3070)

The Vue extractor read a self-closing `<script src="./legacy.js" />` block
as an opening tag running on to the next `</script>`, as the Astro and
Svelte extractors did until PR #3063 (PRs #2995 and #2997) landed. The
`<script setup>` after it was parsed with whatever lay in between and
contributed nothing, and a `<template>` in between lost its calls and its
components too. One copy of the same pattern sat in each of 9 places in 5
files, and that fix reached the 6 copies in Astro, Svelte and the viewer.

sfc-script.ts, which the Vue, Svelte and Astro extractors already share,
now exports the script pattern and the script-or-style pattern, built from
@maxmilian's regexes, plus sfcBlockLineRanges, the line ranges a template
scan skips. A self-closing tag has no body, so it covers nothing and isn't
blanked, and calls in its attributes are read. All 9 sites use them: each
extractor's script blocks, Astro's covered ranges, both Svelte template
scans, Vue's template-components scan, the Vue template-calls blanking
and the viewer's highlight regions. The pair also accepts a closing tag
written with a space, `</script >`, as the template-calls blanking always
did; the viewer finds a block's body from its opening tag for that reason.

Validation on main 61776927 (Windows 11):
- Red/green: 4 Vue tests (a `<script src ... />` block before
  `<script setup>`, one before `<template>`, an attribute call on a
  self-closing script in the template, and `</script >`) fail with the
  previous sources and pass; the 6 Astro and Svelte tests still pass.
- A/B: 5 Vue repos (vuejs/core, vuejs/docs, NekoBox, justgage,
  element-plus-playground; none has a self-closing script) are
  byte-identical to main. 7 Astro repos, 2 Svelte subtrees (shadcn-svelte
  docs routes, SvelteKit's basics test app) and the flask and express
  controls are byte-identical to the Astro and Svelte fix before this one.

Fixes #2989

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-30 14:02:53 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent cb7fb61e76
commit de0a4eb362
8 changed files with 180 additions and 57 deletions
+5 -12
View File
@@ -2,7 +2,7 @@ import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference } fr
import { generateNodeId } from './tree-sitter-helpers';
import { TreeSitterExtractor } from './tree-sitter';
import { isLanguageSupported } from './grammars';
import { foldScriptResult, sfcFileNode } from './sfc-script';
import { foldScriptResult, sfcFileNode, SFC_SCRIPT_ELEMENT, sfcBlockLineRanges } from './sfc-script';
/**
* Astro built-in components — compiler-provided (`<Fragment>`) or shipped by
@@ -161,7 +161,7 @@ export class AstroExtractor {
// A self-closing `<script ... />` (e.g. `is:inline set:html={...}`) has no
// body; match it on its own so it cannot run on to the next `</script>`.
const scriptRegex = /<script(\s[^>]*?)?(?:\/>|>(?<content>[\s\S]*?)<\/script>)/g;
const scriptRegex = new RegExp(SFC_SCRIPT_ELEMENT);
let match;
while ((match = scriptRegex.exec(this.source)) !== null) {
@@ -228,16 +228,9 @@ export class AstroExtractor {
coveredRanges.push([frontmatter.startLine - 1, frontmatter.endLine]);
}
const tagRegex = /<(script|style)(\s[^>]*?)?(?:\/>|>[\s\S]*?<\/\1>)/g;
let tagMatch;
while ((tagMatch = tagRegex.exec(this.source)) !== null) {
// A self-closing tag has no body to skip, and its attributes
// (`set:html={serializeJsonLd(schema)}`) are template expressions.
if (tagMatch[0].endsWith('/>')) continue;
const startLine = (this.source.substring(0, tagMatch.index).match(/\n/g) || []).length;
const endLine = startLine + (tagMatch[0].match(/\n/g) || []).length;
coveredRanges.push([startLine, endLine]);
}
// A self-closing tag covers nothing: its attributes
// (`set:html={serializeJsonLd(schema)}`) are template expressions.
coveredRanges.push(...sfcBlockLineRanges(this.source));
return coveredRanges;
}
+45
View File
@@ -46,6 +46,51 @@ export function sfcScriptLanguage(attrs: string, host: Language): Language {
return 'javascript';
}
/**
* A `<script>` element of a Vue, Svelte or Astro file: group 1 its opening
* tag's attributes, `content` its body. A self-closing tag — Astro's
* `<script is:inline set:html={code} />`, a third-party
* `<script src="/analytics.js" />` — is matched on its own, with no
* `content`: read as an opening tag it ran on to the next script's
* `</script>`, and that script was parsed with the markup in between and
* contributed nothing (#2989).
*
* Shared by the three extractors and the viewer's syntax regions. It carries
* the `g` flag, so each loop runs on its own copy (`new RegExp(...)`).
*/
export const SFC_SCRIPT_ELEMENT = /<script(\s[^>]*?)?(?:\/>|>(?<content>[\s\S]*?)<\/script\s*>)/g;
/**
* A `<script>` or `<style>` element, which the template scans skip: what it
* holds is code or CSS, not markup. A self-closing one (see
* {@link SFC_SCRIPT_ELEMENT}) has no body to skip, and its attributes, like
* `set:html={serializeJsonLd(schema)}`, are template expressions like any
* other's: {@link sfcElementHasBody} says which a match is.
*/
export const SFC_SCRIPT_OR_STYLE_ELEMENT = /<(script|style)(\s[^>]*?)?(?:\/>|>[\s\S]*?<\/\1\s*>)/g;
/** Whether a match of {@link SFC_SCRIPT_OR_STYLE_ELEMENT} has a body: false for a self-closing tag. */
export function sfcElementHasBody(element: string): boolean {
return !element.endsWith('/>');
}
/**
* The lines (0-indexed, inclusive) of each `<script>` and `<style>` element
* with a body in `source`, which a template scan skips. A self-closing tag
* covers nothing.
*/
export function sfcBlockLineRanges(source: string): Array<[number, number]> {
const ranges: Array<[number, number]> = [];
const elements = new RegExp(SFC_SCRIPT_OR_STYLE_ELEMENT);
let match: RegExpExecArray | null;
while ((match = elements.exec(source)) !== null) {
if (!sfcElementHasBody(match[0])) continue;
const startLine = (source.substring(0, match.index).match(/\n/g) || []).length;
ranges.push([startLine, startLine + (match[0].match(/\n/g) || []).length]);
}
return ranges;
}
/** The SFC's own file node — the whole file, as the tree-sitter extractor makes one. */
export function sfcFileNode(filePath: string, source: string, language: Language): Node {
return {
+7 -26
View File
@@ -2,7 +2,7 @@ import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference, Lan
import { generateNodeId } from './tree-sitter-helpers';
import { TreeSitterExtractor } from './tree-sitter';
import { isLanguageSupported } from './grammars';
import { foldScriptResult, sfcFileNode, sfcScriptLanguage } from './sfc-script';
import { foldScriptResult, sfcFileNode, sfcScriptLanguage, SFC_SCRIPT_ELEMENT, sfcBlockLineRanges } from './sfc-script';
/** Svelte 5 rune names — compiler builtins, not real functions */
const SVELTE_RUNES = new Set([
@@ -128,7 +128,7 @@ export class SvelteExtractor {
// A self-closing `<script ... />` (e.g. a third-party script in
// `<svelte:head>`) has no body; match it on its own so it cannot run on to
// the next `</script>`.
const scriptRegex = /<script(\s[^>]*?)?(?:\/>|>(?<content>[\s\S]*?)<\/script>)/g;
const scriptRegex = new RegExp(SFC_SCRIPT_ELEMENT);
let match;
while ((match = scriptRegex.exec(this.source)) !== null) {
@@ -204,20 +204,10 @@ export class SvelteExtractor {
componentNodeId: string,
_scriptBlocks: Array<{ content: string; startLine: number }>
): void {
// Build a set of line ranges covered by <script> and <style> blocks so we skip them
const coveredRanges: Array<[number, number]> = [];
// Find all <script>...</script> and <style>...</style> ranges
const tagRegex = /<(script|style)(\s[^>]*?)?(?:\/>|>[\s\S]*?<\/\1>)/g;
let tagMatch;
while ((tagMatch = tagRegex.exec(this.source)) !== null) {
// A self-closing tag has no body to skip, and its attributes
// (`src={assetUrl('/analytics.js')}`) are template expressions.
if (tagMatch[0].endsWith('/>')) continue;
const startLine = (this.source.substring(0, tagMatch.index).match(/\n/g) || []).length;
const endLine = startLine + (tagMatch[0].match(/\n/g) || []).length;
coveredRanges.push([startLine, endLine]);
}
// Line ranges covered by <script> and <style> blocks, so we skip them. A
// self-closing tag covers nothing: its attributes
// (`src={assetUrl('/analytics.js')}`) are template expressions.
const coveredRanges = sfcBlockLineRanges(this.source);
// Find template expressions: {...} outside of script/style blocks
// Matches curly-brace expressions, excluding Svelte block syntax ({#if}, {:else}, {/if}, {@html}, {@render})
@@ -266,16 +256,7 @@ export class SvelteExtractor {
*/
private extractTemplateComponents(componentNodeId: string): void {
// Build ranges covered by <script> and <style> blocks to skip them
const coveredRanges: Array<[number, number]> = [];
const tagRegex = /<(script|style)(\s[^>]*?)?(?:\/>|>[\s\S]*?<\/\1>)/g;
let tagMatch;
while ((tagMatch = tagRegex.exec(this.source)) !== null) {
// A self-closing tag has no body to skip.
if (tagMatch[0].endsWith('/>')) continue;
const startLine = (this.source.substring(0, tagMatch.index).match(/\n/g) || []).length;
const endLine = startLine + (tagMatch[0].match(/\n/g) || []).length;
coveredRanges.push([startLine, endLine]);
}
const coveredRanges = sfcBlockLineRanges(this.source);
const lines = this.source.split('\n');
// Match PascalCase opening/self-closing tags (closing tags </Foo> start with </ so won't match)
+6 -4
View File
@@ -43,7 +43,7 @@ import { Language } from '../types';
import { EXTRACTORS } from './languages';
import { getParser, loadGrammarsForLanguages } from './grammars';
import { parseWithinBudget } from './parse-budget';
import { sfcScriptLanguage } from './sfc-script';
import { sfcScriptLanguage, SFC_SCRIPT_ELEMENT } from './sfc-script';
import type { LanguageExtractor } from './tree-sitter-types';
/* ------------------------------------------------------------- the classes -- */
@@ -362,8 +362,9 @@ export interface SyntaxRegion {
language: Language;
}
// A self-closing `<script ... />` has no body and must not run on to the next `</script>`.
const SCRIPT_BLOCK = /<script(\s[^>]*?)?(?:\/>|>([\s\S]*?)<\/script>)/gi;
// The extractors' own script element, read case-insensitively here. A
// self-closing `<script ... />` has no body and must not run on to the next `</script>`.
const SCRIPT_BLOCK = new RegExp(SFC_SCRIPT_ELEMENT.source, 'gi');
/** Astro's frontmatter: a `---` fence at the very top of the file. */
const ASTRO_FRONTMATTER = /^(---\r?\n)([\s\S]*?)\r?\n---/;
@@ -390,7 +391,8 @@ export function syntaxRegionsFor(source: string, language: Language): SyntaxRegi
while ((match = SCRIPT_BLOCK.exec(source)) !== null) {
const body = match[2] ?? '';
if (body.trim() === '') continue;
const start = match.index + match[0].length - body.length - '</script>'.length;
// The body starts right after the opening tag, whose attributes hold no `>`.
const start = match.index + match[0].indexOf('>') + 1;
regions.push({
start,
end: start + body.length,
+9 -12
View File
@@ -2,7 +2,7 @@ import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference, Lan
import { generateNodeId } from './tree-sitter-helpers';
import { TreeSitterExtractor } from './tree-sitter';
import { isLanguageSupported } from './grammars';
import { foldScriptResult, sfcFileNode, sfcScriptLanguage } from './sfc-script';
import { foldScriptResult, sfcFileNode, sfcScriptLanguage, SFC_SCRIPT_ELEMENT, sfcBlockLineRanges } from './sfc-script';
import { vueOptionsMembers } from './vue-options-api';
import { vueTemplateCalls } from './vue-template-calls';
@@ -201,12 +201,15 @@ export class VueExtractor {
language: Language;
}> = [];
const scriptRegex = /<script(\s[^>]*)?>(?<content>[\s\S]*?)<\/script>/g;
// A self-closing `<script src="./legacy.js" />` has no body; match it on
// its own so it cannot run on to the next block's `</script>`.
const scriptRegex = new RegExp(SFC_SCRIPT_ELEMENT);
let match;
while ((match = scriptRegex.exec(this.source)) !== null) {
if (match.groups?.content === undefined) continue;
const attrs = match[1] || '';
const content = match.groups?.content || match[2] || '';
const content = match.groups.content;
// TypeScript, TSX or JavaScript, from the lang attribute
const language = sfcScriptLanguage(attrs, 'vue');
@@ -288,15 +291,9 @@ export class VueExtractor {
// Ranges covered by <script> / <style> blocks — skip them so script
// identifiers and CSS selectors aren't mistaken for template tags. This
// also correctly handles nested <template> tags (v-if / slots), which a
// single non-greedy <template>…</template> match would mis-bound.
const coveredRanges: Array<[number, number]> = [];
const blockRegex = /<(script|style)(\s[^>]*)?>[\s\S]*?<\/\1>/g;
let blockMatch;
while ((blockMatch = blockRegex.exec(this.source)) !== null) {
const startLine = (this.source.substring(0, blockMatch.index).match(/\n/g) || []).length;
const endLine = startLine + (blockMatch[0].match(/\n/g) || []).length;
coveredRanges.push([startLine, endLine]);
}
// single non-greedy <template>…</template> match would mis-bound. A
// self-closing `<script ... />` covers nothing.
const coveredRanges = sfcBlockLineRanges(this.source);
const lines = this.source.split('\n');
// Opening / self-closing tags (closing `</Foo>` starts with `</`, so the
+5 -2
View File
@@ -27,6 +27,8 @@
* Approach after the template-binding scan in #2073 by @L0garithmic.
*/
import { SFC_SCRIPT_OR_STYLE_ELEMENT, sfcElementHasBody } from './sfc-script';
export interface VueTemplateCall {
/** The callee as a script-side call names it: `useBar`, `store.fetchUsers`. */
name: string;
@@ -74,10 +76,11 @@ const FOR_ALIAS = /([\s\S]*?)\s+(?:in|of)\s+(\S[\s\S]*)/;
/** Every call written in the root `<template>` of a Vue SFC, in source order. */
export function vueTemplateCalls(source: string): VueTemplateCall[] {
// Blank <script>/<style> blocks and HTML comments, keeping every offset.
// Blank <script>/<style> blocks and HTML comments, keeping every offset. A
// self-closing `<script ... />` has no body to blank: its attributes are markup.
const blank = (m: string) => m.replace(/[^\n]/g, ' ');
const masked = source
.replace(/<(script|style)(\s[^>]*)?>[\s\S]*?<\/\1\s*>/g, blank)
.replace(new RegExp(SFC_SCRIPT_OR_STYLE_ELEMENT), (m) => (sfcElementHasBody(m) ? blank(m) : m))
.replace(/<!--[\s\S]*?-->/g, blank);
const root = rootTemplate(masked);
if (!root) return [];