feat: optimize web bundle with custom minimal Shiki highlighter

Replaces react-shiki with a custom minimal Shiki highlighter to significantly reduce bundle size. Only loads 29 common languages and 2 themes (github-light/dark), using the JavaScript RegExp engine instead of WASM.

- Reduced web bundle from 5.8MB to 2.5MB (57% reduction)
- Saves 622KB by avoiding WASM runtime
- Graceful fallback for unsupported languages
- Removed react-shiki dependency
- Added @shikijs/langs, @shikijs/themes, shiki, hast-util-to-jsx-runtime
- Added light mode CSS rules for syntax highlighting
This commit is contained in:
weishu
2025-12-18 15:09:49 +08:00
parent be00343289
commit 3cf66983fc
6 changed files with 192 additions and 46 deletions
+4 -3
View File
@@ -76,14 +76,17 @@
"@assistant-ui/react-markdown": "^0.11.8", "@assistant-ui/react-markdown": "^0.11.8",
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-slot": "^1.2.0",
"@shikijs/langs": "^3.20.0",
"@shikijs/themes": "^3.20.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"diff": "^7.0.0", "diff": "^7.0.0",
"hast-util-to-jsx-runtime": "^2.3.6",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-shiki": "^0.9.1",
"react-textarea-autosize": "^8.5.9", "react-textarea-autosize": "^8.5.9",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"shiki": "^3.20.0",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
}, },
@@ -1722,8 +1725,6 @@
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-shiki": ["react-shiki@0.9.1", "", { "dependencies": { "clsx": "^2.1.1", "dequal": "^2.0.3", "hast-util-to-jsx-runtime": "^2.3.6", "shiki": "^3.11.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "@types/react-dom": ">=16.8.0", "react": ">= 16.8.0", "react-dom": ">= 16.8.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ln1PnISi7WaSlheSBRdxVruVbU1zMUkCmxe+vmbIvZSsHdfvOF5NBOgf1h4cCr6OjdR0dLAxmPKcx3tobdyxVA=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A=="], "react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A=="],
+4 -1
View File
@@ -14,14 +14,17 @@
"@assistant-ui/react-markdown": "^0.11.8", "@assistant-ui/react-markdown": "^0.11.8",
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-slot": "^1.2.0",
"@shikijs/langs": "^3.20.0",
"@shikijs/themes": "^3.20.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"diff": "^7.0.0", "diff": "^7.0.0",
"hast-util-to-jsx-runtime": "^2.3.6",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-shiki": "^0.9.1",
"react-textarea-autosize": "^8.5.9", "react-textarea-autosize": "^8.5.9",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"shiki": "^3.20.0",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tailwind-merge": "^2.5.5" "tailwind-merge": "^2.5.5"
}, },
+5 -26
View File
@@ -1,21 +1,6 @@
import type { Themes } from 'react-shiki/web' import { useState } from 'react'
import { useMemo, useState } from 'react'
import { useShikiHighlighter } from 'react-shiki/web'
import { usePlatform } from '@/hooks/usePlatform' import { usePlatform } from '@/hooks/usePlatform'
import { useShikiHighlighter } from '@/lib/shiki'
const SHIKI_THEMES: Themes = {
light: 'github-light',
dark: 'github-dark',
}
function normalizeLanguage(language?: string): string {
const raw = language?.trim()
if (!raw) return 'text'
const cleaned = raw.startsWith('language-') ? raw.slice('language-'.length) : raw
const canonical = cleaned.toLowerCase()
if (canonical === 'text' || canonical === 'plaintext' || canonical === 'txt') return 'text'
return cleaned
}
function safeCopyToClipboard(text: string): Promise<void> { function safeCopyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) { if (navigator.clipboard?.writeText) {
@@ -70,14 +55,10 @@ export function CodeBlock(props: {
}) { }) {
const { haptic } = usePlatform() const { haptic } = usePlatform()
const showCopyButton = props.showCopyButton ?? true const showCopyButton = props.showCopyButton ?? true
const normalizedLanguage = useMemo(() => normalizeLanguage(props.language), [props.language])
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const highlighted = useShikiHighlighter(props.code, normalizedLanguage, SHIKI_THEMES, { const highlighted = useShikiHighlighter(props.code, props.language)
delay: 75,
structure: 'inline',
})
const handleCopy = async () => { const handleCopy = async () => {
try { try {
@@ -103,10 +84,8 @@ export function CodeBlock(props: {
</button> </button>
) : null} ) : null}
<pre className="overflow-auto p-2 pr-8 text-xs"> <pre className="shiki overflow-auto p-2 pr-8 text-xs font-mono">
<code className="shiki font-mono"> <code>{highlighted ?? props.code}</code>
{highlighted ?? props.code}
</code>
</pre> </pre>
</div> </div>
) )
@@ -1,24 +1,14 @@
import type { SyntaxHighlighterProps } from '@assistant-ui/react-markdown' import type { SyntaxHighlighterProps } from '@assistant-ui/react-markdown'
import ShikiHighlighter from 'react-shiki/web' import { useShikiHighlighter } from '@/lib/shiki'
const SHIKI_THEMES = {
light: 'github-light',
dark: 'github-dark',
} as const
export function SyntaxHighlighter(props: SyntaxHighlighterProps) { export function SyntaxHighlighter(props: SyntaxHighlighterProps) {
const highlighted = useShikiHighlighter(props.code, props.language)
return ( return (
<div className="aui-md-codeblock overflow-hidden rounded-b-md bg-[var(--app-code-bg)]"> <div className="aui-md-codeblock overflow-hidden rounded-b-md bg-[var(--app-code-bg)]">
<ShikiHighlighter <pre className="shiki overflow-auto p-2 text-xs font-mono">
language={props.language} <code>{highlighted ?? props.code}</code>
theme={SHIKI_THEMES} </pre>
delay={75}
addDefaultStyles={false}
showLanguage={false}
as="div"
>
{props.code}
</ShikiHighlighter>
</div> </div>
) )
} }
+9
View File
@@ -111,6 +111,15 @@ body {
background-color: transparent !important; background-color: transparent !important;
} }
/* Shiki dual theme colors */
.shiki,
.shiki span {
color: var(--shiki-light) !important;
font-style: var(--shiki-light-font-style) !important;
font-weight: var(--shiki-light-font-weight) !important;
text-decoration: var(--shiki-light-text-decoration) !important;
}
html[data-theme="dark"] .shiki, html[data-theme="dark"] .shiki,
html[data-theme="dark"] .shiki span { html[data-theme="dark"] .shiki span {
color: var(--shiki-dark) !important; color: var(--shiki-dark) !important;
+164
View File
@@ -0,0 +1,164 @@
import { createHighlighterCore } from 'shiki/core'
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
import type { HighlighterCore } from 'shiki/core'
import { useState, useEffect, useMemo, type ReactNode } from 'react'
import { toJsxRuntime } from 'hast-util-to-jsx-runtime'
import { jsx, jsxs, Fragment } from 'react/jsx-runtime'
// Only 2 themes
const THEMES = [
import('@shikijs/themes/github-light'),
import('@shikijs/themes/github-dark'),
]
// 30 common languages for LLM code output
const LANGS = [
// Shell
import('@shikijs/langs/shellscript'),
import('@shikijs/langs/powershell'),
// Data formats
import('@shikijs/langs/json'),
import('@shikijs/langs/yaml'),
import('@shikijs/langs/toml'),
import('@shikijs/langs/xml'),
import('@shikijs/langs/ini'),
// Markup
import('@shikijs/langs/markdown'),
import('@shikijs/langs/html'),
import('@shikijs/langs/css'),
import('@shikijs/langs/scss'),
// JavaScript ecosystem
import('@shikijs/langs/javascript'),
import('@shikijs/langs/typescript'),
import('@shikijs/langs/jsx'),
import('@shikijs/langs/tsx'),
// Query languages
import('@shikijs/langs/sql'),
import('@shikijs/langs/graphql'),
// Systems languages
import('@shikijs/langs/c'),
import('@shikijs/langs/rust'),
import('@shikijs/langs/go'),
// JVM
import('@shikijs/langs/java'),
import('@shikijs/langs/kotlin'),
// Scripting
import('@shikijs/langs/python'),
import('@shikijs/langs/php'),
// Apple
import('@shikijs/langs/swift'),
// .NET
import('@shikijs/langs/csharp'),
// DevOps
import('@shikijs/langs/dockerfile'),
import('@shikijs/langs/make'),
// Misc
import('@shikijs/langs/diff'),
]
export const SHIKI_THEMES = {
light: 'github-light',
dark: 'github-dark',
} as const
// Alias common code fence language names to canonical names
export const langAlias: Record<string, string> = {
sh: 'shellscript',
bash: 'shellscript',
zsh: 'shellscript',
shell: 'shellscript',
ps1: 'powershell',
js: 'javascript',
ts: 'typescript',
mjs: 'javascript',
cjs: 'javascript',
mts: 'typescript',
cts: 'typescript',
yml: 'yaml',
md: 'markdown',
htm: 'html',
pgsql: 'sql',
mysql: 'sql',
postgres: 'sql',
gql: 'graphql',
py: 'python',
rs: 'rust',
kt: 'kotlin',
cs: 'csharp',
makefile: 'make',
}
// Singleton highlighter instance
let highlighterPromise: Promise<HighlighterCore> | null = null
function getHighlighter(): Promise<HighlighterCore> {
if (!highlighterPromise) {
highlighterPromise = createHighlighterCore({
themes: THEMES,
langs: LANGS,
engine: createJavaScriptRegexEngine({ forgiving: true }),
})
}
return highlighterPromise
}
function resolveLanguage(lang: string | undefined): string {
if (!lang) return 'text'
const cleaned = lang.startsWith('language-') ? lang.slice('language-'.length) : lang
const lower = cleaned.toLowerCase().trim()
if (lower === 'text' || lower === 'plaintext' || lower === 'txt') return 'text'
return langAlias[lower] ?? lower
}
/**
* Custom hook for syntax highlighting with our minimal Shiki bundle
*/
export function useShikiHighlighter(
code: string,
language: string | undefined
): ReactNode | null {
const [highlighted, setHighlighted] = useState<ReactNode | null>(null)
const lang = useMemo(() => resolveLanguage(language), [language])
useEffect(() => {
let cancelled = false
async function highlight() {
const highlighter = await getHighlighter()
if (cancelled) return
const loadedLangs = highlighter.getLoadedLanguages()
// Skip highlighting for unsupported languages (graceful fallback to plain text)
if (lang === 'text' || !loadedLangs.includes(lang)) {
setHighlighted(null)
return
}
const hast = highlighter.codeToHast(code, {
lang,
themes: SHIKI_THEMES,
defaultColor: false,
structure: 'inline',
})
if (cancelled) return
const rendered = toJsxRuntime(hast, {
jsx,
jsxs,
Fragment,
})
setHighlighted(rendered as ReactNode)
}
// Debounce highlighting
const timer = setTimeout(highlight, 50)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [code, lang])
return highlighted
}