feat(docs): enhance code presentation

This commit is contained in:
Maofeng
2026-09-28 16:00:01 +08:00
parent 91cfb3d068
commit 93373adb9a
6 changed files with 430 additions and 20 deletions
+6 -1
View File
@@ -12,6 +12,10 @@
},
"dependencies": {
"@icones/react": "^0.0.1",
"@shikijs/core": "^4.4.3",
"@shikijs/engine-javascript": "^4.4.3",
"@shikijs/langs": "^4.4.3",
"@shikijs/themes": "^4.4.3",
"@workspace/appearance": "workspace:*",
"@workspace/i18n": "workspace:*",
"@workspace/media": "workspace:*",
@@ -34,10 +38,11 @@
"@vitejs/plugin-react": "^6.1.1",
"babel-plugin-react-compiler": "^1.0.0",
"satteri": "^0.10.5",
"satteri-expressive-code": "^0.1.24",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.3.0",
"vite-plugin-satteri": "^0.3.5",
"wrangler": "^4.135.0"
}
}
}
@@ -0,0 +1,141 @@
import type { HighlighterCore } from "@shikijs/core"
export type HighlightedToken = {
content: string
dark: string
light: string
offset: number
}
export type HighlightedLine = {
offset: number
tokens: HighlightedToken[]
}
export type HighlightedSource = HighlightedLine[]
const languageLoaders = {
bash: () => import("@shikijs/langs/bash"),
css: () => import("@shikijs/langs/css"),
html: () => import("@shikijs/langs/html"),
javascript: () => import("@shikijs/langs/javascript"),
json: () => import("@shikijs/langs/json"),
jsx: () => import("@shikijs/langs/jsx"),
markdown: () => import("@shikijs/langs/markdown"),
mdx: () => import("@shikijs/langs/mdx"),
tsx: () => import("@shikijs/langs/tsx"),
typescript: () => import("@shikijs/langs/typescript"),
yaml: () => import("@shikijs/langs/yaml"),
} as const
type SyntaxLanguage = keyof typeof languageLoaders
const languageByExtension: Readonly<Record<string, SyntaxLanguage>> = {
bash: "bash",
cjs: "javascript",
css: "css",
html: "html",
js: "javascript",
json: "json",
jsx: "jsx",
md: "markdown",
mdx: "mdx",
mjs: "javascript",
sh: "bash",
ts: "typescript",
tsx: "tsx",
yaml: "yaml",
yml: "yaml",
zsh: "bash",
}
const highlighterPromises = new Map<SyntaxLanguage, Promise<HighlighterCore>>()
export async function highlightSource(
source: string,
filename: string
): Promise<HighlightedSource> {
const extension = filename.split(".").pop()?.toLowerCase() ?? ""
const language = languageByExtension[extension]
if (!language) return createPlainSource(source)
const highlighter = await getHighlighter(language)
const lines = highlighter.codeToTokensWithThemes(source, {
lang: language,
themes: {
dark: "github-dark",
light: "github-light",
},
})
let lineOffset = 0
return lines.map((line) => {
const highlightedLine: HighlightedLine = {
offset: lineOffset,
tokens: line.map((token) => ({
content: token.content,
dark: token.variants.dark?.color ?? "currentColor",
light: token.variants.light?.color ?? "currentColor",
offset: token.offset,
})),
}
lineOffset += line.reduce(
(length, token) => length + token.content.length,
0
)
lineOffset += 1
return highlightedLine
})
}
function getHighlighter(language: SyntaxLanguage) {
const cached = highlighterPromises.get(language)
if (cached) return cached
const highlighter = Promise.all([
import("@shikijs/core"),
import("@shikijs/engine-javascript"),
import("@shikijs/themes/github-dark"),
import("@shikijs/themes/github-light"),
languageLoaders[language](),
]).then(
([
{ createHighlighterCore },
{ createJavaScriptRegexEngine },
{ default: githubDark },
{ default: githubLight },
{ default: grammar },
]) =>
createHighlighterCore({
engine: createJavaScriptRegexEngine(),
langs: grammar,
themes: [githubDark, githubLight],
})
)
highlighterPromises.set(language, highlighter)
return highlighter
}
function createPlainSource(source: string): HighlightedSource {
let offset = 0
return source.split("\n").map((content) => {
const line: HighlightedLine = {
offset,
tokens: [
{
content,
dark: "inherit",
light: "inherit",
offset,
},
],
}
offset += content.length + 1
return line
})
}
+124 -19
View File
@@ -5,6 +5,7 @@ import { useTranslate } from "../../lib/locale"
import { getSandboxDefinition } from "./registry"
import { SandboxErrorBoundary } from "./sandbox-error-boundary"
import { SandboxFrame } from "./sandbox-frame"
import { highlightSource, type HighlightedSource } from "./highlight-source"
import type {
SandboxDefinition,
SandboxPreviewModule,
@@ -13,6 +14,8 @@ import type {
type SandboxView = "code" | "preview"
const codeFontSizes = [12, 13, 14, 16] as const
export type SandboxProps = {
className?: string
example: string
@@ -87,14 +90,13 @@ function SandboxViewer({
[definition.files]
)
const [view, setView] = React.useState<SandboxView>("preview")
const [selectedFile, setSelectedFile] = React.useState(
definition.defaultFile ?? filenames[0] ?? ""
)
const [previewModule, setPreviewModule] =
React.useState<SandboxPreviewModule | null>(null)
const [selectedFile, setSelectedFile] = React.useState(definition.defaultFile ?? filenames[0] ?? "")
const [previewModule, setPreviewModule] = React.useState<SandboxPreviewModule | null>(null)
const [previewError, setPreviewError] = React.useState<Error | null>(null)
const [source, setSource] = React.useState<string | null>(null)
const [sourceError, setSourceError] = React.useState<Error | null>(null)
const [highlightedSource, setHighlightedSource] = React.useState<HighlightedSource | null>(null)
const [codeFontSizeIndex, setCodeFontSizeIndex] = React.useState(1)
const [reloadKey, setReloadKey] = React.useState(0)
const [copied, setCopied] = React.useState(false)
const resolvedTitle = title ?? definition.title ?? definition.id
@@ -131,6 +133,7 @@ function SandboxViewer({
let active = true
setSource(null)
setSourceError(null)
setHighlightedSource(null)
resolveSource(file).then(
(value) => {
if (active) setSource(value)
@@ -145,6 +148,25 @@ function SandboxViewer({
}
}, [definition.files, selectedFile, view])
React.useEffect(() => {
if (!source || view !== "code") return
let active = true
setHighlightedSource(null)
highlightSource(source, selectedFile).then(
(value) => {
if (active) setHighlightedSource(value)
},
() => {
if (active) setHighlightedSource(null)
}
)
return () => {
active = false
}
}, [selectedFile, source, view])
React.useEffect(() => {
setSelectedFile(definition.defaultFile ?? filenames[0] ?? "")
}, [definition.defaultFile, filenames])
@@ -182,16 +204,24 @@ function SandboxViewer({
<div className="ml-auto flex items-center gap-1">
{view === "code" ? (
<SandboxAction
disabled={!source}
label={t({ zh: "复制源码", us: "Copy source" })}
onClick={() => void copySource()}
>
<Icon
name={copied ? "lucide:check" : "lucide:copy"}
className="size-4"
<>
<CodeFontSizeControl
decreaseLabel={t({ zh: "减小字号", us: "Decrease font size" })}
increaseLabel={t({ zh: "增大字号", us: "Increase font size" })}
index={codeFontSizeIndex}
onIndexChange={setCodeFontSizeIndex}
/>
</SandboxAction>
<SandboxAction
disabled={!source}
label={t({ zh: "复制源码", us: "Copy source" })}
onClick={() => void copySource()}
>
<Icon
name={copied ? "lucide:check" : "lucide:copy"}
className="size-4"
/>
</SandboxAction>
</>
) : (
<SandboxAction
label={t({ zh: "重置预览", us: "Reset preview" })}
@@ -241,11 +271,21 @@ function SandboxViewer({
))}
</nav>
) : null}
<pre className="m-0 min-w-0 overflow-auto bg-slate-950 p-4 text-sm leading-6 text-slate-100">
<code>
{sourceError
? sourceError.message
: (source ?? t({ zh: "正在加载源码…", us: "Loading source…" }))}
<pre
className="m-0 min-w-0 overflow-auto bg-slate-50 p-4 font-mono text-slate-800 dark:bg-slate-950 dark:text-slate-100"
style={{
fontSize: `${codeFontSizes[codeFontSizeIndex]}px`,
lineHeight: 1.55,
}}
>
<code className="block min-w-max">
{sourceError ? (
sourceError.message
) : highlightedSource ? (
<HighlightedCode source={highlightedSource} />
) : (
(source ?? t({ zh: "正在加载源码…", us: "Loading source…" }))
)}
</code>
</pre>
</div>
@@ -254,6 +294,71 @@ function SandboxViewer({
)
}
function CodeFontSizeControl({
decreaseLabel,
increaseLabel,
index,
onIndexChange,
}: {
decreaseLabel: string
increaseLabel: string
index: number
onIndexChange: React.Dispatch<React.SetStateAction<number>>
}) {
return (
<div
className="inline-flex h-8 items-center rounded-md border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
aria-label={`${codeFontSizes[index]}px`}
>
<SandboxAction
disabled={index === 0}
label={decreaseLabel}
onClick={() => onIndexChange((value) => Math.max(0, value - 1))}
>
<Icon name="lucide:minus" className="size-3.5" />
</SandboxAction>
<span className="w-9 text-center font-mono text-[11px] text-slate-500 tabular-nums dark:text-slate-400">
{codeFontSizes[index]}
</span>
<SandboxAction
disabled={index === codeFontSizes.length - 1}
label={increaseLabel}
onClick={() =>
onIndexChange((value) =>
Math.min(codeFontSizes.length - 1, value + 1)
)
}
>
<Icon name="lucide:plus" className="size-3.5" />
</SandboxAction>
</div>
)
}
function HighlightedCode({ source }: { source: HighlightedSource }) {
return source.map((line, lineIndex) => (
<React.Fragment key={line.offset}>
<span className="sandbox-code-line">
{line.tokens.map((token) => (
<span
key={token.offset}
className="sandbox-code-token"
style={
{
"--sandbox-token-dark": token.dark,
"--sandbox-token-light": token.light,
} as React.CSSProperties
}
>
{token.content}
</span>
))}
</span>
{lineIndex < source.length - 1 ? "\n" : null}
</React.Fragment>
))
}
function SandboxTab({
active,
children,
+10
View File
@@ -86,3 +86,13 @@
@apply bg-indigo-200 text-indigo-950 dark:bg-indigo-500/40 dark:text-indigo-50;
}
}
@layer components {
.sandbox-code-token {
color: var(--sandbox-token-light);
}
.dark .sandbox-code-token {
color: var(--sandbox-token-dark);
}
}
+18
View File
@@ -3,6 +3,7 @@ import babel from "@rolldown/plugin-babel"
import tailwindcss from "@tailwindcss/vite"
import { icones } from "@icones/vite"
import { readFile } from "node:fs/promises"
import expressiveCode from "satteri-expressive-code"
import { fileURLToPath } from "node:url"
import { defineConfig, type Plugin } from "vite"
import { satteri } from "vite-plugin-satteri"
@@ -69,6 +70,23 @@ export default defineConfig({
mdx: {
jsxImportSource: "react",
},
hastPlugins: [
expressiveCode({
frames: false,
textMarkers: false,
themes: ["github-light", "github-dark"],
useDarkModeMediaQuery: false,
themeCssSelector: (theme) =>
theme.type === "dark" ? ".dark" : ":root:not(.dark)",
styleOverrides: {
borderRadius: "0.75rem",
borderWidth: "1px",
codeFontSize: "0.875rem",
codeLineHeight: "1.5rem",
codePaddingInline: "1rem",
},
}),
],
}),
tailwindcss(),
icones({