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", () => {