diff --git a/app/app.css b/app/app.css index 8c5d1220..1f5921cd 100644 --- a/app/app.css +++ b/app/app.css @@ -86,6 +86,32 @@ 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; +} + +/* 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; } @@ -333,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) { @@ -348,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 8e2bbbd6..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"; @@ -84,6 +86,174 @@ 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; +} + +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", + }), + ); + } + + // 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( + Decoration.node(innerPos, innerPos + innerNode.nodeSize, { + 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 + 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 +401,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/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 1ebe01ce..178c94aa 100644 --- a/tests/unit/lib/markdown-decorations.test.ts +++ b/tests/unit/lib/markdown-decorations.test.ts @@ -2,6 +2,10 @@ import { describe, it, expect } from "vitest"; import { MARKDOWN_PATTERNS, findDecorations, + findCodeBlockDecorations, + CODE_FENCE_REGEX, + highlightLine, + getLanguageOptions, type MarkdownPattern, } from "~/lib/markdown-decorations"; @@ -269,3 +273,191 @@ 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); + }); + + 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(); + }); +});