Просмотр исходного кода

refactor(desktop): move markdown rendering to rust (#10000)

Shoubhit Dash 2 месяцев назад
Родитель
Сommit
c737776958

+ 1 - 0
bun.lock

@@ -198,6 +198,7 @@
         "@tauri-apps/plugin-store": "~2",
         "@tauri-apps/plugin-updater": "~2",
         "@tauri-apps/plugin-window-state": "~2",
+        "marked": "catalog:",
         "solid-js": "catalog:",
       },
       "devDependencies": {

+ 8 - 2
packages/app/src/app.tsx

@@ -23,6 +23,7 @@ import { NotificationProvider } from "@/context/notification"
 import { DialogProvider } from "@opencode-ai/ui/context/dialog"
 import { CommandProvider } from "@/context/command"
 import { LanguageProvider, useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
 import { Logo } from "@opencode-ai/ui/logo"
 import Layout from "@/pages/layout"
 import DirectoryLayout from "@/pages/directory-layout"
@@ -45,6 +46,11 @@ declare global {
   }
 }
 
+function MarkedProviderWithNativeParser(props: ParentProps) {
+  const platform = usePlatform()
+  return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
+}
+
 export function AppBaseProviders(props: ParentProps) {
   return (
     <MetaProvider>
@@ -54,11 +60,11 @@ export function AppBaseProviders(props: ParentProps) {
           <UiI18nBridge>
             <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
               <DialogProvider>
-                <MarkedProvider>
+                <MarkedProviderWithNativeParser>
                   <DiffComponentProvider component={Diff}>
                     <CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
                   </DiffComponentProvider>
-                </MarkedProvider>
+                </MarkedProviderWithNativeParser>
               </DialogProvider>
             </ErrorBoundary>
           </UiI18nBridge>

+ 3 - 0
packages/app/src/context/platform.tsx

@@ -46,6 +46,9 @@ export type Platform = {
 
   /** Set the default server URL to use on app startup (desktop only) */
   setDefaultServerUrl?(url: string | null): Promise<void>
+
+  /** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
+  parseMarkdown?(markdown: string): Promise<string>
 }
 
 export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({

+ 99 - 0
packages/desktop/src-tauri/Cargo.lock

@@ -464,6 +464,15 @@ dependencies = [
  "toml 0.9.8",
 ]
 
+[[package]]
+name = "caseless"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8"
+dependencies = [
+ "unicode-normalization",
+]
+
 [[package]]
 name = "cc"
 version = "1.2.47"
@@ -574,6 +583,23 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "comrak"
+version = "0.50.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "321d20bf105b6871a49da44c5fbb93e90a7cd6178ea5a9fe6cbc1e6d4504bc5e"
+dependencies = [
+ "caseless",
+ "entities",
+ "jetscii",
+ "phf 0.13.1",
+ "phf_codegen 0.13.1",
+ "rustc-hash",
+ "smallvec",
+ "typed-arena",
+ "unicode_categories",
+]
+
 [[package]]
 name = "concurrent-queue"
 version = "2.5.0"
@@ -1053,6 +1079,12 @@ dependencies = [
  "windows 0.51.1",
 ]
 
+[[package]]
+name = "entities"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca"
+
 [[package]]
 name = "enumflags2"
 version = "0.7.12"
@@ -2153,6 +2185,12 @@ dependencies = [
  "system-deps",
 ]
 
+[[package]]
+name = "jetscii"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e"
+
 [[package]]
 name = "jni"
 version = "0.21.1"
@@ -2986,6 +3024,7 @@ dependencies = [
 name = "opencode-desktop"
 version = "0.0.0"
 dependencies = [
+ "comrak",
  "futures",
  "gtk",
  "listeners",
@@ -3187,6 +3226,16 @@ dependencies = [
  "phf_shared 0.11.3",
 ]
 
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_shared 0.13.1",
+ "serde",
+]
+
 [[package]]
 name = "phf_codegen"
 version = "0.8.0"
@@ -3207,6 +3256,16 @@ dependencies = [
  "phf_shared 0.11.3",
 ]
 
+[[package]]
+name = "phf_codegen"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
+dependencies = [
+ "phf_generator 0.13.1",
+ "phf_shared 0.13.1",
+]
+
 [[package]]
 name = "phf_generator"
 version = "0.8.0"
@@ -3237,6 +3296,16 @@ dependencies = [
  "rand 0.8.5",
 ]
 
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared 0.13.1",
+]
+
 [[package]]
 name = "phf_macros"
 version = "0.10.0"
@@ -3291,6 +3360,15 @@ dependencies = [
  "siphasher 1.0.1",
 ]
 
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher 1.0.1",
+]
+
 [[package]]
 name = "pin-project-lite"
 version = "0.2.16"
@@ -5478,6 +5556,12 @@ version = "0.2.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
 
+[[package]]
+name = "typed-arena"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
+
 [[package]]
 name = "typeid"
 version = "1.0.3"
@@ -5548,12 +5632,27 @@ version = "1.0.22"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
 
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
 [[package]]
 name = "unicode-segmentation"
 version = "1.12.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
 
+[[package]]
+name = "unicode_categories"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
+
 [[package]]
 name = "untrusted"
 version = "0.9.0"

+ 1 - 0
packages/desktop/src-tauri/Cargo.toml

@@ -41,6 +41,7 @@ semver = "1.0.27"
 reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
 uuid = { version = "1.19.0", features = ["v4"] }
 tauri-plugin-decorum = "1.1.1"
+comrak = { version = "0.50", default-features = false }
 
 
 [target.'cfg(target_os = "linux")'.dependencies]

+ 3 - 1
packages/desktop/src-tauri/src/lib.rs

@@ -1,6 +1,7 @@
 mod cli;
 #[cfg(windows)]
 mod job_object;
+mod markdown;
 mod window_customizer;
 
 use cli::{install_cli, sync_cli};
@@ -283,7 +284,8 @@ pub fn run() {
             install_cli,
             ensure_server_ready,
             get_default_server_url,
-            set_default_server_url
+            set_default_server_url,
+            markdown::parse_markdown_command
         ])
         .setup(move |app| {
             let app = app.handle().clone();

+ 17 - 0
packages/desktop/src-tauri/src/markdown.rs

@@ -0,0 +1,17 @@
+use comrak::{markdown_to_html, Options};
+
+pub fn parse_markdown(input: &str) -> String {
+    let mut options = Options::default();
+    options.extension.strikethrough = true;
+    options.extension.table = true;
+    options.extension.tasklist = true;
+    options.extension.autolink = true;
+    options.render.r#unsafe = true;
+
+    markdown_to_html(input, &options)
+}
+
+#[tauri::command]
+pub async fn parse_markdown_command(markdown: String) -> Result<String, String> {
+    Ok(parse_markdown(&markdown))
+}

+ 4 - 0
packages/desktop/src/index.tsx

@@ -316,6 +316,10 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
   setDefaultServerUrl: async (url: string | null) => {
     await invoke("set_default_server_url", { url })
   },
+
+  parseMarkdown: async (markdown: string) => {
+    return invoke<string>("parse_markdown_command", { markdown })
+  },
 })
 
 createMenu()

+ 101 - 2
packages/ui/src/context/marked.tsx

@@ -1,6 +1,7 @@
 import { marked } from "marked"
 import markedKatex from "marked-katex-extension"
 import markedShiki from "marked-shiki"
+import katex from "katex"
 import { bundledLanguages, type BundledLanguage } from "shiki"
 import { createSimpleContext } from "./helper"
 import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/diffs"
@@ -375,10 +376,95 @@ registerCustomTheme("OpenCode", () => {
   } as unknown as ThemeRegistrationResolved)
 })
 
+function renderMathInText(text: string): string {
+  let result = text
+
+  // Display math: $$...$$
+  const displayMathRegex = /\$\$([\s\S]*?)\$\$/g
+  result = result.replace(displayMathRegex, (_, math) => {
+    try {
+      return katex.renderToString(math, {
+        displayMode: true,
+        throwOnError: false,
+      })
+    } catch {
+      return `$$${math}$$`
+    }
+  })
+
+  // Inline math: $...$
+  const inlineMathRegex = /(?<!\$)\$(?!\$)((?:[^$\\]|\\.)+?)\$(?!\$)/g
+  result = result.replace(inlineMathRegex, (_, math) => {
+    try {
+      return katex.renderToString(math, {
+        displayMode: false,
+        throwOnError: false,
+      })
+    } catch {
+      return `$${math}$`
+    }
+  })
+
+  return result
+}
+
+function renderMathExpressions(html: string): string {
+  // Split on code/pre/kbd tags to avoid processing their contents
+  const codeBlockPattern = /(<(?:pre|code|kbd)[^>]*>[\s\S]*?<\/(?:pre|code|kbd)>)/gi
+  const parts = html.split(codeBlockPattern)
+
+  return parts
+    .map((part, i) => {
+      // Odd indices are the captured code blocks - leave them alone
+      if (i % 2 === 1) return part
+      // Process math only in non-code parts
+      return renderMathInText(part)
+    })
+    .join("")
+}
+
+async function highlightCodeBlocks(html: string): Promise<string> {
+  const codeBlockRegex = /<pre><code(?:\s+class="language-([^"]*)")?>([\s\S]*?)<\/code><\/pre>/g
+  const matches = [...html.matchAll(codeBlockRegex)]
+  if (matches.length === 0) return html
+
+  const highlighter = await getSharedHighlighter({ themes: ["OpenCode"], langs: [] })
+
+  let result = html
+  for (const match of matches) {
+    const [fullMatch, lang, escapedCode] = match
+    const code = escapedCode
+      .replace(/&lt;/g, "<")
+      .replace(/&gt;/g, ">")
+      .replace(/&amp;/g, "&")
+      .replace(/&quot;/g, '"')
+      .replace(/&#39;/g, "'")
+
+    let language = lang || "text"
+    if (!(language in bundledLanguages)) {
+      language = "text"
+    }
+    if (!highlighter.getLoadedLanguages().includes(language)) {
+      await highlighter.loadLanguage(language as BundledLanguage)
+    }
+
+    const highlighted = highlighter.codeToHtml(code, {
+      lang: language,
+      theme: "OpenCode",
+      tabindex: false,
+    })
+    result = result.replace(fullMatch, () => highlighted)
+  }
+
+  return result
+}
+
+export type NativeMarkdownParser = (markdown: string) => Promise<string>
+
 export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({
   name: "Marked",
-  init: () => {
-    return marked.use(
+  init: (props: { nativeParser?: NativeMarkdownParser }) => {
+    const jsParser = marked.use(
       {
         renderer: {
           link({ href, title, text }) {
@@ -407,5 +493,18 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext(
         },
       }),
     )
+
+    if (props.nativeParser) {
+      const nativeParser = props.nativeParser
+      return {
+        async parse(markdown: string): Promise<string> {
+          const html = await nativeParser(markdown)
+          const withMath = renderMathExpressions(html)
+          return highlightCodeBlocks(withMath)
+        },
+      }
+    }
+
+    return jsParser
   },
 })