fix(app): render local Markdown images through the server (#45780)

This commit is contained in:
Luke Parker
2026-08-28 10:30:00 +10:00
committed by GitHub
Unverified
parent d5be4cf888
commit c0fa9aff72
10 changed files with 523 additions and 12 deletions
@@ -0,0 +1,135 @@
import { describe, expect, test } from "bun:test"
import { createApiForServer } from "./api"
import { readLocalImage } from "./image"
function setup(
respond: (init?: RequestInit) => Response | Promise<Response> = () => new Response(new Uint8Array([0, 127, 255])),
) {
const requests: Array<{ url: URL; init?: RequestInit }> = []
const api = createApiForServer({
server: { url: "https://server.example:4096", username: "image-user", password: "secret" },
fetch: (async (input: string | URL | Request, init?: RequestInit) => {
requests.push({ url: new URL(input instanceof Request ? input.url : input), init })
return respond(init)
}) as typeof fetch,
})
return { api, requests }
}
describe("readLocalImage", () => {
test.each([
["images/screen #1 + 50% ?.PNG", "/workspace/project", "images/screen%20%231%20%2B%2050%25%20%3F.PNG"],
["C:/tmp/opencode/screen #1.png", "C:/tmp/opencode/", "screen%20%231.png"],
["d:/screen.png", "d:/", "screen.png"],
["D:/charts/screen.png", "D:/charts/", "screen.png"],
["Z:/charts/screen.png", "Z:/charts/", "screen.png"],
["/tmp/opencode/screen #1.png", "/tmp/opencode/", "screen%20%231.png"],
["/screen.png", "/", "screen.png"],
])("reads %s with the correct location and authentication", async (path, directory, encoded) => {
const { api, requests } = setup()
const signal = new AbortController().signal
const blob = await readLocalImage(api, "/workspace/project", path, signal)
expect(blob).toBeInstanceOf(Blob)
expect(blob?.type).toBe("image/png")
expect(new Uint8Array(await blob!.arrayBuffer())).toEqual(new Uint8Array([0, 127, 255]))
expect(requests).toHaveLength(1)
expect(requests[0].url.origin).toBe("https://server.example:4096")
expect(requests[0].url.pathname).toBe(`/api/fs/read/${encoded}`)
expect([...requests[0].url.searchParams]).toEqual([["location[directory]", directory]])
expect(requests[0].init?.method).toBe("GET")
expect(new Headers(requests[0].init?.headers).get("authorization")).toBe(`Basic ${btoa("image-user:secret")}`)
expect(requests[0].init?.signal).toBe(signal)
})
test("preserves literal percent escapes and encodes the supplied relative location", async () => {
const { api, requests } = setup()
await readLocalImage(api, "C:/project #1 + 50%", "images/literal%20name.png", new AbortController().signal)
expect(requests[0].url.href).toBe(
"https://server.example:4096/api/fs/read/images/literal%2520name.png?location%5Bdirectory%5D=C%3A%2Fproject+%231+%2B+50%25",
)
})
test.each([
["png", "image/png"],
["jpeg", "image/jpeg"],
["JPG", "image/jpeg"],
["gif", "image/gif"],
["webp", "image/webp"],
["SVG", "image/svg+xml"],
["avif", "image/avif"],
["bmp", "image/bmp"],
["ico", "image/x-icon"],
])("uses the MIME type for .%s rather than the response header", async (extension, type) => {
const { api } = setup(
() => new Response("image bytes", { headers: { "content-type": "application/octet-stream" } }),
)
const blob = await readLocalImage(api, "/repo", `image.${extension}`, new AbortController().signal)
expect(blob?.type).toBe(type)
expect(await blob?.text()).toBe("image bytes")
})
test.each([
"https://example.com/image.png",
"HTTP://example.com/image.png",
"file:///tmp/image.png",
"data:image/png;base64,image.png",
"blob:https://example.com/image.png",
"custom+scheme:image.png",
"C:image.png",
"//server/share/image.png",
"\\\\server\\share\\image.png",
"//?/C:/image.png",
"///server/share/image.png",
"image.txt",
"image.html",
"image.constructor",
"image.png.txt",
"image",
"images.png/image",
"image.png/",
"",
])("does not request unsupported input %s", async (path) => {
const { api, requests } = setup()
expect(await readLocalImage(api, "/repo", path, new AbortController().signal)).toBeUndefined()
expect(requests).toHaveLength(0)
})
test.each([400, 401])("propagates declared API errors (%s)", async (status) => {
const error = { _tag: "RequestError", message: "Cannot read image" }
const { api } = setup(() => Response.json(error, { status }))
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toEqual(error)
})
test.each([404, 500])("does not turn an unexpected HTTP status (%s) into a Blob", async (status) => {
const { api } = setup(() => new Response("Not an image", { status }))
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject({
reason: "UnexpectedStatus",
cause: { status },
})
})
test("propagates transport failures", async () => {
const error = new Error("Connection lost")
const { api } = setup(() => Promise.reject(error))
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject({
reason: "Transport",
cause: error,
})
})
test("forwards cancellation to an in-flight fetch", async () => {
const controller = new AbortController()
const { api, requests } = setup(
(init) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true })
}),
)
const result = readLocalImage(api, "/repo", "image.png", controller.signal)
expect(requests[0].init?.signal).toBe(controller.signal)
controller.abort()
await expect(result).rejects.toMatchObject({ reason: "Transport", cause: controller.signal.reason })
})
})
+36
View File
@@ -0,0 +1,36 @@
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import type { OpenCodeClient } from "@opencode-ai/client/promise"
const types = new Map([
["png", "image/png"],
["jpeg", "image/jpeg"],
["jpg", "image/jpeg"],
["gif", "image/gif"],
["webp", "image/webp"],
["svg", "image/svg+xml"],
["avif", "image/avif"],
["bmp", "image/bmp"],
["ico", "image/x-icon"],
])
export async function readLocalImage(
api: Pick<OpenCodeClient, "file">,
directory: string,
path: string,
signal: AbortSignal,
): Promise<Blob | undefined> {
const drive = /^[a-z]:\//i.test(path)
if (/^[\\/]{2}/.test(path) || (!drive && /^[a-z][a-z\d+.-]*:/i.test(path))) return
const type = types.get(path.match(/\.([^./]+)$/)?.[1]?.toLowerCase() ?? "")
if (!type) return
const absolute = drive || path.startsWith("/")
// Scope absolute images to their parent, including files outside the project.
const bytes = await api.file.read(
{
path: absolute ? getFilename(path) : path,
location: { directory: absolute ? getDirectory(path) : directory },
},
{ signal },
)
return new Blob([new Uint8Array(bytes)], { type })
}
@@ -1,4 +1,5 @@
import { DataProvider } from "@opencode-ai/session-ui/context"
import { MarkdownProvider, type ReadMarkdownImage } from "@opencode-ai/session-ui/context/markdown"
import { useNavigate, useParams } from "@solidjs/router"
import { createMemo, type ParentProps } from "solid-js"
import { useProviders } from "@/providers/catalog/providers"
@@ -8,6 +9,7 @@ import { sessionHref } from "@/shell/routes/session"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
import { readLocalImage } from "@/runtime/server/image"
export function SessionUIProvider(
props: ParentProps<{
@@ -21,6 +23,10 @@ export function SessionUIProvider(
const serverSDK = useServerSDK()
const tabs = useTabs()
const directory = () => props.directory
const readImage = createMemo<ReadMarkdownImage>(() => {
const dir = directory()
return (path, signal) => readLocalImage(serverSDK.api, dir, path, signal)
})
const href = (sessionID: string) => sessionHref(props.server, sessionID)
const navigateToSession = async (sessionID: string) => {
const tab = tabs.store.find(
@@ -60,7 +66,9 @@ export function SessionUIProvider(
onNavigateToSession={navigateToSession}
onSessionHref={href}
>
<LocalProvider>{props.children}</LocalProvider>
<MarkdownProvider readImage={readImage()}>
<LocalProvider>{props.children}</LocalProvider>
</MarkdownProvider>
</DataProvider>
)
}
@@ -2,16 +2,28 @@ import { createSignal, Show } from "solid-js"
import { render } from "solid-js/web"
import { Markdown } from "../src/components/markdown"
import { preloadMarkdown } from "../src/components/markdown-cache"
import { MarkdownProvider } from "../src/context/markdown"
import { OpenCode } from "@opencode-ai/client/promise"
import { readLocalImage } from "../../app/src/runtime/server/image"
export { sanitizeMarkdown } from "../src/components/markdown-cache"
export { renderMermaidSvg } from "../src/components/markdown-mermaid"
export async function mountMarkdown(options: { text: string; streaming?: boolean; cached?: boolean }) {
export async function mountMarkdown(options: {
text: string
streaming?: boolean
cached?: boolean
images?: boolean
}) {
if (options.cached) await preloadMarkdown(options.text, "markdown-test")
const host = document.createElement("div")
host.dataset.testid = "markdown-fixture"
document.body.appendChild(host)
render(() => {
const api = OpenCode.make({
baseUrl: location.origin,
headers: { Authorization: `Basic ${btoa("opencode:fixture")}` },
})
const [text, setText] = createSignal(options.text)
const [streaming, setStreaming] = createSignal(options.streaming ?? false)
const [visible, setVisible] = createSignal(true)
@@ -25,14 +37,20 @@ export async function mountMarkdown(options: { text: string; streaming?: boolean
onChange={(event) => setStreaming(event.currentTarget.checked)}
/>
<button onClick={() => setVisible((value) => !value)}>Toggle Markdown</button>
<Show when={visible()}>
<Markdown
text={text()}
streaming={streaming()}
cacheKey={options.cached ? "markdown-test" : undefined}
deferUntilReady
/>
</Show>
<MarkdownProvider
readImage={(path, signal) =>
options.images ? readLocalImage(api, "C:/project", path, signal) : Promise.resolve(undefined)
}
>
<Show when={visible()}>
<Markdown
text={text()}
streaming={streaming()}
cacheKey={options.cached ? "markdown-test" : undefined}
deferUntilReady
/>
</Show>
</MarkdownProvider>
</>
)
}, host)
@@ -1,6 +1,11 @@
import { fileURLToPath } from "node:url"
import { expect, story } from "../../storybook/playwright/story"
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a4ioAAAAASUVORK5CYII=",
"base64",
)
const fixture = `/@fs/${fileURLToPath(new URL("./markdown.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
story.beforeEach(async ({ mount }) => {
@@ -22,7 +27,7 @@ story("sanitizes raw HTML while preserving supported Markdown markup", async ({
}, fixture)
expect(result).toEqual([
"<p><strong>Safe</strong> <em>formatting</em> <code>const x = 1</code></p>",
'<img src="safe.png"><a>unsafe</a>',
'<img data-local-image="safe.png"><a>unsafe</a>',
'<a href="https://example.com" target="_blank" rel="nofollow noopener noreferrer">external</a><a href="/local">local</a>',
'<form name="user-content-document" id="user-content-location"><input name="user-content-cookie"></form>',
"<math><mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow></math>",
@@ -64,7 +69,7 @@ story("keeps Markdown sanitization and link protections after Mermaid renders",
}
}, fixture)
expect(result.before).toBe(
'<a href="https://example.com" target="_blank" rel="nofollow noopener noreferrer">external</a><img src="safe.png">',
'<a href="https://example.com" target="_blank" rel="nofollow noopener noreferrer">external</a><img data-local-image="safe.png">',
)
expect(result.renders).toEqual([
{ root: "svg", text: expect.stringContaining("Start"), unsafe: 0, links: [], markdown: result.before },
@@ -211,3 +216,155 @@ story("replaces completed DOM before live rendering and retains streamed code co
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
await expect(markdown).toHaveCount(0)
})
for (const streaming of [false, true]) {
story(
`loads local images in ${streaming ? "streaming" : "cached"} Markdown and releases their URLs`,
async ({ page }) => {
const requests: string[] = []
await page.route("**/api/fs/read/**", async (route) => {
expect(route.request().headers().authorization).toBe(
`Basic ${Buffer.from("opencode:fixture").toString("base64")}`,
)
requests.push(route.request().url())
await route.fulfill({ contentType: "image/png", body: png })
})
await page.evaluate(
async ({ fixture, streaming }) => {
const { mountMarkdown } = await import(fixture)
await mountMarkdown({
text: "![Chart](C:/tmp/chart%20one.png)\n\n![Again](C:/tmp/chart%20one.png)",
streaming,
cached: !streaming,
images: true,
})
},
{ fixture, streaming },
)
const harness = page.getByTestId("markdown-fixture")
const image = harness.getByRole("img", { name: "Chart", exact: true })
await expect.poll(() => image.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1)
await expect(harness.getByRole("img", { name: "Again", exact: true })).toHaveAttribute("src", /^blob:/)
expect(requests).toHaveLength(1)
expect(new URL(requests[0]).searchParams.get("location[directory]")).toBe("C:/tmp/")
const url = await image.getAttribute("src")
await harness.getByLabel("Streaming").uncheck()
await expect(harness.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
await expect(image).toHaveAttribute("src", url!)
await harness.getByLabel("Markdown text").fill("![Replacement](./images/next.png)")
await expect
.poll(() =>
harness.getByRole("img", { name: "Replacement" }).evaluate((image: HTMLImageElement) => image.naturalWidth),
)
.toBe(1)
expect(requests).toHaveLength(2)
expect(
await page.evaluate(
(url) =>
fetch(url!).then(
() => false,
() => true,
),
url,
),
).toBe(true)
const next = await harness.getByRole("img", { name: "Replacement" }).getAttribute("src")
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
await expect(harness.getByRole("img")).toHaveCount(0)
expect(
await page.evaluate(
(url) =>
fetch(url!).then(
() => false,
() => true,
),
next,
),
).toBe(true)
},
)
}
story("keeps remote images browser-owned and rejects unsafe image sources", async ({ page }) => {
await page.route("https://images.example/chart.png", (route) => {
expect(route.request().headers().authorization).toBeUndefined()
return route.fulfill({ contentType: "image/png", body: png })
})
await page.evaluate(async (fixture) => {
const { mountMarkdown } = await import(fixture)
await mountMarkdown({
images: true,
text: '<img alt="Remote" src="https://images.example/chart.png"><img alt="Unsafe" src="javascript:alert(1)" onerror="alert(2)" data-local-image="/tmp/forged.png">',
})
}, fixture)
const harness = page.getByTestId("markdown-fixture")
await expect
.poll(() => harness.getByRole("img", { name: "Remote" }).evaluate((image: HTMLImageElement) => image.naturalWidth))
.toBe(1)
await expect(harness.getByRole("img", { name: "Remote" })).toHaveAttribute("src", "https://images.example/chart.png")
await expect(harness.locator("[onerror], [src^='javascript:'], [data-local-image]")).toHaveCount(0)
})
story("keeps scripts and external resources inactive inside local SVG images", async ({ page }) => {
const external: string[] = []
await page.context().route("https://images.example/**", async (route) => {
external.push(route.request().url())
await route.abort()
})
await page.route("**/api/fs/read/chart.svg?*", (route) =>
route.fulfill({
contentType: "image/svg+xml",
body: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<script>parent.document.title = "image-script-ran"; fetch("https://images.example/script")</script>
<image href="https://images.example/nested.png" width="32" height="32" />
<rect width="32" height="32" fill="green" />
</svg>`,
}),
)
await page.evaluate(async (fixture) => {
const { mountMarkdown } = await import(fixture)
await mountMarkdown({ images: true, text: "![SVG](Z:/charts/chart.svg)" })
}, fixture)
const image = page.getByTestId("markdown-fixture").getByRole("img", { name: "SVG" })
await expect.poll(() => image.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(32)
await expect(image).toHaveAttribute("src", /^data:image\/svg\+xml;/)
await expect(page).not.toHaveTitle("image-script-ran")
expect(external).toEqual([])
// Opening an image as a document must not give its contents the app's origin.
const preview = await page.context().newPage()
await preview.goto((await image.getAttribute("src"))!)
expect(await preview.evaluate(() => location.origin)).toBe("null")
await preview.close()
})
story("loads file URLs and leaves unreadable images as alt text", async ({ page }) => {
const requested = new Set<string>()
await page.route("**/api/fs/read/**", async (route) => {
const url = new URL(route.request().url())
requested.add(url.pathname)
await route.fulfill(
url.pathname.endsWith("missing.png")
? { status: 404, body: "Not found" }
: { contentType: "image/png", body: png },
)
})
await page.evaluate(async (fixture) => {
const { mountMarkdown } = await import(fixture)
await mountMarkdown({
images: true,
text: "![Available](file:///C:/tmp/chart%25.png)\n\n![Unavailable](file:///tmp/missing.png)",
})
}, fixture)
const harness = page.getByTestId("markdown-fixture")
await expect
.poll(() =>
harness
.getByRole("img", { name: "Available", exact: true })
.evaluate((image: HTMLImageElement) => image.naturalWidth),
)
.toBe(1)
await expect.poll(() => [...requested].sort()).toEqual(["/api/fs/read/chart%25.png", "/api/fs/read/missing.png"])
await expect(harness.getByRole("img", { name: "Unavailable" })).not.toHaveAttribute("src")
await harness.getByLabel("Markdown text").fill("Still usable")
await expect(harness.locator('[data-component="markdown"]')).toHaveText("Still usable")
})
@@ -1,6 +1,7 @@
import { checksum } from "@opencode-ai/util/encode"
import DOMPurify from "dompurify"
import { parseMarkdown } from "./markdown-worker"
import { localImagePath } from "./markdown-image"
export type MarkdownCacheEntry = {
raw: string
@@ -22,6 +23,16 @@ const config = {
}
if (typeof window !== "undefined" && purifier.isSupported) {
purifier.addHook("beforeSanitizeAttributes", (node) => {
if (!(node instanceof HTMLImageElement)) return
// Local paths are not browser URLs. Keep them inert until the host reads them.
node.removeAttribute("data-local-image")
const path = localImagePath(node.getAttribute("src") ?? "")
if (!path) return
node.setAttribute("data-local-image", path)
node.removeAttribute("src")
node.removeAttribute("srcset")
})
purifier.addHook("afterSanitizeAttributes", (node: Element) => {
if (!(node instanceof HTMLAnchorElement)) return
if (node.target !== "_blank") return
@@ -0,0 +1,37 @@
import { expect, test } from "bun:test"
import { localImagePath } from "./markdown-image"
test.each([
["C:/tmp/chart.png", "C:/tmp/chart.png"],
["C:\\tmp\\chart.png", "C:/tmp/chart.png"],
["D:/charts/chart.png", "D:/charts/chart.png"],
["Z:\\charts\\chart.png", "Z:/charts/chart.png"],
["file:///C:/tmp/chart%20one.png", "C:/tmp/chart one.png"],
["file:///d:/charts/chart%20one.png", "d:/charts/chart one.png"],
["file:///tmp/chart.png", "/tmp/chart.png"],
["file://localhost/tmp/chart.png", "/tmp/chart.png"],
["/tmp/chart.png", "/tmp/chart.png"],
["./images/chart%20one.svg", "./images/chart one.svg"],
["chart.png", "chart.png"],
["./chart%25.png", "./chart%.png"],
])("recognizes local image %s", (source, path) => {
expect(localImagePath(source)).toBe(path)
})
test.each([
"https://example.com/chart.png",
"http://example.com/chart.png",
"//example.com/chart.png",
"\\\\server\\share\\chart.png",
"file://server/share/chart.png",
"data:image/png;base64,AA==",
"blob:https://example.com/image",
"javascript:alert(1)",
"custom:image.png",
"C:chart.png",
"chart%00.png",
"chart%ZZ.png",
"",
])("does not read non-local or invalid source %s", (source) => {
expect(localImagePath(source)).toBeUndefined()
})
@@ -0,0 +1,75 @@
import type { ReadMarkdownImage } from "../context/markdown"
export function localImagePath(source: string) {
const value = source.trim().replaceAll("\\", "/")
if (!value || /[\u0000-\u001f\u007f]/.test(value) || value.startsWith("//")) return
if (/^file:/i.test(value)) {
if (!URL.canParse(value)) return
const url = new URL(value)
if (url.hostname && url.hostname !== "localhost") return
return decodePath(url.pathname.replace(/^\/([a-z]:\/)/i, "$1"))
}
if (/^[a-z][a-z\d+.-]*:/i.test(value) && !/^[a-z]:\//i.test(value)) return
return decodePath(value)
}
function decodePath(value: string) {
try {
const path = decodeURIComponent(value)
if (/[\u0000-\u001f\u007f]/.test(path) || path.startsWith("//") || path.startsWith("\\\\")) return
return path
} catch {
return
}
}
export function createMarkdownImages(read: ReadMarkdownImage) {
const entries = new Map<string, { controller: AbortController; result: Promise<string | undefined>; url?: string }>()
return {
update(root: HTMLElement) {
const images = Array.from(root.querySelectorAll<HTMLImageElement>("img[data-local-image]"))
const paths = new Set(images.map((image) => image.dataset.localImage))
entries.forEach((entry, path) => {
if (paths.has(path)) return
entry.controller.abort()
if (entry.url) URL.revokeObjectURL(entry.url)
entries.delete(path)
})
images.forEach((image) => {
const path = image.dataset.localImage
if (!path) return
const existing = entries.get(path)
const entry = existing ?? {
controller: new AbortController(),
result: Promise.resolve<string | undefined>(undefined),
url: undefined as string | undefined,
}
if (!existing) {
entries.set(path, entry)
entry.result = read(path, entry.controller.signal)
.then(async (blob) => {
if (!blob || entry.controller.signal.aborted) return
// SVG documents must not inherit the app origin if opened outside the image element.
if (blob.type === "image/svg+xml")
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(await blob.text())}`
entry.url = URL.createObjectURL(blob)
return entry.url
})
.catch(() => undefined)
}
void entry.result.then((url) => {
if (!url || entry.controller.signal.aborted || !root.contains(image) || image.dataset.localImage !== path)
return
image.src = url
})
})
},
dispose() {
entries.forEach((entry) => {
entry.controller.abort()
if (entry.url) URL.revokeObjectURL(entry.url)
})
entries.clear()
},
}
}
@@ -32,6 +32,8 @@ import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type Markdown
import { inlineCodeKind } from "./markdown-inline-code-kind"
import { renderMermaidSvg } from "./markdown-mermaid"
import { createMarkdownRenderer } from "./markdown-solid"
import { useMarkdown, type ReadMarkdownImage } from "../context/markdown"
import { createMarkdownImages } from "./markdown-image"
type RenderedBlock =
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
@@ -385,6 +387,7 @@ export function Markdown(
) {
const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "deferUntilReady", "class", "classList"])
const i18n = useI18n()
const markdown = useMarkdown()
const [root, setRoot] = createSignal<HTMLDivElement>()
const owner = createUniqueId()
const activeCodeKeys = new Set<string>()
@@ -507,6 +510,8 @@ export function Markdown(
)
let copyCleanup: (() => void) | undefined
let readImage: ReadMarkdownImage | undefined
let images: ReturnType<typeof createMarkdownImages> | undefined
createEffect(() => {
const container = root()
@@ -515,11 +520,17 @@ export function Markdown(
const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner, local.deferUntilReady) : []
if (!container) return
if (isServer) return
if (readImage !== markdown?.readImage) {
images?.dispose()
readImage = markdown?.readImage
images = readImage ? createMarkdownImages(readImage) : undefined
}
delete container.dataset.markdownReady
if (content.length === 0) {
disposeCopyButtons(container)
disposeRenderedMarkdown(container)
container.innerHTML = ""
images?.update(container)
if (result?.ready && result.text === local.text) container.dataset.markdownReady = ""
return
}
@@ -542,6 +553,7 @@ export function Markdown(
disposeRenderedMarkdown(child)
child.remove()
}
images?.update(container)
container
.querySelectorAll<HTMLElement>('[data-slot="markdown-copy-button"]')
.forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"))
@@ -554,6 +566,7 @@ export function Markdown(
})
onCleanup(() => {
images?.dispose()
if (copyCleanup) copyCleanup()
const container = root()
if (container) disposeRenderedMarkdown(container)
@@ -0,0 +1,21 @@
import { createContext, useContext, type ParentProps } from "solid-js"
export type ReadMarkdownImage = (path: string, signal: AbortSignal) => Promise<Blob | undefined>
const context = createContext<{ readonly readImage: ReadMarkdownImage }>()
export function MarkdownProvider(props: ParentProps<{ readImage: ReadMarkdownImage }>) {
return (
<context.Provider
value={{
get readImage() {
return props.readImage
},
}}
>
{props.children}
</context.Provider>
)
}
export const useMarkdown = () => useContext(context)