From e4caa5cad365d77adbce20f1c7cde260c5781f8f Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 28 Jun 2024 13:10:45 +0800 Subject: [PATCH 1/2] feat: shiki highlighter Signed-off-by: Innei --- package.json | 2 + pnpm-lock.yaml | 25 +++ .../components/ui/code-highlighter/index.ts | 1 + .../ui/code-highlighter/shiki/Shiki.tsx | 162 ++++++++++++++++++ .../ui/code-highlighter/shiki/index.ts | 1 + .../ui/code-highlighter/shiki/shared.ts | 12 ++ .../src/components/ui/image/hooks.tsx | 2 +- .../components/ui/modal/stacked/overlay.tsx | 31 ++-- .../components/ui/modal/stacked/provider.tsx | 7 +- src/renderer/src/hono.ts | 52 +++--- src/renderer/src/lib/parse-html.ts | 55 +++++- .../src/modules/settings/tabs/apperance.tsx | 29 ++++ .../(with-layout)/feed/[id]/index.tsx | 2 +- src/renderer/src/store/ui.ts | 2 + 14 files changed, 334 insertions(+), 49 deletions(-) create mode 100644 src/renderer/src/components/ui/code-highlighter/shiki/Shiki.tsx create mode 100644 src/renderer/src/components/ui/code-highlighter/shiki/index.ts create mode 100644 src/renderer/src/components/ui/code-highlighter/shiki/shared.ts diff --git a/package.json b/package.json index 9115a05ef2..12a532e607 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8958b72c9d..d6e89d6144 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/renderer/src/components/ui/code-highlighter/index.ts b/src/renderer/src/components/ui/code-highlighter/index.ts index 875356c8d4..f4cbe9dfce 100644 --- a/src/renderer/src/components/ui/code-highlighter/index.ts +++ b/src/renderer/src/components/ui/code-highlighter/index.ts @@ -1 +1,2 @@ export * from "./copy-button" +export * from "./shiki" diff --git a/src/renderer/src/components/ui/code-highlighter/shiki/Shiki.tsx b/src/renderer/src/components/ui/code-highlighter/shiki/Shiki.tsx new file mode 100644 index 0000000000..19fd0534bc --- /dev/null +++ b/src/renderer/src/components/ui/code-highlighter/shiki/Shiki.tsx @@ -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 | null = + null + +export const ShikiHighLighter: FC = (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 ( +
+        {code}
+      
+ ) + } + return +} + +const ShikiCode: FC = ({ 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 ( +
+        {code}
+      
+ ) + } + return ( +
+
+ +
+ ) +} diff --git a/src/renderer/src/components/ui/code-highlighter/shiki/index.ts b/src/renderer/src/components/ui/code-highlighter/shiki/index.ts new file mode 100644 index 0000000000..7271726f34 --- /dev/null +++ b/src/renderer/src/components/ui/code-highlighter/shiki/index.ts @@ -0,0 +1 @@ +export * from "./Shiki" diff --git a/src/renderer/src/components/ui/code-highlighter/shiki/shared.ts b/src/renderer/src/components/ui/code-highlighter/shiki/shared.ts new file mode 100644 index 0000000000..a70e7cec9e --- /dev/null +++ b/src/renderer/src/components/ui/code-highlighter/shiki/shared.ts @@ -0,0 +1,12 @@ +import { + transformerMetaHighlight, + transformerNotationDiff, + transformerNotationHighlight, +} from "@shikijs/transformers" +import type { ShikiTransformer } from "shiki" + +export const shikiTransformers: ShikiTransformer[] = [ + transformerMetaHighlight(), + transformerNotationDiff(), + transformerNotationHighlight(), +] diff --git a/src/renderer/src/components/ui/image/hooks.tsx b/src/renderer/src/components/ui/image/hooks.tsx index 5addb4034b..791ca6b567 100644 --- a/src/renderer/src/components/ui/image/hooks.tsx +++ b/src/renderer/src/components/ui/image/hooks.tsx @@ -14,7 +14,7 @@ export const usePreviewImages = () => {
), title: "Image", - + overlay: true, CustomModalComponent: ({ children }) => children, clickOutsideToDismiss: true, }) diff --git a/src/renderer/src/components/ui/modal/stacked/overlay.tsx b/src/renderer/src/components/ui/modal/stacked/overlay.tsx index 6da6582b99..577a102757 100644 --- a/src/renderer/src/components/ui/modal/stacked/overlay.tsx +++ b/src/renderer/src/components/ui/modal/stacked/overlay.tsx @@ -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 ( - - - - ) -} +}) => ( + + + +) diff --git a/src/renderer/src/components/ui/modal/stacked/provider.tsx b/src/renderer/src/components/ui/modal/stacked/provider.tsx index c9e5817422..dfd3b321b1 100644 --- a/src/renderer/src/components/ui/modal/stacked/provider.tsx +++ b/src/renderer/src/components/ui/modal/stacked/provider.tsx @@ -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 ( {stack.map((item, index) => ( @@ -31,7 +36,7 @@ const ModalStack = () => { isTop={index === stack.length - 1} /> ))} - {stack.length > 0 && } + {stack.length > 0 && (modalSettingOverlay || forceOverlay) && } ) } diff --git a/src/renderer/src/hono.ts b/src/renderer/src/hono.ts index a4e22f6b4f..af97e56b2e 100644 --- a/src/renderer/src/hono.ts +++ b/src/renderer/src/hono.ts @@ -8,9 +8,9 @@ declare const routes: hono_hono_base.HonoBase { +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(), + }) + }, }, }), } diff --git a/src/renderer/src/modules/settings/tabs/apperance.tsx b/src/renderer/src/modules/settings/tabs/apperance.tsx index 89cf8b6e13..3f8fa9c030 100644 --- a/src/renderer/src/modules/settings/tabs/apperance.tsx +++ b/src/renderer/src/modules/settings/tabs/apperance.tsx @@ -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 = () => { {window.electron && } + { ) } +const ShikiTheme = () => { + const codeHighlightTheme = useUIStore((state) => state.codeHighlightTheme) + return ( +
+ Code Highlight Theme + +
+ ) +} const Fonts = () => { const { data } = useQuery({ diff --git a/src/renderer/src/pages/(external)/(with-layout)/feed/[id]/index.tsx b/src/renderer/src/pages/(external)/(with-layout)/feed/[id]/index.tsx index 3ab4e5b12b..101b6a85ea 100644 --- a/src/renderer/src/pages/(external)/(with-layout)/feed/[id]/index.tsx +++ b/src/renderer/src/pages/(external)/(with-layout)/feed/[id]/index.tsx @@ -97,7 +97,7 @@ export function Component() { - + follow on {" "} {APP_NAME} diff --git a/src/renderer/src/store/ui.ts b/src/renderer/src/store/ui.ts index c47c170378..91bc663d37 100644 --- a/src/renderer/src/store/ui.ts +++ b/src/renderer/src/store/ui.ts @@ -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 From d82f30187bf93ee33fdf2efa4d599b5f4e61d5eb Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 28 Jun 2024 13:19:47 +0800 Subject: [PATCH 2/2] update Signed-off-by: Innei --- src/renderer/src/hono.ts | 52 ++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 29 deletions(-) diff --git a/src/renderer/src/hono.ts b/src/renderer/src/hono.ts index af97e56b2e..a4e22f6b4f 100644 --- a/src/renderer/src/hono.ts +++ b/src/renderer/src/hono.ts @@ -8,9 +8,9 @@ declare const routes: hono_hono_base.HonoBase