mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
feat(markdown): add syntax colors to rendered code blocks
The block follows the theme now, but every token in it is one color. Add syntax highlighting, using the palette from the approved design. Render: add rehype-highlight and pass it to react-markdown. The plugin array sits at module scope, because react-markdown remounts the rendered tree when the array identity changes. That is the same hazard the remarkPlugins memo already guards against. Detection: `detect` is on, because most agent-authored fences carry no language tag and would never highlight without it. `subset` restricts detection to the languages agents actually post. highlight.js guesses badly on short snippets, and a wrong guess colors the whole block wrongly. Palette: eight `--code-syntax-*` aliases carry the roles. Each one points at a token that already exists and already has a `.dark` override, so a custom property resolves per mode at its use site and one definition covers both themes. The mapping is BORROWED, and this is the part to review. Paperclip has no syntax palette. `--status-task-icon-*` and `--chip-match-title-fg` name task-status and search-chip concerns. They were used because they are the only hues that carry a real per-mode value; the fixed `--status-task-*` hues go dim on the dark code surface. The coupling is confined to the alias block. Repointing those eight lines moves the whole palette, and no rule changes. A test asserts that the syntax rules never reference the borrowed tokens directly. Verified in both themes on a real task. Light resolves keyword #7c3aed, identifier oklch(0.145 0 0), function oklch(0.4 0.13 265), comment oklch(0.556 0 0). Dark resolves #9474f0, oklch(0.985 0 0), oklch(0.78 0.1 265), oklch(0.708 0 0). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8e927e6a1a
commit
bc1c6b7ddc
6 files changed
+242
-2
No files matched your search
Generated
+56
-2
@@ -1005,6 +1005,9 @@ importers:
|
||||
react-router-dom:
|
||||
specifier: ^7.18.1
|
||||
version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
rehype-highlight:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
remark-gfm:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
@@ -1862,11 +1865,11 @@ packages:
|
||||
|
||||
'@esbuild-kit/core-utils@3.3.2':
|
||||
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
|
||||
deprecated: 'Merged into tsx: https://tsx.hirok.io'
|
||||
deprecated: 'Merged into tsx: https://tsx.is'
|
||||
|
||||
'@esbuild-kit/esm-loader@2.6.5':
|
||||
resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
|
||||
deprecated: 'Merged into tsx: https://tsx.hirok.io'
|
||||
deprecated: 'Merged into tsx: https://tsx.is'
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
||||
@@ -6596,18 +6599,28 @@ packages:
|
||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hast-util-is-element@3.0.0:
|
||||
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
|
||||
|
||||
hast-util-to-jsx-runtime@2.3.6:
|
||||
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
|
||||
|
||||
hast-util-to-text@4.0.2:
|
||||
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
|
||||
|
||||
hast-util-whitespace@3.0.0:
|
||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||
|
||||
help-me@5.0.0:
|
||||
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
||||
|
||||
highlight.js@11.11.2:
|
||||
resolution: {integrity: sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
hono@4.13.2:
|
||||
resolution: {integrity: sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
@@ -6966,6 +6979,9 @@ packages:
|
||||
loupe@3.2.1:
|
||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||
|
||||
lowlight@3.3.0:
|
||||
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
|
||||
|
||||
lru-cache@10.4.3:
|
||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||
|
||||
@@ -7817,6 +7833,9 @@ packages:
|
||||
regex@6.1.0:
|
||||
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
|
||||
|
||||
rehype-highlight@7.0.2:
|
||||
resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==}
|
||||
|
||||
remark-gfm@4.0.1:
|
||||
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
|
||||
|
||||
@@ -8323,6 +8342,9 @@ packages:
|
||||
unique-slug@2.0.2:
|
||||
resolution: {integrity: sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==}
|
||||
|
||||
unist-util-find-after@5.0.0:
|
||||
resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
|
||||
|
||||
unist-util-is@6.0.1:
|
||||
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
|
||||
|
||||
@@ -14408,6 +14430,10 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hast-util-is-element@3.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -14442,12 +14468,21 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
hast-util-to-text@4.0.2:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/unist': 3.0.3
|
||||
hast-util-is-element: 3.0.0
|
||||
unist-util-find-after: 5.0.0
|
||||
|
||||
hast-util-whitespace@3.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
|
||||
help-me@5.0.0: {}
|
||||
|
||||
highlight.js@11.11.2: {}
|
||||
|
||||
hono@4.13.2: {}
|
||||
|
||||
html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0):
|
||||
@@ -14770,6 +14805,12 @@ snapshots:
|
||||
|
||||
loupe@3.2.1: {}
|
||||
|
||||
lowlight@3.3.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
devlop: 1.1.0
|
||||
highlight.js: 11.11.2
|
||||
|
||||
lru-cache@10.4.3: {}
|
||||
|
||||
lru-cache@11.2.7: {}
|
||||
@@ -16082,6 +16123,14 @@ snapshots:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
rehype-highlight@7.0.2:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-text: 4.0.2
|
||||
lowlight: 3.3.0
|
||||
unist-util-visit: 5.1.0
|
||||
vfile: 6.0.3
|
||||
|
||||
remark-gfm@4.0.1:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -16744,6 +16793,11 @@ snapshots:
|
||||
imurmurhash: 0.1.4
|
||||
optional: true
|
||||
|
||||
unist-util-find-after@5.0.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
unist-util-is: 6.0.1
|
||||
|
||||
unist-util-is@6.0.1:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.12.2",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
|
||||
@@ -668,3 +668,46 @@ describe("MarkdownBody", () => {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("code block syntax highlighting", () => {
|
||||
it("tokenizes a fenced block that declares its language", () => {
|
||||
const html = renderMarkdown(['```typescript', 'const status = "blocked";', '```'].join("\n"));
|
||||
|
||||
// rehype-highlight marks the code element and wraps each token.
|
||||
expect(html).toContain("hljs");
|
||||
expect(html).toContain("hljs-keyword");
|
||||
expect(html).toContain("hljs-string");
|
||||
});
|
||||
|
||||
it("tokenizes an untagged fence, because most agent-authored fences carry no language", () => {
|
||||
const html = renderMarkdown(
|
||||
['```', 'export function run() {', ' return "ok";', '}', '```'].join("\n"),
|
||||
);
|
||||
|
||||
expect(html).toContain("hljs-");
|
||||
});
|
||||
|
||||
it("marks added and removed lines in a diff fence", () => {
|
||||
const html = renderMarkdown(
|
||||
['```diff', '- const a = 1;', '+ const a = 2;', '```'].join("\n"),
|
||||
);
|
||||
|
||||
expect(html).toContain("hljs-deletion");
|
||||
expect(html).toContain("hljs-addition");
|
||||
});
|
||||
|
||||
it("does not throw on a fence whose language is unknown", () => {
|
||||
expect(() =>
|
||||
renderMarkdown(['```notalanguage', 'some text', '```'].join("\n")),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("leaves inline code untokenized", () => {
|
||||
// rehype-highlight only visits `pre > code`. Inline code keeps the
|
||||
// chip treatment and must not pick up a syntax colour.
|
||||
const html = renderMarkdown("Set `status` to blocked.");
|
||||
|
||||
expect(html).toContain(">status</code>");
|
||||
expect(html).not.toContain("hljs");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, Copy, ExternalLink, Github, WrapText } from "lucide-react";
|
||||
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Link, useCaseHref } from "@/lib/router";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
@@ -222,6 +223,37 @@ const scrollableBlockStyle: React.CSSProperties = {
|
||||
overflowX: "auto",
|
||||
};
|
||||
|
||||
/* Syntax highlighting for fenced code blocks.
|
||||
|
||||
`subset` is deliberate. highlight.js auto-detection is unreliable on the
|
||||
short snippets that appear in task chat, and a wrong guess colours the
|
||||
whole block wrongly. Restricting detection to the languages agents
|
||||
actually post makes the guess reliable. `detect` stays on because most
|
||||
agent-authored fences carry no language tag, and without it those blocks
|
||||
would never highlight.
|
||||
|
||||
Defined at module scope so the array identity is stable. react-markdown
|
||||
remounts the rendered tree when its plugin array changes identity, which
|
||||
discards scroll position and selection — the same hazard the
|
||||
`remarkPlugins` memo below guards against. */
|
||||
const HIGHLIGHT_LANGUAGE_SUBSET = [
|
||||
"bash",
|
||||
"diff",
|
||||
"json",
|
||||
"typescript",
|
||||
"javascript",
|
||||
"yaml",
|
||||
"sql",
|
||||
"python",
|
||||
"css",
|
||||
"xml",
|
||||
"markdown",
|
||||
];
|
||||
|
||||
const rehypePlugins: NonNullable<Options["rehypePlugins"]> = [
|
||||
[rehypeHighlight, { detect: true, subset: HIGHLIGHT_LANGUAGE_SUBSET, ignoreMissing: true }],
|
||||
];
|
||||
|
||||
const codeBlockActionsStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: "0.4rem",
|
||||
@@ -942,6 +974,7 @@ function MarkdownBodyImpl({
|
||||
>
|
||||
<Markdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
urlTransform={safeMarkdownUrlTransform}
|
||||
>
|
||||
|
||||
@@ -68,3 +68,45 @@ describe("markdown code block styles", () => {
|
||||
expect(codeBlockActionStyle.backgroundColor).not.toContain("--muted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("code syntax palette", () => {
|
||||
const aliases = [
|
||||
"--code-syntax-keyword",
|
||||
"--code-syntax-string",
|
||||
"--code-syntax-number",
|
||||
"--code-syntax-identifier",
|
||||
"--code-syntax-punctuation",
|
||||
"--code-syntax-comment",
|
||||
"--code-syntax-added",
|
||||
"--code-syntax-removed",
|
||||
];
|
||||
|
||||
it("defines every syntax role as an alias of an existing token", () => {
|
||||
for (const alias of aliases) {
|
||||
const declaration = new RegExp(`${alias}:\\s*var\\(--[a-z0-9_-]+\\)`, "i");
|
||||
expect(stylesheet, `${alias} must be defined as var(--existing-token)`).toMatch(declaration);
|
||||
}
|
||||
});
|
||||
|
||||
it("holds the borrowed-token coupling in the alias layer only", () => {
|
||||
// The syntax rules must reference the aliases, never the borrowed tokens
|
||||
// directly. That keeps repointing to a real palette a one-block edit.
|
||||
const syntaxRules = stylesheet.slice(stylesheet.indexOf(".paperclip-markdown :is(pre, code)"));
|
||||
|
||||
expect(syntaxRules).not.toContain("--status-task-icon-");
|
||||
expect(syntaxRules).not.toContain("--chip-match-title-fg");
|
||||
});
|
||||
|
||||
it("maps the highlight.js token classes the renderer emits", () => {
|
||||
for (const cls of [
|
||||
".hljs-keyword",
|
||||
".hljs-string",
|
||||
".hljs-number",
|
||||
".hljs-comment",
|
||||
".hljs-addition",
|
||||
".hljs-deletion",
|
||||
]) {
|
||||
expect(stylesheet, `${cls} must be mapped`).toContain(cls);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1510,6 +1510,73 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
/* ── Code syntax roles ──
|
||||
Each alias points at a token that already exists and already carries a
|
||||
`.dark` override, so one definition covers both modes. A custom property
|
||||
resolves at its use site, so `.dark` swaps these automatically.
|
||||
|
||||
The mapping is BORROWED. `--status-task-icon-*` and `--chip-match-title-fg`
|
||||
name task-status and search-chip concerns, not syntax roles. Paperclip has
|
||||
no syntax palette, and these were the only hues carrying a real per-mode
|
||||
value: the fixed `--status-task-*` hues go dim on the dark code surface.
|
||||
The coupling is deliberate and confined to this one block. To mint a real
|
||||
palette later, repoint these eight aliases — no rule below changes. */
|
||||
:root {
|
||||
--code-syntax-keyword: var(--status-task-icon-in_review);
|
||||
--code-syntax-string: var(--status-task-icon-done);
|
||||
--code-syntax-number: var(--chip-match-title-fg);
|
||||
--code-syntax-identifier: var(--foreground);
|
||||
--code-syntax-punctuation: var(--muted-foreground);
|
||||
--code-syntax-comment: var(--muted-foreground);
|
||||
--code-syntax-added: var(--status-task-icon-done);
|
||||
--code-syntax-removed: var(--destructive);
|
||||
}
|
||||
|
||||
/* highlight.js token classes, emitted by rehype-highlight. No highlight.js
|
||||
stylesheet is imported, so these rules are the only source of colour. */
|
||||
.paperclip-markdown :is(pre, code) :is(.hljs-keyword, .hljs-built_in, .hljs-literal, .hljs-type, .hljs-meta, .hljs-selector-tag) {
|
||||
color: var(--code-syntax-keyword);
|
||||
}
|
||||
|
||||
.paperclip-markdown :is(pre, code) :is(.hljs-string, .hljs-regexp, .hljs-symbol, .hljs-char, .hljs-quote) {
|
||||
color: var(--code-syntax-string);
|
||||
}
|
||||
|
||||
.paperclip-markdown :is(pre, code) :is(.hljs-number, .hljs-title, .hljs-name, .hljs-section, .hljs-link, .hljs-attr, .hljs-attribute, .hljs-selector-id, .hljs-selector-class) {
|
||||
color: var(--code-syntax-number);
|
||||
}
|
||||
|
||||
.paperclip-markdown :is(pre, code) :is(.hljs-variable, .hljs-params, .hljs-property, .hljs-subst, .hljs-template-variable) {
|
||||
color: var(--code-syntax-identifier);
|
||||
}
|
||||
|
||||
.paperclip-markdown :is(pre, code) :is(.hljs-operator, .hljs-punctuation, .hljs-tag, .hljs-bullet) {
|
||||
color: var(--code-syntax-punctuation);
|
||||
}
|
||||
|
||||
.paperclip-markdown :is(pre, code) :is(.hljs-comment, .hljs-doctag) {
|
||||
color: var(--code-syntax-comment);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ```diff fences. The row tinting from the design is not implemented, so
|
||||
the +/- lines are carried by colour alone here. */
|
||||
.paperclip-markdown :is(pre, code) .hljs-addition {
|
||||
color: var(--code-syntax-added);
|
||||
}
|
||||
|
||||
.paperclip-markdown :is(pre, code) .hljs-deletion {
|
||||
color: var(--code-syntax-removed);
|
||||
}
|
||||
|
||||
.paperclip-markdown :is(pre, code) :is(.hljs-emphasis) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.paperclip-markdown :is(pre, code) :is(.hljs-strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.paperclip-markdown pre code {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
|
||||
Reference in new issue
Block a user