diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3e59d723a..b28a34f6a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,6 +65,8 @@ jobs: packages/review-editor/hooks/useReviewSearch.test.tsx packages/ui/components/AnnotationPanel.props.test.tsx packages/ui/components/Viewer.consumer.test.tsx + packages/ui/components/Viewer.codeBlockHighlightSwap.test.tsx + packages/ui/utils/codeBlockMark.test.ts packages/ui/components/InlineMarkdown.seam.test.tsx packages/ui/components/ImageThumbnail.seam.test.tsx packages/ui/hooks/useAnnotationHighlighter.test.tsx @@ -102,6 +104,13 @@ jobs: - name: Build OpenCode plugin assets run: bun run build:review && bun run build:hook && bun run build:opencode + # This is the only job with the single-file bundles on disk, so it is the + # only place the built-artifact assertions in entry-assets can actually + # run (they skip on an unbuilt checkout). Chief among them: no inlined + # WebAssembly survived the bundle. + - name: Assert built bundles ship no inlined WASM + run: bun test tests/entry-assets.test.ts + - name: Pack OpenCode plugin working-directory: apps/opencode-plugin run: npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP" diff --git a/AGENTS.md b/AGENTS.md index ea4e1dc20..17e3d7140 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -609,7 +609,20 @@ Uses cookies (not localStorage) because each hook invocation runs on a random po ## Syntax Highlighting -Code blocks use bundled `highlight.js`. Language is extracted from fence (```rust) and applied as `language-{lang}`class. Each block highlighted individually via`hljs.highlightElement()`. +There is **one** highlighter in the app: the Shiki instance `@pierre/diffs` already runs for the code-review diff pane, driven by Shiki's **JavaScript regex engine** (`preferredHighlighter: 'shiki-js'`). `highlight.js` is gone. The wrapper is `packages/ui/utils/codeHighlight.ts`: + +- `applyHighlight(el, code, lang, theme)` — imperative drop-in for the old `hljs.highlightElement(el)`. Writes plain text immediately (final size on first paint, no layout shift), then swaps in highlighted markup once the grammar is attached; already-attached grammars highlight synchronously, so there is no flicker on cached highlights. It also enforces that the rendered text is byte-identical to the source and falls back to plain text otherwise, because the annotation layer addresses code blocks by text offset. +- `highlightToHtml(code, lang, theme)` / `ensureHighlight(lang, theme)` — the sync/async pair behind it, for callers that need HTML strings (the code-file hover preview). +- `codeBlockClassName(lang)` — the `pn-code font-mono language-{lang}` class every fenced `` carries. **`pn-code` replaced the old `hljs` class** and is the structural hook `blockTargeting`, vim navigation and `print.css` use (`pre > code.pn-code`); `language-*` is how `blockTargeting` reads a block's language back out of the DOM. +- `onCodeHighlightSwap(listener)` — observes every write `applyHighlight` makes, SYNCHRONOUSLY, immediately after it. Each write replaces the element's children, so it also destroys whatever the annotation layer wrapped inside the fence. + +**Code-block annotation marks and highlight swaps.** `web-highlighter` cannot select inside a `
`, so a fenced block is annotated all-or-nothing: one `` that is the `` element's only child, painted by `paintCodeBlockMark` (`packages/ui/utils/codeBlockMark.ts`) — which MOVES the token spans into the mark rather than flattening them to text, so annotating or re-theming a block never costs it its colours. `Viewer` subscribes to `onCodeHighlightSwap` and re-paints that mark right after any swap, which is what keeps a palette or dark/light change from wiping code-block annotations. Being driven by the swap is also what makes the share/draft restore race safe **by ordering rather than by timing**: a restore that painted before the swap is re-established in the same task the swap ran in, and one that runs after finds the mark already there. Do not "fix" a mark-eating swap by skipping the rewrite when a mark is present — that leaves annotated blocks in stale theme colours.
+
+**Language-less fences render as plain text and are never guessed at (#1212). There is no auto-detection anywhere.** `HighlightedCode` (review suggestions) derives its language from the caller's file path via `detectLanguage`; an unrecognised extension renders plain.
+
+**Theming:** fences resolve the SAME theme the diff pane resolves, via `resolveFenceTheme` / `resolveSyntaxTheme` in `packages/ui/utils/syntaxTheme.ts` (keyed on `(colorTheme, resolvedMode)`; `packages/review-editor/hooks/usePierreTheme.ts` re-exports them). `useFenceTheme()` (`packages/ui/hooks/useFenceTheme.ts`) feeds the components and re-highlights on palette or mode change. Palettes with no Shiki counterpart fall back to `@pierre/diffs`' own `pierre-dark` / `pierre-light`. Consequence: code blocks follow the active palette in both light and dark instead of always rendering github-dark, so **do not add per-theme `.hljs-*`-style token CSS** — pick the right Shiki theme in `SHIKI_THEME_MAP` instead.
+
+**Bundle note:** Pierre imports Shiki's full bundle, so every grammar and theme is already inlined in the single-file builds; reusing its shared highlighter costs no extra bytes and needs no CDN or runtime wasm fetch. The Oniguruma WASM engine is dead weight under `shiki-js` and is aliased to `build/shiki-wasm-stub.ts` in the review, hook and portal Vite configs (via `resolve.alias`, which — unlike `plugins` — is shared with Vite's worker build).
 
 ## Requirements
 
diff --git a/apps/hook/vite.config.ts b/apps/hook/vite.config.ts
index 6b5a0d5e2..a44c1c962 100644
--- a/apps/hook/vite.config.ts
+++ b/apps/hook/vite.config.ts
@@ -18,6 +18,10 @@ export default defineConfig({
   resolve: {
     dedupe: ['react', 'react-dom'],
     alias: {
+      // Drop the dead Oniguruma WASM (~622 KB base64). The plan editor reaches
+      // Pierre's shared highlighter through CodeFilePopout and the fence
+      // highlighter. See build/shiki-wasm-stub.ts.
+      'shiki/wasm': path.resolve(__dirname, '../../build/shiki-wasm-stub.ts'),
       '@': path.resolve(__dirname, '.'),
       '@plannotator/shared': path.resolve(__dirname, '../../packages/shared'),
       '@plannotator/ui': path.resolve(__dirname, '../../packages/ui'),
diff --git a/apps/portal/vite.config.ts b/apps/portal/vite.config.ts
index e3c372158..cc42a3a92 100644
--- a/apps/portal/vite.config.ts
+++ b/apps/portal/vite.config.ts
@@ -39,6 +39,9 @@ export default defineConfig({
   plugins: [faviconPlugin(), react(), tailwindcss()],
   resolve: {
     alias: {
+      // Drop the dead Oniguruma WASM (~622 KB base64). See
+      // build/shiki-wasm-stub.ts.
+      'shiki/wasm': path.resolve(__dirname, '../../build/shiki-wasm-stub.ts'),
       '@': path.resolve(__dirname, '.'),
       '@plannotator/ui': path.resolve(__dirname, '../../packages/ui'),
       '@plannotator/editor/styles': path.resolve(__dirname, '../../packages/editor/index.css'),
diff --git a/apps/review/vite.config.ts b/apps/review/vite.config.ts
index 440f44eba..8efdd272b 100644
--- a/apps/review/vite.config.ts
+++ b/apps/review/vite.config.ts
@@ -33,6 +33,10 @@ export default defineConfig({
   plugins: [demoFileContentPlugin(), react(), tailwindcss(), viteSingleFile()],
   resolve: {
     alias: {
+      // Drop the dead Oniguruma WASM (~622 KB base64, inlined twice here: main
+      // thread + worker). See build/shiki-wasm-stub.ts. `resolve.alias` is
+      // shared with the worker build below; `plugins` would not be.
+      'shiki/wasm': path.resolve(__dirname, '../../build/shiki-wasm-stub.ts'),
       '@': path.resolve(__dirname, '.'),
       '@plannotator/shared': path.resolve(__dirname, '../../packages/shared'),
       '@plannotator/ui': path.resolve(__dirname, '../../packages/ui'),
@@ -42,9 +46,10 @@ export default defineConfig({
     }
   },
   // The Pierre highlight worker (?worker&inline) contains a dynamic
-  // import("shiki/wasm") branch; iife (Vite's default worker format) can't
-  // code-split, so emit the worker as ES with dynamic imports collapsed into
-  // the single inlined bundle.
+  // import("shiki/wasm") branch (aliased to a stub above, but still a dynamic
+  // import edge); iife (Vite's default worker format) can't code-split, so
+  // emit the worker as ES with dynamic imports collapsed into the single
+  // inlined bundle.
   worker: {
     format: 'es',
     rollupOptions: {
diff --git a/build/shiki-wasm-stub.ts b/build/shiki-wasm-stub.ts
new file mode 100644
index 000000000..17706344f
--- /dev/null
+++ b/build/shiki-wasm-stub.ts
@@ -0,0 +1,38 @@
+/**
+ * Build-time stub for `shiki/wasm`.
+ *
+ * `@pierre/diffs` picks its Shiki engine at RUNTIME:
+ *
+ *     engine: preferredHighlighter === "shiki-wasm"
+ *       ? createOnigurumaEngine(import("shiki/wasm"))
+ *       : createJavaScriptRegexEngine()
+ *
+ * (`dist/highlighter/shared_highlighter.js` on the main thread and
+ * `dist/worker/worker.js` inside the inlined worker). Plannotator pins
+ * `preferredHighlighter: 'shiki-js'` everywhere — see
+ * `packages/review-editor/workerPool.tsx` — and Pierre's own default is
+ * `'shiki-js'`, so the Oniguruma branch never executes. But because the choice
+ * is a runtime ternary, the bundler keeps the `import("shiki/wasm")` edge and
+ * inlines `@shikijs/engine-oniguruma/wasm-inlined` — a ~622 KB base64 blob —
+ * into every single-file HTML build (twice in the review app: once on the main
+ * thread, once in the inlined worker).
+ *
+ * Aliasing `shiki/wasm` to this module drops that payload. The JS regex engine
+ * and the WASM engine were verified to produce identical tokens, so nothing
+ * about the rendered output changes; the only thing that changes is that
+ * opting into `'shiki-wasm'` now fails loudly instead of silently costing every
+ * user a megabyte of dead bytes.
+ *
+ * Wired through `resolve.alias` (NOT a plugin) on purpose: `resolve.alias` is
+ * shared with Vite's worker build, `plugins` are not.
+ */
+
+function unavailable(): never {
+  throw new Error(
+    "shiki/wasm is not bundled by Plannotator: the Oniguruma engine is stubbed out " +
+      "in favour of Shiki's JavaScript regex engine (preferredHighlighter: 'shiki-js'). " +
+      'Remove the `shiki/wasm` alias in the app vite config to re-enable it.',
+  );
+}
+
+export default unavailable;
diff --git a/bun.lock b/bun.lock
index a9bce8c66..271e2c146 100644
--- a/bun.lock
+++ b/bun.lock
@@ -63,7 +63,7 @@
     },
     "apps/opencode-plugin": {
       "name": "@plannotator/opencode",
-      "version": "0.25.1",
+      "version": "0.26.1",
       "devDependencies": {
         "@opencode-ai/plugin": "0.0.0-next-16775",
         "@plannotator/server": "workspace:*",
@@ -80,7 +80,7 @@
     },
     "apps/pi-extension": {
       "name": "@plannotator/pi-extension",
-      "version": "0.25.1",
+      "version": "0.26.1",
       "dependencies": {
         "@joplin/turndown-plugin-gfm": "^1.0.64",
         "@pierre/diffs": "1.3.2",
@@ -203,7 +203,6 @@
         "@pierre/diffs": "1.3.2",
         "@plannotator/shared": "workspace:*",
         "@plannotator/ui": "workspace:*",
-        "highlight.js": "^11.11.1",
         "lucide-react": "^1.14.0",
         "marked": "^17.0.6",
         "motion": "^12.38.0",
@@ -219,7 +218,7 @@
     },
     "packages/server": {
       "name": "@plannotator/server",
-      "version": "0.25.1",
+      "version": "0.26.1",
       "dependencies": {
         "@pierre/diffs": "1.3.2",
         "@plannotator/ai": "workspace:*",
@@ -278,7 +277,6 @@
         "clsx": "^2.1.1",
         "diff": "^8.0.4",
         "dompurify": "^3.3.3",
-        "highlight.js": "^11.11.1",
         "katex": "^0.16.47",
         "lucide-react": "^1.14.0",
         "marked": "^17.0.6",
@@ -1716,7 +1714,7 @@
 
     "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
 
-    "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
+    "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
 
     "hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="],
 
@@ -2642,8 +2640,6 @@
 
     "@earendil-works/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="],
 
-    "@earendil-works/pi-coding-agent/highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
-
     "@earendil-works/pi-tui/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
 
     "@google/genai/google-auth-library": ["google-auth-library@10.7.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ=="],
diff --git a/packages/editor/index.css b/packages/editor/index.css
index 33e8eb81b..c53a9d518 100644
--- a/packages/editor/index.css
+++ b/packages/editor/index.css
@@ -40,8 +40,14 @@
   width: 0 !important;
 }
 
-/* Code blocks */
-pre code.hljs {
+/* Code blocks.
+ *
+ * Token colours come from the active Shiki theme as inline styles (the same
+ * theme the code-review diff pane resolves), so this rule only owns layout and
+ * the block background. The old `.hljs-*` override stacks that existed to make
+ * highlight.js's hardcoded github-dark palette survive light mode are gone with
+ * it: a light palette now resolves a light Shiki theme. */
+pre code.pn-code {
   display: block;
   padding: 1rem;
   border-radius: var(--radius);
@@ -50,71 +56,13 @@ pre code.hljs {
   line-height: 1.6;
 }
 
-/* Fix: hljs markdown grammar applies github-dark token colors that are unreadable
-   in light mode. Emphasis/strong/code tokens use #c9d1d9 or #8b949e which wash out
-   against light backgrounds. Force them to inherit the base code color. */
-pre code.hljs .hljs-emphasis {
-  color: inherit !important;
-  font-style: normal !important;
-}
-pre code.hljs .hljs-strong {
-  color: inherit !important;
-}
-pre code.hljs .hljs-code {
-  color: inherit !important;
-}
-
-/* Light mode code - override hljs dark theme */
-.light pre code.hljs {
-  color: oklch(0.25 0.02 260) !important;
-}
-
-/* The code-file hover preview is a 
, so the pre code.hljs - rule above misses it. The unscoped .hljs-* token rules below already color - its tokens; this just supplies the matching default text color. */ +/* The code-file hover preview is not a
, so the rule above misses
+   it. Its tokens carry their own theme colours; this supplies the default text
+   colour for the parts that have none. */
 .light .code-snippet-preview {
   color: oklch(0.25 0.02 260) !important;
 }
 
-.light .hljs-keyword,
-.light .hljs-selector-tag,
-.light .hljs-built_in,
-.light .hljs-name,
-.light .hljs-tag {
-  color: oklch(0.45 0.25 280) !important;
-}
-
-.light .hljs-string,
-.light .hljs-title,
-.light .hljs-section,
-.light .hljs-attribute,
-.light .hljs-literal,
-.light .hljs-template-tag,
-.light .hljs-template-variable,
-.light .hljs-type {
-  color: oklch(0.45 0.18 150) !important;
-}
-
-.light .hljs-comment,
-.light .hljs-quote {
-  color: oklch(0.55 0.02 260) !important;
-  font-style: italic;
-}
-
-.light .hljs-number,
-.light .hljs-symbol,
-.light .hljs-bullet {
-  color: oklch(0.50 0.20 50) !important;
-}
-
-.light .hljs-attr,
-.light .hljs-variable,
-.light .hljs-template-variable,
-.light .hljs-class .hljs-title,
-.light .hljs-function {
-  color: oklch(0.45 0.20 280) !important;
-}
-
 /* Annotation highlights moved to packages/ui/theme.css (shared with the
    code-review description annotations). */
 
diff --git a/packages/review-editor/components/HighlightedCode.tsx b/packages/review-editor/components/HighlightedCode.tsx
index 28c5b51b2..e400b7bd3 100644
--- a/packages/review-editor/components/HighlightedCode.tsx
+++ b/packages/review-editor/components/HighlightedCode.tsx
@@ -1,19 +1,25 @@
 import React, { useRef, useEffect } from 'react';
-import hljs from 'highlight.js';
-import 'highlight.js/styles/github-dark.css';
+import { applyHighlight } from '@plannotator/ui/utils/codeHighlight';
+import { useFenceTheme } from '@plannotator/ui/hooks/useFenceTheme';
 
-/** Renders a single highlighted code element using highlight.js */
+/**
+ * A single highlighted code element, rendered by the same Shiki instance and in
+ * the same resolved theme as the diff pane next to it.
+ *
+ * `language` comes from the caller's file path (`detectLanguage`) — there is no
+ * auto-detection, so a snippet whose file type we do not recognise renders as
+ * plain text rather than being guessed at.
+ */
 export const HighlightedCode: React.FC<{ code: string; language?: string }> = ({ code, language }) => {
   const codeRef = useRef(null);
+  const fenceTheme = useFenceTheme();
 
   useEffect(() => {
     if (codeRef.current) {
-      codeRef.current.removeAttribute('data-highlighted');
       codeRef.current.className = language ? `language-${language}` : '';
-      codeRef.current.textContent = code;
-      hljs.highlightElement(codeRef.current);
+      applyHighlight(codeRef.current, code, language, fenceTheme);
     }
-  }, [code, language]);
+  }, [code, language, fenceTheme]);
 
   return {code};
 };
diff --git a/packages/review-editor/hooks/usePierreTheme.ts b/packages/review-editor/hooks/usePierreTheme.ts
index 8bf6743d4..8d9d37f26 100644
--- a/packages/review-editor/hooks/usePierreTheme.ts
+++ b/packages/review-editor/hooks/usePierreTheme.ts
@@ -3,50 +3,14 @@ import type { DiffLineBgIntensity } from '@plannotator/shared/config';
 import { useTheme } from '@plannotator/ui/components/ThemeProvider';
 import { useConfigValue } from '@plannotator/ui/config';
 
-export const SHIKI_THEME_MAP: Record = {
-  'andromeeda': { dark: 'andromeeda', light: null },
-  'aurora-x': { dark: 'aurora-x', light: null },
-  'ayu-dark': { dark: 'ayu-dark', light: null },
-  'catppuccin': { dark: 'catppuccin-mocha', light: 'catppuccin-latte' },
-  'colorblind': { dark: 'pierre-dark-protanopia-deuteranopia', light: 'pierre-light-protanopia-deuteranopia' },
-  'dark-plus': { dark: 'dark-plus', light: 'light-plus' },
-  'dracula': { dark: 'dracula', light: null },
-  'everforest': { dark: 'everforest-dark', light: 'everforest-light' },
-  'everforest-hard': { dark: 'everforest-dark', light: 'everforest-light' },
-  'everforest-soft': { dark: 'everforest-dark', light: 'everforest-light' },
-  'github': { dark: 'github-dark', light: 'github-light' },
-  'gruvbox': { dark: 'gruvbox-dark-medium', light: 'gruvbox-light-medium' },
-  'houston': { dark: 'houston', light: null },
-  'kanagawa-dragon': { dark: 'kanagawa-dragon', light: null },
-  'kanagawa-lotus': { dark: null, light: 'kanagawa-lotus' },
-  'kanagawa-wave': { dark: 'kanagawa-wave', light: null },
-  'laserwave': { dark: 'laserwave', light: null },
-  'material': { dark: 'material-theme', light: 'material-theme-lighter' },
-  'min': { dark: 'min-dark', light: 'min-light' },
-  'monokai-pro': { dark: 'monokai', light: null },
-  'night-owl': { dark: 'night-owl', light: null },
-  'nord': { dark: 'nord', light: null },
-  'one-dark-pro': { dark: 'one-dark-pro', light: null },
-  'one-light': { dark: null, light: 'one-light' },
-  'plastic': { dark: 'plastic', light: null },
-  'poimandres': { dark: 'poimandres', light: null },
-  'red': { dark: 'red', light: null },
-  'rose-pine': { dark: 'rose-pine', light: 'rose-pine-dawn' },
-  'slack': { dark: 'slack-dark', light: 'slack-ochin' },
-  'snazzy-light': { dark: null, light: 'snazzy-light' },
-  'solarized': { dark: 'solarized-dark', light: 'solarized-light' },
-  'synthwave-84': { dark: 'synthwave-84', light: null },
-  'tokyo-night': { dark: 'tokyo-night', light: null },
-  'vesper': { dark: 'vesper', light: null },
-  'vitesse': { dark: 'vitesse-dark', light: 'vitesse-light' },
-  'vitesse-black': { dark: 'vitesse-black', light: null },
-};
-
-export function resolveSyntaxTheme(colorTheme: string, mode: 'dark' | 'light'): { dark: string; light: string } | undefined {
-  const map = SHIKI_THEME_MAP[colorTheme];
-  if (!map || !map[mode]) return undefined;
-  return { dark: map.dark || 'pierre-dark', light: map.light || 'pierre-light' };
-}
+/**
+ * The (colorTheme, mode) -> Shiki theme mapping moved to
+ * `@plannotator/ui/utils/syntaxTheme` so the plan editor's markdown fences
+ * resolve the same theme this diff pane does. Re-exported here because it is
+ * the import path the review editor has always used.
+ */
+import { resolveSyntaxTheme } from '@plannotator/ui/utils/syntaxTheme';
+export { SHIKI_THEME_MAP, resolveSyntaxTheme } from '@plannotator/ui/utils/syntaxTheme';
 
 export interface PierreTheme {
   type: 'dark' | 'light';
diff --git a/packages/review-editor/index.css b/packages/review-editor/index.css
index f4e5cbb22..fe4a68193 100644
--- a/packages/review-editor/index.css
+++ b/packages/review-editor/index.css
@@ -1530,9 +1530,9 @@ diffs-container {
   animation: overlay-fade-in 0.15s ease-out both;
 }
 
-/* Code navigation peek panel — strip hljs hardcoded background */
+/* Code navigation peek panel — strip the syntax theme's block background */
 .code-nav-peek code,
-.code-nav-peek .hljs {
+.code-nav-peek .pn-code {
   background: transparent !important;
 }
 
@@ -1617,7 +1617,7 @@ diffs-container {
    tighten the box. `.my-5` is unique to the code-block wrapper in this renderer. */
 .md-compact .my-5 { margin-top: 0.625rem; margin-bottom: 0.625rem; } /* was 20px */
 .md-compact pre { font-size: 0.75rem; }                              /* 12px, under the body */
-.md-compact pre code.hljs { padding: 0.5rem 0.625rem; }              /* tighter than hljs default */
+.md-compact pre code.pn-code { padding: 0.5rem 0.625rem; }           /* tighter than the 1rem default */
 
 /* PR description + comment media: cap wide screenshots/videos to the card width
    (GitHub embeds them at their natural size, e.g. width="1440"), and wrap long
diff --git a/packages/review-editor/package.json b/packages/review-editor/package.json
index daf4e43d5..279a6e494 100644
--- a/packages/review-editor/package.json
+++ b/packages/review-editor/package.json
@@ -15,7 +15,6 @@
     "@pierre/diffs": "1.3.2",
     "@plannotator/shared": "workspace:*",
     "@plannotator/ui": "workspace:*",
-    "highlight.js": "^11.11.1",
     "lucide-react": "^1.14.0",
     "marked": "^17.0.6",
     "motion": "^12.38.0",
diff --git a/packages/review-editor/utils/detectLanguage.ts b/packages/review-editor/utils/detectLanguage.ts
index a0ad37e90..699f4c70c 100644
--- a/packages/review-editor/utils/detectLanguage.ts
+++ b/packages/review-editor/utils/detectLanguage.ts
@@ -1,4 +1,4 @@
-/** Map file extension to highlight.js language name */
+/** Map file extension to a Shiki language name (undefined = render plain) */
 export function detectLanguage(filePath: string): string | undefined {
   const ext = filePath.split('.').pop()?.toLowerCase();
   const map: Record = {
diff --git a/packages/ui/components/GraphvizBlock.tsx b/packages/ui/components/GraphvizBlock.tsx
index b84de1202..1752fbb0a 100644
--- a/packages/ui/components/GraphvizBlock.tsx
+++ b/packages/ui/components/GraphvizBlock.tsx
@@ -458,7 +458,7 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
 
   const inlineSource = (
     
-      {block.content}
+      {block.content}
     
); diff --git a/packages/ui/components/InlineMarkdown.tsx b/packages/ui/components/InlineMarkdown.tsx index d6b2a3ffc..e6403f02c 100644 --- a/packages/ui/components/InlineMarkdown.tsx +++ b/packages/ui/components/InlineMarkdown.tsx @@ -1,7 +1,8 @@ import React, { useState, useRef, useCallback, useEffect, useMemo } from "react"; import { createPortal } from "react-dom"; -import hljs from "highlight.js"; import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/core/code-file"; +import { ensureHighlight, highlightToHtml } from "../utils/codeHighlight"; +import { useFenceTheme } from "../hooks/useFenceTheme"; import { transformPlainText } from "../utils/inlineTransforms"; import { getImageSrc } from "./ImageThumbnail"; import { useCodePathValidation, type CodePathValidationContextValue } from "./CodePathValidationContext"; @@ -93,18 +94,34 @@ const CodeSnippetPreview: React.FC<{ const end = Math.min(allLines.length, (lineEnd ?? line)); const snippet = allLines.slice(start, end).join('\n'); - const highlightedLines = useMemo(() => { - const lang = extToLanguage(filepath); - const lines = snippet.split('\n'); - return lines.map(line => { - try { - if (lang) return hljs.highlight(line, { language: lang }).value; - return hljs.highlightAuto(line).value; - } catch { - return line.replace(/&/g, '&').replace(//g, '>'); - } + const fenceTheme = useFenceTheme(); + // Bumped once the grammar is attached, to re-run the memo below with the + // highlighter warm. Until then the snippet renders as plain text — no + // auto-detection, and an unknown extension simply stays plain. + const [highlighterGeneration, setHighlighterGeneration] = useState(0); + const lang = extToLanguage(filepath); + + useEffect(() => { + if (!lang) return; + let cancelled = false; + void ensureHighlight(lang, fenceTheme).then((ok) => { + if (ok && !cancelled) setHighlighterGeneration((n) => n + 1); }); - }, [snippet, filepath]); + return () => { cancelled = true; }; + }, [lang, fenceTheme]); + + const highlightedLines = useMemo(() => { + // Highlight the snippet as one unit so multi-line constructs (block + // comments, template literals) tokenise correctly, then split back into + // rows: the highlighter joins lines with "\n" and never emits one inside a + // span, so the split is exact. + const html = lang ? highlightToHtml(snippet, lang, fenceTheme) : null; + if (html !== null) return html.split('\n'); + return snippet.split('\n').map(line => + line.replace(/&/g, '&').replace(//g, '>')); + // highlighterGeneration is the "grammar just became available" signal. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [snippet, lang, fenceTheme, highlighterGeneration]); if (!anchorEl) return null; @@ -126,7 +143,7 @@ const CodeSnippetPreview: React.FC<{ {filepath.split('/').pop()} {lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`}
-
+
{snippet.split('\n').map((_, i) => ( diff --git a/packages/ui/components/MermaidBlock.tsx b/packages/ui/components/MermaidBlock.tsx index f67e61d9d..a896dbf66 100644 --- a/packages/ui/components/MermaidBlock.tsx +++ b/packages/ui/components/MermaidBlock.tsx @@ -523,7 +523,7 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => { const inlineSource = (
-      {block.content}
+      {block.content}
     
); diff --git a/packages/ui/components/Viewer.codeBlockHighlightSwap.test.tsx b/packages/ui/components/Viewer.codeBlockHighlightSwap.test.tsx new file mode 100644 index 000000000..8c35b6c9b --- /dev/null +++ b/packages/ui/components/Viewer.codeBlockHighlightSwap.test.tsx @@ -0,0 +1,328 @@ +/** + * A code-block annotation mark must survive every syntax-highlight swap. + * + * Fenced code is annotated by hand — one `` inside the + * `` element — while `applyHighlight` owns that same element's children. + * Every swap (palette change, dark/light toggle, or the first async grammar + * attach after load) replaces those children, so without the swap listener in + * `Viewer` the mark is silently wiped and never comes back. + * + * Both tests assert the SAME pair of facts after the swap: the mark is still + * there, AND the tokens carry the new theme's colours. Getting one without the + * other is the bug in either direction. + * + * `@pierre/diffs` is stood in for through `__setCodeHighlightModuleForTests`, + * which keeps Shiki's full bundle out of the test and — more importantly — + * lets the second test decide EXACTLY when the async swap lands relative to the + * restore it races. That ordering is a released promise, never a sleep. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import React from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { act } from 'react'; + +import { AnnotationType, type Annotation, type Block } from '../types'; +import { + __resetCodeHighlightCacheForTests, + __setCodeHighlightModuleForTests, +} from '../utils/codeHighlight'; + +const hasDom = typeof document !== 'undefined'; + +// Viewer pulls in @plannotator/web-highlighter, whose UMD bundle reads `window` +// at module-eval time. Import lazily so this file loads under the DOM-less +// default `bun test` run. +const viewerMod = hasDom ? await import('./Viewer') : null; +const Viewer = viewerMod?.Viewer as typeof import('./Viewer')['Viewer']; +type ViewerHandle = import('./Viewer').ViewerHandle; +const themeMod = hasDom ? await import('./ThemeProvider') : null; +const ThemeProvider = themeMod?.ThemeProvider as typeof import('./ThemeProvider')['ThemeProvider']; +const useTheme = themeMod?.useTheme as typeof import('./ThemeProvider')['useTheme']; + +const CODE = 'const archived = true;'; +const codeBlocks: Block[] = [ + { id: 'code-1', type: 'code', content: CODE, language: 'typescript', order: 0, startLine: 1 }, +]; + +/** One distinctive colour per Shiki theme, so "did the tokens re-theme?" is a + * string match rather than a guess. */ +const TOKEN_COLOR: Record = { + 'github-dark': '#79c0ff', + 'github-light': '#0550ae', + 'kanagawa-wave': '#7e9cd8', +}; + +/** + * Stand-in for `@pierre/diffs`. `attach` decides when a (lang, theme) pair + * becomes available: `'immediate'` resolves on the microtask queue, `'gated'` + * hands back a release function so a test can hold the async swap open. + */ +function fakePierre(attach: 'immediate' | 'gated'): { + mod: typeof import('@pierre/diffs'); + release: () => void; +} { + const pending: Array<() => void> = []; + const mod = { + getSharedHighlighter: () => + attach === 'immediate' + ? Promise.resolve(undefined) + : new Promise((resolve) => pending.push(() => resolve(undefined))), + getHighlighterIfLoaded: () => ({ + codeToTokens: (code: string, { theme }: { theme: string }) => ({ + // One token per line reproduces the source byte-for-byte, which is what + // `highlightToHtml` insists on before it will emit markup at all. + tokens: code + .split('\n') + .map((line) => [{ content: line, color: TOKEN_COLOR[theme] ?? '#ffffff' }]), + }), + }), + }; + return { + mod: mod as unknown as typeof import('@pierre/diffs'), + release: () => { + const waiting = pending.splice(0); + waiting.forEach((resolve) => resolve()); + }, + }; +} + +/** A whole-fence annotation, the shape `applyCodeBlockAnnotation` produces. */ +function codeBlockAnnotation(id: string, type: AnnotationType): Annotation { + return { + id, + blockId: 'code-1', + startOffset: 0, + endOffset: CODE.length, + type, + originalText: CODE, + createdA: Date.now(), + }; +} + +let root: Root | null = null; +let host: HTMLElement | null = null; +let keySeq = 0; + +interface Controls { + setColorTheme: (theme: string) => void; + viewer: ViewerHandle | null; + removeAnnotation: (id: string) => void; +} +const controls: Controls = { + setColorTheme: () => {}, + viewer: null, + removeAnnotation: () => {}, +}; + +const Harness: React.FC<{ initial: Annotation[] }> = ({ initial }) => { + const [annotations, setAnnotations] = React.useState(initial); + const theme = useTheme(); + const viewerRef = React.useRef(null); + React.useEffect(() => { + controls.setColorTheme = theme.setColorTheme; + controls.viewer = viewerRef.current; + // Mirrors App's removeAnnotation: strip the highlight, then drop it from + // state. The two happen in that order, one tick apart. + controls.removeAnnotation = (id: string) => { + viewerRef.current?.removeHighlight(id); + setAnnotations((prev) => prev.filter((a) => a.id !== id)); + }; + }); + return ( + setAnnotations((prev) => [...prev, annotation])} + onSelectAnnotation={() => {}} + selectedAnnotationId={null} + mode="redline" + inputMethod="pinpoint" + taterMode={false} + disableCodePathValidation + /> + ); +}; + +async function mountHarness(initial: Annotation[] = []): Promise { + host = document.createElement('div'); + document.body.appendChild(host); + await act(async () => { + root = createRoot(host!); + root.render( + // A fresh storage key per mount: ThemeProvider persists the palette, and + // a leftover cookie would otherwise decide the starting theme. + + + , + ); + }); +} + +/** Let queued microtasks (the highlighter attach + its swap) run. */ +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function codeEl(): HTMLElement { + const el = document.querySelector('[data-block-id="code-1"] code'); + if (!el) throw new Error('fenced code block did not render'); + return el; +} + +function tokenColors(): string[] { + return Array.from(codeEl().querySelectorAll('span[style]')).map( + (span) => span.getAttribute('style') ?? '', + ); +} + +afterEach(async () => { + if (root) { + await act(async () => { + root!.unmount(); + }); + root = null; + } + host?.remove(); + host = null; + controls.viewer = null; + controls.setColorTheme = () => {}; + controls.removeAnnotation = () => {}; + if (hasDom) document.body.innerHTML = ''; + __resetCodeHighlightCacheForTests(); +}); + +describe('code-block annotations across highlight swaps', () => { + test.skipIf(!hasDom)('a palette change re-themes the tokens and keeps the mark', async () => { + const { mod } = fakePierre('immediate'); + __setCodeHighlightModuleForTests(mod); + + await mountHarness(); + await flush(); + + // Baseline: the fence is highlighted in the github-dark palette. + expect(tokenColors().join(' ')).toContain(TOKEN_COLOR['github-dark']); + + // Annotate the whole fence (pinpoint + redline is the code-block path). + const block = document.querySelector('[data-block-id="code-1"]')!; + await act(async () => { + block.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + block.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + const mark = codeEl().querySelector('mark[data-bind-id]'); + expect(mark).not.toBeNull(); + expect(mark!.textContent).toBe(CODE); + + // Switch the palette. This is the reported repro. + await act(async () => { + controls.setColorTheme('kanagawa-wave'); + }); + await flush(); + + const after = codeEl(); + // Both facts, together: the mark is still there... + const survivor = after.querySelector('mark[data-bind-id]'); + expect(survivor).not.toBeNull(); + expect(survivor!.textContent).toBe(CODE); + expect(after.textContent).toBe(CODE); + // ...and the tokens moved to the new theme. + const styles = tokenColors().join(' '); + expect(styles).toContain(TOKEN_COLOR['kanagawa-wave']); + expect(styles).not.toContain(TOKEN_COLOR['github-dark']); + }); + + test.skipIf(!hasDom)( + 'an async swap that lands after a share/draft restore does not wipe it', + async () => { + // The cousin bug: restore fires on a timer after load, and on a slow + // machine the FIRST async highlight swap can land after it. Held open + // explicitly here so the ordering is decided by the test, not by luck. + const { mod, release } = fakePierre('gated'); + __setCodeHighlightModuleForTests(mod); + + const restored: Annotation = { + id: 'codeblock-restored', + blockId: 'code-1', + startOffset: 0, + endOffset: CODE.length, + type: AnnotationType.DELETION, + originalText: CODE, + createdA: Date.now(), + }; + + await mountHarness([restored]); + await flush(); + + // The swap is still pending: the fence is plain and unmarked. + expect(tokenColors()).toEqual([]); + expect(codeEl().querySelector('mark[data-bind-id]')).toBeNull(); + + // Restore runs first — exactly what App does on a share/draft load. + await act(async () => { + controls.viewer?.applySharedAnnotations([restored]); + }); + expect(codeEl().querySelector(`[data-bind-id="${restored.id}"]`)).not.toBeNull(); + + // Now let the swap land on top of the restored mark. + await act(async () => { + release(); + }); + await flush(); + + const after = codeEl(); + expect(after.querySelector(`[data-bind-id="${restored.id}"]`)).not.toBeNull(); + expect(after.textContent).toBe(CODE); + expect(tokenColors().join(' ')).toContain(TOKEN_COLOR['github-dark']); + }, + ); + + test.skipIf(!hasDom)('removal is honoured even though it re-highlights the block', async () => { + // Removing an annotation re-highlights the fence on its way out, and the + // host only drops it from state on the NEXT tick — so for one tick the + // swap listener sees a list that still names the annotation whose mark was + // just deleted. It must not be the one painted back in. + const { mod } = fakePierre('immediate'); + __setCodeHighlightModuleForTests(mod); + + const older = codeBlockAnnotation('codeblock-older', AnnotationType.COMMENT); + const newer = codeBlockAnnotation('codeblock-newer', AnnotationType.DELETION); + await mountHarness([older, newer]); + await flush(); + + // Two annotations, one fence: the later one owns the mark, exactly as it + // does when a block is annotated twice. + expect(codeEl().querySelector('mark[data-bind-id]')?.getAttribute('data-bind-id')) + .toBe(newer.id); + + await act(async () => { + controls.removeAnnotation(newer.id); + }); + await flush(); + + // The removed one is gone, and the fence falls back to the annotation that + // is still on it rather than being left bare. + const after = codeEl(); + expect(after.querySelector(`[data-bind-id="${newer.id}"]`)).toBeNull(); + expect(after.querySelector(`[data-bind-id="${older.id}"]`)).not.toBeNull(); + expect(after.textContent).toBe(CODE); + + // And a later palette change — after the tombstone has been retired — + // still honours the removal. + await act(async () => { + controls.setColorTheme('kanagawa-wave'); + }); + await flush(); + expect(codeEl().querySelector(`[data-bind-id="${newer.id}"]`)).toBeNull(); + expect(codeEl().querySelector(`[data-bind-id="${older.id}"]`)).not.toBeNull(); + expect(tokenColors().join(' ')).toContain(TOKEN_COLOR['kanagawa-wave']); + }); +}); diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index 1a1b39e6a..e7794a25a 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -1,7 +1,9 @@ import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react'; import { createPortal } from 'react-dom'; -import hljs from 'highlight.js'; import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types'; +import { applyHighlight, codeBlockClassName, onCodeHighlightSwap } from '../utils/codeHighlight'; +import { paintCodeBlockMark } from '../utils/codeBlockMark'; +import { useFenceTheme } from '../hooks/useFenceTheme'; import { computeListIndices, groupBlocks, type Frontmatter } from '../utils/parser'; import { buildHeadingSlugMap } from '../utils/slugify'; import { copyTextToClipboard } from '../utils/clipboard'; @@ -238,6 +240,11 @@ export const Viewer = forwardRef(({ const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null); const [locationHash, setLocationHash] = useState(() => window.location.hash); const globalCommentButtonRef = useRef(null); + // Read through a ref: only the imperative removeHighlight path below needs + // it, and CodeBlock re-highlights itself on palette change. + const fenceTheme = useFenceTheme(); + const fenceThemeRef = useRef(fenceTheme); + fenceThemeRef.current = fenceTheme; const handleCopyPlan = async () => { if (await copyTextToClipboard(markdown)) { @@ -351,12 +358,7 @@ export const Viewer = forwardRef(({ const id = `codeblock-${Date.now()}`; const codeText = codeEl.textContent || ''; - const wrapper = document.createElement('mark'); - wrapper.className = `annotation-highlight ${type === AnnotationType.DELETION ? 'deletion' : type === AnnotationType.COMMENT ? 'comment' : ''}`.trim(); - wrapper.dataset.bindId = id; - wrapper.textContent = codeText; - - codeEl.replaceChildren(wrapper); + paintCodeBlockMark(codeEl, id, type); const newAnnotation: Annotation = { id, @@ -377,6 +379,63 @@ export const Viewer = forwardRef(({ window.getSelection()?.removeAllRanges(); }, []); + // Live annotation list for the imperative DOM paths below, which run outside + // React's render (highlight swaps, the imperative handle). + const annotationsRef = useRef(annotations); + annotationsRef.current = annotations; + + // `removeHighlight` runs BEFORE the host drops the annotation from state and + // re-highlights the block on the way out, so for one tick `annotationsRef` + // still lists an annotation whose mark is deliberately gone. Remember those + // ids so the swap listener below never paints a removed annotation back in, + // whichever tick that block's re-highlight lands in. + const removedAnnotationIdsRef = useRef>(new Set()); + // Retire a tombstone as soon as the host's list agrees the annotation is + // gone: the window it guards is only the tick between removeHighlight and + // the state update, and keeping it would block a later restore that brings + // the same annotation (same id) back from a draft. + for (const id of removedAnnotationIdsRef.current) { + if (!annotations.some((a) => a.id === id)) removedAnnotationIdsRef.current.delete(id); + } + + // A highlight swap replaces a `` element's children — that is how the + // palette/mode change repaints tokens, and how the first async grammar + // attach lands after load. It also destroys any annotation mark inside the + // fence. Re-paint it here, SYNCHRONOUSLY after the write, so the block ends + // up with both the new theme's tokens and its mark. + // + // Being driven by the swap is also what makes the restore race safe without + // timing: a share/draft restore that painted before the swap is + // re-established in the same task the swap ran in, and one that runs after + // it finds the mark already present and leaves it alone. + useEffect(() => onCodeHighlightSwap((codeEl) => { + const container = containerRef.current; + if (!container || !container.contains(codeEl)) return; + // The swap always clears the element, so a surviving mark means this write + // was not the one that owns this block's contents. + if (codeEl.querySelector('[data-bind-id]')) return; + + const codeText = codeEl.textContent ?? ''; + if (!codeText) return; + const blockId = codeEl.closest('[data-block-id]')?.getAttribute('data-block-id') ?? ''; + + // Fenced code is annotated all-or-nothing, so this block's annotations are + // exactly the ones whose originalText is its full text. Share-restored + // annotations arrive with an empty blockId (it is filled in during restore), + // so an unset blockId still counts. The last one wins, matching what + // annotating the same block twice does. + const owner = annotationsRef.current.filter((a) => + a.type !== AnnotationType.GLOBAL_COMMENT + && !a.diffContext + && a.originalText === codeText + && (a.blockId === blockId || !a.blockId) + && !removedAnnotationIdsRef.current.has(a.id) + && !container.querySelector(`[data-bind-id="${a.id}"], [data-highlight-id="${a.id}"]`) + ).at(-1); + + if (owner) paintCodeBlockMark(codeEl, owner.id, owner.type); + }), []); + // Pinpoint mode: hover + click to select elements const handlePinpointCodeBlockClick = useCallback((blockId: string, element: HTMLElement) => { if (readOnlyRef.current) return; @@ -617,6 +676,10 @@ export const Viewer = forwardRef(({ // Imperative handle — delegates to hook, extends removeHighlight for code blocks useImperativeHandle(ref, () => ({ removeHighlight: (id: string) => { + // The re-highlight below notifies the swap listener, which would happily + // paint this annotation's mark straight back in — the host has not + // dropped it from state yet. Tombstone the id first. + removedAnnotationIdsRef.current.add(id); // Code block annotations need syntax re-highlighting after removal. // Must run BEFORE hookRemoveHighlight, which removes the elements. const manualHighlights = containerRef.current?.querySelectorAll(`[data-bind-id="${id}"]`); @@ -628,13 +691,9 @@ export const Viewer = forwardRef(({ el.remove(); codeEl.textContent = plainText; const block = blocks.find(b => b.id === codeEl.closest('[data-block-id]')?.getAttribute('data-block-id')); - codeEl.removeAttribute('data-highlighted'); - codeEl.className = `hljs font-mono${block?.language ? ` language-${block.language}` : ''}`; - // Skip highlighting language-less fences so highlight.js doesn't - // auto-detect a language and color plain text. - if (block?.language) { - hljs.highlightElement(codeEl); - } + codeEl.className = codeBlockClassName(block?.language); + // Language-less fences stay plain (#1212) — applyHighlight never guesses. + applyHighlight(codeEl, plainText, block?.language, fenceThemeRef.current); } }); diff --git a/packages/ui/components/blocks/CodeBlock.tsx b/packages/ui/components/blocks/CodeBlock.tsx index 33feb63ee..90b86fc6a 100644 --- a/packages/ui/components/blocks/CodeBlock.tsx +++ b/packages/ui/components/blocks/CodeBlock.tsx @@ -1,8 +1,8 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; -import hljs from 'highlight.js'; -import 'highlight.js/styles/github-dark.css'; import type { Block } from '../../types'; import { copyTextToClipboard } from '../../utils/clipboard'; +import { applyHighlight, codeBlockClassName } from '../../utils/codeHighlight'; +import { useFenceTheme } from '../../hooks/useFenceTheme'; interface CodeBlockProps { block: Block; @@ -15,20 +15,16 @@ export const CodeBlock: React.FC = ({ block, onHover, onLeave }) const [copied, setCopied] = useState(false); const containerRef = useRef(null); const codeRef = useRef(null); + const fenceTheme = useFenceTheme(); - // Highlight code block on mount and when content/language changes. - // Skip highlighting for language-less fences so highlight.js doesn't - // auto-detect a language and color plain text. + // Highlight on mount, on content/language change, and whenever the palette + // changes. Language-less fences stay plain text (#1212) — nothing is guessed. useEffect(() => { if (codeRef.current) { - // Reset any previous highlighting - codeRef.current.removeAttribute('data-highlighted'); - codeRef.current.className = `hljs font-mono${block.language ? ` language-${block.language}` : ''}`; - if (block.language) { - hljs.highlightElement(codeRef.current); - } + codeRef.current.className = codeBlockClassName(block.language); + applyHighlight(codeRef.current, block.content, block.language, fenceTheme); } - }, [block.content, block.language]); + }, [block.content, block.language, fenceTheme]); const handleCopy = useCallback(async () => { if (await copyTextToClipboard(block.content)) { @@ -46,7 +42,7 @@ export const CodeBlock: React.FC = ({ block, onHover, onLeave }) }; // Build className for code element - const codeClassName = `hljs font-mono${block.language ? ` language-${block.language}` : ''}`; + const codeClassName = codeBlockClassName(block.language); return (
= ({ block }) => { const codeRef = useRef(null); + const fenceTheme = useFenceTheme(); useEffect(() => { if (codeRef.current) { - codeRef.current.removeAttribute("data-highlighted"); - codeRef.current.className = `hljs font-mono${block.language ? ` language-${block.language}` : ""}`; - // Skip highlighting language-less fences so highlight.js doesn't - // auto-detect a language and color plain text. - if (block.language) { - hljs.highlightElement(codeRef.current); - } + codeRef.current.className = codeBlockClassName(block.language); + // Language-less fences stay plain (#1212) — applyHighlight never guesses. + applyHighlight(codeRef.current, block.content, block.language, fenceTheme); } - }, [block.content, block.language]); + }, [block.content, block.language, fenceTheme]); return (
-        
+        
           {block.content}
         
       
diff --git a/packages/ui/globals.d.ts b/packages/ui/globals.d.ts index 978d0ca64..261285a2c 100644 --- a/packages/ui/globals.d.ts +++ b/packages/ui/globals.d.ts @@ -1,4 +1,4 @@ -// Allow side-effect CSS imports (highlight.js themes, overlayscrollbars, etc.) +// Allow side-effect CSS imports (overlayscrollbars, fontsource, etc.) declare module '*.css'; // Image asset imports (sprites, screenshots). Consumers compiling this shipped diff --git a/packages/ui/hooks/useFenceTheme.ts b/packages/ui/hooks/useFenceTheme.ts new file mode 100644 index 000000000..f9f829bfe --- /dev/null +++ b/packages/ui/hooks/useFenceTheme.ts @@ -0,0 +1,17 @@ +import { useTheme } from '../components/ThemeProvider'; +import { resolveFenceTheme } from '../utils/syntaxTheme'; + +/** + * The Shiki theme name that code snippets should render in right now. + * + * Same (colorTheme, mode) resolution the code-review diff pane uses, so fences, + * suggestion cards and diff hunks all agree. Re-renders on palette or mode + * change, which is what drives the re-highlight in the components below. + * + * `ThemeProvider`'s default context supplies the Plannotator palette in dark + * mode, so this is safe to call outside a provider. + */ +export function useFenceTheme(): string { + const { colorTheme, resolvedMode } = useTheme(); + return resolveFenceTheme(colorTheme, resolvedMode ?? 'dark'); +} diff --git a/packages/ui/hooks/useVimSelection.test.tsx b/packages/ui/hooks/useVimSelection.test.tsx index 296cb759c..f5fc620f2 100644 --- a/packages/ui/hooks/useVimSelection.test.tsx +++ b/packages/ui/hooks/useVimSelection.test.tsx @@ -55,7 +55,7 @@ function VimHarness({
-
const x = 1;
+
const x = 1;
Native link diff --git a/packages/ui/package.json b/packages/ui/package.json index a0cd59c90..4f6ace75e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -77,7 +77,6 @@ "clsx": "^2.1.1", "diff": "^8.0.4", "dompurify": "^3.3.3", - "highlight.js": "^11.11.1", "katex": "^0.16.47", "lucide-react": "^1.14.0", "marked": "^17.0.6", diff --git a/packages/ui/print.css b/packages/ui/print.css index e37823c43..05b514546 100644 --- a/packages/ui/print.css +++ b/packages/ui/print.css @@ -4,7 +4,8 @@ * 1. @media print — standard print styles * 2. .plannotator-print — class added via JS beforeprint/afterprint events * to guarantee overrides that @media print alone cannot achieve - * (e.g. beating Tailwind layers + hljs github-dark theme). + * (e.g. beating Tailwind layers, and the syntax theme's per-token inline + * colours — inline styles only lose to an !important author rule). */ /* ============================================================ @@ -12,7 +13,7 @@ * These use .plannotator-print on for maximum specificity. * ============================================================ */ -/* Code blocks: override github-dark.css .hljs{background:#0d1117} */ +/* Code blocks: flatten the syntax theme's dark block background to paper */ .plannotator-print pre, .plannotator-print pre[class] { background: #f5f5f5 !important; @@ -23,18 +24,19 @@ } .plannotator-print pre code, -.plannotator-print code.hljs, -.plannotator-print pre code.hljs, -.plannotator-print .hljs { +.plannotator-print code.pn-code, +.plannotator-print pre code.pn-code, +.plannotator-print .code-snippet-preview { background: transparent !important; background-color: transparent !important; color: #1a1a1a !important; } +/* The syntax theme colours every token with an inline `style`, so these + !important rules are what flattens code to black on paper. */ .plannotator-print pre span, .plannotator-print pre code span, -.plannotator-print .hljs span, -.plannotator-print [class*="hljs-"] { +.plannotator-print .code-snippet-preview span { color: #1a1a1a !important; background: transparent !important; background-color: transparent !important; @@ -284,9 +286,8 @@ pre code, pre code[class], - pre code.hljs, - code.hljs, - code[data-highlighted] { + pre code.pn-code, + code.pn-code { font-size: 9pt !important; background: transparent !important; background-color: transparent !important; @@ -298,14 +299,14 @@ word-wrap: break-word !important; } - .hljs { + .code-snippet-preview { background: transparent !important; background-color: transparent !important; color: #1a1a1a !important; } - pre span, pre code span, .hljs span, code span, - [class*="hljs-"] { + pre span, pre code span, code span, + .code-snippet-preview span { color: #1a1a1a !important; background: transparent !important; background-color: transparent !important; diff --git a/packages/ui/themes/colorblind.css b/packages/ui/themes/colorblind.css index a233d8c73..12c4ef9e4 100644 --- a/packages/ui/themes/colorblind.css +++ b/packages/ui/themes/colorblind.css @@ -81,111 +81,9 @@ --diffs-deletion-color-override: #a55c1e; } -/* Plan-editor code blocks (bundled highlight.js, github-dark base stylesheet). - * Token palette mirrors @pierre/theme's protanopia-deuteranopia shiki themes: - * purples/blues for keywords, blue for strings and numbers, orange reserved - * for variables/tags — no red-vs-green token pairs anywhere. !important is - * required to beat the app-level `.light .hljs-*` override rules. */ -.theme-colorblind .hljs-keyword, -.theme-colorblind .hljs-selector-tag, -.theme-colorblind .hljs-doctag { - color: #b969f3 !important; -} -.theme-colorblind .hljs-string, -.theme-colorblind .hljs-quote, -.theme-colorblind .hljs-regexp { - color: #97c4ff !important; -} -.theme-colorblind .hljs-number, -.theme-colorblind .hljs-literal, -.theme-colorblind .hljs-symbol, -.theme-colorblind .hljs-bullet { - color: #96d9f6 !important; -} -.theme-colorblind .hljs-title, -.theme-colorblind .hljs-section, -.theme-colorblind .hljs-function, -.theme-colorblind .hljs-class .hljs-title { - color: #ba8ffd !important; -} -.theme-colorblind .hljs-type, -.theme-colorblind .hljs-built_in, -.theme-colorblind .hljs-builtin-name, -.theme-colorblind .hljs-selector-class, -.theme-colorblind .hljs-selector-pseudo { - color: #e290f0 !important; -} -.theme-colorblind .hljs-variable, -.theme-colorblind .hljs-template-variable, -.theme-colorblind .hljs-attr, -.theme-colorblind .hljs-attribute, -.theme-colorblind .hljs-name, -.theme-colorblind .hljs-tag, -.theme-colorblind .hljs-selector-id, -.theme-colorblind .hljs-template-tag { - color: #ffa359 !important; -} -.theme-colorblind .hljs-comment, -.theme-colorblind .hljs-meta { - color: #9198a1 !important; -} -.theme-colorblind .hljs-addition { - color: #97c4ff !important; - background-color: rgb(79 131 209 / 0.18) !important; -} -.theme-colorblind .hljs-deletion { - color: #ffa359 !important; - background-color: rgb(201 110 18 / 0.18) !important; -} +/* Code-block token colours are no longer hand-written here. Plan-editor fences + * are highlighted by the same Shiki instance, in the same resolved theme, as + * the code-review diff pane — for this palette that is @pierre/theme's + * `pierre-{dark,light}-protanopia-deuteranopia`, which is exactly what the + * removed highlight.js `.hljs-*` overrides were approximating by hand. */ -.theme-colorblind.light .hljs-keyword, -.theme-colorblind.light .hljs-selector-tag, -.theme-colorblind.light .hljs-doctag { - color: #8836c7 !important; -} -.theme-colorblind.light .hljs-string, -.theme-colorblind.light .hljs-quote, -.theme-colorblind.light .hljs-regexp { - color: #215584 !important; -} -.theme-colorblind.light .hljs-number, -.theme-colorblind.light .hljs-literal, -.theme-colorblind.light .hljs-symbol, -.theme-colorblind.light .hljs-bullet { - color: #2182a1 !important; -} -.theme-colorblind.light .hljs-title, -.theme-colorblind.light .hljs-section, -.theme-colorblind.light .hljs-function, -.theme-colorblind.light .hljs-class .hljs-title { - color: #5731a7 !important; -} -.theme-colorblind.light .hljs-type, -.theme-colorblind.light .hljs-built_in, -.theme-colorblind.light .hljs-builtin-name, -.theme-colorblind.light .hljs-selector-class, -.theme-colorblind.light .hljs-selector-pseudo { - color: #a631be !important; -} -.theme-colorblind.light .hljs-variable, -.theme-colorblind.light .hljs-template-variable, -.theme-colorblind.light .hljs-attr, -.theme-colorblind.light .hljs-attribute, -.theme-colorblind.light .hljs-name, -.theme-colorblind.light .hljs-tag, -.theme-colorblind.light .hljs-selector-id, -.theme-colorblind.light .hljs-template-tag { - color: #ac6023 !important; -} -.theme-colorblind.light .hljs-comment, -.theme-colorblind.light .hljs-meta { - color: #6b7280 !important; -} -.theme-colorblind.light .hljs-addition { - color: #215584 !important; - background-color: rgb(33 108 171 / 0.12) !important; -} -.theme-colorblind.light .hljs-deletion { - color: #ac6023 !important; - background-color: rgb(165 92 30 / 0.12) !important; -} diff --git a/packages/ui/utils/blockTargeting.ts b/packages/ui/utils/blockTargeting.ts index bf08f8b90..99e9c3ab1 100644 --- a/packages/ui/utils/blockTargeting.ts +++ b/packages/ui/utils/blockTargeting.ts @@ -15,7 +15,7 @@ const SKIP_SELECTORS = [ '[data-pinpoint-ignore]', ].join(','); -const INLINE_TARGET_SELECTOR = 'strong,em,a,code:not(.hljs)'; +const INLINE_TARGET_SELECTOR = 'strong,em,a,code:not(.pn-code)'; const TABLE_EDGE_ZONE = 22; /** The semantic kind of a document target. */ @@ -198,7 +198,7 @@ export function buildSemanticTargetGraph(container: HTMLElement): SemanticTarget const group = block.closest('[data-pinpoint-group]'); const parentKey = group ? groupTargets.get(group)?.key ?? null : null; - const codeElement = block.querySelector('pre > code.hljs'); + const codeElement = block.querySelector('pre > code.pn-code'); const mathElement = block.matches('.math-annotatable,[data-math-tex]') ? block : block.querySelector('.math-annotatable,[data-math-tex]'); @@ -453,7 +453,7 @@ export function resolveSemanticTargetAtPoint( const blockTarget = targetForBlock(graph, block); if (!blockTarget) return null; - const code = block.querySelector('pre > code.hljs'); + const code = block.querySelector('pre > code.pn-code'); if ( code && (pointerTarget === code || code.contains(pointerTarget) || pointerTarget.closest('pre')) diff --git a/packages/ui/utils/codeBlockMark.test.ts b/packages/ui/utils/codeBlockMark.test.ts new file mode 100644 index 000000000..27ac0281c --- /dev/null +++ b/packages/ui/utils/codeBlockMark.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'bun:test'; + +import { codeBlockMarkClassName, paintCodeBlockMark } from './codeBlockMark'; +import { AnnotationType } from '../types'; + +const hasDom = typeof document !== 'undefined'; + +describe('code block mark class', () => { + test('carries the annotation kind the stylesheet keys on', () => { + expect(codeBlockMarkClassName(AnnotationType.DELETION)).toBe('annotation-highlight deletion'); + expect(codeBlockMarkClassName(AnnotationType.COMMENT)).toBe('annotation-highlight comment'); + // Global comments never wrap a block, so there is no modifier to add. + expect(codeBlockMarkClassName(AnnotationType.GLOBAL_COMMENT)).toBe('annotation-highlight'); + }); +}); + +describe.if(hasDom)('paintCodeBlockMark', () => { + function fence(html: string): HTMLElement { + const code = document.createElement('code'); + code.innerHTML = html; + return code; + } + + test('wraps the whole fence in one mark and keeps the token spans', () => { + const code = fence('const x = 1'); + paintCodeBlockMark(code, 'ann-1', AnnotationType.DELETION); + + expect(code.children.length).toBe(1); + const mark = code.firstElementChild as HTMLElement; + expect(mark.tagName).toBe('MARK'); + expect(mark.dataset.bindId).toBe('ann-1'); + expect(mark.className).toBe('annotation-highlight deletion'); + // The point of moving children instead of flattening: the palette's + // colours survive being annotated (and being re-themed). + expect(mark.querySelector('span[style*="#79c0ff"]')).not.toBeNull(); + expect(code.textContent).toBe('const x = 1'); + }); + + test('a second annotation replaces the first mark instead of nesting in it', () => { + const code = fence('const x = 1'); + paintCodeBlockMark(code, 'ann-1', AnnotationType.DELETION); + paintCodeBlockMark(code, 'ann-2', AnnotationType.COMMENT); + + const marks = code.querySelectorAll('mark[data-bind-id]'); + expect(marks.length).toBe(1); + expect((marks[0] as HTMLElement).dataset.bindId).toBe('ann-2'); + expect(code.querySelector('span[style*="#79c0ff"]')).not.toBeNull(); + expect(code.textContent).toBe('const x = 1'); + }); + + test('a plain (language-less) fence is wrapped without inventing markup', () => { + const code = document.createElement('code'); + code.textContent = 'plain text'; + paintCodeBlockMark(code, 'ann-3', AnnotationType.COMMENT); + + expect(code.textContent).toBe('plain text'); + expect(code.querySelector('b')).toBeNull(); + }); +}); diff --git a/packages/ui/utils/codeBlockMark.ts b/packages/ui/utils/codeBlockMark.ts new file mode 100644 index 000000000..539d3f89c --- /dev/null +++ b/packages/ui/utils/codeBlockMark.ts @@ -0,0 +1,50 @@ +/** + * The annotation `` that covers a whole fenced code block. + * + * `web-highlighter` cannot select inside a `
`, so fenced code is annotated
+ * as an all-or-nothing block: one `` that is the ``
+ * element's only child and holds everything the fence renders. Several places
+ * need to (re)paint exactly that shape — creating an annotation, and restoring
+ * one after `applyHighlight` replaced the element's children — so the DOM
+ * contract lives here rather than being written out twice.
+ *
+ * The children are MOVED into the mark, never flattened to text. Highlighted
+ * fences render as Shiki token ``s, and flattening would drop the
+ * palette's colours on the floor the moment a block was annotated or
+ * re-themed.
+ */
+import { AnnotationType } from '../types';
+
+export function codeBlockMarkClassName(type: AnnotationType): string {
+  return `annotation-highlight ${
+    type === AnnotationType.DELETION ? 'deletion' : type === AnnotationType.COMMENT ? 'comment' : ''
+  }`.trim();
+}
+
+/**
+ * Wrap everything inside `codeEl` in a single annotation mark and return it.
+ *
+ * Any mark a previous annotation left behind is unwrapped first, so a second
+ * annotation on the same block replaces the first (what has always happened)
+ * instead of nesting inside it.
+ */
+export function paintCodeBlockMark(
+  codeEl: Element,
+  id: string,
+  type: AnnotationType,
+): HTMLElement {
+  codeEl.querySelectorAll('mark[data-bind-id]').forEach((existing) => {
+    const parent = existing.parentNode;
+    if (!parent) return;
+    while (existing.firstChild) parent.insertBefore(existing.firstChild, existing);
+    existing.remove();
+  });
+
+  const doc = codeEl.ownerDocument ?? document;
+  const wrapper = doc.createElement('mark');
+  wrapper.className = codeBlockMarkClassName(type);
+  wrapper.dataset.bindId = id;
+  while (codeEl.firstChild) wrapper.appendChild(codeEl.firstChild);
+  codeEl.replaceChildren(wrapper);
+  return wrapper;
+}
diff --git a/packages/ui/utils/codeHighlight.test.ts b/packages/ui/utils/codeHighlight.test.ts
new file mode 100644
index 000000000..229222328
--- /dev/null
+++ b/packages/ui/utils/codeHighlight.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, test } from 'bun:test';
+
+import { codeBlockClassName, CODE_BLOCK_CLASS, applyHighlight, highlightToHtml } from './codeHighlight';
+import { resolveFenceTheme, resolveSyntaxTheme, DEFAULT_SYNTAX_THEME, SHIKI_THEME_MAP } from './syntaxTheme';
+
+const hasDom = typeof document !== 'undefined';
+
+describe('code block class', () => {
+  test('carries the structural class and the language hook', () => {
+    expect(codeBlockClassName('rust')).toBe(`${CODE_BLOCK_CLASS} font-mono language-rust`);
+  });
+
+  test('omits the language hook for language-less fences', () => {
+    expect(codeBlockClassName()).toBe(`${CODE_BLOCK_CLASS} font-mono`);
+    expect(codeBlockClassName(undefined)).not.toContain('language-');
+  });
+});
+
+describe('fence theme resolution', () => {
+  test('matches the theme the diff pane resolves, per mode', () => {
+    expect(resolveFenceTheme('kanagawa-wave', 'dark')).toBe('kanagawa-wave');
+    expect(resolveFenceTheme('github', 'light')).toBe('github-light');
+    expect(resolveFenceTheme('colorblind', 'dark')).toBe('pierre-dark-protanopia-deuteranopia');
+    expect(resolveFenceTheme('colorblind', 'light')).toBe('pierre-light-protanopia-deuteranopia');
+  });
+
+  test('falls back to the Pierre defaults for unmapped palettes', () => {
+    // The default Plannotator palette has no Shiki counterpart, so it renders
+    // in exactly what @pierre/diffs uses when handed no theme at all.
+    expect(resolveSyntaxTheme('plannotator', 'dark')).toBeUndefined();
+    expect(resolveFenceTheme('plannotator', 'dark')).toBe(DEFAULT_SYNTAX_THEME.dark);
+    expect(resolveFenceTheme('plannotator', 'light')).toBe(DEFAULT_SYNTAX_THEME.light);
+  });
+
+  test('falls back per mode when a palette only defines one side', () => {
+    // dracula is dark-only; its light mode must still resolve to something.
+    expect(SHIKI_THEME_MAP['dracula']?.light).toBeNull();
+    expect(resolveFenceTheme('dracula', 'dark')).toBe('dracula');
+    expect(resolveFenceTheme('dracula', 'light')).toBe(DEFAULT_SYNTAX_THEME.light);
+  });
+
+  test('every mapped theme name is non-empty', () => {
+    for (const [palette, pair] of Object.entries(SHIKI_THEME_MAP)) {
+      expect(pair.dark ?? pair.light, `${palette} maps to nothing`).toBeTruthy();
+    }
+  });
+});
+
+describe('highlightToHtml', () => {
+  test('returns null until a grammar is attached, so callers render plain', () => {
+    expect(highlightToHtml('const x = 1', 'typescript', 'pierre-dark')).toBeNull();
+  });
+});
+
+describe.if(hasDom)('applyHighlight', () => {
+  test('language-less fences render as plain text, never guessed (#1212)', () => {
+    const el = document.createElement('code');
+    applyHighlight(el, 'plain text & more', undefined, 'pierre-dark');
+    expect(el.textContent).toBe('plain text & more');
+    // Escaped into text nodes, not parsed as markup.
+    expect(el.querySelector('b')).toBeNull();
+    expect(el.children.length).toBe(0);
+  });
+
+  test('writes the exact source immediately so there is no layout shift', () => {
+    const el = document.createElement('code');
+    const code = 'fn main() {\n    println!("hi");\n}';
+    applyHighlight(el, code, 'rust', 'pierre-dark');
+    // The highlighter is cold here, so the synchronous result is the plain
+    // source at its final size; the highlighted swap lands later.
+    expect(el.textContent).toBe(code);
+  });
+});
diff --git a/packages/ui/utils/codeHighlight.ts b/packages/ui/utils/codeHighlight.ts
new file mode 100644
index 000000000..a51ea6e88
--- /dev/null
+++ b/packages/ui/utils/codeHighlight.ts
@@ -0,0 +1,293 @@
+/**
+ * Syntax highlighting for markdown fences and suggestion snippets.
+ *
+ * There is exactly ONE highlighter in the app: the Shiki instance
+ * `@pierre/diffs` already runs for the code-review diff pane
+ * (`getSharedHighlighter`, driven by Shiki's JavaScript regex engine). Reusing
+ * it rather than standing up a second one buys three things:
+ *
+ *   - Fences render in the SAME resolved theme as the diff pane, so a code
+ *     block and a diff hunk finally agree about what "Kanagawa Wave" looks
+ *     like. See `./syntaxTheme.ts`.
+ *   - Zero added bundle weight. Pierre imports Shiki's full bundle, so every
+ *     grammar and theme is already inlined; a separate fine-grained highlighter
+ *     would have duplicated a subset of what is already there.
+ *   - Every language Shiki bundles, not a hand-curated shortlist.
+ *
+ * The API is deliberately imperative (`applyHighlight(el, ...)`) because that is
+ * exactly the shape the removed `hljs.highlightElement(el)` had. The annotation
+ * layer reaches into these `` elements to wrap ``s and to restore
+ * plain text afterwards, so keeping the DOM contract identical keeps that code
+ * working untouched.
+ *
+ * Language-less fences are never highlighted and never guessed at — see #1212.
+ * There is no auto-detection anywhere in this module.
+ */
+
+type PierreModule = typeof import('@pierre/diffs');
+
+/**
+ * Structural class on every fenced-code `` element.
+ *
+ * `blockTargeting`, the vim navigation layer and the print stylesheet all
+ * address code blocks through `pre > code.pn-code`. It used to be `.hljs`,
+ * which named a library the app no longer ships; the hook itself is unchanged,
+ * only the name is. The `language-*` class alongside it is still how
+ * `blockTargeting` reads a block's language back out of the DOM.
+ */
+export const CODE_BLOCK_CLASS = 'pn-code';
+
+export function codeBlockClassName(language?: string): string {
+  return `${CODE_BLOCK_CLASS} font-mono${language ? ` language-${language}` : ''}`;
+}
+
+/** Shiki's `FontStyle` bitmask. Inlined so this module needs no shiki types. */
+const FONT_STYLE_ITALIC = 1;
+const FONT_STYLE_BOLD = 2;
+const FONT_STYLE_UNDERLINE = 4;
+const FONT_STYLE_STRIKETHROUGH = 8;
+
+let pierre: PierreModule | undefined;
+let pierreLoad: Promise | undefined;
+
+/** `${lang} ${theme}` pairs attached to the shared highlighter. */
+const ready = new Set();
+/** Pairs the highlighter refused (unknown grammar or theme). Never retried. */
+const rejected = new Set();
+const inflight = new Map>();
+
+const pairKey = (lang: string, theme: string) => `${lang} ${theme}`;
+
+function loadPierre(): Promise {
+  pierreLoad ??= import('@pierre/diffs').then(
+    (mod) => {
+      pierre = mod;
+      return mod;
+    },
+    () => undefined,
+  );
+  return pierreLoad;
+}
+
+function escapeHtml(value: string): string {
+  return value
+    .replace(/&/g, '&')
+    .replace(//g, '>');
+}
+
+interface ThemedTokenish {
+  content: string;
+  color?: string;
+  bgColor?: string;
+  fontStyle?: number;
+  htmlStyle?: Record | string;
+}
+
+function tokenStyle(token: ThemedTokenish): string {
+  if (typeof token.htmlStyle === 'string') return token.htmlStyle;
+  const parts: string[] = [];
+  if (token.htmlStyle) {
+    for (const [prop, value] of Object.entries(token.htmlStyle)) parts.push(`${prop}:${value}`);
+  }
+  if (token.color) parts.push(`color:${token.color}`);
+  if (token.bgColor) parts.push(`background-color:${token.bgColor}`);
+  const fontStyle = token.fontStyle ?? 0;
+  if (fontStyle > 0) {
+    if (fontStyle & FONT_STYLE_ITALIC) parts.push('font-style:italic');
+    if (fontStyle & FONT_STYLE_BOLD) parts.push('font-weight:bold');
+    const decorations: string[] = [];
+    if (fontStyle & FONT_STYLE_UNDERLINE) decorations.push('underline');
+    if (fontStyle & FONT_STYLE_STRIKETHROUGH) decorations.push('line-through');
+    if (decorations.length) parts.push(`text-decoration:${decorations.join(' ')}`);
+  }
+  return parts.join(';');
+}
+
+/**
+ * Highlighted markup for `code`, or `null` when it cannot be produced right now
+ * (highlighter not loaded yet, grammar/theme not attached yet, or the tokens do
+ * not reconstruct the input exactly).
+ *
+ * Synchronous by design: once a (lang, theme) pair is attached, every later
+ * block using it highlights during the same tick, so there is no flicker on
+ * cached highlights.
+ */
+export function highlightToHtml(code: string, lang: string, theme: string): string | null {
+  const mod = pierre;
+  if (!mod || !ready.has(pairKey(lang, theme))) return null;
+  const highlighter = mod.getHighlighterIfLoaded();
+  if (!highlighter) {
+    // The shared highlighter was disposed out from under us; everything we
+    // believed was attached is gone with it.
+    ready.clear();
+    return null;
+  }
+  let lines: ThemedTokenish[][];
+  try {
+    lines = highlighter.codeToTokens(code, { lang, theme }).tokens as ThemedTokenish[][];
+  } catch {
+    ready.delete(pairKey(lang, theme));
+    return null;
+  }
+
+  // Invariant: the rendered text must be byte-identical to the source. The
+  // annotation layer addresses these blocks by text offset, so a tokenizer that
+  // normalised line endings (or dropped a trailing newline) would silently
+  // misplace every highlight in the block. Bail to plain text instead.
+  let html = '';
+  let plain = '';
+  for (let i = 0; i < lines.length; i++) {
+    if (i > 0) {
+      html += '\n';
+      plain += '\n';
+    }
+    for (const token of lines[i]!) {
+      plain += token.content;
+      const style = tokenStyle(token);
+      html += style
+        ? `${escapeHtml(token.content)}`
+        : escapeHtml(token.content);
+    }
+  }
+  return plain === code ? html : null;
+}
+
+/**
+ * Attach `lang` + `theme` to the shared highlighter. Resolves `false` for
+ * grammars or themes Shiki does not know, which is a normal outcome for a fence
+ * tagged with something made up: that block simply stays plain.
+ */
+export function ensureHighlight(lang: string, theme: string): Promise {
+  const key = pairKey(lang, theme);
+  if (ready.has(key)) return Promise.resolve(true);
+  if (rejected.has(key)) return Promise.resolve(false);
+  const existing = inflight.get(key);
+  if (existing) return existing;
+
+  const load = loadPierre()
+    .then(async (mod) => {
+      if (!mod) return false;
+      await mod.getSharedHighlighter({
+        themes: [theme],
+        langs: [lang],
+        preferredHighlighter: 'shiki-js',
+      });
+      ready.add(key);
+      return true;
+    })
+    .catch(() => {
+      rejected.add(key);
+      return false;
+    })
+    .then((ok) => {
+      inflight.delete(key);
+      return ok;
+    });
+
+  inflight.set(key, load);
+  return load;
+}
+
+/** Monotonic stamp per element so a slow async highlight from a previous
+ *  content/theme never lands on top of a newer one. */
+const renderSeq = new WeakMap();
+let seqCounter = 0;
+
+type HighlightSwapListener = (el: HTMLElement) => void;
+const swapListeners = new Set();
+
+/**
+ * Observe every write `applyHighlight` makes to a `` element.
+ *
+ * Each write REPLACES the element's children, which destroys anything the
+ * annotation layer wrapped inside it — a whole-fence `` is
+ * gone the moment the palette changes or the first async grammar attach lands.
+ * Listeners run SYNCHRONOUSLY, immediately after the write, so re-applying a
+ * mark from a listener is ordered by construction rather than by a timer: a
+ * restore that ran before the swap is re-established in the same task the swap
+ * happened in, and a restore that runs after it finds the mark already there.
+ *
+ * Returns an unsubscribe function.
+ */
+export function onCodeHighlightSwap(listener: HighlightSwapListener): () => void {
+  swapListeners.add(listener);
+  return () => {
+    swapListeners.delete(listener);
+  };
+}
+
+function notifyHighlightSwap(el: HTMLElement): void {
+  if (swapListeners.size === 0) return;
+  for (const listener of Array.from(swapListeners)) {
+    // A misbehaving observer must never take syntax highlighting down with it.
+    try {
+      listener(el);
+    } catch {}
+  }
+}
+
+/**
+ * Drop-in replacement for `hljs.highlightElement(el)`.
+ *
+ * Writes plain text immediately so the block has its final size and content on
+ * the very first paint (no layout shift, no empty flash), then swaps in
+ * highlighted markup when the grammar is attached. When the grammar is already
+ * attached the highlighted markup is written straight away with no intermediate
+ * plain state.
+ */
+export function applyHighlight(
+  el: HTMLElement,
+  code: string,
+  lang: string | undefined,
+  theme: string,
+): void {
+  const seq = ++seqCounter;
+  renderSeq.set(el, seq);
+
+  // #1212: a fence with no language stays plain. Never guess.
+  if (!lang) {
+    el.textContent = code;
+    notifyHighlightSwap(el);
+    return;
+  }
+
+  const immediate = highlightToHtml(code, lang, theme);
+  if (immediate !== null) {
+    el.innerHTML = immediate;
+    notifyHighlightSwap(el);
+    return;
+  }
+
+  el.textContent = code;
+  notifyHighlightSwap(el);
+  void ensureHighlight(lang, theme).then((ok) => {
+    if (!ok || renderSeq.get(el) !== seq || !el.isConnected) return;
+    const html = highlightToHtml(code, lang, theme);
+    if (html === null) return;
+    el.innerHTML = html;
+    notifyHighlightSwap(el);
+  });
+}
+
+/** Test seam: forget every cached attachment and module handle. */
+export function __resetCodeHighlightCacheForTests(): void {
+  ready.clear();
+  rejected.clear();
+  inflight.clear();
+  pierre = undefined;
+  pierreLoad = undefined;
+}
+
+/**
+ * Test seam: stand in for `@pierre/diffs` so a test can drive real swaps
+ * (including WHEN the async one lands) without loading Shiki's full bundle.
+ * Pass `undefined` to go back to the real dynamic import.
+ */
+export function __setCodeHighlightModuleForTests(mod: PierreModule | undefined): void {
+  ready.clear();
+  rejected.clear();
+  inflight.clear();
+  pierre = mod;
+  pierreLoad = mod ? Promise.resolve(mod) : undefined;
+}
diff --git a/packages/ui/utils/syntaxTheme.ts b/packages/ui/utils/syntaxTheme.ts
new file mode 100644
index 000000000..e25c53f88
--- /dev/null
+++ b/packages/ui/utils/syntaxTheme.ts
@@ -0,0 +1,83 @@
+/**
+ * Maps a Plannotator colour theme onto the Shiki theme that renders code in it.
+ *
+ * This used to live in `packages/review-editor/hooks/usePierreTheme.ts` and only
+ * served the diff pane. It moved here so the plan/annotate editor's markdown
+ * fences resolve the SAME theme the diff pane resolves, which is what makes a
+ * fenced code block and a diff hunk finally look like they belong to the same
+ * app. `usePierreTheme` re-exports both symbols, so the review editor's imports
+ * are unchanged.
+ *
+ * Names on the right are resolved by `@pierre/diffs` — the `pierre-*` ones come
+ * from `@pierre/theme`, the rest from `@shikijs/themes`. Both registries are
+ * already bundled (Pierre pulls in Shiki's full bundle), so consuming them here
+ * costs no additional bytes.
+ */
+
+/** Plannotator theme id -> Shiki theme name, per mode. `null` = this palette
+ *  has no counterpart in that mode and falls back to the Pierre default. */
+export const SHIKI_THEME_MAP: Record = {
+  'andromeeda': { dark: 'andromeeda', light: null },
+  'aurora-x': { dark: 'aurora-x', light: null },
+  'ayu-dark': { dark: 'ayu-dark', light: null },
+  'catppuccin': { dark: 'catppuccin-mocha', light: 'catppuccin-latte' },
+  'colorblind': { dark: 'pierre-dark-protanopia-deuteranopia', light: 'pierre-light-protanopia-deuteranopia' },
+  'dark-plus': { dark: 'dark-plus', light: 'light-plus' },
+  'dracula': { dark: 'dracula', light: null },
+  'everforest': { dark: 'everforest-dark', light: 'everforest-light' },
+  'everforest-hard': { dark: 'everforest-dark', light: 'everforest-light' },
+  'everforest-soft': { dark: 'everforest-dark', light: 'everforest-light' },
+  'github': { dark: 'github-dark', light: 'github-light' },
+  'gruvbox': { dark: 'gruvbox-dark-medium', light: 'gruvbox-light-medium' },
+  'houston': { dark: 'houston', light: null },
+  'kanagawa-dragon': { dark: 'kanagawa-dragon', light: null },
+  'kanagawa-lotus': { dark: null, light: 'kanagawa-lotus' },
+  'kanagawa-wave': { dark: 'kanagawa-wave', light: null },
+  'laserwave': { dark: 'laserwave', light: null },
+  'material': { dark: 'material-theme', light: 'material-theme-lighter' },
+  'min': { dark: 'min-dark', light: 'min-light' },
+  'monokai-pro': { dark: 'monokai', light: null },
+  'night-owl': { dark: 'night-owl', light: null },
+  'nord': { dark: 'nord', light: null },
+  'one-dark-pro': { dark: 'one-dark-pro', light: null },
+  'one-light': { dark: null, light: 'one-light' },
+  'plastic': { dark: 'plastic', light: null },
+  'poimandres': { dark: 'poimandres', light: null },
+  'red': { dark: 'red', light: null },
+  'rose-pine': { dark: 'rose-pine', light: 'rose-pine-dawn' },
+  'slack': { dark: 'slack-dark', light: 'slack-ochin' },
+  'snazzy-light': { dark: null, light: 'snazzy-light' },
+  'solarized': { dark: 'solarized-dark', light: 'solarized-light' },
+  'synthwave-84': { dark: 'synthwave-84', light: null },
+  'tokyo-night': { dark: 'tokyo-night', light: null },
+  'vesper': { dark: 'vesper', light: null },
+  'vitesse': { dark: 'vitesse-dark', light: 'vitesse-light' },
+  'vitesse-black': { dark: 'vitesse-black', light: null },
+};
+
+/** `@pierre/diffs`' own `DEFAULT_THEMES`. Anything the map does not cover (the
+ *  Plannotator default palette, plus every palette with no counterpart in the
+ *  active mode) renders in these, which is exactly what the diff pane does when
+ *  `resolveSyntaxTheme` returns `undefined`. */
+export const DEFAULT_SYNTAX_THEME = { dark: 'pierre-dark', light: 'pierre-light' } as const;
+
+/**
+ * The theme pair to hand `@pierre/diffs`, or `undefined` to let it use its own
+ * defaults. Returning `undefined` (rather than the default pair) is deliberate:
+ * it keeps the diff pane's prop identity stable for palettes that never
+ * customised it.
+ */
+export function resolveSyntaxTheme(colorTheme: string, mode: 'dark' | 'light'): { dark: string; light: string } | undefined {
+  const map = SHIKI_THEME_MAP[colorTheme];
+  if (!map || !map[mode]) return undefined;
+  return { dark: map.dark || DEFAULT_SYNTAX_THEME.dark, light: map.light || DEFAULT_SYNTAX_THEME.light };
+}
+
+/**
+ * The single concrete Shiki theme name for the palette currently on screen.
+ * Markdown fences render one mode at a time, so unlike the diff pane (which
+ * hands Pierre a dark/light pair and lets CSS pick) they want a resolved name.
+ */
+export function resolveFenceTheme(colorTheme: string, mode: 'dark' | 'light'): string {
+  return resolveSyntaxTheme(colorTheme, mode)?.[mode] ?? DEFAULT_SYNTAX_THEME[mode];
+}
diff --git a/packages/ui/utils/vimNavigation.test.ts b/packages/ui/utils/vimNavigation.test.ts
index 8746517ff..7e08aaeb2 100644
--- a/packages/ui/utils/vimNavigation.test.ts
+++ b/packages/ui/utils/vimNavigation.test.ts
@@ -23,7 +23,7 @@ function createDocumentFixture(): HTMLElement {
     'A1A2',
     'B1B2C1C2',
     '
', - '
const answer = 42;
', + '
const answer = 42;
', '

Charlie delta

', ].join(''); document.body.appendChild(container); diff --git a/tests/entry-assets.test.ts b/tests/entry-assets.test.ts index 46161bbe3..4eb76f3b3 100644 --- a/tests/entry-assets.test.ts +++ b/tests/entry-assets.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; const root = resolve(import.meta.dir, '..'); @@ -24,9 +24,51 @@ describe('review entry assets', () => { expect(theme).toContain("--font-sans: 'Inter Variable'"); expect(theme).toContain("--font-mono: 'Geist Mono Variable'"); + // Syntax highlighting is the bundled Shiki instance @pierre/diffs already + // runs (JavaScript regex engine, no WASM, no network). A CDN-loaded + // highlighter or a runtime wasm fetch would break the single-file builds. const codeBlock = read('packages/ui/components/blocks/CodeBlock.tsx'); - expect(codeBlock).toContain("import hljs from 'highlight.js';"); - expect(codeBlock).toContain("import 'highlight.js/styles/github-dark.css';"); + expect(codeBlock).toContain("from '../../utils/codeHighlight'"); + + const highlighter = read('packages/ui/utils/codeHighlight.ts'); + expect(highlighter).toContain("import('@pierre/diffs')"); + expect(highlighter).toContain("preferredHighlighter: 'shiki-js'"); + expect(highlighter).not.toMatch(/https?:\/\//); + }); + + test('nothing depends on highlight.js any more', () => { + for (const manifest of ['packages/ui/package.json', 'packages/review-editor/package.json']) { + expect(read(manifest)).not.toContain('highlight.js'); + } + }); + + test('the dead Oniguruma WASM is aliased out of every bundled app', () => { + for (const config of [ + 'apps/review/vite.config.ts', + 'apps/hook/vite.config.ts', + 'apps/portal/vite.config.ts', + ]) { + expect(read(config)).toContain("'shiki/wasm': path.resolve("); + } + }); + + // The alias assertions above only read SOURCE. A future @pierre/diffs bump + // could reach the same inlined blob through a different import specifier and + // every source check would still pass, so this reads the ARTIFACT: a base64 + // WASM module always starts `\0asm\x01\0\0\0`, which encodes with the + // `AGFzbQ` prefix regardless of how it got inlined. + // + // dist/ is gitignored, so this skips cleanly on an unbuilt checkout. The CI + // job that builds the bundles runs this file right after the build so the + // assertion is not silently optional there. + const bundles = ['apps/review/dist/index.html', 'apps/hook/dist/index.html']; + test.each(bundles)('%s ships no inlined WebAssembly (skipped if unbuilt)', (path) => { + const full = resolve(root, path); + if (!existsSync(full)) return; + // Asserted on a boolean, not the string: these bundles are ~20MB and a + // `toContain` failure would print all of it. + const inlinedWasm = readFileSync(full, 'utf8').includes('AGFzbQ'); + expect({ path, inlinedWasm }).toEqual({ path, inlinedWasm: false }); }); });