mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
An Astro { } expression containing a template string inside ${} closed at
the first } in that nested string, leaving the rest of the expression
uncolored. Astro and Vue now share one scanner for embedded JavaScript,
which already skips template substitutions.
Closes #6209
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
137 lines
4.8 KiB
TypeScript
137 lines
4.8 KiB
TypeScript
import {
|
|
type Input,
|
|
type NestedParse,
|
|
type SyntaxNode,
|
|
type SyntaxNodeRef,
|
|
parseMixed,
|
|
} from "@lezer/common";
|
|
import { parser as cssParser } from "@lezer/css";
|
|
import { parser as htmlParser } from "@lezer/html";
|
|
import { parser as jsParser } from "@lezer/javascript";
|
|
import { findClosingBrace } from "../js-scanner.js";
|
|
|
|
interface Range {
|
|
from: number;
|
|
to: number;
|
|
}
|
|
|
|
const typescriptParser = jsParser.configure({ dialect: "ts" });
|
|
const expressionParser = jsParser.configure({ top: "SingleExpression" });
|
|
|
|
function findInterpolationEnd(text: string, start: number): number {
|
|
return findClosingBrace(text, start, (position) => text.charCodeAt(position + 1) === 125);
|
|
}
|
|
|
|
function findExpressions(text: string): Range[] {
|
|
const ranges: Range[] = [];
|
|
for (let position = 0; position < text.length; position++) {
|
|
if (text.charCodeAt(position) !== 123) continue;
|
|
if (text.charCodeAt(position + 1) !== 123) continue;
|
|
const closing = findInterpolationEnd(text, position + 2);
|
|
if (closing < 0) break;
|
|
// Lezer rejects empty inner ranges, so `{{}}` gets no nested parse.
|
|
if (closing > position + 2) ranges.push({ from: position + 2, to: closing });
|
|
position = closing + 1;
|
|
}
|
|
return ranges;
|
|
}
|
|
|
|
function findDirectiveExpression(value: string): Range | null {
|
|
const quote = value[0];
|
|
const isQuoted = (quote === '"' || quote === "'") && value.length >= 2 && value.endsWith(quote);
|
|
const inner = isQuoted ? value.slice(1, -1) : value;
|
|
const trimmed = inner.trim();
|
|
if (!trimmed) return null;
|
|
const from = (isQuoted ? 1 : 0) + inner.indexOf(trimmed);
|
|
return { from, to: from + trimmed.length };
|
|
}
|
|
|
|
function getOpenTagAttributes(node: SyntaxNode, input: Input): Record<string, string> {
|
|
const attributes: Record<string, string> = Object.create(null);
|
|
const openTag = node.getChild("OpenTag");
|
|
if (!openTag) return attributes;
|
|
|
|
for (const attribute of openTag.getChildren("Attribute")) {
|
|
const name = attribute.getChild("AttributeName");
|
|
if (!name) continue;
|
|
const value =
|
|
attribute.getChild("AttributeValue") || attribute.getChild("UnquotedAttributeValue");
|
|
const key = input.read(name.from, name.to).toLowerCase();
|
|
attributes[key] = value ? input.read(value.from, value.to).replace(/^["']|["']$/g, "") : "";
|
|
}
|
|
return attributes;
|
|
}
|
|
|
|
function isDirectiveAttribute(attribute: string): boolean {
|
|
return (
|
|
attribute.startsWith("v-") ||
|
|
attribute.startsWith(":") ||
|
|
attribute.startsWith("@") ||
|
|
attribute.startsWith(".")
|
|
);
|
|
}
|
|
|
|
function directiveOverlay(
|
|
node: SyntaxNodeRef,
|
|
parent: SyntaxNode,
|
|
input: Input,
|
|
): NestedParse | null {
|
|
const attributeName = parent.getChild("AttributeName");
|
|
if (!attributeName) return null;
|
|
if (!isDirectiveAttribute(input.read(attributeName.from, attributeName.to).toLowerCase())) {
|
|
return null;
|
|
}
|
|
|
|
const range = findDirectiveExpression(input.read(node.from, node.to));
|
|
if (!range) return null;
|
|
return {
|
|
parser: expressionParser,
|
|
overlay: [{ from: node.from + range.from, to: node.from + range.to }],
|
|
};
|
|
}
|
|
|
|
function textOverlay(node: SyntaxNodeRef, input: Input): NestedParse | null {
|
|
const overlays = findExpressions(input.read(node.from, node.to)).map(({ from, to }) => ({
|
|
from: node.from + from,
|
|
to: node.from + to,
|
|
}));
|
|
return overlays.length > 0 ? { parser: expressionParser, overlay: overlays } : null;
|
|
}
|
|
|
|
function templateOverlay(node: SyntaxNodeRef, input: Input): NestedParse | null {
|
|
const parent = node.node.parent;
|
|
if (parent?.name === "Attribute") return directiveOverlay(node, parent, input);
|
|
if (node.name !== "Text") return null;
|
|
return textOverlay(node, input);
|
|
}
|
|
|
|
function scriptLanguage(attributes: Record<string, string>): NestedParse | null {
|
|
if (attributes.src) return null;
|
|
|
|
const language = (attributes.lang || attributes.type || "").toLowerCase();
|
|
if (language.includes("tsx")) return { parser: jsParser.configure({ dialect: "ts jsx" }) };
|
|
if (language.includes("typescript") || language === "ts") return { parser: typescriptParser };
|
|
if (language.includes("jsx")) return { parser: jsParser.configure({ dialect: "jsx" }) };
|
|
return { parser: jsParser };
|
|
}
|
|
|
|
function scriptOverlay(node: SyntaxNodeRef, input: Input): NestedParse | null {
|
|
const parent = node.node.parent;
|
|
if (!parent) return null;
|
|
return scriptLanguage(getOpenTagAttributes(parent, input));
|
|
}
|
|
|
|
function nestedLanguage(node: SyntaxNodeRef, input: Input): NestedParse | null {
|
|
const name = node.name;
|
|
if (name === "Text" || name === "UnquotedAttributeValue" || name === "AttributeValue") {
|
|
return templateOverlay(node, input);
|
|
}
|
|
if (name === "StyleText") return { parser: cssParser };
|
|
if (name === "ScriptText") return scriptOverlay(node, input);
|
|
return null;
|
|
}
|
|
|
|
export const vueParser = htmlParser.configure({
|
|
wrap: parseMixed(nestedLanguage),
|
|
});
|