diff --git a/bun.lock b/bun.lock index 9d7d15a1d1..dd59ea9d98 100644 --- a/bun.lock +++ b/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": { diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index c59cbe8988..1c82439d4d 100644 --- a/packages/app/src/app.tsx +++ b/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 {props.children} +} + export function AppBaseProviders(props: ParentProps) { return ( @@ -54,11 +60,11 @@ export function AppBaseProviders(props: ParentProps) { }> - + {props.children} - + diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 6d2d3db060..89056b2c84 100644 --- a/packages/app/src/context/platform.tsx +++ b/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 + + /** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */ + parseMarkdown?(markdown: string): Promise } export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 5505f4e4d0..a41739a697 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/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" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index bcbf068bbb..cafd7ec42a 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/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] diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index aea730926a..6d601e9ee2 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/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(); diff --git a/packages/desktop/src-tauri/src/markdown.rs b/packages/desktop/src-tauri/src/markdown.rs new file mode 100644 index 0000000000..a2a53b2224 --- /dev/null +++ b/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 { + Ok(parse_markdown(&markdown)) +} diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index ac21b3c286..f9eb19a585 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -316,6 +316,10 @@ const createPlatform = (password: Accessor): Platform => ({ setDefaultServerUrl: async (url: string | null) => { await invoke("set_default_server_url", { url }) }, + + parseMarkdown: async (markdown: string) => { + return invoke("parse_markdown_command", { markdown }) + }, }) createMenu() diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 6cf1dd54e8..71881353ae 100644 --- a/packages/ui/src/context/marked.tsx +++ b/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 = /(? { + 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 { + const codeBlockRegex = /
([\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(/</g, "<")
+      .replace(/>/g, ">")
+      .replace(/&/g, "&")
+      .replace(/"/g, '"')
+      .replace(/'/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
+
 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 {
+          const html = await nativeParser(markdown)
+          const withMath = renderMathExpressions(html)
+          return highlightCodeBlocks(withMath)
+        },
+      }
+    }
+
+    return jsParser
   },
 })