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:
parent
cf6ffd4919
commit
3443df9b13
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export * from "./copy-button"
|
||||
export * from "./shiki"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./Shiki"
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import {
|
||||
transformerMetaHighlight,
|
||||
transformerNotationDiff,
|
||||
transformerNotationHighlight,
|
||||
} from "@shikijs/transformers"
|
||||
import type { ShikiTransformer } from "shiki"
|
||||
|
||||
export const shikiTransformers: ShikiTransformer[] = [
|
||||
transformerMetaHighlight(),
|
||||
transformerNotationDiff(),
|
||||
transformerNotationHighlight(),
|
||||
]
|
||||
|
|
@ -14,7 +14,7 @@ export const usePreviewImages = () => {
|
|||
</div>
|
||||
),
|
||||
title: "Image",
|
||||
|
||||
overlay: true,
|
||||
CustomModalComponent: ({ children }) => children,
|
||||
clickOutsideToDismiss: true,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue