mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 16:52:09 +08:00
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:
co-authored by
Claude Opus 5.5
parent
cb7fb61e76
commit
de0a4eb362
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
Reference in New Issue
Block a user