Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"luxon": "3.6.1",
"marked": "17.0.1",
"marked-shiki": "1.2.1",
"remend": "1.3.0",
"@playwright/test": "1.51.0",
"typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
Expand Down
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"motion-dom": "12.34.3",
"motion-utils": "12.29.2",
"remeda": "catalog:",
"remend": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",
"solid-list": "catalog:",
Expand Down
32 changes: 32 additions & 0 deletions packages/ui/src/components/markdown-stream.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test"
import { stream } from "./markdown-stream"

describe("markdown stream", () => {
test("heals incomplete emphasis while streaming", () => {
expect(stream("hello **world", true)).toEqual([{ raw: "hello **world", src: "hello **world**", mode: "live" }])
expect(stream("say `code", true)).toEqual([{ raw: "say `code", src: "say `code`", mode: "live" }])
})

test("keeps incomplete links non-clickable until they finish", () => {
expect(stream("see [docs](https://example.com/gu", true)).toEqual([
{ raw: "see [docs](https://example.com/gu", src: "see docs", mode: "live" },
])
})

test("splits an unfinished trailing code fence from stable content", () => {
expect(stream("before\n\n```ts\nconst x = 1", true)).toEqual([
{ raw: "before\n\n", src: "before\n\n", mode: "live" },
{ raw: "```ts\nconst x = 1", src: "```ts\nconst x = 1", mode: "live" },
])
})

test("keeps reference-style markdown as one block", () => {
expect(stream("[docs][1]\n\n[1]: https://example.com", true)).toEqual([
{
raw: "[docs][1]\n\n[1]: https://example.com",
src: "[docs][1]\n\n[1]: https://example.com",
mode: "live",
},
])
})
})
49 changes: 49 additions & 0 deletions packages/ui/src/components/markdown-stream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { marked, type Tokens } from "marked"
import remend from "remend"

export type Block = {
raw: string
src: string
mode: "full" | "live"
}

function refs(text: string) {
return /^\[[^\]]+\]:\s+\S+/m.test(text) || /^\[\^[^\]]+\]:\s+/m.test(text)
}

function open(raw: string) {
const match = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)
if (!match) return false
const mark = match[1]
if (!mark) return false
const char = mark[0]
const size = mark.length
const last = raw.trimEnd().split("\n").at(-1)?.trim() ?? ""
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last)
}

function heal(text: string) {
return remend(text, { linkMode: "text-only" })
}

export function stream(text: string, live: boolean) {
if (!live) return [{ raw: text, src: text, mode: "full" }] satisfies Block[]
const src = heal(text)
if (refs(text)) return [{ raw: text, src, mode: "live" }] satisfies Block[]
const tokens = marked.lexer(text)
const tail = tokens.findLastIndex((token) => token.type !== "space")
if (tail < 0) return [{ raw: text, src, mode: "live" }] satisfies Block[]
const last = tokens[tail]
if (!last || last.type !== "code") return [{ raw: text, src, mode: "live" }] satisfies Block[]
const code = last as Tokens.Code
if (!open(code.raw)) return [{ raw: text, src, mode: "live" }] satisfies Block[]
const head = tokens
.slice(0, tail)
.map((token) => token.raw)
.join("")
if (!head) return [{ raw: code.raw, src: code.raw, mode: "live" }] satisfies Block[]
return [
{ raw: head, src: heal(head), mode: "live" },
{ raw: code.raw, src: code.raw, mode: "live" },
] satisfies Block[]
}
49 changes: 3 additions & 46 deletions packages/ui/src/components/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { useMarked } from "../context/marked"
import { useI18n } from "../context/i18n"
import DOMPurify from "dompurify"
import morphdom from "morphdom"
import { marked, type Tokens } from "marked"
import { checksum } from "@opencode-ai/util/encode"
import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
import { isServer } from "solid-js/web"
import { stream } from "./markdown-stream"

type Entry = {
hash: string
Expand Down Expand Up @@ -58,47 +58,6 @@ function fallback(markdown: string) {
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
}

type Block = {
raw: string
mode: "full" | "live"
}

function references(markdown: string) {
return /^\[[^\]]+\]:\s+\S+/m.test(markdown) || /^\[\^[^\]]+\]:\s+/m.test(markdown)
}

function incomplete(raw: string) {
const open = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)
if (!open) return false
const mark = open[1]
if (!mark) return false
const char = mark[0]
const size = mark.length
const last = raw.trimEnd().split("\n").at(-1)?.trim() ?? ""
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last)
}

function blocks(markdown: string, streaming: boolean) {
if (!streaming || references(markdown)) return [{ raw: markdown, mode: "full" }] satisfies Block[]
const tokens = marked.lexer(markdown)
const last = tokens.findLast((token) => token.type !== "space")
if (!last || last.type !== "code") return [{ raw: markdown, mode: "full" }] satisfies Block[]
const code = last as Tokens.Code
if (!incomplete(code.raw)) return [{ raw: markdown, mode: "full" }] satisfies Block[]
const head = tokens
.slice(
0,
tokens.findLastIndex((token) => token.type !== "space"),
)
.map((token) => token.raw)
.join("")
if (!head) return [{ raw: code.raw, mode: "live" }] satisfies Block[]
return [
{ raw: head, mode: "full" },
{ raw: code.raw, mode: "live" },
] satisfies Block[]
}

type CopyLabels = {
copy: string
copied: string
Expand Down Expand Up @@ -251,8 +210,6 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
timeouts.set(button, timeout)
}

decorate(root, getLabels())

const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
for (const button of buttons) {
if (button instanceof HTMLButtonElement) updateLabel(button)
Expand Down Expand Up @@ -304,7 +261,7 @@ export function Markdown(

const base = src.key ?? checksum(src.text)
return Promise.all(
blocks(src.text, src.streaming).map(async (block, index) => {
stream(src.text, src.streaming).map(async (block, index) => {
const hash = checksum(block.raw)
const key = base ? `${base}:${index}:${block.mode}` : hash

Expand All @@ -316,7 +273,7 @@ export function Markdown(
}
}

const next = await Promise.resolve(marked.parse(block.raw))
const next = await Promise.resolve(marked.parse(block.src))
const safe = sanitize(next)
if (key && hash) touch(key, { hash, html: safe })
return safe
Expand Down
Loading