diff --git a/packages/highlight/package.json b/packages/highlight/package.json index bfd844e3f7..e96fc8f465 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -3,7 +3,8 @@ "version": "0.7.0-beta.1", "files": [ "dist", - "!dist/**/*.map" + "!dist/**/*.map", + "src/astro/LICENSE" ], "type": "module", "main": "./dist/index.js", diff --git a/packages/highlight/src/__tests__/highlighter.test.ts b/packages/highlight/src/__tests__/highlighter.test.ts index 830d4e4ba5..2349854d85 100644 --- a/packages/highlight/src/__tests__/highlighter.test.ts +++ b/packages/highlight/src/__tests__/highlighter.test.ts @@ -118,6 +118,32 @@ describe("highlightCode", () => { expect(result[10].find((t) => t.text === "color")?.style).toBe("property"); }); + it("highlights Astro components across frontmatter, markup, and style", () => { + const code = `--- +const title = "Hello"; +--- +
+

{title}

+
+`; + + const tokens = highlightCode(code, "Page.astro").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "const", style: "keyword" }, + { text: '"Hello"', style: "string" }, + { text: "main", style: "tag" }, + { text: "class", style: "attribute" }, + { text: "title", style: "variable" }, + { text: "page", style: "class" }, + { text: "color", style: "property" }, + ]), + ); + }); + it("highlights TSX code with correct dialect", () => { const code = 'const el =
hello
;'; const result = highlightCode(code, "test.tsx"); diff --git a/packages/highlight/src/__tests__/parsers.test.ts b/packages/highlight/src/__tests__/parsers.test.ts index 06dc90a38b..1d0d0ebcdd 100644 --- a/packages/highlight/src/__tests__/parsers.test.ts +++ b/packages/highlight/src/__tests__/parsers.test.ts @@ -24,6 +24,7 @@ describe("isLanguageSupported", () => { expect(isLanguageSupported("test.nix")).toBe(true); expect(isLanguageSupported("test.ex")).toBe(true); expect(isLanguageSupported("Counter.svelte")).toBe(true); + expect(isLanguageSupported("Page.astro")).toBe(true); }); it("returns false for unsupported file extensions", () => { @@ -65,6 +66,7 @@ describe("getSupportedExtensions", () => { expect(extensions).toContain("nix"); expect(extensions).toContain("json"); expect(extensions).toContain("svelte"); + expect(extensions).toContain("astro"); }); }); diff --git a/packages/highlight/src/astro/LICENSE b/packages/highlight/src/astro/LICENSE new file mode 100644 index 0000000000..6a4a7b9fe4 --- /dev/null +++ b/packages/highlight/src/astro/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Zulfazli (Fazelllyyy) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/highlight/src/astro/parser.ts b/packages/highlight/src/astro/parser.ts new file mode 100644 index 0000000000..444235488f --- /dev/null +++ b/packages/highlight/src/astro/parser.ts @@ -0,0 +1,249 @@ +// Adapted from @fazelstudio/codemirror-lang-astro@0.2.0 (MIT). +// The parser is kept pure so server-side diff highlighting does not load editor-only modules. +import { + type Input, + type NestedParse, + type PartialParse, + Parser, + parseMixed, + type SyntaxNode, + type SyntaxNodeRef, + type Tree, + type TreeFragment, +} from "@lezer/common"; +import { parser as cssParser } from "@lezer/css"; +import { parser as htmlParser } from "@lezer/html"; +import { parser as jsParser } from "@lezer/javascript"; + +interface Range { + from: number; + to: number; +} + +const jsxParser = jsParser.configure({ dialect: "ts jsx" }); +const typescriptParser = jsParser.configure({ dialect: "ts" }); + +function isSpace(code: number): boolean { + return code === 32 || code === 9 || code === 10 || code === 13; +} + +function isRegexStart(text: string, position: number): boolean { + let previous = position - 1; + while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--; + if (previous < 0) return true; + + const character = text[previous]; + if (/[)\]}<"'`\d]/.test(character)) return false; + const code = text.charCodeAt(previous); + if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61; + if (!/[A-Za-z_$]/.test(character)) return true; + + let start = previous; + while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--; + const keyword = text.slice(start + 1, previous + 1); + return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test( + keyword, + ); +} + +function skipQuotedText(text: string, opening: number): number { + const quote = text.charCodeAt(opening); + for (let position = opening + 1; position < text.length; position++) { + const code = text.charCodeAt(position); + if (code === 92) position++; + else if (code === quote) return position; + } + return text.length - 1; +} + +function skipLineComment(text: string, opening: number): number { + const newline = text.indexOf("\n", opening + 2); + return newline >= 0 ? newline : text.length - 1; +} + +function skipBlockComment(text: string, opening: number): number { + const closing = text.indexOf("*/", opening + 2); + return closing >= 0 ? closing + 1 : text.length - 1; +} + +function skipRegex(text: string, opening: number): number { + let isInCharacterClass = false; + for (let position = opening + 1; position < text.length; position++) { + const code = text.charCodeAt(position); + if (code === 10 || code === 13) return position; + if (code === 92) position++; + else if (isInCharacterClass && code === 93) isInCharacterClass = false; + else if (!isInCharacterClass && code === 91) isInCharacterClass = true; + else if (!isInCharacterClass && code === 47) return position; + } + return text.length - 1; +} + +function findClosingBrace(text: string, opening: number): number { + let depth = 0; + for (let position = opening; position < text.length; position++) { + const code = text.charCodeAt(position); + if (code === 47 && text.charCodeAt(position + 1) === 47) { + position = skipLineComment(text, position); + } else if (code === 47 && text.charCodeAt(position + 1) === 42) { + position = skipBlockComment(text, position); + } else if (code === 47 && isRegexStart(text, position)) { + position = skipRegex(text, position); + } else if (code === 34 || code === 39 || code === 96) { + position = skipQuotedText(text, position); + } else if (code === 123) { + depth++; + } else if (code === 125 && --depth === 0) { + return position; + } + } + return -1; +} + +function findExpressions(text: string): Range[] { + const ranges: Range[] = []; + for (let position = 0; position < text.length; position++) { + if (text.charCodeAt(position) !== 123) continue; + const closing = findClosingBrace(text, position); + if (closing < 0) break; + ranges.push({ from: position, to: closing }); + position = closing; + } + return ranges; +} + +function maskExpressions(text: string): string { + const characters = text.split(""); + for (const { from, to } of findExpressions(text)) { + for (let position = from + 1; position < to; position++) characters[position] = "a"; + } + return characters.join(""); +} + +function expressionOverlays(node: SyntaxNodeRef, input: Input): Range[] | null { + const overlays = findExpressions(input.read(node.from, node.to)).map(({ from, to }) => ({ + from: node.from + from + 1, + to: node.from + to, + })); + return overlays.length > 0 ? overlays : null; +} + +function isFenceEnd(text: string, position: number): boolean { + if (position >= text.length) return true; + const code = text.charCodeAt(position); + return code === 10 || code === 13 || code === 32 || code === 9; +} + +function findFrontmatter(text: string): Range | null { + const from = text.charCodeAt(0) === 0xfeff ? 1 : 0; + if (!text.startsWith("---", from) || !isFenceEnd(text, from + 3)) return null; + + let newline = text.indexOf("\n", from + 3); + while (newline >= 0) { + const closing = newline + 1; + if (text.startsWith("---", closing) && isFenceEnd(text, closing + 3)) { + return { from, to: closing + 3 }; + } + newline = text.indexOf("\n", closing); + } + return null; +} + +function maskDocument(text: string, frontmatter: Range | null): string { + if (!frontmatter) return maskExpressions(text); + return ( + text.slice(0, frontmatter.from) + + "" + + maskExpressions(text.slice(frontmatter.to)) + ); +} + +function getOpenTagAttributes(node: SyntaxNode, input: Input): Record { + const attributes: Record = 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 nestedLanguage(node: SyntaxNodeRef, input: Input): NestedParse | null { + if (node.name === "Comment") { + const isFrontmatter = + input.read(node.from, node.from + 3) === "---" && input.read(node.to - 3, node.to) === "---"; + const from = node.from + 4; + const to = node.to - 3; + return isFrontmatter && to > from + ? { parser: typescriptParser, overlay: [{ from, to }] } + : null; + } + + const canContainExpression = + node.name === "Text" || + node.name === "UnquotedAttributeValue" || + node.name === "AttributeValue"; + if (canContainExpression) { + const overlay = expressionOverlays(node, input); + return overlay ? { parser: jsxParser, overlay } : null; + } + if (node.name === "StyleText") return { parser: cssParser }; + if (node.name !== "ScriptText" || !node.node.parent) return null; + + const attributes = getOpenTagAttributes(node.node.parent, input); + if (attributes.src) return null; + const language = (attributes.lang || attributes.type || "").toLowerCase(); + let dialect = ""; + if (language.includes("tsx")) dialect = "ts jsx"; + else if (language.includes("typescript") || language === "ts") dialect = "ts"; + else if (language.includes("jsx")) dialect = "jsx"; + return { parser: dialect ? jsParser.configure({ dialect }) : jsParser }; +} + +class CompletedParse implements PartialParse { + private isDone = false; + + constructor(private readonly tree: Tree) {} + + advance(): Tree | null { + if (this.isDone) return null; + this.isDone = true; + return this.tree; + } + + get parsedPos(): number { + return this.tree.length; + } + + stopAt(): void {} + + get stoppedAt(): null { + return null; + } +} + +const mountNestedLanguages = parseMixed(nestedLanguage); + +class AstroParser extends Parser { + createParse( + input: Input, + fragments: readonly TreeFragment[], + ranges: readonly Range[], + ): PartialParse { + const from = ranges[0]?.from ?? 0; + const to = ranges[0]?.to ?? input.length; + const text = input.read(from, to); + const tree = htmlParser.parse(maskDocument(text, findFrontmatter(text))); + return mountNestedLanguages(new CompletedParse(tree), input, fragments, ranges); + } +} + +export const astroParser = new AstroParser(); diff --git a/packages/highlight/src/parsers.ts b/packages/highlight/src/parsers.ts index 066c7e1672..9ccb3129be 100644 --- a/packages/highlight/src/parsers.ts +++ b/packages/highlight/src/parsers.ts @@ -17,6 +17,7 @@ import { parser as yamlParser } from "@lezer/yaml"; import { parser as elixirParser } from "lezer-elixir"; import type { Parser } from "@lezer/common"; import { csharpLanguage } from "./csharp/language.js"; +import { astroParser } from "./astro/parser.js"; import { nixLanguage } from "./nix/language.js"; import { parser as svelteBaseParser } from "./svelte/parser.js"; import { configureNesting, defaultNesting } from "./svelte/nesting.js"; @@ -53,6 +54,8 @@ const languagesByExtension: Record = { htm: language(htmlParser), // Svelte svelte: language(svelteBaseParser.configure({ wrap: configureNesting(defaultNesting) })), + // Astro + astro: language(astroParser), // XML xml: language(xmlParser), // Java diff --git a/packages/server/src/server/utils/diff-highlighter.test.ts b/packages/server/src/server/utils/diff-highlighter.test.ts index 07abdb4967..cc6754c50b 100644 --- a/packages/server/src/server/utils/diff-highlighter.test.ts +++ b/packages/server/src/server/utils/diff-highlighter.test.ts @@ -175,6 +175,20 @@ index 1234567..abcdefg 100644 {/if} `; +const ASTRO_DIFF = `diff --git a/Page.astro b/Page.astro +index 1234567..abcdefg 100644 +--- a/Page.astro ++++ b/Page.astro +@@ -1,7 +1,7 @@ + --- +-const title = "Hello"; ++const title = "Hello, Astro"; + --- +
+

{title}

+
+`; + describe("parseDiff", () => { it("parses a simple diff with one hunk", () => { const files = parseDiff(SIMPLE_DIFF); @@ -513,6 +527,18 @@ describe("highlightDiffFromHunks", () => { expect(markupLine!.tokens!.some((t) => t.text === "span" && t.style === "tag")).toBe(true); }); + it("adds syntax highlighting tokens to Astro components", () => { + const [file] = parseDiff(ASTRO_DIFF); + const highlighted = highlightDiffFromHunks(file); + const addedLine = highlighted.hunks[0].lines.find((line) => line.type === "add"); + const markupLine = highlighted.hunks[0].lines.find((line) => line.content.includes(" { const files = parseDiff(OBJECTIVE_C_DIFF); const highlighted = highlightDiffFromHunks(files[0]);