mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user