78a5a030ce
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
633 lines
20 KiB
TypeScript
633 lines
20 KiB
TypeScript
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"
|
|
import {
|
|
ComponentProps,
|
|
createEffect,
|
|
createMemo,
|
|
createResource,
|
|
createSignal,
|
|
createUniqueId,
|
|
onCleanup,
|
|
splitProps,
|
|
} from "solid-js"
|
|
import { isServer } from "solid-js/web"
|
|
import { bundledLanguages } from "shiki"
|
|
import { canReusePendingBlock, project, type Block, type Projection } from "./markdown-stream"
|
|
import {
|
|
disposeStreamingCode,
|
|
highlightStreamingCode,
|
|
MarkdownWorkerDisposedError,
|
|
MarkdownWorkerSupersededError,
|
|
MarkdownWorkerUnavailableError,
|
|
} from "./markdown-worker"
|
|
import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
|
|
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
|
|
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
|
|
import { inlineCodeKind } from "./markdown-inline-code-kind"
|
|
|
|
type RenderedBlock =
|
|
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
|
|
| {
|
|
key: string
|
|
mode: "code"
|
|
raw: string
|
|
hash: string
|
|
language: string
|
|
complete: boolean
|
|
generation: number
|
|
stable: MarkdownToken[]
|
|
unstable: MarkdownToken[]
|
|
}
|
|
|
|
type RenderResult = {
|
|
text: string
|
|
blocks: RenderedBlock[]
|
|
}
|
|
|
|
const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>()
|
|
|
|
const iconPaths = {
|
|
copy: '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>',
|
|
check: '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>',
|
|
}
|
|
|
|
function escape(text: string) {
|
|
return text
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'")
|
|
}
|
|
|
|
function fallback(markdown: string) {
|
|
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
|
|
}
|
|
|
|
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 }
|
|
} catch (error) {
|
|
if (
|
|
!(error instanceof MarkdownWorkerDisposedError) &&
|
|
!(error instanceof MarkdownWorkerSupersededError) &&
|
|
!(error instanceof MarkdownWorkerUnavailableError)
|
|
)
|
|
console.error("Markdown highlighting worker failed", error)
|
|
return { language: name, generation: 0, stable: [], unstable: [[text, ""] as MarkdownToken] }
|
|
}
|
|
}
|
|
|
|
type CopyLabels = {
|
|
copy: string
|
|
copied: string
|
|
}
|
|
|
|
const urlPattern = /^https?:\/\/[^\s<>()`"']+$/
|
|
|
|
function codeUrl(text: string) {
|
|
const href = text.trim().replace(/[),.;!?]+$/, "")
|
|
if (!urlPattern.test(href)) return
|
|
try {
|
|
const url = new URL(href)
|
|
return url.toString()
|
|
} catch {
|
|
return
|
|
}
|
|
}
|
|
|
|
function createIcon(path: string, slot: string) {
|
|
const icon = document.createElement("div")
|
|
icon.setAttribute("data-component", "icon")
|
|
icon.setAttribute("data-size", "small")
|
|
icon.setAttribute("data-slot", slot)
|
|
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
|
|
svg.setAttribute("data-slot", "icon-svg")
|
|
svg.setAttribute("fill", "none")
|
|
svg.setAttribute("viewBox", "0 0 20 20")
|
|
svg.setAttribute("aria-hidden", "true")
|
|
svg.innerHTML = path
|
|
icon.appendChild(svg)
|
|
return icon
|
|
}
|
|
|
|
function createCopyButton(labels: CopyLabels) {
|
|
const button = document.createElement("button")
|
|
button.type = "button"
|
|
button.setAttribute("data-component", "icon-button")
|
|
button.setAttribute("data-variant", "secondary")
|
|
button.setAttribute("data-size", "small")
|
|
button.setAttribute("data-slot", "markdown-copy-button")
|
|
button.setAttribute("aria-label", labels.copy)
|
|
button.setAttribute("data-tooltip", labels.copy)
|
|
button.appendChild(createIcon(iconPaths.copy, "copy-icon"))
|
|
button.appendChild(createIcon(iconPaths.check, "check-icon"))
|
|
return button
|
|
}
|
|
|
|
function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boolean) {
|
|
if (copied) {
|
|
button.setAttribute("data-copied", "true")
|
|
button.setAttribute("aria-label", labels.copied)
|
|
button.setAttribute("data-tooltip", labels.copied)
|
|
return
|
|
}
|
|
button.removeAttribute("data-copied")
|
|
button.setAttribute("aria-label", labels.copy)
|
|
button.setAttribute("data-tooltip", labels.copy)
|
|
}
|
|
|
|
const shellLanguages = new Set(["bash", "sh", "shell", "zsh", "fish", "console", "terminal"])
|
|
|
|
function codeKind(language: string | undefined) {
|
|
const value = language?.toLowerCase()
|
|
if (!value) return
|
|
if (shellLanguages.has(value)) return "shell"
|
|
}
|
|
|
|
function codeLanguage(block: HTMLPreElement) {
|
|
const code = block.querySelector("code")
|
|
if (!(code instanceof HTMLElement)) return
|
|
return code.className.match(/(?:^|\s)language-([^\s]+)/)?.[1]
|
|
}
|
|
|
|
function applyCodeMetadata(wrapper: HTMLElement, language: string | undefined) {
|
|
if (!document.body.hasAttribute("data-new-layout")) {
|
|
delete wrapper.dataset.language
|
|
delete wrapper.dataset.codeKind
|
|
return
|
|
}
|
|
|
|
if (language) wrapper.dataset.language = language
|
|
else delete wrapper.dataset.language
|
|
|
|
const kind = codeKind(language)
|
|
if (kind) wrapper.dataset.codeKind = kind
|
|
else delete wrapper.dataset.codeKind
|
|
}
|
|
|
|
function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
|
|
const parent = block.parentElement
|
|
if (!parent) return
|
|
const wrapped = parent.getAttribute("data-component") === "markdown-code"
|
|
if (!wrapped) {
|
|
const wrapper = document.createElement("div")
|
|
wrapper.setAttribute("data-component", "markdown-code")
|
|
applyCodeMetadata(wrapper, codeLanguage(block))
|
|
parent.replaceChild(wrapper, block)
|
|
wrapper.appendChild(block)
|
|
wrapper.appendChild(createCopyButton(labels))
|
|
return
|
|
}
|
|
|
|
applyCodeMetadata(parent, codeLanguage(block))
|
|
|
|
const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
|
|
(el): el is HTMLButtonElement => el instanceof HTMLButtonElement,
|
|
)
|
|
|
|
if (buttons.length === 0) {
|
|
parent.appendChild(createCopyButton(labels))
|
|
return
|
|
}
|
|
|
|
for (const button of buttons.slice(1)) {
|
|
button.remove()
|
|
}
|
|
}
|
|
|
|
function markCodeLinks(root: HTMLDivElement) {
|
|
const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
|
|
for (const code of codeNodes) {
|
|
const href = codeUrl(code.textContent ?? "")
|
|
const parentLink =
|
|
code.parentElement instanceof HTMLAnchorElement && code.parentElement.classList.contains("external-link")
|
|
? code.parentElement
|
|
: null
|
|
|
|
if (!href) {
|
|
if (parentLink) parentLink.replaceWith(code)
|
|
continue
|
|
}
|
|
|
|
if (parentLink) {
|
|
parentLink.href = href
|
|
continue
|
|
}
|
|
|
|
const link = document.createElement("a")
|
|
link.href = href
|
|
link.className = "external-link"
|
|
link.target = "_blank"
|
|
link.rel = "noopener noreferrer"
|
|
code.parentNode?.replaceChild(link, code)
|
|
link.appendChild(code)
|
|
}
|
|
}
|
|
|
|
function markInlineCode(root: HTMLDivElement) {
|
|
const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
|
|
for (const code of codeNodes) {
|
|
if (!(code instanceof HTMLElement)) continue
|
|
delete code.dataset.inlineCodeKind
|
|
const kind = inlineCodeKind(code.textContent ?? "")
|
|
if (kind) code.dataset.inlineCodeKind = kind
|
|
}
|
|
}
|
|
|
|
function decorate(root: HTMLDivElement, labels: CopyLabels) {
|
|
const blocks = Array.from(root.querySelectorAll("pre"))
|
|
for (const block of blocks) {
|
|
ensureCodeWrapper(block, labels)
|
|
}
|
|
if (!document.body.hasAttribute("data-new-layout")) return
|
|
markInlineCode(root)
|
|
markCodeLinks(root)
|
|
}
|
|
|
|
function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
|
|
const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
|
|
|
|
const updateLabel = (button: HTMLButtonElement) => {
|
|
const labels = getLabels()
|
|
const copied = button.getAttribute("data-copied") === "true"
|
|
setCopyState(button, labels, copied)
|
|
}
|
|
|
|
const handleClick = async (event: MouseEvent) => {
|
|
const target = event.target
|
|
if (!(target instanceof Element)) return
|
|
|
|
const button = target.closest('[data-slot="markdown-copy-button"]')
|
|
if (!(button instanceof HTMLButtonElement)) return
|
|
const code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
|
|
const content = code?.textContent ?? ""
|
|
if (!content) return
|
|
const clipboard = navigator?.clipboard
|
|
if (!clipboard) return
|
|
await clipboard.writeText(content)
|
|
const labels = getLabels()
|
|
setCopyState(button, labels, true)
|
|
const existing = timeouts.get(button)
|
|
if (existing) clearTimeout(existing)
|
|
const timeout = setTimeout(() => setCopyState(button, labels, false), 2000)
|
|
timeouts.set(button, timeout)
|
|
}
|
|
|
|
const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
|
|
for (const button of buttons) {
|
|
if (button instanceof HTMLButtonElement) updateLabel(button)
|
|
}
|
|
|
|
root.addEventListener("click", handleClick)
|
|
|
|
return () => {
|
|
root.removeEventListener("click", handleClick)
|
|
for (const timeout of timeouts.values()) {
|
|
clearTimeout(timeout)
|
|
}
|
|
}
|
|
}
|
|
|
|
function initialResult(text: string, key: string | undefined, projection: Projection, owner: string): RenderResult {
|
|
if (!text) return { text, blocks: [] }
|
|
const base = key ?? checksum(text)
|
|
if (base) {
|
|
const blocks = projection.blocks.flatMap((block, index) => {
|
|
if (block.mode === "code") return []
|
|
const cacheKey = `${base}:${index}:${block.mode}`
|
|
const cached = getCachedMarkdown(cacheKey)
|
|
if (cached?.raw !== block.raw) return []
|
|
return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }]
|
|
})
|
|
if (blocks.length === projection.blocks.length) return { text, blocks }
|
|
}
|
|
return {
|
|
text,
|
|
blocks: [
|
|
{
|
|
key: "initial",
|
|
mode: "full",
|
|
raw: text,
|
|
hash: checksum(text) ?? "",
|
|
html: fallback(text),
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
export function Markdown(
|
|
props: ComponentProps<"div"> & {
|
|
text: string
|
|
cacheKey?: string
|
|
streaming?: boolean
|
|
class?: string
|
|
classList?: Record<string, boolean>
|
|
},
|
|
) {
|
|
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),
|
|
)
|
|
const [html] = createResource(
|
|
() => {
|
|
return {
|
|
text: local.text,
|
|
key: local.cacheKey,
|
|
projection: projection(),
|
|
}
|
|
},
|
|
async (src) => {
|
|
if (isServer)
|
|
return {
|
|
text: src.text,
|
|
blocks: [
|
|
{
|
|
key: "server",
|
|
mode: "full" as const,
|
|
raw: src.text,
|
|
hash: checksum(src.text) ?? "",
|
|
html: fallback(src.text),
|
|
},
|
|
],
|
|
} satisfies RenderResult
|
|
if (!src.text) return { text: src.text, blocks: [] } satisfies RenderResult
|
|
|
|
const base = src.key ?? checksum(src.text)
|
|
return Promise.all(
|
|
src.projection.blocks.map(async (block, index) => {
|
|
const key = base ? `${base}:${index}:${block.mode}` : undefined
|
|
const blockKey = markdownBlockKey(owner, src.key, index, block.mode)
|
|
|
|
if (block.mode === "code") {
|
|
const cached = completedCode.get(blockKey)
|
|
if (block.complete && cached?.raw === block.raw) return cached
|
|
const result = await code(block.src, block.language, blockKey, block.complete)
|
|
const rendered = {
|
|
key: blockKey,
|
|
mode: block.mode,
|
|
raw: block.raw,
|
|
hash: String(block.raw.length),
|
|
complete: !!block.complete,
|
|
...result,
|
|
}
|
|
if (block.complete) completedCode.set(blockKey, rendered)
|
|
return rendered
|
|
}
|
|
|
|
if (key) {
|
|
const cached = getCachedMarkdown(key)
|
|
if (cached?.raw === block.raw) {
|
|
touchCachedMarkdown(key, cached)
|
|
return { key: blockKey, mode: block.mode, ...cached }
|
|
}
|
|
}
|
|
|
|
const hash = checksum(block.raw)
|
|
const safe = sanitizeMarkdown(await Promise.resolve(marked.parse(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 }
|
|
}),
|
|
)
|
|
.then((blocks) => ({ text: src.text, blocks }) satisfies RenderResult)
|
|
.catch(
|
|
() =>
|
|
({
|
|
text: src.text,
|
|
blocks: [
|
|
{
|
|
key: base ?? "fallback",
|
|
mode: "full" as const,
|
|
raw: src.text,
|
|
hash: checksum(src.text) ?? "",
|
|
html: fallback(src.text),
|
|
},
|
|
],
|
|
}) satisfies RenderResult,
|
|
)
|
|
},
|
|
{
|
|
initialValue: initialResult(local.text, local.cacheKey, projection(), owner),
|
|
},
|
|
)
|
|
|
|
let copyCleanup: (() => void) | undefined
|
|
|
|
createEffect(() => {
|
|
const container = root()
|
|
const result = html.latest ?? html()
|
|
const projected = projection()
|
|
const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner) : []
|
|
if (!container) return
|
|
if (isServer) return
|
|
if (content.length === 0) {
|
|
container.innerHTML = ""
|
|
return
|
|
}
|
|
|
|
const labels = {
|
|
copy: i18n.t("ui.message.copy"),
|
|
copied: i18n.t("ui.message.copied"),
|
|
}
|
|
const nextCodeKeys = new Set(content.filter((block) => block.mode === "code").map((block) => block.key))
|
|
activeCodeKeys.forEach((key) => {
|
|
if (!nextCodeKeys.has(key)) disposeCode(key)
|
|
})
|
|
activeCodeKeys.clear()
|
|
nextCodeKeys.forEach((key) => activeCodeKeys.add(key))
|
|
content.forEach((block, index) => updateBlock(container, index, block, labels))
|
|
while (container.children.length > content.length) container.lastElementChild?.remove()
|
|
container
|
|
.querySelectorAll<HTMLButtonElement>('[data-slot="markdown-copy-button"]')
|
|
.forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"))
|
|
if (!copyCleanup)
|
|
copyCleanup = setupCodeCopy(container, () => ({
|
|
copy: i18n.t("ui.message.copy"),
|
|
copied: i18n.t("ui.message.copied"),
|
|
}))
|
|
})
|
|
|
|
onCleanup(() => {
|
|
if (copyCleanup) copyCleanup()
|
|
activeCodeKeys.forEach(disposeCode)
|
|
completedCode.clear()
|
|
})
|
|
|
|
return (
|
|
<div
|
|
data-component="markdown"
|
|
classList={{
|
|
...local.classList,
|
|
[local.class ?? ""]: !!local.class,
|
|
}}
|
|
ref={setRoot}
|
|
{...others}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function pendingBlocks(
|
|
result: RenderResult | undefined,
|
|
projection: Projection | undefined,
|
|
cacheKey: string | undefined,
|
|
owner: string,
|
|
) {
|
|
if (!result) return []
|
|
if (!projection || result.text === projection.text) return result.blocks
|
|
const initial = result.blocks.length === 1 && result.blocks[0]?.key === "initial"
|
|
return projection.blocks.map((block, index) => {
|
|
const current = initial ? undefined : result.blocks[index]
|
|
if (current && canReusePendingBlock(current, block)) return current
|
|
const key = markdownBlockKey(owner, cacheKey, index, block.mode)
|
|
if (block.mode !== "code")
|
|
return { key, mode: block.mode, raw: block.raw, hash: String(block.raw.length), html: fallback(block.src) }
|
|
return {
|
|
key,
|
|
mode: block.mode,
|
|
raw: block.raw,
|
|
hash: String(block.raw.length),
|
|
language: block.language ?? "text",
|
|
complete: !!block.complete,
|
|
stable: [],
|
|
generation: 0,
|
|
unstable: [[block.src, ""] as MarkdownToken],
|
|
}
|
|
})
|
|
}
|
|
|
|
function disposeCode(key: string) {
|
|
disposeStreamingCode(key)
|
|
}
|
|
|
|
function updateBlock(container: HTMLDivElement, index: number, block: RenderedBlock, labels: CopyLabels) {
|
|
const current = container.children[index]
|
|
if (block.mode === "code") {
|
|
updateCodeBlock(container, current, block, labels)
|
|
return
|
|
}
|
|
if (
|
|
current instanceof HTMLDivElement &&
|
|
current.dataset.markdownKey === block.key &&
|
|
current.dataset.markdownHash === block.hash
|
|
)
|
|
return
|
|
|
|
const next = document.createElement("div")
|
|
next.dataset.markdownBlock = ""
|
|
next.dataset.markdownKey = block.key
|
|
next.dataset.markdownHash = block.hash
|
|
next.style.display = "contents"
|
|
next.innerHTML = block.html
|
|
decorate(next, labels)
|
|
|
|
if (!(current instanceof HTMLDivElement)) {
|
|
container.appendChild(next)
|
|
return
|
|
}
|
|
|
|
morphdom(current, next, {
|
|
onBeforeElUpdated: (fromEl, toEl) => {
|
|
if (
|
|
fromEl instanceof HTMLButtonElement &&
|
|
toEl instanceof HTMLButtonElement &&
|
|
fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
|
|
toEl.getAttribute("data-slot") === "markdown-copy-button"
|
|
) {
|
|
return false
|
|
}
|
|
if (fromEl.isEqualNode(toEl)) return false
|
|
return true
|
|
},
|
|
})
|
|
}
|
|
|
|
function updateCodeBlock(
|
|
container: HTMLDivElement,
|
|
current: Element | undefined,
|
|
block: Extract<RenderedBlock, { mode: "code" }>,
|
|
labels: CopyLabels,
|
|
) {
|
|
const existing = current instanceof HTMLDivElement && current.dataset.markdownKey === block.key ? current : undefined
|
|
const next = existing ?? document.createElement("div")
|
|
next.dataset.markdownBlock = ""
|
|
next.dataset.markdownKey = block.key
|
|
next.dataset.markdownHash = block.hash
|
|
next.dataset.markdownComplete = block.complete ? "true" : "false"
|
|
next.style.display = "contents"
|
|
|
|
const code = existing?.querySelector("code")
|
|
if (code instanceof HTMLElement) {
|
|
const wrapper = code.closest('[data-component="markdown-code"]')
|
|
if (wrapper instanceof HTMLElement) applyCodeMetadata(wrapper, block.language)
|
|
code.className = `language-${block.language}`
|
|
const previous = renderedCodeTokens.get(next)
|
|
const reset = shouldResetCodeTokens(previous, {
|
|
language: block.language,
|
|
generation: block.generation,
|
|
stableCount: block.stable.length,
|
|
raw: block.raw,
|
|
})
|
|
const stableCount = reset ? 0 : previous!.stableCount
|
|
const tail = [...block.stable.slice(stableCount), ...block.unstable]
|
|
const prior = reset ? [] : previous!.unstable
|
|
const prefix = prior.findIndex((token, index) => !sameToken(token, tail[index]))
|
|
const keep = stableCount + (prefix < 0 ? Math.min(prior.length, tail.length) : prefix)
|
|
while (code.children.length > keep) code.lastElementChild?.remove()
|
|
tail
|
|
.slice(keep - stableCount)
|
|
.map(createTokenSpan)
|
|
.forEach((span) => code.appendChild(span))
|
|
renderedCodeTokens.set(next, {
|
|
language: block.language,
|
|
generation: block.generation,
|
|
stableCount: block.stable.length,
|
|
unstable: block.unstable,
|
|
raw: block.raw,
|
|
})
|
|
return
|
|
}
|
|
|
|
const wrapper = document.createElement("div")
|
|
wrapper.setAttribute("data-component", "markdown-code")
|
|
applyCodeMetadata(wrapper, block.language)
|
|
const pre = document.createElement("pre")
|
|
pre.className = "shiki OpenCode"
|
|
const codeElement = document.createElement("code")
|
|
codeElement.className = `language-${block.language}`
|
|
;[...block.stable, ...block.unstable].map(createTokenSpan).forEach((span) => codeElement.appendChild(span))
|
|
pre.appendChild(codeElement)
|
|
wrapper.appendChild(pre)
|
|
wrapper.appendChild(createCopyButton(labels))
|
|
next.appendChild(wrapper)
|
|
renderedCodeTokens.set(next, {
|
|
language: block.language,
|
|
generation: block.generation,
|
|
stableCount: block.stable.length,
|
|
unstable: block.unstable,
|
|
raw: block.raw,
|
|
})
|
|
if (current) current.replaceWith(next)
|
|
else container.appendChild(next)
|
|
}
|
|
|
|
function sameToken(left: MarkdownToken, right: MarkdownToken | undefined) {
|
|
return !!right && left[0] === right[0] && left[1] === right[1]
|
|
}
|
|
|
|
function createTokenSpan(token: MarkdownToken) {
|
|
const span = document.createElement("span")
|
|
span.setAttribute("style", token[1])
|
|
span.textContent = token[0]
|
|
return span
|
|
}
|