From 14862c9c068815b503601c7a5df26aab5fcf7b73 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 10:38:53 -0700 Subject: [PATCH 1/2] refactor(tui): use native fenced-code highlighting --- bun.lock | 3 - package.json | 1 - src/tui/markdown-parser.test.ts | 22 ++-- src/tui/syntax-highlight.test.ts | 220 +++++++++++++++++++++++-------- src/tui/syntax-highlight.ts | 183 +++++-------------------- 5 files changed, 207 insertions(+), 222 deletions(-) diff --git a/bun.lock b/bun.lock index 0624d460b..e35c80050 100644 --- a/bun.lock +++ b/bun.lock @@ -28,7 +28,6 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@opentui/core": "0.5.11", "arktype": "catalog:", - "highlight.js": "^11.11.1", "isomorphic-git": "catalog:", }, "devDependencies": { @@ -633,8 +632,6 @@ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], - "highlight.js": ["highlight.js@11.12.0", "", {}, "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg=="], - "hono": ["hono@4.13.7", "", {}, "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ=="], "hono-openapi": ["hono-openapi@1.3.3", "", { "dependencies": { "@hono/standard-validator": "^0.4.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "openapi-types": "^12.1.3" }, "peerDependencies": { "hono": "^4.11.2" } }, "sha512-+LVFPQEc4eDrXr/Rd0QQkRh/DbEZRXnm1RZU4bVaoqmWiWgspT4nNHIQMgi44X+D9/8k1eGzHvR0KLNPJ/0xaQ=="], diff --git a/package.json b/package.json index dae7712ee..b6fdb730a 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,6 @@ "@modelcontextprotocol/sdk": "^1.29.0", "@opentui/core": "0.5.11", "arktype": "catalog:", - "highlight.js": "^11.11.1", "isomorphic-git": "catalog:" }, "devDependencies": { diff --git a/src/tui/markdown-parser.test.ts b/src/tui/markdown-parser.test.ts index 2fef19d1a..b28d38ada 100644 --- a/src/tui/markdown-parser.test.ts +++ b/src/tui/markdown-parser.test.ts @@ -7,7 +7,6 @@ import { withholdIncompleteHeading, type StyledSegment, } from "./markdown-parser.js"; -import { color } from "./semantic-theme.js"; function firstLine(text: string): StyledSegment[] { return parseMarkdown(text)[0] ?? []; @@ -124,19 +123,19 @@ describe("block elements", () => { expect(lines[0]?.[0]?.text).toContain("╭"); }); - test("highlights a fenced block by its language token", () => { + test("a fenced block keeps its language cap with a plain body", () => { const lines = parseMarkdown('```js\nconst x = "hi";\n```'); - const keyword = lines.flat().find((s) => s.text === "const"); - expect(keyword?.code).toBe(true); - expect(keyword?.color).toBe(color("syntaxKeyword")); - const str = lines.flat().find((s) => s.text === '"hi"'); - expect(str?.color).toBe(color("syntaxString")); + const body = lines.flat().find((s) => s.text.includes("const x")); + expect(body?.code).toBe(true); + expect(body?.color).toBeUndefined(); + expect(lines[0]?.map((s) => s.text).join("")).toContain("js"); }); - test("an unclosed streaming fence still highlights its body", () => { + test("an unclosed streaming fence keeps a plain body", () => { const lines = parseMarkdown("```js\nconst x = 1;"); - const keyword = lines.flat().find((s) => s.text === "const"); - expect(keyword?.color).toBe(color("syntaxKeyword")); + const body = lines.flat().find((s) => s.text.includes("const x")); + expect(body?.code).toBe(true); + expect(body?.color).toBeUndefined(); }); test("drops a half-typed closing fence from the streaming tail", () => { @@ -173,8 +172,7 @@ describe("block elements", () => { // The blank body line is not an empty segment array — it still paints ▏. const blankBody = lines.find( (line) => - line.length === 1 && - line[0]?.text === "▏ " && + line.map((s) => s.text).join("") === "▏ " && line[0]?.codeFence === true, ); expect(blankBody).toBeDefined(); diff --git a/src/tui/syntax-highlight.test.ts b/src/tui/syntax-highlight.test.ts index 9d0525a14..6acf203e1 100644 --- a/src/tui/syntax-highlight.test.ts +++ b/src/tui/syntax-highlight.test.ts @@ -1,71 +1,179 @@ +/** + * Fenced-code highlighting after the highlight.js → tree-sitter swap. + * + * Colour lives in the native renderer: a fenced block paints through + * MarkdownRenderable with transcriptSyntaxStyle() over the bundled + * tree-sitter grammars (javascript, typescript, markdown, zig). The + * synchronous StyledSegment model keeps geometry only — plain code segments + * cached by width. These tests pin both halves: the plain sync fallback and + * the supported/unsupported language parity the native renderer applies. + */ + import { describe, expect, test } from "bun:test"; -import type { StyledSegment } from "./markdown-parser.js"; +import { MarkdownRenderable, RGBA, type CapturedSpan } from "@opentui/core"; +import { withTestRenderer, type Harness } from "./harness"; +import { transcriptSyntaxStyle } from "./stream"; import { highlightCode } from "./syntax-highlight.js"; -import { color } from "./semantic-theme.js"; - -function segmentFor( - lines: StyledSegment[][], - text: string, -): StyledSegment | undefined { - return lines.flat().find((seg) => seg.text === text); -} +import { UI } from "./theme"; describe("highlightCode", () => { - test("colours keywords, strings, and comments from the theme tokens", () => { - const lines = highlightCode('const x = "hi"; // note', "javascript", 80); - expect(segmentFor(lines, "const")?.color).toBe(color("syntaxKeyword")); - expect(segmentFor(lines, '"hi"')?.color).toBe(color("syntaxString")); - expect(segmentFor(lines, "// note")?.color).toBe(color("syntaxComment")); + test("segments every line as plain code with no colours", () => { + expect(highlightCode('const x = "hi";\n// yo', "javascript", 80)).toEqual([ + [{ text: 'const x = "hi";', code: true }], + [{ text: "// yo", code: true }], + ]); }); - test("colours numbers from the syntaxNumber token", () => { - const lines = highlightCode("let n = 42;", "javascript", 80); - expect(segmentFor(lines, "42")?.color).toBe(color("syntaxNumber")); - }); - - test("every highlighted segment is marked as code", () => { + test("the cache is keyed by width", () => { const lines = highlightCode("const x = 1;", "javascript", 80); - for (const seg of lines.flat()) expect(seg.code).toBe(true); + expect(highlightCode("const x = 1;", "javascript", 80)).toBe(lines); + expect(highlightCode("const x = 1;", "javascript", 81)).not.toBe(lines); + expect(highlightCode("const x = 1;", "javascript", 81)).toEqual(lines); }); +}); - test("preserves line count so wrapping stays aligned", () => { - const code = "line one\nline two\nline three"; - expect(highlightCode(code, "javascript", 80)).toHaveLength(3); - }); +const HEADING_FG = RGBA.fromHex(UI.heading); +const KEYWORD_FG = RGBA.fromHex(UI.inFlightBright); +const STRING_FG = RGBA.fromHex(UI.done); +const COMMENT_FG = RGBA.fromHex(UI.textFaint); +const FUNCTION_FG = RGBA.fromHex(UI.inFlight); +const SYNTAX_FGS = [KEYWORD_FG, STRING_FG, COMMENT_FG, FUNCTION_FG]; - test("decodes HTML entities back to their source characters", () => { - const lines = highlightCode("const ok = a > b && c < d;", "javascript", 80); - const rendered = lines - .flat() - .map((s) => s.text) - .join(""); - expect(rendered).toContain(">"); - expect(rendered).toContain("<"); - expect(rendered).toContain("&&"); - }); +function renderFence(h: Harness, fence: string): void { + h.root.add( + new MarkdownRenderable(h.renderer, { + syntaxStyle: transcriptSyntaxStyle(), + fg: UI.text, + width: 80, + flexShrink: 0, + content: fence, + streaming: false, + }), + ); +} - test("falls back to uncoloured code for an unknown language", () => { - const lines = highlightCode("some plain text", "not-a-language", 80); - for (const seg of lines.flat()) { - expect(seg.code).toBe(true); - expect(seg.color).toBeUndefined(); - } - }); +async function settleSpans( + h: Harness, + ready: (spans: CapturedSpan[]) => boolean, +): Promise { + let spans = h.captureSpans().lines.flatMap((line) => line.spans); + for (let i = 0; i < 400 && !ready(spans); i++) { + await h.renderOnce(); + await new Promise((resolve) => setTimeout(resolve, 25)); + spans = h.captureSpans().lines.flatMap((line) => line.spans); + } + return spans; +} - test("falls back to uncoloured code when the language is absent", () => { - const lines = highlightCode("plain fenced text", undefined, 80); - expect(segmentFor(lines, "plain fenced text")?.color).toBeUndefined(); - }); +function coloured(spans: CapturedSpan[], text: string, fg: RGBA): boolean { + return spans.some((s) => s.text.includes(text) && s.fg.equals(fg)); +} - test("caches identical (text, language, width) requests", () => { - const first = highlightCode("const cached = 1;", "javascript", 80); - const second = highlightCode("const cached = 1;", "javascript", 80); - expect(second).toBe(first); - }); +describe("native fenced rendering", () => { + test("a js fence colours keywords, strings, comments, and numbers", async () => { + await withTestRenderer( + async (h) => { + renderFence( + h, + '```js\nconst greeting = "hi"; // yo\nconst n = 42;\n```', + ); + const spans = await settleSpans( + h, + (s) => + coloured(s, "const", KEYWORD_FG) && + coloured(s, "hi", STRING_FG) && + coloured(s, "// yo", COMMENT_FG) && + coloured(s, "42", STRING_FG), + ); + expect(coloured(spans, "const", KEYWORD_FG)).toBe(true); + expect(coloured(spans, "hi", STRING_FG)).toBe(true); + expect(coloured(spans, "// yo", COMMENT_FG)).toBe(true); + expect(coloured(spans, "42", STRING_FG)).toBe(true); + }, + { width: 80, height: 24 }, + ); + }, 30000); - test("keys the cache on width so a resize does not reuse stale output", () => { - const narrow = highlightCode("const w = 1;", "javascript", 40); - const wide = highlightCode("const w = 1;", "javascript", 120); - expect(wide).not.toBe(narrow); - }); + test("a ts fence colours keywords and numbers", async () => { + await withTestRenderer( + async (h) => { + renderFence(h, "```ts\nconst n: number = 42;\n```"); + const spans = await settleSpans( + h, + (s) => + coloured(s, "const", KEYWORD_FG) && coloured(s, "42", STRING_FG), + ); + expect(coloured(spans, "const", KEYWORD_FG)).toBe(true); + expect(coloured(spans, "42", STRING_FG)).toBe(true); + }, + { width: 80, height: 24 }, + ); + }, 30000); + + test("a markdown fence colours headings", async () => { + await withTestRenderer( + async (h) => { + renderFence(h, "```markdown\n# heading\n```"); + const spans = await settleSpans(h, (s) => + coloured(s, "heading", HEADING_FG), + ); + expect(coloured(spans, "heading", HEADING_FG)).toBe(true); + }, + { width: 80, height: 24 }, + ); + }, 30000); + + test("jsx and tsx fences highlight through their grammar aliases", async () => { + for (const fence of [ + "```jsx\nconst el =
;\n```", + "```tsx\nconst el =
;\n```", + ]) { + await withTestRenderer( + async (h) => { + renderFence(h, fence); + const spans = await settleSpans(h, (s) => + coloured(s, "const", KEYWORD_FG), + ); + expect(coloured(spans, "const", KEYWORD_FG)).toBe(true); + }, + { width: 80, height: 24 }, + ); + } + }, 30000); + + test("a zig fence colours keywords", async () => { + await withTestRenderer( + async (h) => { + renderFence(h, "```zig\nconst x: i32 = 1;\n```"); + const spans = await settleSpans(h, (s) => + coloured(s, "const", KEYWORD_FG), + ); + expect(coloured(spans, "const", KEYWORD_FG)).toBe(true); + }, + { width: 80, height: 24 }, + ); + }, 30000); + + test("python, go, and rust fences render PLAIN with no syntax colours", async () => { + const fences: [string, string][] = [ + ['```python\ndef greet():\n return "hi" # yo\n```', "def greet"], + ["```go\npackage main\n\nfunc main() {}\n```", "func main"], + ['```rust\nfn main() {\n println!("hi");\n}\n```', "fn main"], + ]; + for (const [fence, marker] of fences) { + await withTestRenderer( + async (h) => { + renderFence(h, fence); + const spans = await settleSpans(h, () => + h.captureCharFrame().includes(marker), + ); + expect(h.captureCharFrame()).toContain(marker); + expect( + spans.some((s) => SYNTAX_FGS.some((fg) => s.fg.equals(fg))), + ).toBe(false); + }, + { width: 80, height: 24 }, + ); + } + }, 60000); }); diff --git a/src/tui/syntax-highlight.ts b/src/tui/syntax-highlight.ts index 8cfc1be5e..9b2e05183 100644 --- a/src/tui/syntax-highlight.ts +++ b/src/tui/syntax-highlight.ts @@ -1,167 +1,50 @@ -// The common-language build (~40 grammars) instead of the full ~190-grammar -// bundle: it keeps startup and bundle size in check, and unknown languages -// already fall back to plain code. -import hljs from "highlight.js/lib/common"; +/// + +/** + * Fenced-code styling for the synchronous markdown model. + * + * Tree-sitter highlighting is asynchronous, so the synchronous StyledSegment + * model cannot colour tokens. Live transcript colour belongs to the native + * MarkdownRenderable/CodeRenderable path with transcriptSyntaxStyle() over the + * bundled tree-sitter grammars (javascript, typescript, markdown, zig). This + * module keeps the legacy path's geometry with plain segments. + * + * The DOM lib reference replaces the implicit reference highlight.js supplied; + * Bun's fetch, Web Crypto, and timer types rely on those globals repo-wide. + */ import type { StyledSegment } from "./markdown-parser.js"; -import { color, type SemanticRole } from "./semantic-theme.js"; - -// Map highlight.js scope classes to the semantic syntax palette. hljs emits a -// class attribute whose first token is prefixed `hljs-` and whose optional -// second token names a sub-scope (`title function_`, `title class_`). We look at -// the whole attribute so those sub-scopes route to the right role. -function roleForClass(cls: string): SemanticRole | undefined { - if (cls.includes("comment") || cls.includes("quote")) return "syntaxComment"; - if ( - cls.includes("string") || - cls.includes("regexp") || - cls.includes("char") || - cls.includes("symbol") - ) { - return "syntaxString"; - } - if (cls.includes("number")) return "syntaxNumber"; - if (cls.includes("function")) return "syntaxFunction"; - if (cls.includes("class") || cls.includes("type") || cls.includes("built_in")) - return "syntaxType"; - if ( - cls.includes("keyword") || - cls.includes("literal") || - cls.includes("meta") - ) - return "syntaxKeyword"; - if (cls.includes("operator")) return "syntaxOperator"; - if (cls.includes("punctuation")) return "syntaxPunctuation"; - if ( - cls.includes("variable") || - cls.includes("attr") || - cls.includes("property") || - cls.includes("params") || - cls.includes("title") || - cls.includes("selector") - ) { - return "syntaxVariable"; - } - return undefined; -} - -const ENTITIES: Record = { - amp: "&", - lt: "<", - gt: ">", - quot: '"', - "#x27": "'", - "#39": "'", -}; - -function decodeEntities(text: string): string { - return text.replace( - /&(#x27|#39|amp|lt|gt|quot);/g, - (_, name: string) => ENTITIES[name] ?? _, - ); -} - -interface Token { - text: string; - role: SemanticRole | undefined; -} - -// hljs output is a well-formed subset of HTML: text, entities, and `` wrappers that may nest. Walk it once, keeping a stack of the -// active scope roles so the innermost recognized scope colours each text run. -function tokenizeHljsHtml(html: string): Token[] { - const tokens: Token[] = []; - const stack: (SemanticRole | undefined)[] = []; - const tagRe = /|<\/span>/g; - let last = 0; - - const pushText = (raw: string) => { - if (raw.length === 0) return; - const role = stack.length > 0 ? stack[stack.length - 1] : undefined; - tokens.push({ text: decodeEntities(raw), role }); - }; - - for (let m = tagRe.exec(html); m !== null; m = tagRe.exec(html)) { - pushText(html.slice(last, m.index)); - last = tagRe.lastIndex; - if (m[1] !== undefined) { - const role = roleForClass(m[1]); - stack.push( - role ?? (stack.length > 0 ? stack[stack.length - 1] : undefined), - ); - } else { - stack.pop(); - } - } - pushText(html.slice(last)); - - return tokens; -} - -// Break a flat token stream into visual lines, splitting any token that spans a -// newline so each rendered line is a self-contained segment array. -function tokensToLines(tokens: Token[]): StyledSegment[][] { - const lines: StyledSegment[][] = [[]]; - for (const token of tokens) { - const parts = token.text.split("\n"); - parts.forEach((part, i) => { - if (i > 0) lines.push([]); - if (part.length === 0) return; - const seg: StyledSegment = { text: part, code: true }; - if (token.role !== undefined) seg.color = color(token.role); - const line = lines[lines.length - 1]; - if (line == null) throw new Error("highlight line missing"); - line.push(seg); - }); - } - return lines; -} function plainLines(code: string): StyledSegment[][] { - return code - .split("\n") - .map((line) => (line.length === 0 ? [] : [{ text: line, code: true }])); + return code.split("\n").map((text) => [{ text, code: true }]); } -const cache = new Map(); -const CACHE_LIMIT = 256; +const codeCache = new Map(); +const CODE_CACHE_LIMIT = 256; function cached( key: string, - compute: () => StyledSegment[][], + build: () => StyledSegment[][], ): StyledSegment[][] { - const hit = cache.get(key); + const hit = codeCache.get(key); if (hit !== undefined) return hit; - const value = compute(); - // Streaming re-highlights a growing block every drain, minting a fresh key - // each time; bound the map so those transient entries cannot accumulate. - if (cache.size >= CACHE_LIMIT) { - const oldest = cache.keys().next().value; - if (oldest !== undefined) cache.delete(oldest); + const built = build(); + codeCache.set(key, built); + if (codeCache.size > CODE_CACHE_LIMIT) { + const oldest = codeCache.keys().next(); + if (!oldest.done) codeCache.delete(oldest.value); } - cache.set(key, value); - return value; + return built; } -// Highlight a fenced block's body into styled lines. Unknown or absent languages -// fall back to plain muted code. `width` participates in the cache key so cached -// output never leaks across a resize even though highlighting itself is -// width-independent. +/** + * Styled lines for a fenced body in the synchronous model. Colour is owned by + * the native renderer, so every language is plain here. Width remains in the + * cache key to preserve the parser-facing API's resize semantics. + */ export function highlightCode( code: string, - language: string | undefined, - width: number, + _language: string | undefined, + width?: number, ): StyledSegment[][] { - const lang = - language !== undefined && hljs.getLanguage(language) !== undefined - ? language - : undefined; - const key = `${width}\x1f${lang ?? ""}\x1f${code}`; - return cached(key, () => { - if (lang === undefined) return plainLines(code); - const html = hljs.highlight(code, { - language: lang, - ignoreIllegals: true, - }).value; - return tokensToLines(tokenizeHljsHtml(html)); - }); + return cached(`${width ?? ""}\x1f${code}`, () => plainLines(code)); } From 17b52136dfb7d92f09110b27d47e29cf4a325183 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 28 Sep 2026 11:01:05 -0700 Subject: [PATCH 2/2] fix(tui): pin native highlighting ownership --- src/tui/markdown-parser.test.ts | 3 +- src/tui/syntax-highlight.test.ts | 64 ++++++++++++++++++++++++++++++++ src/tui/syntax-highlight.ts | 9 ++--- tsconfig.base.json | 2 +- 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/tui/markdown-parser.test.ts b/src/tui/markdown-parser.test.ts index b28d38ada..9ea1b4814 100644 --- a/src/tui/markdown-parser.test.ts +++ b/src/tui/markdown-parser.test.ts @@ -172,7 +172,8 @@ describe("block elements", () => { // The blank body line is not an empty segment array — it still paints ▏. const blankBody = lines.find( (line) => - line.map((s) => s.text).join("") === "▏ " && + line.length === 1 && + line[0]?.text === "▏ " && line[0]?.codeFence === true, ); expect(blankBody).toBeDefined(); diff --git a/src/tui/syntax-highlight.test.ts b/src/tui/syntax-highlight.test.ts index 6acf203e1..da0e9b5a8 100644 --- a/src/tui/syntax-highlight.test.ts +++ b/src/tui/syntax-highlight.test.ts @@ -12,6 +12,8 @@ import { describe, expect, test } from "bun:test"; import { MarkdownRenderable, RGBA, type CapturedSpan } from "@opentui/core"; import { withTestRenderer, type Harness } from "./harness"; +import { appendStreamRow } from "./shell/chrome.js"; +import { createAppShell } from "./shell/index.js"; import { transcriptSyntaxStyle } from "./stream"; import { highlightCode } from "./syntax-highlight.js"; import { UI } from "./theme"; @@ -69,6 +71,68 @@ function coloured(spans: CapturedSpan[], text: string, fg: RGBA): boolean { return spans.some((s) => s.text.includes(text) && s.fg.equals(fg)); } +function hasSyntaxColour(spans: CapturedSpan[], text: string): boolean { + return spans.some( + (span) => + span.text.includes(text) && SYNTAX_FGS.some((fg) => span.fg.equals(fg)), + ); +} + +async function expectTranscriptHighlighting( + h: Harness, + supportedLanguage: "js" | "ts", + streaming: boolean, +): Promise { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 40 }, + wireKeys: false, + }); + try { + appendStreamRow(shell, { + role: "assistant", + streaming, + text: [ + `\`\`\`${supportedLanguage}`, + "const supported_route = 42;", + "```", + "", + "```python", + "def python_plain():", + ' return "visible"', + "```", + ].join("\n"), + }); + const spans = await settleSpans( + h, + (current) => + coloured(current, "const", KEYWORD_FG) && + current.some((span) => span.text.includes("python_plain")), + ); + + expect(coloured(spans, "const", KEYWORD_FG)).toBe(true); + expect(h.captureCharFrame()).toContain("python_plain"); + expect(hasSyntaxColour(spans, "python_plain")).toBe(false); + } finally { + shell.dispose(); + } +} + +describe("production transcript fenced rendering", () => { + test("settled assistant fences use production syntax ownership", async () => { + await withTestRenderer( + (h) => expectTranscriptHighlighting(h, "js", false), + { width: 80, height: 40 }, + ); + }, 30000); + + test("streaming assistant fences use production syntax ownership", async () => { + await withTestRenderer((h) => expectTranscriptHighlighting(h, "ts", true), { + width: 80, + height: 40, + }); + }, 30000); +}); + describe("native fenced rendering", () => { test("a js fence colours keywords, strings, comments, and numbers", async () => { await withTestRenderer( diff --git a/src/tui/syntax-highlight.ts b/src/tui/syntax-highlight.ts index 9b2e05183..fd45cd04a 100644 --- a/src/tui/syntax-highlight.ts +++ b/src/tui/syntax-highlight.ts @@ -1,5 +1,3 @@ -/// - /** * Fenced-code styling for the synchronous markdown model. * @@ -8,14 +6,13 @@ * MarkdownRenderable/CodeRenderable path with transcriptSyntaxStyle() over the * bundled tree-sitter grammars (javascript, typescript, markdown, zig). This * module keeps the legacy path's geometry with plain segments. - * - * The DOM lib reference replaces the implicit reference highlight.js supplied; - * Bun's fetch, Web Crypto, and timer types rely on those globals repo-wide. */ import type { StyledSegment } from "./markdown-parser.js"; function plainLines(code: string): StyledSegment[][] { - return code.split("\n").map((text) => [{ text, code: true }]); + return code + .split("\n") + .map((text) => (text.length === 0 ? [] : [{ text, code: true }])); } const codeCache = new Map(); diff --git a/tsconfig.base.json b/tsconfig.base.json index 5c435138e..0758dc03b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -23,6 +23,6 @@ "module": "ESNext", "noEmit": true, - "lib": ["ESNext"] + "lib": ["ESNext", "DOM"] } }