feat(web): add Shiki syntax highlighting to Markdown code blocks

Quentin Gliech and Claude Opus 4.6 (1M context) created

Share the Shiki highlighter between FileViewer and Markdown via a
singleton in src/lib/shiki.ts. The Markdown component uses
@shikijs/rehype/core with a pre-resolved highlighter (react-markdown
doesn't support async plugins).

The highlighter loads in a useEffect — Markdown renders without
highlighting initially, then re-renders with syntax colors once
the highlighter is ready.

Also update sanitize schema to allow Shiki's style/class attributes
on pre/code/span elements, and adjust prose styles so inline code
inside highlighted blocks doesn't get bg-muted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Change summary

webui2/package.json                        |  1 
webui2/pnpm-lock.yaml                      | 16 ++++++++++++
webui2/src/components/code/file-viewer.tsx | 24 ++----------------
webui2/src/components/content/markdown.tsx | 31 ++++++++++++++++++++++-
webui2/src/lib/shiki.ts                    | 23 +++++++++++++++++
5 files changed, 72 insertions(+), 23 deletions(-)

Detailed changes

webui2/package.json 🔗

@@ -20,6 +20,7 @@
   "dependencies": {
     "@apollo/client": "^4.1.6",
     "@shikijs/langs": "^4.0.2",
+    "@shikijs/rehype": "^4.0.2",
     "@shikijs/themes": "^4.0.2",
     "@tanstack/react-router": "^1.168.8",
     "class-variance-authority": "^0.7.1",

webui2/pnpm-lock.yaml 🔗

@@ -14,6 +14,9 @@ importers:
       '@shikijs/langs':
         specifier: ^4.0.2
         version: 4.0.2
+      '@shikijs/rehype':
+        specifier: ^4.0.2
+        version: 4.0.2
       '@shikijs/themes':
         specifier: ^4.0.2
         version: 4.0.2
@@ -2131,6 +2134,10 @@ packages:
     resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==}
     engines: {node: '>=20'}
 
+  '@shikijs/rehype@4.0.2':
+    resolution: {integrity: sha512-cmPlKLD8JeojasNFoY64162ScpEdEdQUMuVodPCrv1nx1z3bjmGwoKWDruQWa/ejSznImlaeB0Ty6Q3zPaVQAA==}
+    engines: {node: '>=20'}
+
   '@shikijs/themes@4.0.2':
     resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==}
     engines: {node: '>=20'}
@@ -6848,6 +6855,15 @@ snapshots:
       '@shikijs/vscode-textmate': 10.0.2
       '@types/hast': 3.0.4
 
+  '@shikijs/rehype@4.0.2':
+    dependencies:
+      '@shikijs/types': 4.0.2
+      '@types/hast': 3.0.4
+      hast-util-to-string: 3.0.1
+      shiki: 4.0.2
+      unified: 11.0.5
+      unist-util-visit: 5.1.0
+
   '@shikijs/themes@4.0.2':
     dependencies:
       '@shikijs/types': 4.0.2

webui2/src/components/code/file-viewer.tsx 🔗

@@ -6,33 +6,15 @@ import { toJsxRuntime } from "hast-util-to-jsx-runtime";
 import { Copy } from "lucide-react";
 import { useState, useEffect, useCallback, Fragment, type ReactNode } from "react";
 import { jsx, jsxs } from "react/jsx-runtime";
-import { createHighlighterCore, type HighlighterCore, type ShikiTransformer } from "shiki/core";
-import { createOnigurumaEngine } from "shiki/engine/oniguruma";
+import type { ShikiTransformer } from "shiki/core";
 
 import type { GitBlob } from "@/__generated__/graphql";
 import { Button } from "@/components/ui/button";
 import { Skeleton } from "@/components/ui/skeleton";
+import { getHighlighter, SHIKI_THEMES } from "@/lib/shiki";
 
 import styles from "./file-viewer.module.css";
 
-// ── Shiki highlighter (lazy singleton) ────────────────────────────────────────
-
-let highlighterPromise: Promise<HighlighterCore> | null = null;
-
-function getHighlighter(): Promise<HighlighterCore> {
-  if (!highlighterPromise) {
-    highlighterPromise = createHighlighterCore({
-      themes: [
-        import("@shikijs/themes/github-light"),
-        import("@shikijs/themes/github-dark"),
-      ],
-      langs: [],
-      engine: createOnigurumaEngine(import("shiki/wasm")),
-    });
-  }
-  return highlighterPromise;
-}
-
 interface LangEntry {
   id: string;
   load: () => Promise<unknown>;
@@ -231,7 +213,7 @@ export function FileViewer({ blob }: FileViewerProps) {
 
       const hast = highlighter.codeToHast(blob.text!, {
         lang,
-        themes: { light: "github-light", dark: "github-dark" },
+        themes: SHIKI_THEMES,
         defaultColor: false,
         transformers: [lineNumberTransformer()],
       });

webui2/src/components/content/markdown.tsx 🔗

@@ -1,6 +1,8 @@
+import rehypeShikiFromHighlighter from "@shikijs/rehype/core";
 import { Link } from "@tanstack/react-router";
-import { useMemo } from "react";
+import { useMemo, useState, useEffect } from "react";
 import ReactMarkdown from "react-markdown";
+import type { PluggableList } from "unified";
 import rehypeAutolinkHeadings from "rehype-autolink-headings";
 import rehypeExternalLinks from "rehype-external-links";
 import rehypeRaw from "rehype-raw";
@@ -8,7 +10,9 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
 import rehypeSlug from "rehype-slug";
 import remarkEmoji from "remark-emoji";
 import remarkGfm from "remark-gfm";
+import type { HighlighterCore } from "shiki/core";
 
+import { getHighlighter, SHIKI_THEMES } from "@/lib/shiki";
 import { cn } from "@/lib/utils";
 
 // Sanitization schema: start from the safe default and allow a small set of
@@ -19,6 +23,10 @@ const sanitizeSchema = {
   attributes: {
     ...defaultSchema.attributes,
     a: [...(defaultSchema.attributes?.a ?? []), "aria-hidden", "class"],
+    // Allow Shiki's style attribute (CSS variables for theme colors) and class on code/spans
+    pre: [...(defaultSchema.attributes?.pre ?? []), "class", "style", "tabIndex"],
+    code: [...(defaultSchema.attributes?.code ?? []), "class", "style"],
+    span: [...(defaultSchema.attributes?.span ?? []), "class", "style"],
     "*": [...(defaultSchema.attributes?.["*"] ?? []), "id"],
   },
 };
@@ -72,7 +80,21 @@ function isImagePath(path: string): boolean {
 
 // Renders a Markdown string with GitHub-flavoured extensions (tables, task
 // lists, strikethrough). Used in Timeline comments and code browser READMEs.
+function useShikiHighlighter(): HighlighterCore | null {
+  const [highlighter, setHighlighter] = useState<HighlighterCore | null>(null);
+  useEffect(() => {
+    let cancelled = false;
+    void getHighlighter().then((h) => {
+      if (!cancelled) setHighlighter(h);
+    });
+    return () => { cancelled = true; };
+  }, []);
+  return highlighter;
+}
+
 export function Markdown({ content, className, repoContext }: MarkdownProps) {
+  const highlighter = useShikiHighlighter();
+
   // Rewrite image src to /gitraw for raw content serving.
   // Links are handled by the custom `a` component below.
   const urlTransform = useMemo(() => {
@@ -156,8 +178,10 @@ export function Markdown({ content, className, repoContext }: MarkdownProps) {
     <div
       className={cn(
         "prose prose-sm dark:prose-invert max-w-none",
-        "prose-pre:bg-muted prose-pre:text-foreground",
+        "prose-pre:rounded-md prose-pre:text-sm",
         "prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded-sm prose-code:text-sm prose-code:before:content-none prose-code:after:content-none",
+        // Don't style inline code inside Shiki-highlighted pre blocks
+        "prose-pre:prose-code:bg-transparent prose-pre:prose-code:p-0",
         "prose-img:inline prose-img:my-0",
         className,
       )}
@@ -167,6 +191,9 @@ export function Markdown({ content, className, repoContext }: MarkdownProps) {
         rehypePlugins={[
           rehypeRaw,
           [rehypeSanitize, sanitizeSchema],
+          ...(highlighter
+            ? [[rehypeShikiFromHighlighter, highlighter, { themes: SHIKI_THEMES, defaultColor: false }] as PluggableList[number]]
+            : []),
           rehypeSlug,
           [rehypeAutolinkHeadings, { behavior: "append" }],
           [rehypeExternalLinks, { target: "_blank", rel: ["noopener", "noreferrer"] }],

webui2/src/lib/shiki.ts 🔗

@@ -0,0 +1,23 @@
+// Shared Shiki highlighter singleton.
+// Used by both FileViewer (codeToHast) and Markdown (rehype plugin).
+
+import { createHighlighterCore, type HighlighterCore } from "shiki/core";
+import { createOnigurumaEngine } from "shiki/engine/oniguruma";
+
+let highlighterPromise: Promise<HighlighterCore> | null = null;
+
+export function getHighlighter(): Promise<HighlighterCore> {
+  if (!highlighterPromise) {
+    highlighterPromise = createHighlighterCore({
+      themes: [
+        import("@shikijs/themes/github-light"),
+        import("@shikijs/themes/github-dark"),
+      ],
+      langs: [],
+      engine: createOnigurumaEngine(import("shiki/wasm")),
+    });
+  }
+  return highlighterPromise;
+}
+
+export const SHIKI_THEMES = { light: "github-light", dark: "github-dark" } as const;