From 2c36f82b355ddbde55799cac9ec2360ecafc90df Mon Sep 17 00:00:00 2001 From: James Adam Date: Fri, 10 Apr 2026 11:48:24 +0100 Subject: [PATCH 1/2] Add code block styling with background and monospace font Fenced code blocks (``` ... ```) now get a background color and monospace font via the existing markdown decoration system, matching the inline code style. Fence delimiters are dimmed, inline markdown patterns are suppressed inside code blocks, and the max-width constraint is lifted for code content. --- app/app.css | 16 +++ app/lib/markdown-decorations.ts | 121 ++++++++++++++++++++ tests/unit/lib/markdown-decorations.test.ts | 114 ++++++++++++++++++ 3 files changed, 251 insertions(+) diff --git a/app/app.css b/app/app.css index 8c5d1220..6c09b51c 100644 --- a/app/app.css +++ b/app/app.css @@ -86,6 +86,22 @@ body { border-radius: 2px; } +.tiptap p.md-code-block { + font-family: var(--font-mono); + font-size: 0.9em; + background-color: var(--color-border); + max-width: none; + padding: 0 1em; +} + +.tiptap p.md-code-block-open { + padding-top: 0.5em; +} + +.tiptap p.md-code-block-close { + padding-bottom: 0.5em; +} + .md-strikethrough { text-decoration: line-through; } diff --git a/app/lib/markdown-decorations.ts b/app/lib/markdown-decorations.ts index 8e2bbbd6..70e18a22 100644 --- a/app/lib/markdown-decorations.ts +++ b/app/lib/markdown-decorations.ts @@ -84,6 +84,113 @@ export const MARKDOWN_PATTERNS: MarkdownPattern[] = [ }, ]; +export const CODE_FENCE_REGEX = /^(`{3,})(.*)?$/; + +interface ParagraphInfo { + node: Parameters[0]>[0]; + pos: number; +} + +export function findCodeBlockDecorations( + paragraphs: ParagraphInfo[], +): { decorations: Decoration[]; codeBlockRanges: Array<{ from: number; to: number }> } { + const decorations: Decoration[] = []; + const codeBlockRanges: Array<{ from: number; to: number }> = []; + let i = 0; + + while (i < paragraphs.length) { + const { node: openNode, pos: openPos } = paragraphs[i]; + const openText = openNode.textContent; + const openMatch = CODE_FENCE_REGEX.exec(openText); + + if (!openMatch) { + i++; + continue; + } + + const fenceChar = openMatch[1]; + const fenceLen = fenceChar.length; + + // Search for closing fence + let j = i + 1; + let closedAt = -1; + while (j < paragraphs.length) { + const closeText = paragraphs[j].node.textContent; + const closeMatch = CODE_FENCE_REGEX.exec(closeText); + if (closeMatch && closeMatch[1].length >= fenceLen && !closeMatch[2]?.trim()) { + closedAt = j; + break; + } + j++; + } + + if (closedAt === -1) { + // No closing fence — not a code block + i++; + continue; + } + + // Track the range from opening fence node start to closing fence node end + const blockFrom = openPos; + const closePara = paragraphs[closedAt]; + const blockTo = closePara.pos + closePara.node.nodeSize; + codeBlockRanges.push({ from: blockFrom, to: blockTo }); + + // Opening fence: node decoration + inline delimiter + decorations.push( + Decoration.node(openPos, openPos + openNode.nodeSize, { + class: "md-code-block md-code-block-open", + }), + ); + if (openNode.textContent.length > 0) { + decorations.push( + Decoration.inline(openPos + 1, openPos + 1 + openNode.textContent.length, { + class: "md-delimiter", + }), + ); + } + + // Inner lines: node decoration for background + monospace + for (let k = i + 1; k < closedAt; k++) { + const { node: innerNode, pos: innerPos } = paragraphs[k]; + decorations.push( + Decoration.node(innerPos, innerPos + innerNode.nodeSize, { + class: "md-code-block", + }), + ); + } + + // Closing fence: node decoration + inline delimiter + decorations.push( + Decoration.node(closePara.pos, closePara.pos + closePara.node.nodeSize, { + class: "md-code-block md-code-block-close", + }), + ); + if (closePara.node.textContent.length > 0) { + decorations.push( + Decoration.inline(closePara.pos + 1, closePara.pos + 1 + closePara.node.textContent.length, { + class: "md-delimiter", + }), + ); + } + + i = closedAt + 1; + } + + return { decorations, codeBlockRanges }; +} + +function posInsideCodeBlock( + pos: number, + nodeSize: number, + codeBlockRanges: Array<{ from: number; to: number }>, +): boolean { + for (const range of codeBlockRanges) { + if (pos >= range.from && pos + nodeSize <= range.to) return true; + } + return false; +} + export function findDecorations( text: string, basePos: number, @@ -231,8 +338,22 @@ export function markdownDecorations(): Plugin[] { decorations(state) { const decorations: Decoration[] = []; + // First pass: collect paragraphs and find code blocks + const paragraphs: ParagraphInfo[] = []; + state.doc.descendants((node, pos) => { + if (node.type.name === "paragraph") { + paragraphs.push({ node, pos }); + } + }); + + const { decorations: codeBlockDecos, codeBlockRanges } = + findCodeBlockDecorations(paragraphs); + decorations.push(...codeBlockDecos); + + // Second pass: inline patterns, skipping nodes inside code blocks state.doc.descendants((node, pos) => { if (!node.isText || !node.text) return; + if (posInsideCodeBlock(pos, node.nodeSize, codeBlockRanges)) return; for (const pattern of MARKDOWN_PATTERNS) { decorations.push(...findDecorations(node.text, pos, pattern)); } diff --git a/tests/unit/lib/markdown-decorations.test.ts b/tests/unit/lib/markdown-decorations.test.ts index 1ebe01ce..3f627f9d 100644 --- a/tests/unit/lib/markdown-decorations.test.ts +++ b/tests/unit/lib/markdown-decorations.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import { MARKDOWN_PATTERNS, findDecorations, + findCodeBlockDecorations, + CODE_FENCE_REGEX, type MarkdownPattern, } from "~/lib/markdown-decorations"; @@ -269,3 +271,115 @@ describe("findDecorations", () => { }); }); }); + +describe("CODE_FENCE_REGEX", () => { + it("matches triple backticks", () => { + expect(CODE_FENCE_REGEX.test("```")).toBe(true); + }); + + it("matches triple backticks with language", () => { + expect(CODE_FENCE_REGEX.test("```js")).toBe(true); + }); + + it("matches 4+ backticks", () => { + expect(CODE_FENCE_REGEX.test("````")).toBe(true); + }); + + it("does not match fewer than 3 backticks", () => { + expect(CODE_FENCE_REGEX.test("``")).toBe(false); + }); + + it("does not match backticks mid-line", () => { + expect(CODE_FENCE_REGEX.test("some ``` text")).toBe(false); + }); +}); + +describe("findCodeBlockDecorations", () => { + // Helper to create mock paragraph nodes matching the shape + // findCodeBlockDecorations expects + function makeParagraphs(lines: string[]) { + let pos = 0; + return lines.map((text) => { + // nodeSize = 1 (open tag) + text length + 1 (close tag) + const nodeSize = text.length + 2; + const para = { + node: { + textContent: text, + nodeSize, + type: { name: "paragraph" }, + }, + pos, + }; + pos += nodeSize; + return para; + }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function decoInfo(d: any) { + return { + from: d.from, + to: d.to, + class: d.type?.attrs?.class ?? d.type?.spec?.class, + }; + } + + it("detects a simple fenced code block", () => { + const paras = makeParagraphs(["```", "hello", "```"]); + const { decorations, codeBlockRanges } = findCodeBlockDecorations(paras); + + expect(codeBlockRanges).toHaveLength(1); + // 3 paragraphs: "```" (nodeSize 5) + "hello" (7) + "```" (5) = 17 + expect(codeBlockRanges[0]).toEqual({ from: 0, to: 17 }); + + const classes = decorations.map(decoInfo).map((d) => d.class); + expect(classes).toContain("md-code-block md-code-block-open"); + expect(classes).toContain("md-code-block"); + expect(classes).toContain("md-code-block md-code-block-close"); + }); + + it("detects a code block with language specifier", () => { + const paras = makeParagraphs(["```typescript", "const x = 1;", "```"]); + const { codeBlockRanges } = findCodeBlockDecorations(paras); + expect(codeBlockRanges).toHaveLength(1); + }); + + it("dims fence delimiters", () => { + const paras = makeParagraphs(["```", "code", "```"]); + const { decorations } = findCodeBlockDecorations(paras); + const delimiterDecos = decorations.map(decoInfo).filter((d) => d.class === "md-delimiter"); + // Both opening ``` and closing ``` get delimiter decorations + expect(delimiterDecos).toHaveLength(2); + }); + + it("does not match unclosed fences", () => { + const paras = makeParagraphs(["```", "code", "no closing"]); + const { codeBlockRanges } = findCodeBlockDecorations(paras); + expect(codeBlockRanges).toHaveLength(0); + }); + + it("handles multiple code blocks", () => { + const paras = makeParagraphs(["```", "a", "```", "text", "```", "b", "```"]); + const { codeBlockRanges } = findCodeBlockDecorations(paras); + expect(codeBlockRanges).toHaveLength(2); + }); + + it("requires closing fence to have at least as many backticks as opening", () => { + const paras = makeParagraphs(["````", "code", "```", "more", "````"]); + const { codeBlockRanges } = findCodeBlockDecorations(paras); + // The ``` line is not a valid close for ```` — only ```` closes it + expect(codeBlockRanges).toHaveLength(1); + // The block spans from the first ```` to the last ```` + expect(codeBlockRanges[0]).toEqual({ + from: paras[0].pos, + to: paras[4].pos + paras[4].node.nodeSize, + }); + }); + + it("returns empty for no code blocks", () => { + const paras = makeParagraphs(["hello", "world"]); + const { decorations, codeBlockRanges } = findCodeBlockDecorations(paras); + expect(codeBlockRanges).toHaveLength(0); + expect(decorations).toHaveLength(0); + }); +}); From 7dd919a9514daf2ca99b30c5f084cdf890fc358b Mon Sep 17 00:00:00 2001 From: James Adam Date: Fri, 10 Apr 2026 11:53:27 +0100 Subject: [PATCH 2/2] Add syntax highlighting to code blocks using sugar-high Integrates sugar-high (~1KB gzipped, zero deps) for syntax highlighting inside fenced code blocks. Tokens are applied as ProseMirror inline decorations (sh-keyword, sh-string, sh-comment, etc.) with colours for both light and dark themes. Built-in language presets for JS/TS/JSX (default), Python, Rust, Go, C/C++, Java, and CSS. Unrecognised languages fall back to the default JS-oriented tokenizer which handles most C-family syntax reasonably. --- app/app.css | 22 ++++++ app/lib/markdown-decorations.ts | 65 ++++++++++++++++- package-lock.json | 7 ++ package.json | 1 + tests/unit/lib/markdown-decorations.test.ts | 78 +++++++++++++++++++++ 5 files changed, 172 insertions(+), 1 deletion(-) diff --git a/app/app.css b/app/app.css index 6c09b51c..1f5921cd 100644 --- a/app/app.css +++ b/app/app.css @@ -102,6 +102,16 @@ body { padding-bottom: 0.5em; } +/* Syntax highlighting tokens (sugar-high) */ +.sh-keyword { color: #8b5cf6; } +.sh-string { color: #16a34a; } +.sh-comment { color: var(--color-muted); font-style: italic; } +.sh-class { color: #0891b2; } +.sh-property { color: #2563eb; } +.sh-entity { color: #c026d3; } +.sh-jsxliterals { color: #0891b2; } +.sh-sign { color: var(--color-muted); } + .md-strikethrough { text-decoration: line-through; } @@ -349,6 +359,12 @@ body { [data-theme="dark"] .cm-addition { color: #4ade80; } [data-theme="dark"] .cm-deletion { color: #f87171; } [data-theme="dark"] .preview a { color: #60a5fa; } +[data-theme="dark"] .sh-keyword { color: #a78bfa; } +[data-theme="dark"] .sh-string { color: #4ade80; } +[data-theme="dark"] .sh-class { color: #22d3ee; } +[data-theme="dark"] .sh-property { color: #60a5fa; } +[data-theme="dark"] .sh-entity { color: #e879f9; } +[data-theme="dark"] .sh-jsxliterals { color: #22d3ee; } /* Dark theme — auto mode follows system preference */ @media (prefers-color-scheme: dark) { @@ -364,6 +380,12 @@ body { [data-theme="auto"] .cm-addition { color: #4ade80; } [data-theme="auto"] .cm-deletion { color: #f87171; } [data-theme="auto"] .preview a { color: #60a5fa; } + [data-theme="auto"] .sh-keyword { color: #a78bfa; } + [data-theme="auto"] .sh-string { color: #4ade80; } + [data-theme="auto"] .sh-class { color: #22d3ee; } + [data-theme="auto"] .sh-property { color: #60a5fa; } + [data-theme="auto"] .sh-entity { color: #e879f9; } + [data-theme="auto"] .sh-jsxliterals { color: #22d3ee; } } /* Onboarding marquee button */ diff --git a/app/lib/markdown-decorations.ts b/app/lib/markdown-decorations.ts index 70e18a22..c24bb2fe 100644 --- a/app/lib/markdown-decorations.ts +++ b/app/lib/markdown-decorations.ts @@ -1,5 +1,7 @@ import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { tokenize, SugarHigh } from "sugar-high"; +import * as presets from "sugar-high/presets"; export type PatternType = "inline" | "prefix" | "heading" | "link"; @@ -86,6 +88,59 @@ export const MARKDOWN_PATTERNS: MarkdownPattern[] = [ export const CODE_FENCE_REGEX = /^(`{3,})(.*)?$/; +const TOKEN_TYPE_NAMES = SugarHigh.TokenTypes as unknown as string[]; + +// Token types that get no special colour — skip them to avoid unnecessary DOM nodes +const SKIP_TOKEN_TYPES = new Set(["identifier", "break", "space"]); + +const LANGUAGE_PRESETS: Record = { + css: presets.css, + rust: presets.rust, + rs: presets.rust, + python: presets.python, + py: presets.python, + c: presets.c, + cpp: presets.c, + "c++": presets.c, + h: presets.c, + go: presets.go, + golang: presets.go, + java: presets.java, +}; + +export function getLanguageOptions(lang: string | undefined) { + if (!lang) return undefined; + return LANGUAGE_PRESETS[lang.toLowerCase()]; +} + +export function highlightLine( + text: string, + basePos: number, + lang: string | undefined, +): Decoration[] { + if (text.length === 0) return []; + + const options = getLanguageOptions(lang); + const tokens = tokenize(text, options ?? undefined); + const decorations: Decoration[] = []; + let offset = 0; + + for (const [typeIndex, tokenText] of tokens) { + const typeName = TOKEN_TYPE_NAMES[typeIndex]; + const len = tokenText.length; + if (!SKIP_TOKEN_TYPES.has(typeName) && len > 0) { + decorations.push( + Decoration.inline(basePos + offset, basePos + offset + len, { + class: `sh-${typeName}`, + }), + ); + } + offset += len; + } + + return decorations; +} + interface ParagraphInfo { node: Parameters[0]>[0]; pos: number; @@ -150,7 +205,10 @@ export function findCodeBlockDecorations( ); } - // Inner lines: node decoration for background + monospace + // Extract language from fence info string (e.g. "```js" → "js") + const lang = openMatch[2]?.trim() || undefined; + + // Inner lines: node decoration for background + monospace, plus syntax highlighting for (let k = i + 1; k < closedAt; k++) { const { node: innerNode, pos: innerPos } = paragraphs[k]; decorations.push( @@ -158,6 +216,11 @@ export function findCodeBlockDecorations( class: "md-code-block", }), ); + // Syntax highlight the text content (pos + 1 to skip paragraph open token) + const innerText = innerNode.textContent; + if (innerText.length > 0) { + decorations.push(...highlightLine(innerText, innerPos + 1, lang)); + } } // Closing fence: node decoration + inline delimiter diff --git a/package-lock.json b/package-lock.json index 01510106..7b94d0d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-router": "^7.10.0", + "sugar-high": "^1.1.0", "y-protocols": "^1.0.7", "yaml": "^2.8.2", "yjs": "^13.6.29" @@ -8071,6 +8072,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sugar-high": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/sugar-high/-/sugar-high-1.1.0.tgz", + "integrity": "sha512-pL68G9H5VgK5z5aRAp8Yl4+obwbfEpr4BCCdO9V9JrWTFQiPMuN2GKxl9Vn/oxzkcS8TOTFFNti3LXe0UWX2Yw==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 2e4abeaa..49158cb9 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-router": "^7.10.0", + "sugar-high": "^1.1.0", "y-protocols": "^1.0.7", "yaml": "^2.8.2", "yjs": "^13.6.29" diff --git a/tests/unit/lib/markdown-decorations.test.ts b/tests/unit/lib/markdown-decorations.test.ts index 3f627f9d..178c94aa 100644 --- a/tests/unit/lib/markdown-decorations.test.ts +++ b/tests/unit/lib/markdown-decorations.test.ts @@ -4,6 +4,8 @@ import { findDecorations, findCodeBlockDecorations, CODE_FENCE_REGEX, + highlightLine, + getLanguageOptions, type MarkdownPattern, } from "~/lib/markdown-decorations"; @@ -382,4 +384,80 @@ describe("findCodeBlockDecorations", () => { expect(codeBlockRanges).toHaveLength(0); expect(decorations).toHaveLength(0); }); + + it("produces syntax highlighting decorations for inner lines", () => { + const paras = makeParagraphs(["```js", "const x = 1;", "```"]); + const { decorations } = findCodeBlockDecorations(paras); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const classes = decorations.map((d: any) => d.type?.attrs?.class ?? d.type?.spec?.class).filter(Boolean); + // Should have sh- prefixed syntax highlight classes + expect(classes.some((c: string) => c.startsWith("sh-"))).toBe(true); + expect(classes).toContain("sh-keyword"); // "const" is a keyword + }); +}); + +describe("highlightLine", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function decoInfo(d: any) { + return { + from: d.from, + to: d.to, + class: d.type?.attrs?.class, + }; + } + + it("highlights JavaScript keywords", () => { + const decos = highlightLine("const x = 1;", 0, "js").map(decoInfo); + expect(decos[0]).toEqual({ from: 0, to: 5, class: "sh-keyword" }); + }); + + it("highlights strings", () => { + const decos = highlightLine('"hello"', 0, "js").map(decoInfo); + const stringDecos = decos.filter((d) => d.class === "sh-string"); + expect(stringDecos.length).toBeGreaterThan(0); + }); + + it("highlights comments", () => { + const decos = highlightLine("// a comment", 0, "js").map(decoInfo); + expect(decos[0]?.class).toBe("sh-comment"); + }); + + it("respects basePos offset", () => { + const decos = highlightLine("const x", 100, "js").map(decoInfo); + expect(decos[0]).toEqual({ from: 100, to: 105, class: "sh-keyword" }); + }); + + it("returns empty for empty text", () => { + expect(highlightLine("", 0, "js")).toHaveLength(0); + }); + + it("works without a language specifier", () => { + const decos = highlightLine("const x = 1;", 0, undefined); + expect(decos.length).toBeGreaterThan(0); + }); +}); + +describe("getLanguageOptions", () => { + it("returns a preset for known languages", () => { + expect(getLanguageOptions("python")).toBeDefined(); + expect(getLanguageOptions("py")).toBeDefined(); + expect(getLanguageOptions("rust")).toBeDefined(); + expect(getLanguageOptions("go")).toBeDefined(); + expect(getLanguageOptions("css")).toBeDefined(); + expect(getLanguageOptions("java")).toBeDefined(); + expect(getLanguageOptions("c")).toBeDefined(); + }); + + it("is case-insensitive", () => { + expect(getLanguageOptions("Python")).toBeDefined(); + expect(getLanguageOptions("RUST")).toBeDefined(); + }); + + it("returns undefined for unknown languages", () => { + expect(getLanguageOptions("brainfuck")).toBeUndefined(); + }); + + it("returns undefined for no language", () => { + expect(getLanguageOptions(undefined)).toBeUndefined(); + }); });