mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
perf(ui): single Shiki highlighter, palette-matched code blocks, drop highlight.js (#1218)
* perf(build): stub out the dead Oniguruma WASM in every bundle
@pierre/diffs picks its Shiki engine with a runtime ternary:
engine: preferredHighlighter === "shiki-wasm"
? createOnigurumaEngine(import("shiki/wasm"))
: createJavaScriptRegexEngine()
Plannotator pins `preferredHighlighter: 'shiki-js'` (and Pierre's own
default is 'shiki-js'), so the Oniguruma branch never executes. Because
the choice is a runtime ternary, bundlers keep the `import("shiki/wasm")`
edge anyway and inline `@shikijs/engine-oniguruma/wasm-inlined`, a
~622 KB base64 blob, into the single-file HTML builds. The review app
paid for it twice: once on the main thread (via
`highlighter/shared_highlighter.js`) and once inside the `?worker&inline`
Pierre worker.
Alias `shiki/wasm` to a stub that throws if it is ever reached. Wired via
`resolve.alias` rather than a plugin because `resolve.alias` is shared
with Vite's worker build and `plugins` are not.
Highlighting output is unchanged: the JS regex engine was already the one
doing the work. Opting back into 'shiki-wasm' now fails loudly instead of
silently costing every user a megabyte of dead bytes.
apps/review/dist/index.html 19,424,646 -> 18,180,545 (-1,244,101 raw / -463,348 gzip)
apps/hook/dist/index.html 23,032,467 -> 22,410,416 (-622,051 raw / -233,485 gzip)
* perf(ui): consolidate code highlighting onto Shiki, drop highlight.js
The app shipped two highlighters. Shiki already tokenised the code-review
diff pane (via @pierre/diffs, JavaScript regex engine); highlight.js
separately coloured markdown fences and review suggestion snippets at
~982 KB minified for a full build of ~190 grammars. That second
highlighter is now gone.
Every call site moves onto `packages/ui/utils/codeHighlight.ts`, a thin
wrapper over Pierre's SHARED Shiki instance:
CodeBlock, Viewer, PlanCleanDiffView markdown fences
InlineMarkdown code-file hover preview
HighlightedCode review suggestion snippets
Reusing Pierre's instance rather than standing up a second fine-grained
one is deliberate. Pierre imports Shiki's full bundle, so every grammar
and theme is ALREADY inlined in the single-file builds: a separate
highlighter with a curated language list would have duplicated a subset
of bytes that are already there. Sharing costs nothing, gives every
language Shiki bundles instead of a shortlist, and — the point of the
change — guarantees fences resolve the exact same theme the diff pane
resolves.
Theming. `SHIKI_THEME_MAP` / `resolveSyntaxTheme` move from
`packages/review-editor/hooks/usePierreTheme.ts` to
`packages/ui/utils/syntaxTheme.ts`; usePierreTheme re-exports them, so
the review editor's imports are unchanged. `useFenceTheme()` feeds the
components and re-highlights on palette or mode change. Code blocks now
follow the active palette across all ~52 themes in both light and dark,
instead of always rendering github-dark and relying on hand-written
`.hljs-*` override stacks to stay legible. Those stacks are deleted:
`packages/editor/index.css`'s light-mode token palette, and
`colorblind.css`'s hand-tuned tokens which existed to APPROXIMATE
@pierre/theme's protanopia-deuteranopia themes that are now simply used.
Behaviour held fixed:
- Language-less fences stay plain text (#1212). No auto-detection
anywhere, including the hover preview, which previously called
`hljs.highlightAuto`. `HighlightedCode` derives its language from
the caller's file path; an unknown extension renders plain.
- `applyHighlight(el, ...)` keeps the imperative `hljs.highlightElement`
DOM contract the annotation layer reaches into, and writes plain text
at final size first so async highlighting causes no layout shift.
Already-attached grammars highlight synchronously — no flicker on
cached highlights.
- It also verifies the rendered text is byte-identical to the source
and falls back to plain otherwise, because annotations address code
blocks by text offset.
- `@plannotator/ui`'s public API is unchanged: the highlighter is a
module-level default like the package's other seams, no new props.
The `hljs` class on fenced `<code>` becomes `pn-code` (it is a
structural hook for blockTargeting, vim navigation and print.css, and it
named a library we no longer ship). `language-*` stays.
apps/review/dist/index.html 18,180,545 -> 17,270,889 (-909,656 raw / -291,921 gzip)
apps/hook/dist/index.html 22,410,416 -> 21,704,434 (-705,982 raw / -238,096 gzip)
Verified the diff pane is untouched: the rendered Pierre shadow-DOM
markup is byte-for-byte identical between an origin/main build and this
one (SHA-256 aa1ee88a…).
* fix(ui): strip stray NUL bytes from the code-highlight source
Two U+0000 bytes slipped into comments in the previous commit, which made
git treat the file as binary. Replaced with spaces; no behaviour change.
* fix(ui): keep code-block annotation marks across highlight swaps
Fenced code is annotated by hand: one `<mark data-bind-id>` inside the
`<code>` element, which `applyHighlight` also owns. Every highlight swap
(palette change, dark/light toggle, or the first async grammar attach
after load) replaces that element's children, so the mark was silently
wiped and nothing put it back. Annotation state, the sidebar panel and
exports were unaffected; the loss was purely visual, and deterministic.
`applyHighlight` now publishes every write through `onCodeHighlightSwap`,
synchronously, immediately after it. `Viewer` subscribes and re-paints the
fence's mark, so a swapped block ends up with BOTH the new theme's tokens
and its annotation. The shared painter (`paintCodeBlockMark`) moves the
token spans into the mark instead of flattening them to text, so creating
an annotation no longer costs a block its colours either.
Being driven by the swap also fixes the cousin race by ordering rather
than timing: share/draft restore runs on a timer after load, and on a slow
machine the first async swap could land after it and wipe the restored
marks per block. A restore that painted before the swap is now
re-established in the same task the swap ran in, and one that runs after
finds the mark already there.
Removal tombstones the id before re-highlighting, because the host drops
the annotation from state a tick later — without it the swap listener
would paint the just-removed annotation back in, and a fence carrying a
second annotation would end up bare.
Also closes the named gap in the WASM coverage: entry-assets only grepped
source, so a future @pierre/diffs bump could reintroduce the inlined blob
through a different import specifier unnoticed. It now greps the built
`apps/{review,hook}/dist/index.html` for the base64 WASM magic, skipping
on an unbuilt checkout and running for real in the CI job that builds the
bundles.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
@@ -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=="],
|
||||
|
||||
+11
-63
@@ -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 <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). */
|
||||
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, string> = {
|
||||
|
||||
@@ -458,7 +458,7 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
|
||||
|
||||
const inlineSource = (
|
||||
<pre className="rounded-lg text-[13px] overflow-x-auto bg-muted/50 border border-border/30 p-4">
|
||||
<code className={`hljs font-mono language-${block.language ?? 'graphviz'}`}>{block.content}</code>
|
||||
<code className={`pn-code font-mono language-${block.language ?? 'graphviz'}`}>{block.content}</code>
|
||||
</pre>
|
||||
);
|
||||
|
||||
|
||||
@@ -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, '<').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, '<').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<{
|
||||
<span>{filepath.split('/').pop()}</span>
|
||||
<span className="opacity-60">{lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`}</span>
|
||||
</div>
|
||||
<div className="hljs code-snippet-preview overflow-auto text-[12px] leading-5 min-h-0" style={{ padding: 0, background: 'var(--code-bg, #1e293b)' }}>
|
||||
<div className="code-snippet-preview overflow-auto text-[12px] leading-5 min-h-0" style={{ padding: 0, background: 'var(--code-bg, #1e293b)' }}>
|
||||
<table className="border-collapse w-full">
|
||||
<tbody>
|
||||
{snippet.split('\n').map((_, i) => (
|
||||
|
||||
@@ -523,7 +523,7 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
|
||||
|
||||
const inlineSource = (
|
||||
<pre className="rounded-lg text-[13px] overflow-x-auto bg-muted/50 border border-border/30 p-4">
|
||||
<code className="hljs font-mono language-mermaid">{block.content}</code>
|
||||
<code className="pn-code font-mono language-mermaid">{block.content}</code>
|
||||
</pre>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* A code-block annotation mark must survive every syntax-highlight swap.
|
||||
*
|
||||
* Fenced code is annotated by hand — one `<mark data-bind-id>` inside the
|
||||
* `<code>` 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<string, string> = {
|
||||
'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<Annotation[]>(initial);
|
||||
const theme = useTheme();
|
||||
const viewerRef = React.useRef<ViewerHandle>(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 (
|
||||
<Viewer
|
||||
ref={viewerRef}
|
||||
blocks={codeBlocks}
|
||||
markdown={`\`\`\`typescript\n${CODE}\n\`\`\``}
|
||||
annotations={annotations}
|
||||
onAddAnnotation={(annotation) => setAnnotations((prev) => [...prev, annotation])}
|
||||
onSelectAnnotation={() => {}}
|
||||
selectedAnnotationId={null}
|
||||
mode="redline"
|
||||
inputMethod="pinpoint"
|
||||
taterMode={false}
|
||||
disableCodePathValidation
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
async function mountHarness(initial: Annotation[] = []): Promise<void> {
|
||||
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.
|
||||
<ThemeProvider
|
||||
defaultTheme="dark"
|
||||
defaultColorTheme="github"
|
||||
colorThemeStorageKey={`plannotator-color-theme-swap-test-${++keySeq}`}
|
||||
storageKey={`plannotator-theme-swap-test-${keySeq}`}
|
||||
>
|
||||
<Harness initial={initial} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Let queued microtasks (the highlighter attach + its swap) run. */
|
||||
async function flush(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function codeEl(): HTMLElement {
|
||||
const el = document.querySelector<HTMLElement>('[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<HTMLElement>('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<HTMLElement>('[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<HTMLElement>('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<HTMLElement>('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']);
|
||||
});
|
||||
});
|
||||
@@ -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<ViewerHandle, ViewerProps>(({
|
||||
const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
|
||||
const [locationHash, setLocationHash] = useState(() => window.location.hash);
|
||||
const globalCommentButtonRef = useRef<HTMLButtonElement>(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<ViewerHandle, ViewerProps>(({
|
||||
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<ViewerHandle, ViewerProps>(({
|
||||
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<Set<string>>(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 `<code>` 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<ViewerHandle, ViewerProps>(({
|
||||
// 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 <mark> elements.
|
||||
const manualHighlights = containerRef.current?.querySelectorAll(`[data-bind-id="${id}"]`);
|
||||
@@ -628,13 +691,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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<CodeBlockProps> = ({ block, onHover, onLeave })
|
||||
const [copied, setCopied] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const codeRef = useRef<HTMLElement>(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<CodeBlockProps> = ({ block, onHover, onLeave })
|
||||
};
|
||||
|
||||
// Build className for code element
|
||||
const codeClassName = `hljs font-mono${block.language ? ` language-${block.language}` : ''}`;
|
||||
const codeClassName = codeBlockClassName(block.language);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import hljs from "highlight.js";
|
||||
import { parseMarkdownToBlocks, computeListIndices } from "../../utils/parser";
|
||||
import { applyHighlight, codeBlockClassName } from "../../utils/codeHighlight";
|
||||
import { useFenceTheme } from "../../hooks/useFenceTheme";
|
||||
import { ListItemBody } from "../ListItemBody";
|
||||
import type { Block, Annotation, EditorMode, ImageAttachment } from "../../types";
|
||||
import { AnnotationType } from "../../types";
|
||||
@@ -695,26 +696,20 @@ const SimpleBlockRenderer: React.FC<{ block: Block; orderedIndex?: number | null
|
||||
|
||||
const SimpleCodeBlock: React.FC<{ block: Block }> = ({ block }) => {
|
||||
const codeRef = useRef<HTMLElement>(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 (
|
||||
<div className="relative group my-5">
|
||||
<pre className="bg-muted/50 border border-border/30 rounded-lg overflow-x-auto">
|
||||
<code
|
||||
ref={codeRef}
|
||||
className={`hljs font-mono${block.language ? ` language-${block.language}` : ""}`}
|
||||
>
|
||||
<code ref={codeRef} className={codeBlockClassName(block.language)}>
|
||||
{block.content}
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
Vendored
+1
-1
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -55,7 +55,7 @@ function VimHarness({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div data-block-id="code"><pre><code className="hljs">const x = 1;</code></pre></div>
|
||||
<div data-block-id="code"><pre><code className="pn-code">const x = 1;</code></pre></div>
|
||||
<a data-testid="native-link" href="#destination">Native link</a>
|
||||
<input data-testid="native-input" defaultValue="typing stays native" />
|
||||
</article>
|
||||
|
||||
@@ -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",
|
||||
|
||||
+14
-13
@@ -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 <html> 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<HTMLElement>('[data-pinpoint-group]');
|
||||
const parentKey = group ? groupTargets.get(group)?.key ?? null : null;
|
||||
const codeElement = block.querySelector<HTMLElement>('pre > code.hljs');
|
||||
const codeElement = block.querySelector<HTMLElement>('pre > code.pn-code');
|
||||
const mathElement = block.matches('.math-annotatable,[data-math-tex]')
|
||||
? block
|
||||
: block.querySelector<HTMLElement>('.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<HTMLElement>('pre > code.hljs');
|
||||
const code = block.querySelector<HTMLElement>('pre > code.pn-code');
|
||||
if (
|
||||
code
|
||||
&& (pointerTarget === code || code.contains(pointerTarget) || pointerTarget.closest('pre'))
|
||||
|
||||
@@ -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('<span style="color:#79c0ff">const</span> 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('<span style="color:#79c0ff">const</span> 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 <b>text</b>';
|
||||
paintCodeBlockMark(code, 'ann-3', AnnotationType.COMMENT);
|
||||
|
||||
expect(code.textContent).toBe('plain <b>text</b>');
|
||||
expect(code.querySelector('b')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* The annotation `<mark>` that covers a whole fenced code block.
|
||||
*
|
||||
* `web-highlighter` cannot select inside a `<pre>`, so fenced code is annotated
|
||||
* as an all-or-nothing block: one `<mark data-bind-id>` that is the `<code>`
|
||||
* 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 `<span>`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;
|
||||
}
|
||||
@@ -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 <b>text</b> & more', undefined, 'pierre-dark');
|
||||
expect(el.textContent).toBe('plain <b>text</b> & 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);
|
||||
});
|
||||
});
|
||||
@@ -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 `<code>` elements to wrap `<mark>`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 `<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<PierreModule | undefined> | undefined;
|
||||
|
||||
/** `${lang} ${theme}` pairs attached to the shared highlighter. */
|
||||
const ready = new Set<string>();
|
||||
/** Pairs the highlighter refused (unknown grammar or theme). Never retried. */
|
||||
const rejected = new Set<string>();
|
||||
const inflight = new Map<string, Promise<boolean>>();
|
||||
|
||||
const pairKey = (lang: string, theme: string) => `${lang} ${theme}`;
|
||||
|
||||
function loadPierre(): Promise<PierreModule | undefined> {
|
||||
pierreLoad ??= import('@pierre/diffs').then(
|
||||
(mod) => {
|
||||
pierre = mod;
|
||||
return mod;
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
return pierreLoad;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
interface ThemedTokenish {
|
||||
content: string;
|
||||
color?: string;
|
||||
bgColor?: string;
|
||||
fontStyle?: number;
|
||||
htmlStyle?: Record<string, string> | 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
|
||||
? `<span style="${style}">${escapeHtml(token.content)}</span>`
|
||||
: 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<boolean> {
|
||||
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<HTMLElement, number>();
|
||||
let seqCounter = 0;
|
||||
|
||||
type HighlightSwapListener = (el: HTMLElement) => void;
|
||||
const swapListeners = new Set<HighlightSwapListener>();
|
||||
|
||||
/**
|
||||
* Observe every write `applyHighlight` makes to a `<code>` element.
|
||||
*
|
||||
* Each write REPLACES the element's children, which destroys anything the
|
||||
* annotation layer wrapped inside it — a whole-fence `<mark data-bind-id>` 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;
|
||||
}
|
||||
@@ -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<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 },
|
||||
};
|
||||
|
||||
/** `@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];
|
||||
}
|
||||
@@ -23,7 +23,7 @@ function createDocumentFixture(): HTMLElement {
|
||||
'<thead><tr><th>A1</th><th>A2</th></tr></thead>',
|
||||
'<tbody><tr><td>B1</td><td>B2</td></tr><tr><td>C1</td><td>C2</td></tr></tbody>',
|
||||
'</table></div>',
|
||||
'<div data-block-id="code"><pre><code class="hljs">const answer = 42;</code></pre></div>',
|
||||
'<div data-block-id="code"><pre><code class="pn-code">const answer = 42;</code></pre></div>',
|
||||
'<p data-block-id="outro">Charlie delta</p>',
|
||||
].join('');
|
||||
document.body.appendChild(container);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user