@@ -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",
@@ -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
@@ -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()],
});
@@ -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"] }],
@@ -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;