Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
15 changes: 14 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<code>` 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 `<pre>`, so a fenced block is annotated all-or-nothing: one `<mark data-bind-id>` that is the `<code>` 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

Expand Down
4 changes: 4 additions & 0 deletions apps/hook/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
3 changes: 3 additions & 0 deletions apps/portal/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
11 changes: 8 additions & 3 deletions apps/review/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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: {
Expand Down
38 changes: 38 additions & 0 deletions build/shiki-wasm-stub.ts
Original file line number Diff line number Diff line change
@@ -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;
12 changes: 4 additions & 8 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 11 additions & 63 deletions packages/editor/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 <div class="hljs">, 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 <pre><code>, 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). */

Expand Down
20 changes: 13 additions & 7 deletions packages/review-editor/components/HighlightedCode.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(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 ref={codeRef}>{code}</code>;
};
52 changes: 8 additions & 44 deletions packages/review-editor/hooks/usePierreTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { dark: string | null; light: string | null }> = {
'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';
Expand Down
Loading