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]);