diff --git a/public/viz/viz-hydrate.ts b/public/viz/viz-hydrate.ts index 7a877d8..bcf884e 100644 --- a/public/viz/viz-hydrate.ts +++ b/public/viz/viz-hydrate.ts @@ -20,7 +20,11 @@ function hydrateViz(root: Document | Element = document): void { } try { - const json = atob(encoded); + // Base64 of UTF-8 bytes, as the server emits it; atob alone would + // mangle every code point above U+00FF. + const json = new TextDecoder().decode( + Uint8Array.from(atob(encoded), (ch) => ch.charCodeAt(0)), + ); const data: unknown = JSON.parse(json); renderer(container, data); } catch (err) { diff --git a/src/lib/viz/plugin.ts b/src/lib/viz/plugin.ts index 37d4ddd..8bcb317 100644 --- a/src/lib/viz/plugin.ts +++ b/src/lib/viz/plugin.ts @@ -9,6 +9,31 @@ export interface VizPluginEnv { hasViz?: boolean; } +function vizError(message: string): string { + return `
${escapeHtml(message)}
\n`; +} + +function renderVizBlock(info: string, content: string, env: unknown): string { + const vizType = info.slice(VIZ_PREFIX.length) as VizType; + if (!SUPPORTED_TYPES.has(vizType)) { + return `
Unknown visualization type: ${escapeHtml(vizType)}
\n`; + } + + const raw = content.trimEnd(); + const result = validate(vizType, raw); + + if (!result.ok) { + return vizError(`Invalid viz data: ${result.error ?? "unknown error"}`); + } + + // Buffer, not btoa: btoa throws on any code point above U+00FF, and the + // hydrator decodes these bytes back as UTF-8. + const encoded = Buffer.from(raw, "utf8").toString("base64"); + (env as VizPluginEnv).hasViz = true; + + return `
\n`; +} + export function vizPlugin(md: MarkdownIt): void { const defaultFence = md.renderer.rules["fence"] ?? @@ -24,21 +49,14 @@ export function vizPlugin(md: MarkdownIt): void { return defaultFence(tokens, idx, options, env, self); } - const vizType = info.slice(VIZ_PREFIX.length) as VizType; - if (!SUPPORTED_TYPES.has(vizType)) { - return `
Unknown visualization type: ${escapeHtml(vizType)}
\n`; - } - - const raw = token.content.trimEnd(); - const result = validate(vizType, raw); - - if (!result.ok) { - return `
Invalid viz data: ${escapeHtml(result.error ?? "unknown error")}
\n`; + try { + return renderVizBlock(info, token.content, env); + } catch (err) { + // A throw here escapes renderMarkdown and 500s the note on every + // request, so one bad block must never take the page down. + return vizError( + `Viz render failed: ${err instanceof Error ? err.message : String(err)}`, + ); } - - const encoded = btoa(raw); - (env as VizPluginEnv).hasViz = true; - - return `
\n`; }; } diff --git a/tests/lib/viz/plugin.test.ts b/tests/lib/viz/plugin.test.ts index 9a699c8..3ee17b9 100644 --- a/tests/lib/viz/plugin.test.ts +++ b/tests/lib/viz/plugin.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from "bun:test"; import { renderMarkdown } from "../../../src/lib/markdown.ts"; +// The server emits base64 of UTF-8 bytes; mirror the hydrator's decode. +function decodeVizData(html: string): string { + const match = html.match(/data-viz="([^"]+)"/); + expect(match).not.toBeNull(); + return Buffer.from(match?.[1] ?? "", "base64").toString("utf8"); +} + describe("viz plugin", () => { test("renders viz-graph block as container div", () => { const { html } = renderMarkdown( @@ -61,10 +68,32 @@ describe("viz plugin", () => { test("base64 data decodes back to original JSON", () => { const json = '{"nodes":[{"id":"a"}],"links":[]}'; const { html } = renderMarkdown(`\`\`\`viz-graph\n${json}\n\`\`\``); - const match = html.match(/data-viz="([^"]+)"/); - expect(match).not.toBeNull(); - const decoded = atob(match?.[1] ?? ""); - expect(decoded).toBe(json); + expect(decodeVizData(html)).toBe(json); + }); + + test("non-Latin-1 content round-trips instead of throwing", () => { + const json = + '{"nodes":[{"id":"a","label":"\u4e2d\u6587 \ud83c\udf3f \u201cquoted\u201d"}],"links":[]}'; + const { html, hasViz } = renderMarkdown(`\`\`\`viz-graph\n${json}\n\`\`\``); + expect(hasViz).toBe(true); + expect(html).not.toContain("viz-error"); + expect(decodeVizData(html)).toBe(json); + }); + + test("a throwing renderer degrades to viz-error, not a thrown render", () => { + const originalFrom = Buffer.from; + (Buffer as { from: unknown }).from = () => { + throw new Error("boom"); + }; + try { + const { html } = renderMarkdown( + '```viz-graph\n{"nodes":[{"id":"a"}],"links":[]}\n```', + ); + expect(html).toContain("viz-error"); + expect(html).toContain("boom"); + } finally { + (Buffer as { from: unknown }).from = originalFrom; + } }); test("multiple viz blocks all render and set hasViz", () => {