diff --git a/webui2/package.json b/webui2/package.json index a6da49516da772262879b113c49762002dd37492..3e66e6acd889812525784711bed1ced4373c71f3 100644 --- a/webui2/package.json +++ b/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", diff --git a/webui2/pnpm-lock.yaml b/webui2/pnpm-lock.yaml index c6e36fc27d265715768b12dc3acbefd7247799bd..3c1bdb8847051156e8727d4551ece38c08cb93fc 100644 --- a/webui2/pnpm-lock.yaml +++ b/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 diff --git a/webui2/src/components/code/file-viewer.tsx b/webui2/src/components/code/file-viewer.tsx index 81fe268161f740889d09b45ffaed79f751a544aa..ba51c001bc240d63889040ec900e9f6f51c9c874 100644 --- a/webui2/src/components/code/file-viewer.tsx +++ b/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 | null = null; - -function getHighlighter(): Promise { - 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; @@ -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()], }); diff --git a/webui2/src/components/content/markdown.tsx b/webui2/src/components/content/markdown.tsx index d16d26216d9193b457442b4061646f14f3de763e..90024cc377cd810f9825c31546888b7b8af3bc63 100644 --- a/webui2/src/components/content/markdown.tsx +++ b/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(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) {
| null = null; + +export function getHighlighter(): Promise { + 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;