feat: shiki highlighter (#96)

* feat: shiki highlighter

Signed-off-by: Innei <i@innei.in>

* update

Signed-off-by: Innei <i@innei.in>

---------

Signed-off-by: Innei <i@innei.in>
This commit is contained in:
Innei 2024-06-28 14:30:47 +08:00 committed by GitHub
parent cf6ffd4919
commit 3443df9b13
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 305 additions and 26 deletions

View File

@ -50,6 +50,7 @@
"@radix-ui/react-tabs": "1.1.0",
"@radix-ui/react-toast": "1.2.1",
"@radix-ui/react-tooltip": "1.1.1",
"@shikijs/transformers": "1.9.1",
"@tanstack/query-sync-storage-persister": "5.45.0",
"@tanstack/react-query": "5.45.1",
"@tanstack/react-query-persist-client": "5.45.1",
@ -93,6 +94,7 @@
"rehype-parse": "9.0.0",
"rehype-sanitize": "6.0.0",
"rehype-stringify": "10.0.0",
"shiki": "1.9.1",
"sonner": "^1.5.0",
"superjson": "2.2.1",
"swiper": "11.1.4",

View File

@ -91,6 +91,9 @@ importers:
'@radix-ui/react-tooltip':
specifier: 1.1.1
version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@shikijs/transformers':
specifier: 1.9.1
version: 1.9.1
'@tanstack/query-sync-storage-persister':
specifier: 5.45.0
version: 5.45.0
@ -220,6 +223,9 @@ importers:
rehype-stringify:
specifier: 10.0.0
version: 10.0.0
shiki:
specifier: 1.9.1
version: 1.9.1
sonner:
specifier: ^1.5.0
version: 1.5.0(patch_hash=6s3tquyt5wnkqaogymn3mkivuq)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -2106,6 +2112,12 @@ packages:
cpu: [x64]
os: [win32]
'@shikijs/core@1.9.1':
resolution: {integrity: sha512-EmUful2MQtY8KgCF1OkBtOuMcvaZEvmdubhW0UHCGXi21O9dRLeADVCj+k6ZS+de7Mz9d2qixOXJ+GLhcK3pXg==}
'@shikijs/transformers@1.9.1':
resolution: {integrity: sha512-wPrGTpBURQ95IKPIhPQE3bGsANpPPtea1+aVHZp0aYtgxfL5UM3QbJ5rNdCuhcyjz/JNp5ZvSItOr+ayJxebJQ==}
'@sinclair/typebox@0.27.8':
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
@ -5227,6 +5239,9 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shiki@1.9.1:
resolution: {integrity: sha512-8PDkgb5ja3nfujTjvC4VytL6wGOGCtFAClUb2r3QROevYXxcq+/shVJK5s6gy0HZnjaJgFxd6BpPqpRfqne5rA==}
short-unique-id@5.2.0:
resolution: {integrity: sha512-cMGfwNyfDZ/nzJ2k2M+ClthBIh//GlZl1JEf47Uoa9XR11bz8Pa2T2wQO4bVrRdH48LrIDWJahQziKo3MjhsWg==}
hasBin: true
@ -7758,6 +7773,12 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.18.0':
optional: true
'@shikijs/core@1.9.1': {}
'@shikijs/transformers@1.9.1':
dependencies:
shiki: 1.9.1
'@sinclair/typebox@0.27.8': {}
'@sindresorhus/is@4.6.0': {}
@ -11549,6 +11570,10 @@ snapshots:
shebang-regex@3.0.0: {}
shiki@1.9.1:
dependencies:
'@shikijs/core': 1.9.1
short-unique-id@5.2.0: {}
siginfo@2.0.0: {}

View File

@ -1 +1,2 @@
export * from "./copy-button"
export * from "./shiki"

View File

@ -0,0 +1,162 @@
/* eslint-disable @eslint-react/dom/no-dangerously-set-innerhtml */
import { useUIStore } from "@renderer/store"
import type { FC } from "react"
import { useLayoutEffect, useMemo, useRef, useState } from "react"
import type {
BundledLanguage,
BundledTheme,
DynamicImportLanguageRegistration,
DynamicImportThemeRegistration,
} from "shiki"
import { createHighlighterCore } from "shiki/core"
import { default as getWasm } from "shiki/wasm"
import { CopyButton } from "../copy-button"
import { shikiTransformers } from "./shared"
const shiki = await createHighlighterCore({
themes: [
import("shiki/themes/github-dark.mjs"),
],
langs: [],
loadWasm: getWasm,
})
export interface ShikiProps {
language: string | undefined
code: string
attrs?: string
}
let langModule: Record<
BundledLanguage,
DynamicImportLanguageRegistration
> | null = null
let themeModule: Record<BundledTheme, DynamicImportThemeRegistration> | null =
null
export const ShikiHighLighter: FC<ShikiProps> = (props) => {
const { code, language } = props
const loadThemesRef = useRef([] as string[])
const loadLanguagesRef = useRef([] as string[])
const [loaded, setLoaded] = useState(false)
const codeTheme = useUIStore((s) => s.codeHighlightTheme)
useLayoutEffect(() => {
let isMounted = true
setLoaded(false)
async function loadShikiLanguage(language: string, languageModule: any) {
if (!shiki) return
if (!shiki.getLoadedLanguages().includes(language)) {
await shiki.loadLanguage(await languageModule())
}
}
async function loadShikiTheme(theme: string, themeModule: any) {
if (!shiki) return
if (!shiki.getLoadedThemes().includes(theme)) {
await shiki.loadTheme(await themeModule())
}
}
async function register() {
if (!language || !codeTheme) return
const [{ bundledLanguages }, { bundledThemes }] =
langModule && themeModule ?
[
{
bundledLanguages: langModule,
},
{ bundledThemes: themeModule },
] :
await Promise.all([import("shiki/langs"), import("shiki/themes")])
langModule = bundledLanguages
themeModule = bundledThemes
if (
language &&
loadLanguagesRef.current.includes(language) &&
codeTheme &&
(loadThemesRef.current.includes(codeTheme))
) {
return
}
return Promise.all([
(async () => {
if (language) {
const importFn = (bundledLanguages as any)[language]
if (!importFn) return
await loadShikiLanguage(language || "", importFn)
loadLanguagesRef.current.push(language)
}
})(),
(async () => {
if (codeTheme) {
const importFn = (bundledThemes as any)[codeTheme]
if (!importFn) return
await loadShikiTheme(codeTheme || "", importFn)
loadThemesRef.current.push(codeTheme)
}
})(),
])
}
register().then(() => {
if (isMounted) {
setLoaded(true)
}
})
return () => {
isMounted = false
}
}, [codeTheme, language])
if (!loaded) {
return (
<pre>
<code>{code}</code>
</pre>
)
}
return <ShikiCode {...props} codeTheme={codeTheme} />
}
const ShikiCode: FC<ShikiProps & {
codeTheme: string
}> = ({ code, language, codeTheme }) => {
const rendered = useMemo(() => {
try {
return shiki.codeToHtml(code, {
lang: language!,
themes: {
dark: codeTheme,
light: codeTheme,
},
transformers: shikiTransformers,
})
} catch {
// console.error(err)
return null
}
}, [code, language, codeTheme])
if (!rendered) {
return (
<pre>
<code>{code}</code>
</pre>
)
}
return (
<div className="group relative">
<div dangerouslySetInnerHTML={{ __html: rendered }} />
<CopyButton value={code} className="absolute right-2 top-2 opacity-0 duration-200 group-hover:opacity-100" />
</div>
)
}

View File

@ -0,0 +1 @@
export * from "./Shiki"

View File

@ -0,0 +1,12 @@
import {
transformerMetaHighlight,
transformerNotationDiff,
transformerNotationHighlight,
} from "@shikijs/transformers"
import type { ShikiTransformer } from "shiki"
export const shikiTransformers: ShikiTransformer[] = [
transformerMetaHighlight(),
transformerNotationDiff(),
transformerNotationHighlight(),
]

View File

@ -14,7 +14,7 @@ export const usePreviewImages = () => {
</div>
),
title: "Image",
overlay: true,
CustomModalComponent: ({ children }) => children,
clickOutsideToDismiss: true,
})

View File

@ -1,4 +1,3 @@
import { useUIStore } from "@renderer/store"
import { m } from "framer-motion"
import { RootPortal } from "../../portal"
@ -9,20 +8,16 @@ export const ModalOverlay = ({
}: {
onClick?: () => void
zIndex?: number
}) => {
const modalSettingOverlay = useUIStore((state) => state.modalOverlay)
if (!modalSettingOverlay) return null
return (
<RootPortal>
<m.div
id="modal-overlay"
onClick={onClick}
className="fixed inset-0 z-[11] bg-zinc-50/80 dark:bg-neutral-900/80"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ zIndex }}
/>
</RootPortal>
)
}
}) => (
<RootPortal>
<m.div
id="modal-overlay"
onClick={onClick}
className="fixed inset-0 z-[11] bg-zinc-50/80 dark:bg-neutral-900/80"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ zIndex }}
/>
</RootPortal>
)

View File

@ -1,3 +1,4 @@
import { useUIStore } from "@renderer/store"
import { AnimatePresence } from "framer-motion"
import { useAtomValue } from "jotai"
import type { FC, PropsWithChildren } from "react"
@ -21,6 +22,10 @@ const ModalStack = () => {
// Vite HMR issue
// useDismissAllWhenRouterChange()
const modalSettingOverlay = useUIStore((state) => state.modalOverlay)
const forceOverlay = stack.some((item) => item.overlay)
return (
<AnimatePresence mode="popLayout">
{stack.map((item, index) => (
@ -31,7 +36,7 @@ const ModalStack = () => {
isTop={index === stack.length - 1}
/>
))}
{stack.length > 0 && <ModalOverlay zIndex={MODAL_STACK_Z_INDEX + stack.length - 1} />}
{stack.length > 0 && (modalSettingOverlay || forceOverlay) && <ModalOverlay zIndex={MODAL_STACK_Z_INDEX + stack.length - 1} />}
</AnimatePresence>
)
}

View File

@ -1,8 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ShikiHighLighter } from "@renderer/components/ui/code-highlighter"
import { Image } from "@renderer/components/ui/image"
import { toJsxRuntime } from "hast-util-to-jsx-runtime"
import { createElement } from "react"
import { Fragment, jsx, jsxs } from "react/jsx-runtime"
import { renderToString } from "react-dom/server"
import rehypeInferDescriptionMeta from "rehype-infer-description-meta"
import rehypeParse from "rehype-parse"
import rehypeSanitize, { defaultSchema } from "rehype-sanitize"
@ -11,9 +13,12 @@ import { unified } from "unified"
import { visit } from "unist-util-visit"
import { VFile } from "vfile"
export const parseHtml = async (content: string, options?: {
renderInlineStyle: boolean
}) => {
export const parseHtml = async (
content: string,
options?: {
renderInlineStyle: boolean
},
) => {
const file = new VFile(content)
const { renderInlineStyle = false } = options || {}
@ -24,7 +29,9 @@ export const parseHtml = async (content: string, options?: {
attributes: {
...defaultSchema.attributes,
"*": renderInlineStyle ? [...defaultSchema.attributes!["*"], "style"] : defaultSchema.attributes!["*"],
"*": renderInlineStyle ?
[...defaultSchema.attributes!["*"], "style"] :
defaultSchema.attributes!["*"],
},
})
.use(rehypeInferDescriptionMeta)
@ -62,7 +69,45 @@ export const parseHtml = async (content: string, options?: {
jsxs: (type, props, key) => jsxs(type as any, props, key),
passNode: true,
components: {
img: (props) => createElement(Image, { ...props, popper: true }),
img: ({ node, ...props }) => createElement(Image, { ...props, popper: true }),
pre: ({ node, ...props }) => {
if (!props.children) return null
let language = "plaintext"
let codeString = null as string | null
if (props.className?.includes("language-")) {
language = props.className.replace("language-", "")
}
if (typeof props.children !== "object") {
language = "plaintext"
codeString = props.children.toString()
} else {
if (
"type" in props.children &&
props.children.type === "code" &&
props.children.props.className?.includes("language-")
) {
language = props.children.props.className.replace(
"language-",
"",
)
}
const code =
"props" in props.children && props.children.props.children
if (!code) return null
const $text = document.createElement("div")
$text.innerHTML = renderToString(code)
codeString = $text.textContent
}
if (!codeString) return null
// return createElement("pre", { ...props, className: "shiki" })
return createElement(ShikiHighLighter, {
code: codeString,
language: language.toLowerCase(),
})
},
},
}),
}

View File

@ -11,6 +11,7 @@ import { getOS } from "@renderer/lib/utils"
import { uiActions, useUIStore } from "@renderer/store"
import { useQuery } from "@tanstack/react-query"
import { useCallback } from "react"
import { bundledThemes } from "shiki/themes"
import { SettingSwitch } from "../control"
import { SettingSectionTitle } from "../section"
@ -82,6 +83,7 @@ export const SettingAppearance = () => {
<SettingSectionTitle title="Content" />
{window.electron && <Fonts />}
<ShikiTheme />
<SettingSwitch
label="Render inline style"
checked={state.readerRenderInlineStyle}
@ -100,6 +102,33 @@ export const SettingAppearance = () => {
</div>
)
}
const ShikiTheme = () => {
const codeHighlightTheme = useUIStore((state) => state.codeHighlightTheme)
return (
<div className="mb-3 flex items-center justify-between">
<span className="shrink-0 text-sm font-medium">Code Highlight Theme</span>
<Select
defaultValue="github-dark"
value={codeHighlightTheme}
onValueChange={(value) => {
uiActions.set("codeHighlightTheme", value)
}}
>
<SelectTrigger size="sm" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent className="h-64">
{Object.keys(bundledThemes)
?.map((theme) => (
<SelectItem key={theme} value={theme}>
{theme}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
const Fonts = () => {
const { data } = useQuery({

View File

@ -97,7 +97,7 @@ export function Component() {
</div>
<a className="mb-8" href={`${DEEPLINK_SCHEME}add?id=${id}`}>
<StyledButton>
<FollowIcon className="size-3" />
<FollowIcon className="mr-1 size-3" />
follow on
{" "}
{APP_NAME}

View File

@ -25,6 +25,7 @@ interface UIState {
// content
readerFontFamily: string
readerRenderInlineStyle: boolean
codeHighlightTheme: string
}
const createDefaultUIState = (): UIState => ({
@ -40,6 +41,7 @@ const createDefaultUIState = (): UIState => ({
modalDraggable: true,
modalOpaque: true,
readerRenderInlineStyle: false,
codeHighlightTheme: "github-dark",
})
interface UIActions {
clear: () => void