fix(app): move markdown parsing to worker (#40356)

This commit is contained in:
Luke Parker
2026-08-04 14:13:34 +10:00
committed by GitHub
parent 7b265e3617
commit 405feb7cb1
25 changed files with 826 additions and 705 deletions
+2 -9
View File
@@ -814,11 +814,8 @@
"diff": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",
"luxon": "catalog:",
"marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"morphdom": "2.7.8",
"motion": "12.34.5",
"remeda": "catalog:",
@@ -831,7 +828,6 @@
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/katex": "0.16.7",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
@@ -991,7 +987,6 @@
"katex": "0.16.27",
"luxon": "catalog:",
"marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"morphdom": "2.7.8",
"motion": "12.34.5",
@@ -1137,7 +1132,7 @@
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"luxon": "3.6.1",
"marked": "17.0.6",
"marked": "18.0.7",
"marked-shiki": "1.2.1",
"opentui-spinner": "0.0.7",
"remeda": "2.26.0",
@@ -4318,9 +4313,7 @@
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
"marked": ["marked@18.0.7", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA=="],
"marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
+1 -1
View File
@@ -70,7 +70,7 @@
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"marked": "17.0.6",
"marked": "18.0.7",
"marked-shiki": "1.2.1",
"remend": "1.3.0",
"@playwright/test": "1.59.1",
+1 -4
View File
@@ -3,7 +3,6 @@ import * as Sentry from "@sentry/solid"
import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/session-ui/file"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
@@ -405,9 +404,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
<QueryProvider>
<WslServersProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</DialogProvider>
</WslServersProvider>
</QueryProvider>
@@ -1,7 +1,6 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMarked } from "@opencode-ai/ui/context/marked"
import { useQuery } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js"
@@ -44,7 +43,6 @@ export function createHomeSessionsController(home: HomeController) {
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
const marked = useMarked()
const projectDirectories = createMemo(() => {
const project = home.project.selected()
if (!project) return home.project.list().flatMap(directories)
@@ -119,7 +117,7 @@ export function createHomeSessionsController(home: HomeController) {
(ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) =>
(ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => {
if (part.type !== "text" || !part.text) return []
return preloadMarkdown(part.text, part.id, marked)
return preloadMarkdown(part.text, part.id)
}),
),
),
-4
View File
@@ -31,7 +31,6 @@
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/katex": "0.16.7",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
@@ -54,11 +53,8 @@
"diff": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",
"luxon": "catalog:",
"marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"morphdom": "2.7.8",
"motion": "12.34.5",
"remeda": "catalog:",
@@ -1,6 +1,6 @@
import { checksum } from "@opencode-ai/core/util/encode"
import DOMPurify from "dompurify"
import { project } from "./markdown-stream"
import { parseMarkdown } from "./markdown-worker"
export type MarkdownCacheEntry = {
raw: string
@@ -52,27 +52,18 @@ export function touchCachedMarkdown(key: string, value: MarkdownCacheEntry) {
cache.delete(first)
}
export async function preloadMarkdown(
text: string,
cacheKey: string,
parser: { parse(text: string): string | Promise<string> },
) {
await Promise.all(
project(undefined, text, false).blocks.map(async (block, index) => {
if (block.mode === "code") return
const key = `${cacheKey}:${index}:${block.mode}`
const cached = getCachedMarkdown(key)
if (cached?.raw === block.raw) {
touchCachedMarkdown(key, cached)
return
}
const hash = checksum(block.raw)
if (!hash) return
touchCachedMarkdown(key, {
raw: block.raw,
hash,
html: sanitizeMarkdown(await Promise.resolve(parser.parse(block.src))),
})
}),
)
export async function preloadMarkdown(text: string, cacheKey: string) {
const key = `${cacheKey}:0:full`
const cached = getCachedMarkdown(key)
if (cached?.raw === text) {
touchCachedMarkdown(key, cached)
return
}
const hash = checksum(text)
if (!hash) return
touchCachedMarkdown(key, {
raw: text,
hash,
html: sanitizeMarkdown(await parseMarkdown(text)),
})
}
@@ -1,18 +0,0 @@
import { expect, test } from "bun:test"
import { preloadMarkdown } from "./markdown-cache"
test("preloads completed markdown into the render cache", async () => {
const parsed: string[] = []
const parser = {
parse(text: string) {
parsed.push(text)
return `<p>${text}</p>`
},
}
const key = `markdown-preload-${crypto.randomUUID()}`
await preloadMarkdown("prepared response", key, parser)
await preloadMarkdown("prepared response", key, parser)
expect(parsed).toEqual(["prepared response"])
})
@@ -0,0 +1,11 @@
import type { Block, Projection } from "./markdown-stream"
export function completedProjection(text: string): Projection {
return { text, blocks: [{ raw: text, src: text, mode: "full" }] }
}
export function canReusePendingBlock(current: Pick<Block, "mode" | "raw"> | undefined, next: Block) {
if (!current || current.mode !== next.mode) return false
if (next.mode === "code" || next.mode === "live") return next.raw.startsWith(current.raw)
return current.raw === next.raw
}
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { canReusePendingBlock, project, stream } from "./markdown-stream"
import { canReusePendingBlock } from "./markdown-projection"
import { project, stream } from "./markdown-stream"
describe("markdown stream", () => {
test("heals incomplete emphasis while streaming", () => {
@@ -129,6 +130,9 @@ describe("markdown stream", () => {
expect(
canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "code", raw: "```ts\none two", src: "" }),
).toBe(true)
expect(canReusePendingBlock({ mode: "live", raw: "partial" }, { mode: "live", raw: "partial text", src: "" })).toBe(
true,
)
expect(canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "live", raw: "one", src: "" })).toBe(false)
})
@@ -145,6 +149,39 @@ describe("markdown stream", () => {
})
})
test("finalizes only the live tail when streaming stops", () => {
const live = project(undefined, "# Plan\n\nFinished paragraph.\n\n- final item", true)
const final = project(live, live.text, false)
expect(final.blocks[0]).toBe(live.blocks[0])
expect(final.blocks[1]).toBe(live.blocks[1])
expect(final.blocks[2]).toEqual({ raw: "- final item", src: "- final item", mode: "full" })
})
test("catches up paced text before finalizing", () => {
const live = project(undefined, "# Plan\n\nFinished paragraph.\n\n- final", true)
const final = project(live, `${live.text} item`, false)
expect(canReusePendingBlock(live.blocks[0], final.blocks[0]!)).toBe(true)
expect(canReusePendingBlock(live.blocks[1], final.blocks[1]!)).toBe(true)
expect(final.blocks[2]).toEqual({ raw: "- final item", src: "- final item", mode: "full" })
})
test("completes an open code block when streaming stops", () => {
const live = project(undefined, "```ts\nconst value = 1", true)
const final = project(live, live.text, false)
expect(final.blocks).toEqual([
{
raw: "```ts\nconst value = 1",
src: "const value = 1",
mode: "code",
language: "ts",
complete: true,
},
])
})
test("does not add a blank line before the first streamed code", () => {
const previous = project(undefined, "```ts\n", true)
const next = project(previous, `${previous.text}const x = 1`, true)
@@ -1,5 +1,6 @@
import { marked, type Tokens } from "marked"
import remend from "remend"
import { completedProjection } from "./markdown-projection"
export type Block = {
raw: string
@@ -50,7 +51,7 @@ function heal(text: string) {
}
export function stream(text: string, live: boolean): Block[] {
if (!live) return [{ raw: text, src: text, mode: "full" }] satisfies Block[]
if (!live) return completedProjection(text).blocks
if (refs(text)) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
const tokens = marked.lexer(text)
const tail = tokens.findLastIndex((token) => token.type !== "space")
@@ -84,14 +85,25 @@ export function stream(text: string, live: boolean): Block[] {
return [...result, { raw, src: openCode(code.raw), mode: "code", language: language(code.lang) }]
}
export function canReusePendingBlock(current: Pick<Block, "mode" | "raw"> | undefined, next: Block) {
if (!current || current.mode !== next.mode) return false
if (next.mode === "code") return next.raw.startsWith(current.raw)
return current.raw === next.raw
}
export function project(previous: Projection | undefined, text: string, live: boolean): Projection {
if (!live || !previous || !text.startsWith(previous.text)) return { text, blocks: stream(text, live) }
if (!live) {
const current =
previous?.text === text
? previous
: previous && text.startsWith(previous.text)
? project(previous, text, true)
: undefined
if (!current) return completedProjection(text)
return {
text,
blocks: current.blocks.map((block) => {
if (block.mode === "live") return { raw: block.raw, src: block.raw, mode: "full" }
if (block.mode === "code" && !block.complete) return { ...block, complete: true }
return block
}),
}
}
if (!previous || !text.startsWith(previous.text)) return { text, blocks: stream(text, live) }
const tail = previous.blocks.at(-1)
const suffix = text.slice(previous.text.length)
if (!suffix || tail?.mode !== "code" || tail.complete || closesFence(tail.raw, suffix))
@@ -10,6 +10,7 @@ const response = (id: number, reset: boolean, stable: [string, string][], unstab
type: "highlight" as const,
id,
key: "code",
language: "typescript",
reset,
stable,
unstable,
@@ -20,6 +21,7 @@ test("accumulates stable worker tokens and replaces the unstable tail", () => {
type: "highlight",
id: 1,
key: "code",
language: "typescript",
reset: true,
stable: [token("one\n")],
unstable: [token("tw")],
@@ -28,6 +30,7 @@ test("accumulates stable worker tokens and replaces the unstable tail", () => {
type: "highlight",
id: 2,
key: "code",
language: "typescript",
reset: false,
stable: [token("two\n")],
unstable: [token("three")],
@@ -35,6 +38,7 @@ test("accumulates stable worker tokens and replaces the unstable tail", () => {
expect(second.stable.map((item) => item[0])).toEqual(["one\n", "two\n"])
expect(second.unstable.map((item) => item[0])).toEqual(["three"])
expect(second.language).toBe("typescript")
})
test("increments generation only when the worker resets token identity", () => {
@@ -45,12 +49,13 @@ test("increments generation only when the worker resets token identity", () => {
})
test("ignores stale worker responses and resets replacement streams", () => {
const current = { id: 2, generation: 1, stable: [token("current")], unstable: [] }
const current = { id: 2, generation: 1, language: "typescript", stable: [token("current")], unstable: [] }
expect(
applyMarkdownWorkerResponse(current, {
type: "highlight",
id: 1,
key: "code",
language: "typescript",
reset: false,
stable: [token("stale")],
unstable: [],
@@ -62,6 +67,7 @@ test("ignores stale worker responses and resets replacement streams", () => {
type: "highlight",
id: 3,
key: "code",
language: "typescript",
reset: true,
stable: [token("replacement")],
unstable: [],
@@ -1,27 +1,32 @@
import type { ThemeRegistrationResolved } from "shiki"
import type { Projection } from "./markdown-stream"
export type MarkdownToken = [content: string, style: string]
export type MarkdownWorkerRequest =
| { type: "init"; theme: ThemeRegistrationResolved }
| { type: "parse"; id: number; text: string }
| { type: "project"; id: number; key: string; text: string; live: boolean }
| { type: "highlight"; id: number; key: string; text: string; language: string; complete?: boolean }
| { type: "dispose"; key: string }
export type MarkdownWorkerResponse =
| { type: "parse"; id: number; html: string }
| { type: "project"; id: number; key: string; projection: Projection }
| {
type: "highlight"
id: number
key: string
language: string
reset: boolean
stable: MarkdownToken[]
unstable: MarkdownToken[]
}
| { type: "error"; id: number; key: string; message: string }
| { type: "error"; id: number; key?: string; message: string }
| { type: "superseded"; id: number; key: string }
export type MarkdownWorkerState = {
id: number
generation: number
language: string
stable: MarkdownToken[]
unstable: MarkdownToken[]
}
@@ -42,6 +47,7 @@ export function applyMarkdownWorkerResponse(
return {
id: response.id,
generation: (state?.generation ?? 0) + (response.reset ? 1 : 0),
language: response.language,
stable: response.reset ? response.stable : [...(state?.stable ?? []), ...response.stable],
unstable: response.unstable,
}
@@ -1,5 +1,4 @@
import MarkdownShikiWorkerUrl from "./markdown-shiki.worker.ts?worker&url"
import { OpenCodeTheme } from "@opencode-ai/ui/context/marked"
import MarkdownWorkerUrl from "./markdown.worker.ts?worker&url"
import {
applyMarkdownWorkerResponse,
shouldReleaseMarkdownWorkerState,
@@ -8,18 +7,32 @@ import {
type MarkdownWorkerState,
} from "./markdown-worker-protocol"
import { createWorkerTransport } from "./markdown-worker-transport"
import type { Projection } from "./markdown-stream"
type Pending = {
type HighlightPending = {
key: string
complete: boolean
resolve: (state: MarkdownWorkerState) => void
reject: (error: Error) => void
}
type ProjectPending = {
key: string
resolve: (projection: Projection) => void
reject: (error: Error) => void
}
type ParsePending = {
resolve: (html: string) => void
reject: (error: Error) => void
}
let worker: Worker | undefined
let disabled: Error | undefined
let nextID = 0
const pending = new Map<number, Pending>()
const pending = new Map<number, HighlightPending>()
const projects = new Map<number, ProjectPending>()
const parses = new Map<number, ParsePending>()
const states = new Map<string, MarkdownWorkerState>()
const keys = new Set<string>()
const latest = new Map<string, number>()
@@ -32,6 +45,43 @@ const transport = createWorkerTransport<Extract<MarkdownWorkerRequest, { type: "
result.reject(new MarkdownWorkerSupersededError())
},
})
const projectTransport = createWorkerTransport<Extract<MarkdownWorkerRequest, { type: "project" }>>({
post: (request) => worker!.postMessage(request),
supersede: (request) => {
const result = projects.get(request.id)
if (!result) return
projects.delete(request.id)
result.reject(new MarkdownWorkerSupersededError())
},
})
export function parseMarkdown(text: string) {
const instance = getWorker()
const id = ++nextID
return new Promise<string>((resolve, reject) => {
parses.set(id, { resolve, reject })
instance.postMessage({ type: "parse", id, text } satisfies MarkdownWorkerRequest)
})
}
export function projectMarkdown(key: string, text: string, live: boolean) {
getWorker()
const id = ++nextID
return new Promise<Projection>((resolve, reject) => {
projects.set(id, { key, resolve, reject })
projectTransport.send({ type: "project", id, key, text, live })
})
}
export function disposeMarkdownProjection(key: string) {
projectTransport.dispose(key)
projects.forEach((request, id) => {
if (request.key !== key) return
projects.delete(id)
request.reject(new MarkdownWorkerDisposedError())
})
worker?.postMessage({ type: "dispose", key } satisfies MarkdownWorkerRequest)
}
export function highlightStreamingCode(key: string, text: string, language: string, complete = false) {
const instance = getWorker()
@@ -67,48 +117,97 @@ function getWorker() {
if (worker) return worker
if (disabled) throw new MarkdownWorkerUnavailableError(disabled.message)
try {
worker = new Worker(MarkdownShikiWorkerUrl, { type: "module" })
worker = new Worker(MarkdownWorkerUrl, { type: "module" })
} catch (error) {
disabled = error instanceof Error ? error : new Error(String(error))
throw new MarkdownWorkerUnavailableError(disabled.message)
}
worker.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
if (event.data.type === "parse") {
const result = parses.get(event.data.id)
if (!result) return
parses.delete(event.data.id)
result.resolve(event.data.html)
return
}
if (event.data.type === "project") {
const result = projects.get(event.data.id)
if (!result) {
projectTransport.complete(event.data.key, event.data.id)
return
}
projects.delete(event.data.id)
result.resolve(event.data.projection)
projectTransport.complete(event.data.key, event.data.id)
return
}
if (event.data.type === "error") {
const parsed = parses.get(event.data.id)
if (parsed) {
parses.delete(event.data.id)
parsed.reject(new Error(event.data.message))
return
}
const projected = projects.get(event.data.id)
if (projected) {
projects.delete(event.data.id)
projected.reject(new Error(event.data.message))
projectTransport.complete(projected.key, event.data.id)
return
}
}
if (event.data.type === "superseded") {
const projected = projects.get(event.data.id)
if (projected) {
projects.delete(event.data.id)
projected.reject(new MarkdownWorkerSupersededError())
projectTransport.complete(projected.key, event.data.id)
return
}
}
const key = event.data.key
if (!key) return
const result = pending.get(event.data.id)
if (!result) {
transport.complete(event.data.key, event.data.id)
transport.complete(key, event.data.id)
return
}
pending.delete(event.data.id)
if (!keys.has(event.data.key)) {
if (!keys.has(key)) {
result.reject(new MarkdownWorkerDisposedError())
transport.complete(event.data.key, event.data.id)
transport.complete(key, event.data.id)
return
}
if (event.data.type === "superseded") {
result.reject(new MarkdownWorkerSupersededError())
transport.complete(event.data.key, event.data.id)
transport.complete(key, event.data.id)
return
}
if (event.data.type === "error") {
result.reject(new Error(event.data.message))
transport.complete(event.data.key, event.data.id)
transport.complete(key, event.data.id)
return
}
const state = applyMarkdownWorkerResponse(states.get(event.data.key), event.data)
if (shouldReleaseMarkdownWorkerState(result.complete, latest.get(event.data.key), event.data.id)) {
states.delete(event.data.key)
keys.delete(event.data.key)
latest.delete(event.data.key)
} else states.set(event.data.key, state)
const state = applyMarkdownWorkerResponse(states.get(key), event.data)
if (shouldReleaseMarkdownWorkerState(result.complete, latest.get(key), event.data.id)) {
states.delete(key)
keys.delete(key)
latest.delete(key)
} else states.set(key, state)
result.resolve(state)
transport.complete(event.data.key, event.data.id)
transport.complete(key, event.data.id)
}
const fail = (message: string) => {
const error = new Error(message)
disabled = error
transport.reset()
projectTransport.reset()
pending.forEach((request) => request.reject(error))
projects.forEach((request) => request.reject(error))
parses.forEach((request) => request.reject(error))
pending.clear()
projects.clear()
parses.clear()
states.clear()
keys.clear()
latest.clear()
@@ -117,6 +216,5 @@ function getWorker() {
}
worker.onerror = (event) => fail(event.message || "Markdown highlighting worker failed")
worker.onmessageerror = () => fail("Markdown worker response failed")
worker.postMessage({ type: "init", theme: OpenCodeTheme } satisfies MarkdownWorkerRequest)
return worker
}
+53 -15
View File
@@ -1,4 +1,3 @@
import { useMarked } from "@opencode-ai/ui/context/marked"
import { useI18n } from "@opencode-ai/ui/context/i18n"
import morphdom from "morphdom"
import { checksum } from "@opencode-ai/core/util/encode"
@@ -6,7 +5,6 @@ import {
type Accessor,
type ComponentProps,
createEffect,
createMemo,
createResource,
createSignal,
createUniqueId,
@@ -18,14 +16,17 @@ import { isServer, render } from "solid-js/web"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { bundledLanguages } from "shiki"
import { canReusePendingBlock, project, type Block, type Projection } from "./markdown-stream"
import { canReusePendingBlock, completedProjection } from "./markdown-projection"
import type { Block, Projection } from "./markdown-stream"
import {
disposeMarkdownProjection,
disposeStreamingCode,
highlightStreamingCode,
MarkdownWorkerDisposedError,
MarkdownWorkerSupersededError,
MarkdownWorkerUnavailableError,
parseMarkdown,
projectMarkdown,
} from "./markdown-worker"
import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
@@ -67,10 +68,14 @@ function fallback(markdown: string) {
}
async function code(text: string, language: string | undefined, key: string, complete = false) {
const name = language && language in bundledLanguages ? language : "text"
try {
const result = await highlightStreamingCode(key, text, name, complete)
return { language: name, generation: result.generation, stable: result.stable, unstable: result.unstable }
const result = await highlightStreamingCode(key, text, language ?? "text", complete)
return {
language: result.language,
generation: result.generation,
stable: result.stable,
unstable: result.unstable,
}
} catch (error) {
if (
!(error instanceof MarkdownWorkerDisposedError) &&
@@ -78,7 +83,7 @@ async function code(text: string, language: string | undefined, key: string, com
!(error instanceof MarkdownWorkerUnavailableError)
)
console.error("Markdown highlighting worker failed", error)
return { language: name, generation: 0, stable: [], unstable: [[text, ""] as MarkdownToken] }
return { language: language ?? "text", generation: 0, stable: [], unstable: [[text, ""] as MarkdownToken] }
}
}
@@ -352,6 +357,10 @@ function initialResult(text: string, key: string | undefined, projection: Projec
}
}
function pendingProjection(text: string): Projection {
return { text, blocks: text ? [{ raw: text, src: text, mode: "live" }] : [] }
}
export function Markdown(
props: ComponentProps<"div"> & {
text: string
@@ -362,21 +371,44 @@ export function Markdown(
},
) {
const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"])
const marked = useMarked()
const i18n = useI18n()
const [root, setRoot] = createSignal<HTMLDivElement>()
const owner = createUniqueId()
const activeCodeKeys = new Set<string>()
const completedCode = new Map<string, Extract<RenderedBlock, { mode: "code" }>>()
const projection = createMemo((previous: Projection | undefined) =>
project(previous, local.text, local.streaming ?? false),
let streamed = false
const [projection] = createResource(
() => {
if (isServer) return
const live = local.streaming ?? false
if (live) streamed = true
if (!live && !streamed) return
return { key: owner, text: local.text, live }
},
(src) => projectMarkdown(src.key, src.text, src.live),
{ initialValue: pendingProjection("") },
)
const currentProjection = () => {
if (!(local.streaming ?? false) && !streamed) return completedProjection(local.text)
const value = projection.latest
if (value?.text === local.text) return value
if (value?.text) return value
return pendingProjection(local.text)
}
const [html] = createResource(
() => {
if (isServer)
return {
text: local.text,
key: local.cacheKey,
projection: pendingProjection(local.text),
}
const value = !(local.streaming ?? false) && !streamed ? completedProjection(local.text) : projection.latest
if (!value || value.text !== local.text) return
return {
text: local.text,
key: local.cacheKey,
projection: projection(),
projection: value,
}
},
async (src) => {
@@ -426,7 +458,7 @@ export function Markdown(
}
const hash = checksum(block.raw)
const safe = sanitizeMarkdown(await Promise.resolve(marked.parse(block.src)))
const safe = sanitizeMarkdown(await parseMarkdown(block.src))
if (key && hash) touchCachedMarkdown(key, { raw: block.raw, hash, html: safe })
return { key: blockKey, mode: block.mode, raw: block.raw, hash: hash ?? "", html: safe }
}),
@@ -449,7 +481,12 @@ export function Markdown(
)
},
{
initialValue: initialResult(local.text, local.cacheKey, projection(), owner),
initialValue: initialResult(
local.text,
local.cacheKey,
local.streaming ? pendingProjection(local.text) : completedProjection(local.text),
owner,
),
},
)
@@ -458,7 +495,7 @@ export function Markdown(
createEffect(() => {
const container = root()
const result = html.latest ?? html()
const projected = projection()
const projected = currentProjection()
const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner) : []
if (!container) return
if (isServer) return
@@ -497,6 +534,7 @@ export function Markdown(
onCleanup(() => {
if (copyCleanup) copyCleanup()
disposeMarkdownProjection(owner)
activeCodeKeys.forEach(disposeCode)
completedCode.clear()
})
@@ -1,6 +1,8 @@
/// <reference lib="webworker" />
import { ShikiStreamTokenizer } from "@shikijs/stream"
import { createMarkdownParser } from "@opencode-ai/ui/context/marked-parser"
import { OpenCodeTheme } from "@opencode-ai/ui/context/marked-theme"
import {
bundledLanguages,
createHighlighter,
@@ -11,6 +13,7 @@ import {
} from "shiki"
import type { MarkdownToken, MarkdownWorkerRequest, MarkdownWorkerResponse } from "./markdown-worker-protocol"
import { createLatestWorkerQueue } from "./markdown-worker-queue"
import { project, type Projection } from "./markdown-stream"
type Stream = {
language: string
@@ -19,30 +22,70 @@ type Stream = {
}
const streams = new Map<string, Stream>()
const projections = new Map<string, Projection>()
let highlighter: ReturnType<typeof createHighlighter> | undefined
const queue = createLatestWorkerQueue<Extract<MarkdownWorkerRequest, { type: "highlight" }>>({
const highlightQueue = createLatestWorkerQueue<Extract<MarkdownWorkerRequest, { type: "highlight" }>>({
run: highlight,
supersede: (request) => post({ type: "superseded", id: request.id, key: request.key }),
dispose: (key) => void streams.delete(key),
})
const projectQueue = createLatestWorkerQueue<Extract<MarkdownWorkerRequest, { type: "project" }>>({
run: runProject,
supersede: (request) => post({ type: "superseded", id: request.id, key: request.key }),
dispose: (key) => void projections.delete(key),
})
const parser = createMarkdownParser(async (code, language) => {
const instance = await getHighlighter()
const name = language in bundledLanguages ? language : "text"
if (!instance.getLoadedLanguages().includes(name))
await instance.loadLanguage(bundledLanguages[name as BundledLanguage])
return instance.codeToHtml(code, { lang: name as BundledLanguage, theme: "OpenCode", tabindex: false })
})
self.onmessage = (event: MessageEvent<MarkdownWorkerRequest>) => {
if (event.data.type === "init") {
highlighter ??= createHighlighter({ themes: [event.data.theme], langs: [] })
if (event.data.type === "dispose") {
highlightQueue.dispose(event.data.key)
projectQueue.dispose(event.data.key)
return
}
if (event.data.type === "dispose") {
queue.dispose(event.data.key)
if (event.data.type === "parse") {
void parse(event.data)
return
}
if (event.data.type === "project") {
projectQueue.highlight(event.data)
return
}
queue.highlight(event.data)
highlightQueue.highlight(event.data)
}
async function parse(request: Extract<MarkdownWorkerRequest, { type: "parse" }>) {
try {
post({ type: "parse", id: request.id, html: await parser.parse(request.text) })
} catch (error) {
post({ type: "error", id: request.id, message: error instanceof Error ? error.message : String(error) })
}
}
async function runProject(request: Extract<MarkdownWorkerRequest, { type: "project" }>) {
try {
const projection = project(projections.get(request.key), request.text, request.live)
projections.set(request.key, projection)
post({ type: "project", id: request.id, key: request.key, projection })
} catch (error) {
post({
type: "error",
id: request.id,
key: request.key,
message: error instanceof Error ? error.message : String(error),
})
}
}
async function highlight(request: Extract<MarkdownWorkerRequest, { type: "highlight" }>) {
try {
const instance = await highlighter
if (!instance) throw new Error("Shiki worker is not initialized")
const instance = await getHighlighter()
const language = request.language in bundledLanguages ? request.language : "text"
if (!instance.getLoadedLanguages().includes(language))
await instance.loadLanguage(bundledLanguages[language as BundledLanguage])
@@ -54,6 +97,7 @@ async function highlight(request: Extract<MarkdownWorkerRequest, { type: "highli
type: "highlight",
id: request.id,
key: request.key,
language,
reset: true,
stable: result.tokens
.flatMap((line, index) =>
@@ -81,6 +125,7 @@ async function highlight(request: Extract<MarkdownWorkerRequest, { type: "highli
type: "highlight",
id: request.id,
key: request.key,
language,
reset,
stable: result.stable.filter((token) => token.content.length > 0).map(token),
unstable: result.unstable.filter((token) => token.content.length > 0).map(token),
@@ -95,6 +140,10 @@ async function highlight(request: Extract<MarkdownWorkerRequest, { type: "highli
}
}
function getHighlighter() {
return (highlighter ??= createHighlighter({ themes: [OpenCodeTheme], langs: [] }))
}
function post(response: MarkdownWorkerResponse) {
self.postMessage(response)
}
@@ -1739,9 +1739,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
<Show when={text()}>
<div data-component="text-part" data-timeline-part-id={part().id}>
<div data-slot="text-part-body">
<Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
</Show>
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
</div>
<Show when={showCopy()}>
<div data-slot="text-part-copy-wrapper" data-interrupted={interrupted() ? "" : undefined}>
@@ -1776,9 +1774,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
return (
<Show when={text()}>
<div data-component="reasoning-part" data-timeline-part-id={part().id}>
<Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
</Show>
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
</div>
</Show>
)
+3
View File
@@ -1,5 +1,8 @@
import { WorkerPoolManager } from "@pierre/diffs/worker"
import ShikiWorkerUrl from "@pierre/diffs/worker/worker.js?worker&url"
import { registerOpenCodeTheme } from "@opencode-ai/ui/context/marked-theme-register"
registerOpenCodeTheme()
export type WorkerPoolStyle = "unified" | "split"
-1
View File
@@ -94,7 +94,6 @@
"katex": "0.16.27",
"luxon": "catalog:",
"marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"morphdom": "2.7.8",
"motion": "12.34.5",
@@ -1,17 +0,0 @@
import type { MarkedExtension } from "marked"
// Keep adjacent tilde and backtick runs separate until markedjs/marked#4011 is released.
export const markedCodeSpanBoundary = {
tokenizer: {
inlineText(src) {
const match = /^(`+(?=~)|~+(?=`))/.exec(src)
if (!match) return false
return {
type: "text",
raw: match[0],
text: match[0],
escaped: this.lexer.state.inRawBlock,
}
},
},
} satisfies MarkedExtension
@@ -0,0 +1,19 @@
import { expect, test } from "bun:test"
import { createMarkdownParser } from "./marked-parser"
const parser = createMarkdownParser((code, language) => `<pre data-language="${language}">${code}</pre>`)
test("renders links with application attributes", async () => {
expect(await parser.parse("[OpenCode](https://opencode.ai)")).toBe(
'<p><a href="https://opencode.ai" class="external-link" target="_blank" rel="noopener noreferrer">OpenCode</a></p>\n',
)
})
test("renders inline and block math", async () => {
expect(await parser.parse("\\(x^2\\)")).toContain('<span class="katex">')
expect(await parser.parse("$$\nx^2\n$$\n")).toContain('<span class="katex-display">')
})
test("uses the configured code highlighter", async () => {
expect(await parser.parse("```ts\nconst value = 1\n```\n")).toBe('<pre data-language="ts">const value = 1</pre>\n')
})
+68
View File
@@ -0,0 +1,68 @@
import katex from "katex"
import { Marked, type MarkedExtension, type Tokens } from "marked"
import markedShiki from "marked-shiki"
export function createMarkdownParser(highlight: (code: string, language: string) => string | Promise<string>) {
return new Marked(
{
renderer: {
link({ href, title, text }) {
const titleAttr = title ? ` title="${title}"` : ""
return `<a href="${href}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`
},
},
},
katexExtension,
markedShiki({ highlight }),
)
}
const inlineMathRegex = /^\\\(((?:\\.|[^\\\n])*?)\\\)/
const blockMathRegex = /^\$\$\n([\s\S]+?)\n\$\$(?:\n|$)/
const katexExtension: MarkedExtension = {
extensions: [
{
name: "inlineKatex",
level: "inline",
start(src) {
const index = src.indexOf("\\(")
if (index === -1) return
return index
},
tokenizer(src) {
const match = src.match(inlineMathRegex)
if (!match) return
return {
type: "inlineKatex",
raw: match[0],
text: match[1].trim(),
displayMode: false,
}
},
renderer: renderKatexToken,
},
{
name: "blockKatex",
level: "block",
tokenizer(src) {
const match = src.match(blockMathRegex)
if (!match) return
return {
type: "blockKatex",
raw: match[0],
text: match[1].trim(),
displayMode: true,
}
},
renderer: renderKatexToken,
},
],
}
function renderKatexToken(token: Tokens.Generic) {
return katex.renderToString(typeof token.text === "string" ? token.text : "", {
displayMode: token.displayMode === true,
throwOnError: false,
})
}
@@ -1,9 +1,8 @@
import { expect, test } from "bun:test"
import { Marked } from "marked"
import { markedCodeSpanBoundary } from "./marked-code-span"
test("preserves code spans adjacent to tildes", async () => {
const marked = new Marked(markedCodeSpanBoundary)
const marked = new Marked()
expect(await marked.parse("~`0.1576` to measurement-window-only `0.00092`")).toBe(
"<p>~<code>0.1576</code> to measurement-window-only <code>0.00092</code></p>\n",
@@ -0,0 +1,10 @@
import { registerCustomTheme } from "@pierre/diffs"
import { OpenCodeTheme } from "./marked-theme"
let registered = false
export function registerOpenCodeTheme() {
if (registered) return
registered = true
registerCustomTheme("OpenCode", () => Promise.resolve(OpenCodeTheme))
}
+372
View File
@@ -0,0 +1,372 @@
import type { ThemeRegistrationResolved } from "@pierre/diffs"
export const OpenCodeTheme = {
name: "OpenCode",
bg: "var(--color-background-stronger)",
fg: "var(--text-base)",
colors: {
"editor.background": "var(--color-background-stronger)",
"editor.foreground": "var(--text-base)",
"gitDecoration.addedResourceForeground": "var(--syntax-diff-add)",
"gitDecoration.deletedResourceForeground": "var(--syntax-diff-delete)",
"gitDecoration.modifiedResourceForeground": "var(--syntax-diff-unknown)",
// "gitDecoration.conflictingResourceForeground": "#ffca00",
// "gitDecoration.modifiedResourceForeground": "#1a76d4",
// "gitDecoration.untrackedResourceForeground": "#00cab1",
// "gitDecoration.ignoredResourceForeground": "#84848A",
// "terminal.titleForeground": "#adadb1",
// "terminal.titleInactiveForeground": "#84848A",
// "terminal.background": "#141415",
// "terminal.foreground": "#adadb1",
// "terminal.ansiBlack": "#141415",
// "terminal.ansiRed": "#ff2e3f",
// "terminal.ansiGreen": "#0dbe4e",
// "terminal.ansiYellow": "#ffca00",
// "terminal.ansiBlue": "#008cff",
// "terminal.ansiMagenta": "#c635e4",
// "terminal.ansiCyan": "#08c0ef",
// "terminal.ansiWhite": "#c6c6c8",
// "terminal.ansiBrightBlack": "#141415",
// "terminal.ansiBrightRed": "#ff2e3f",
// "terminal.ansiBrightGreen": "#0dbe4e",
// "terminal.ansiBrightYellow": "#ffca00",
// "terminal.ansiBrightBlue": "#008cff",
// "terminal.ansiBrightMagenta": "#c635e4",
// "terminal.ansiBrightCyan": "#08c0ef",
// "terminal.ansiBrightWhite": "#c6c6c8",
},
tokenColors: [
{
scope: ["comment", "punctuation.definition.comment", "string.comment"],
settings: {
foreground: "var(--syntax-comment)",
},
},
{
scope: ["entity.other.attribute-name"],
settings: {
foreground: "var(--syntax-property)", // maybe attribute
},
},
{
scope: ["constant", "entity.name.constant", "variable.other.constant", "variable.language", "entity"],
settings: {
foreground: "var(--syntax-constant)",
},
},
{
scope: ["entity.name", "meta.export.default", "meta.definition.variable"],
settings: {
foreground: "var(--syntax-type)",
},
},
{
scope: ["meta.object.member"],
settings: {
foreground: "var(--syntax-primitive)",
},
},
{
scope: [
"variable.parameter.function",
"meta.jsx.children",
"meta.block",
"meta.tag.attributes",
"entity.name.constant",
"meta.embedded.expression",
"meta.template.expression",
"string.other.begin.yaml",
"string.other.end.yaml",
],
settings: {
foreground: "var(--syntax-punctuation)",
},
},
{
scope: ["entity.name.function", "support.type.primitive"],
settings: {
foreground: "var(--syntax-primitive)",
},
},
{
scope: ["support.class.component"],
settings: {
foreground: "var(--syntax-type)",
},
},
{
scope: "keyword",
settings: {
foreground: "var(--syntax-keyword)",
},
},
{
scope: [
"keyword.operator",
"storage.type.function.arrow",
"punctuation.separator.key-value.css",
"entity.name.tag.yaml",
"punctuation.separator.key-value.mapping.yaml",
],
settings: {
foreground: "var(--syntax-operator)",
},
},
{
scope: ["storage", "storage.type"],
settings: {
foreground: "var(--syntax-keyword)",
},
},
{
scope: ["storage.modifier.package", "storage.modifier.import", "storage.type.java"],
settings: {
foreground: "var(--syntax-primitive)",
},
},
{
scope: [
"string",
"punctuation.definition.string",
"string punctuation.section.embedded source",
"entity.name.tag",
],
settings: {
foreground: "var(--syntax-string)",
},
},
{
scope: "support",
settings: {
foreground: "var(--syntax-primitive)",
},
},
{
scope: ["support.type.object.module", "variable.other.object", "support.type.property-name.css"],
settings: {
foreground: "var(--syntax-object)",
},
},
{
scope: "meta.property-name",
settings: {
foreground: "var(--syntax-property)",
},
},
{
scope: "variable",
settings: {
foreground: "var(--syntax-variable)",
},
},
{
scope: "variable.other",
settings: {
foreground: "var(--syntax-variable)",
},
},
{
scope: [
"invalid.broken",
"invalid.illegal",
"invalid.unimplemented",
"invalid.deprecated",
"message.error",
"markup.deleted",
"meta.diff.header.from-file",
"punctuation.definition.deleted",
"brackethighlighter.unmatched",
"token.error-token",
],
settings: {
foreground: "var(--syntax-critical)",
},
},
{
scope: "carriage-return",
settings: {
foreground: "var(--syntax-keyword)",
},
},
{
scope: "string source",
settings: {
foreground: "var(--syntax-variable)",
},
},
{
scope: "string variable",
settings: {
foreground: "var(--syntax-constant)",
},
},
{
scope: [
"source.regexp",
"string.regexp",
"string.regexp.character-class",
"string.regexp constant.character.escape",
"string.regexp source.ruby.embedded",
"string.regexp string.regexp.arbitrary-repitition",
"string.regexp constant.character.escape",
],
settings: {
foreground: "var(--syntax-regexp)",
},
},
{
scope: "support.constant",
settings: {
foreground: "var(--syntax-primitive)",
},
},
{
scope: "support.variable",
settings: {
foreground: "var(--syntax-variable)",
},
},
{
scope: "meta.module-reference",
settings: {
foreground: "var(--syntax-info)",
},
},
{
scope: "punctuation.definition.list.begin.markdown",
settings: {
foreground: "var(--syntax-punctuation)",
},
},
{
scope: ["markup.heading", "markup.heading entity.name"],
settings: {
fontStyle: "bold",
foreground: "var(--syntax-info)",
},
},
{
scope: "markup.quote",
settings: {
foreground: "var(--syntax-info)",
},
},
{
scope: "markup.italic",
settings: {
fontStyle: "italic",
// foreground: "",
},
},
{
scope: "markup.bold",
settings: {
fontStyle: "bold",
foreground: "var(--text-strong)",
},
},
{
scope: [
"markup.raw",
"markup.inserted",
"meta.diff.header.to-file",
"punctuation.definition.inserted",
"markup.changed",
"punctuation.definition.changed",
"markup.ignored",
"markup.untracked",
],
settings: {
foreground: "var(--text-base)",
},
},
{
scope: "meta.diff.range",
settings: {
fontStyle: "bold",
foreground: "var(--syntax-unknown)",
},
},
{
scope: "meta.diff.header",
settings: {
foreground: "var(--syntax-unknown)",
},
},
{
scope: "meta.separator",
settings: {
fontStyle: "bold",
foreground: "var(--syntax-unknown)",
},
},
{
scope: "meta.output",
settings: {
foreground: "var(--syntax-unknown)",
},
},
{
scope: "meta.export.default",
settings: {
foreground: "var(--syntax-unknown)",
},
},
{
scope: [
"brackethighlighter.tag",
"brackethighlighter.curly",
"brackethighlighter.round",
"brackethighlighter.square",
"brackethighlighter.angle",
"brackethighlighter.quote",
],
settings: {
foreground: "var(--syntax-unknown)",
},
},
{
scope: ["constant.other.reference.link", "string.other.link"],
settings: {
fontStyle: "underline",
foreground: "var(--syntax-unknown)",
},
},
{
scope: "token.info-token",
settings: {
foreground: "var(--syntax-info)",
},
},
{
scope: "token.warn-token",
settings: {
foreground: "var(--syntax-warning)",
},
},
{
scope: "token.debug-token",
settings: {
foreground: "var(--syntax-info)",
},
},
],
semanticTokenColors: {
comment: "var(--syntax-comment)",
string: "var(--syntax-string)",
number: "var(--syntax-constant)",
regexp: "var(--syntax-regexp)",
keyword: "var(--syntax-keyword)",
variable: "var(--syntax-variable)",
parameter: "var(--syntax-variable)",
property: "var(--syntax-property)",
function: "var(--syntax-primitive)",
method: "var(--syntax-primitive)",
type: "var(--syntax-type)",
class: "var(--syntax-type)",
namespace: "var(--syntax-type)",
enumMember: "var(--syntax-primitive)",
"variable.constant": "var(--syntax-constant)",
"variable.defaultLibrary": "var(--syntax-unknown)",
},
} as unknown as ThemeRegistrationResolved
+20 -562
View File
@@ -1,570 +1,28 @@
import { marked, type MarkedExtension, type Tokens } from "marked"
import markedShiki from "marked-shiki"
import katex from "katex"
import { getSharedHighlighter } from "@pierre/diffs"
import { bundledLanguages, type BundledLanguage } from "shiki"
import { createSimpleContext } from "./helper"
import { markedCodeSpanBoundary } from "./marked-code-span"
import { getSharedHighlighter, registerCustomTheme, ThemeRegistrationResolved } from "@pierre/diffs"
import { createMarkdownParser } from "./marked-parser"
import { registerOpenCodeTheme } from "./marked-theme-register"
export const OpenCodeTheme = {
name: "OpenCode",
bg: "var(--color-background-stronger)",
fg: "var(--text-base)",
colors: {
"editor.background": "var(--color-background-stronger)",
"editor.foreground": "var(--text-base)",
"gitDecoration.addedResourceForeground": "var(--syntax-diff-add)",
"gitDecoration.deletedResourceForeground": "var(--syntax-diff-delete)",
"gitDecoration.modifiedResourceForeground": "var(--syntax-diff-unknown)",
// "gitDecoration.conflictingResourceForeground": "#ffca00",
// "gitDecoration.modifiedResourceForeground": "#1a76d4",
// "gitDecoration.untrackedResourceForeground": "#00cab1",
// "gitDecoration.ignoredResourceForeground": "#84848A",
// "terminal.titleForeground": "#adadb1",
// "terminal.titleInactiveForeground": "#84848A",
// "terminal.background": "#141415",
// "terminal.foreground": "#adadb1",
// "terminal.ansiBlack": "#141415",
// "terminal.ansiRed": "#ff2e3f",
// "terminal.ansiGreen": "#0dbe4e",
// "terminal.ansiYellow": "#ffca00",
// "terminal.ansiBlue": "#008cff",
// "terminal.ansiMagenta": "#c635e4",
// "terminal.ansiCyan": "#08c0ef",
// "terminal.ansiWhite": "#c6c6c8",
// "terminal.ansiBrightBlack": "#141415",
// "terminal.ansiBrightRed": "#ff2e3f",
// "terminal.ansiBrightGreen": "#0dbe4e",
// "terminal.ansiBrightYellow": "#ffca00",
// "terminal.ansiBrightBlue": "#008cff",
// "terminal.ansiBrightMagenta": "#c635e4",
// "terminal.ansiBrightCyan": "#08c0ef",
// "terminal.ansiBrightWhite": "#c6c6c8",
},
tokenColors: [
{
scope: ["comment", "punctuation.definition.comment", "string.comment"],
settings: {
foreground: "var(--syntax-comment)",
},
},
{
scope: ["entity.other.attribute-name"],
settings: {
foreground: "var(--syntax-property)", // maybe attribute
},
},
{
scope: ["constant", "entity.name.constant", "variable.other.constant", "variable.language", "entity"],
settings: {
foreground: "var(--syntax-constant)",
},
},
{
scope: ["entity.name", "meta.export.default", "meta.definition.variable"],
settings: {
foreground: "var(--syntax-type)",
},
},
{
scope: ["meta.object.member"],
settings: {
foreground: "var(--syntax-primitive)",
},
},
{
scope: [
"variable.parameter.function",
"meta.jsx.children",
"meta.block",
"meta.tag.attributes",
"entity.name.constant",
"meta.embedded.expression",
"meta.template.expression",
"string.other.begin.yaml",
"string.other.end.yaml",
],
settings: {
foreground: "var(--syntax-punctuation)",
},
},
{
scope: ["entity.name.function", "support.type.primitive"],
settings: {
foreground: "var(--syntax-primitive)",
},
},
{
scope: ["support.class.component"],
settings: {
foreground: "var(--syntax-type)",
},
},
{
scope: "keyword",
settings: {
foreground: "var(--syntax-keyword)",
},
},
{
scope: [
"keyword.operator",
"storage.type.function.arrow",
"punctuation.separator.key-value.css",
"entity.name.tag.yaml",
"punctuation.separator.key-value.mapping.yaml",
],
settings: {
foreground: "var(--syntax-operator)",
},
},
{
scope: ["storage", "storage.type"],
settings: {
foreground: "var(--syntax-keyword)",
},
},
{
scope: ["storage.modifier.package", "storage.modifier.import", "storage.type.java"],
settings: {
foreground: "var(--syntax-primitive)",
},
},
{
scope: [
"string",
"punctuation.definition.string",
"string punctuation.section.embedded source",
"entity.name.tag",
],
settings: {
foreground: "var(--syntax-string)",
},
},
{
scope: "support",
settings: {
foreground: "var(--syntax-primitive)",
},
},
{
scope: ["support.type.object.module", "variable.other.object", "support.type.property-name.css"],
settings: {
foreground: "var(--syntax-object)",
},
},
{
scope: "meta.property-name",
settings: {
foreground: "var(--syntax-property)",
},
},
{
scope: "variable",
settings: {
foreground: "var(--syntax-variable)",
},
},
{
scope: "variable.other",
settings: {
foreground: "var(--syntax-variable)",
},
},
{
scope: [
"invalid.broken",
"invalid.illegal",
"invalid.unimplemented",
"invalid.deprecated",
"message.error",
"markup.deleted",
"meta.diff.header.from-file",
"punctuation.definition.deleted",
"brackethighlighter.unmatched",
"token.error-token",
],
settings: {
foreground: "var(--syntax-critical)",
},
},
{
scope: "carriage-return",
settings: {
foreground: "var(--syntax-keyword)",
},
},
{
scope: "string source",
settings: {
foreground: "var(--syntax-variable)",
},
},
{
scope: "string variable",
settings: {
foreground: "var(--syntax-constant)",
},
},
{
scope: [
"source.regexp",
"string.regexp",
"string.regexp.character-class",
"string.regexp constant.character.escape",
"string.regexp source.ruby.embedded",
"string.regexp string.regexp.arbitrary-repitition",
"string.regexp constant.character.escape",
],
settings: {
foreground: "var(--syntax-regexp)",
},
},
{
scope: "support.constant",
settings: {
foreground: "var(--syntax-primitive)",
},
},
{
scope: "support.variable",
settings: {
foreground: "var(--syntax-variable)",
},
},
{
scope: "meta.module-reference",
settings: {
foreground: "var(--syntax-info)",
},
},
{
scope: "punctuation.definition.list.begin.markdown",
settings: {
foreground: "var(--syntax-punctuation)",
},
},
{
scope: ["markup.heading", "markup.heading entity.name"],
settings: {
fontStyle: "bold",
foreground: "var(--syntax-info)",
},
},
{
scope: "markup.quote",
settings: {
foreground: "var(--syntax-info)",
},
},
{
scope: "markup.italic",
settings: {
fontStyle: "italic",
// foreground: "",
},
},
{
scope: "markup.bold",
settings: {
fontStyle: "bold",
foreground: "var(--text-strong)",
},
},
{
scope: [
"markup.raw",
"markup.inserted",
"meta.diff.header.to-file",
"punctuation.definition.inserted",
"markup.changed",
"punctuation.definition.changed",
"markup.ignored",
"markup.untracked",
],
settings: {
foreground: "var(--text-base)",
},
},
{
scope: "meta.diff.range",
settings: {
fontStyle: "bold",
foreground: "var(--syntax-unknown)",
},
},
{
scope: "meta.diff.header",
settings: {
foreground: "var(--syntax-unknown)",
},
},
{
scope: "meta.separator",
settings: {
fontStyle: "bold",
foreground: "var(--syntax-unknown)",
},
},
{
scope: "meta.output",
settings: {
foreground: "var(--syntax-unknown)",
},
},
{
scope: "meta.export.default",
settings: {
foreground: "var(--syntax-unknown)",
},
},
{
scope: [
"brackethighlighter.tag",
"brackethighlighter.curly",
"brackethighlighter.round",
"brackethighlighter.square",
"brackethighlighter.angle",
"brackethighlighter.quote",
],
settings: {
foreground: "var(--syntax-unknown)",
},
},
{
scope: ["constant.other.reference.link", "string.other.link"],
settings: {
fontStyle: "underline",
foreground: "var(--syntax-unknown)",
},
},
{
scope: "token.info-token",
settings: {
foreground: "var(--syntax-info)",
},
},
{
scope: "token.warn-token",
settings: {
foreground: "var(--syntax-warning)",
},
},
{
scope: "token.debug-token",
settings: {
foreground: "var(--syntax-info)",
},
},
],
semanticTokenColors: {
comment: "var(--syntax-comment)",
string: "var(--syntax-string)",
number: "var(--syntax-constant)",
regexp: "var(--syntax-regexp)",
keyword: "var(--syntax-keyword)",
variable: "var(--syntax-variable)",
parameter: "var(--syntax-variable)",
property: "var(--syntax-property)",
function: "var(--syntax-primitive)",
method: "var(--syntax-primitive)",
type: "var(--syntax-type)",
class: "var(--syntax-type)",
namespace: "var(--syntax-type)",
enumMember: "var(--syntax-primitive)",
"variable.constant": "var(--syntax-constant)",
"variable.defaultLibrary": "var(--syntax-unknown)",
},
} as unknown as ThemeRegistrationResolved
export { OpenCodeTheme } from "./marked-theme"
registerCustomTheme("OpenCode", () => Promise.resolve(OpenCodeTheme))
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 = /\\\(((?:\\.|[^\\\n])*?)\\\)/g
result = result.replace(inlineMathRegex, (_, math) => {
try {
return katex.renderToString(math, {
displayMode: false,
throwOnError: false,
})
} catch {
return `\\(${math}\\)`
}
})
return result
}
const inlineMathRegex = /^\\\(((?:\\.|[^\\\n])*?)\\\)/
const blockMathRegex = /^\$\$\n([\s\S]+?)\n\$\$(?:\n|$)/
const katexExtension: MarkedExtension = {
extensions: [
{
name: "inlineKatex",
level: "inline",
start(src) {
const index = src.indexOf("\\(")
if (index === -1) return
return index
},
tokenizer(src) {
const match = src.match(inlineMathRegex)
if (!match) return
return {
type: "inlineKatex",
raw: match[0],
text: match[1].trim(),
displayMode: false,
}
},
renderer: renderKatexToken,
},
{
name: "blockKatex",
level: "block",
tokenizer(src) {
const match = src.match(blockMathRegex)
if (!match) return
return {
type: "blockKatex",
raw: match[0],
text: match[1].trim(),
displayMode: true,
}
},
renderer: renderKatexToken,
},
],
}
function renderKatexToken(token: Tokens.Generic) {
return katex.renderToString(typeof token.text === "string" ? token.text : "", {
displayMode: token.displayMode === true,
throwOnError: false,
})
}
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: [],
preferredHighlighter: "shiki-wasm",
})
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>
registerOpenCodeTheme()
export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({
name: "Marked",
init: (props: { nativeParser?: NativeMarkdownParser }) => {
const jsParser = marked.use(
markedCodeSpanBoundary,
{
renderer: {
link({ href, title, text }) {
const titleAttr = title ? ` title="${title}"` : ""
return `<a href="${href}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`
},
},
},
katexExtension,
markedShiki({
async highlight(code, lang) {
const highlighter = await getSharedHighlighter({
themes: ["OpenCode"],
langs: [],
preferredHighlighter: "shiki-wasm",
})
if (!(lang in bundledLanguages)) {
lang = "text"
}
if (!highlighter.getLoadedLanguages().includes(lang)) {
await highlighter.loadLanguage(lang as BundledLanguage)
}
return highlighter.codeToHtml(code, {
lang: lang || "text",
theme: "OpenCode",
tabindex: false,
})
},
}),
)
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
},
init: () =>
createMarkdownParser(async (code, language) => {
const highlighter = await getSharedHighlighter({
themes: ["OpenCode"],
langs: [],
preferredHighlighter: "shiki-wasm",
})
const name = language in bundledLanguages ? language : "text"
if (!highlighter.getLoadedLanguages().includes(name)) await highlighter.loadLanguage(name as BundledLanguage)
return highlighter.codeToHtml(code, {
lang: name,
theme: "OpenCode",
tabindex: false,
})
}),
})