feat(docs): enhance code presentation
This commit is contained in:
+6
-1
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user