fix(markdown): restore diagrams and preserve link origin

This commit is contained in:
Mohamed Boudra
2026-10-15 15:32:19 +02:00
parent 554530a667
commit 3d06f652b2
40 changed files with 3777 additions and 80 deletions
@@ -0,0 +1,74 @@
import { test } from "../support/fixtures";
import {
closeDiagramFullscreen,
closeDiagramFullscreenFromOutside,
expectCompletedDiagram,
expectFullscreenDiagram,
expectFullscreenDiagramClosed,
openDiagramFullscreen,
requestDiagram,
waitForDiagramTurnToComplete,
zoomAndPanFullscreenDiagram,
} from "../support/helpers/diagram";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const MERMAID_DIAGRAM = [
"```mermaid",
"flowchart LR",
" Start --> Middle",
" Middle --> Review",
" Review --> Ship",
"```",
].join("\n");
test("opens a Mermaid diagram fullscreen and closes it again", async ({ page }) => {
test.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "mermaid-fullscreen-",
title: "Fullscreen Mermaid",
featureValues: {
mockStreamingAssistantResponse: MERMAID_DIAGRAM,
mockStreamingAssistantIntervalMs: 10,
},
});
try {
await test.step("Open the conversation and wait for the rendered diagram", async () => {
await openAgentRoute(page, agent);
await requestDiagram(agent);
await waitForDiagramTurnToComplete(agent);
await expectCompletedDiagram(page, ["Start", "Ship"]);
});
await test.step("The toolbar opens the diagram fullscreen", async () => {
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["Start", "Review", "Ship"]);
});
await test.step("The fullscreen diagram zooms with the wheel and pans by dragging", async () => {
await zoomAndPanFullscreenDiagram(page);
});
await test.step("The close action returns to the conversation", async () => {
await closeDiagramFullscreen(page);
await expectFullscreenDiagramClosed(page);
await expectCompletedDiagram(page, ["Start", "Ship"]);
});
await test.step("Escape closes the fullscreen diagram", async () => {
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["Start", "Ship"]);
await page.keyboard.press("Escape");
await expectFullscreenDiagramClosed(page);
});
await test.step("Clicking outside the diagram closes the fullscreen viewer", async () => {
await openDiagramFullscreen(page);
await expectFullscreenDiagram(page, ["Start", "Ship"]);
await closeDiagramFullscreenFromOutside(page);
await expectFullscreenDiagramClosed(page);
});
} finally {
await agent.cleanup();
}
});
@@ -0,0 +1,72 @@
import { test } from "../support/fixtures";
import {
expectCompletedDiagram,
expectDiagramRemainsRenderedWhileStreaming,
expectDiagramWithLabels,
reloadConversation,
requestDiagram,
waitForDiagramTurnToComplete,
} from "../support/helpers/diagram";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const STREAMED_MERMAID = [
"```mermaid",
"flowchart LR",
" Start --> Middle",
' Middle --> Done["<i>Done</i>"]',
" Middle --> Review",
" Review --> Verify",
" Verify --> Ship",
" Ship --> Package",
" Package --> Sign",
" Sign --> Upload",
" Upload --> Publish",
" Publish --> Deploy",
" Deploy --> Observe",
" Observe --> Validate",
" Validate --> Announce",
" Announce --> Document",
" Document --> Archive",
" Archive --> Release",
"```",
].join("\n");
test("keeps a Mermaid diagram rendered while its message streams, completes, and reloads", async ({
page,
}) => {
test.setTimeout(120_000);
let diagramTurnCompleted!: Promise<void>;
const agent = await seedMockAgentWorkspace({
repoPrefix: "mermaid-streaming-",
title: "Streaming Mermaid",
featureValues: {
mockStreamingAssistantResponse: STREAMED_MERMAID,
mockStreamingAssistantIntervalMs: 150,
},
});
try {
await test.step("Open the conversation and request a diagram", async () => {
await openAgentRoute(page, agent);
await requestDiagram(agent);
diagramTurnCompleted = waitForDiagramTurnToComplete(agent);
});
await test.step("The diagram renders and stays rendered while tokens arrive", async () => {
await expectDiagramWithLabels(page, ["Start", "Middle"]);
await expectDiagramRemainsRenderedWhileStreaming(page, diagramTurnCompleted);
});
await test.step("The completed diagram shows the final streamed content", async () => {
await diagramTurnCompleted;
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
});
await test.step("The completed diagram remains rendered after reload", async () => {
await reloadConversation(page);
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
});
} finally {
await agent.cleanup();
}
});
@@ -35,7 +35,7 @@ test("chat and file preview share the library renderer and resolve file links",
}, testInfo) => {
const { writeFile, mkdir } = await import("node:fs/promises");
const source =
"First paragraph.\n\nSecond paragraph.\n\n```ts\nconst answer = 42;\n```\n\n> A quote\n\n| Key || Value |\n| --- | --- | --- |\n| Answer || 42 |\n\nOpen `README.md` or `src/说明.md:2-3`.";
"First paragraph.\n\nSecond paragraph.\n\n```ts\nconst answer = 42;\n```\n\n> A quote\n\n| Key || Value |\n| --- | --- | --- |\n| Answer || 42 |\n\nOpen `README.md` or `src/说明.md:2-3`.\n\n[Space file](my%20file.ts) and `my%20file.ts`.";
const session = await seedMockAgentWorkspace({
repoPrefix: "shared-markdown-",
title: "Shared Markdown",
@@ -44,6 +44,11 @@ test("chat and file preview share the library renderer and resolve file links",
});
try {
await writeFile(`${session.cwd}/README.md`, source);
await writeFile(`${session.cwd}/my file.ts`, "Explicit link opened the space filename.");
await writeFile(
`${session.cwd}/my%20file.ts`,
"Inline code opened the literal percent filename.",
);
await mkdir(`${session.cwd}/packages/app/src`, { recursive: true });
await writeFile(
`${session.cwd}/packages/app/src/说明.md`,
@@ -67,6 +72,16 @@ test("chat and file preview share the library renderer and resolve file links",
await page.getByRole("button", { name: "Shared Markdown", exact: true }).click();
await message.getByRole("link", { name: "src/说明.md:2-3" }).click();
await expect(page.getByText("Unicode suffix lookup succeeded.", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Shared Markdown", exact: true }).click();
await message.getByRole("link", { name: "Space file", exact: true }).click();
await expect(
page.getByText("Explicit link opened the space filename.", { exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Shared Markdown", exact: true }).click();
await message.getByRole("link", { name: "my%20file.ts", exact: true }).click();
await expect(
page.getByText("Inline code opened the literal percent filename.", { exact: true }),
).toBeVisible();
await testInfo.attach("shared-renderer", {
body: await page.screenshot(),
contentType: "image/png",
+150
View File
@@ -0,0 +1,150 @@
import { expect, type Page } from "@playwright/test";
import type { MockAgentWorkspace } from "./mock-agent";
const DIAGRAM_NAME = "Diagram";
function renderedDiagram(page: Page) {
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
const svg = diagram.locator("iframe").contentFrame().locator("#diagram svg");
return { diagram, svg };
}
export async function requestDiagram(agent: MockAgentWorkspace): Promise<void> {
await agent.client.sendAgentMessage(agent.agentId, "Render the requested Mermaid diagram.");
}
export async function expectDiagramWithLabels(
page: Page,
labels: readonly string[],
): Promise<void> {
const { diagram, svg } = renderedDiagram(page);
await expect(diagram).toBeVisible({ timeout: 30_000 });
await expect(svg).toBeVisible({ timeout: 30_000 });
for (const label of labels) {
await expect(svg).toContainText(label);
}
}
export async function expectDiagramRemainsRenderedWhileStreaming(
page: Page,
completion: Promise<void>,
): Promise<void> {
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
let turnCompleted = false;
const completed = completion.then(() => {
turnCompleted = true;
return true;
});
for (;;) {
expect(await diagram.isVisible()).toBe(true);
if (turnCompleted) return completion;
const box = await diagram.boundingBox();
if (turnCompleted) return completion;
expect(box, "the inline diagram should have measurable layout").not.toBeNull();
expect(
box?.height,
"the inline diagram should remain tall enough to read",
).toBeGreaterThanOrEqual(56);
const didComplete = await Promise.race([completed, page.waitForTimeout(16).then(() => false)]);
if (didComplete) return completion;
}
}
export async function waitForDiagramTurnToComplete(agent: MockAgentWorkspace): Promise<void> {
await agent.client.waitForFinish(agent.agentId, 30_000);
}
export async function expectCompletedDiagram(page: Page, labels: readonly string[]): Promise<void> {
await expectDiagramWithLabels(page, labels);
}
export async function reloadConversation(page: Page): Promise<void> {
await page.reload({ waitUntil: "domcontentloaded" });
}
const FULLSCREEN_VIEWPORT = "mermaid-fullscreen-viewport";
function diagramSvg(page: Page, viewportTestId: string) {
return page
.getByTestId(`${viewportTestId}-canvas`)
.last()
.locator("iframe")
.contentFrame()
.locator("#diagram svg");
}
/**
* The viewport toolbar only reveals itself on hover, so the diagram is hovered first. The
* pointer is parked elsewhere beforehand so the hover transition fires even when a previous
* step left the cursor inside the viewport.
*/
export async function openDiagramFullscreen(page: Page): Promise<void> {
await page.mouse.move(0, 0);
await page.getByTestId("mermaid-viewport-canvas").last().hover();
await page.getByTestId("mermaid-fullscreen").last().click();
}
export async function closeDiagramFullscreen(page: Page): Promise<void> {
await page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`).hover();
await page.getByTestId("mermaid-fullscreen-close").click();
}
export async function zoomAndPanFullscreenDiagram(page: Page): Promise<void> {
const canvas = page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`);
const svg = diagramSvg(page, FULLSCREEN_VIEWPORT);
const initial = await svg.boundingBox();
expect(initial, "the fullscreen diagram should have measurable layout").not.toBeNull();
await page.mouse.move(initial!.x + initial!.width / 2, initial!.y + initial!.height / 2);
await page.mouse.wheel(0, -100);
const zoomed = await svg.boundingBox();
expect(zoomed, "the zoomed diagram should have measurable layout").not.toBeNull();
expect(zoomed!.width).toBeGreaterThan(initial!.width * 1.1);
const viewport = await canvas.boundingBox();
expect(viewport, "the fullscreen viewport should have measurable layout").not.toBeNull();
await page.mouse.move(viewport!.x + viewport!.width / 2, viewport!.y + viewport!.height / 2);
await page.mouse.down();
await page.mouse.move(
viewport!.x + viewport!.width / 2 - 120,
viewport!.y + viewport!.height / 2,
);
await page.mouse.up();
const panned = await svg.boundingBox();
expect(panned, "the panned diagram should have measurable layout").not.toBeNull();
expect(Math.abs(panned!.x - zoomed!.x)).toBeGreaterThan(100);
}
export async function closeDiagramFullscreenFromOutside(page: Page): Promise<void> {
const canvas = page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`);
const viewport = await canvas.boundingBox();
const svg = await diagramSvg(page, FULLSCREEN_VIEWPORT).boundingBox();
expect(viewport, "the fullscreen viewport should have measurable layout").not.toBeNull();
expect(svg, "the fullscreen diagram should have measurable layout").not.toBeNull();
const x = viewport!.x + viewport!.width / 2;
const y =
svg!.y + svg!.height < viewport!.y + viewport!.height - 8
? viewport!.y + viewport!.height - 4
: viewport!.y + 4;
await page.mouse.click(x, y);
}
export async function expectFullscreenDiagram(
page: Page,
labels: readonly string[],
): Promise<void> {
const viewport = page.getByTestId(FULLSCREEN_VIEWPORT);
await expect(viewport).toBeVisible({ timeout: 30_000 });
const svg = diagramSvg(page, FULLSCREEN_VIEWPORT);
await expect(svg).toBeVisible({ timeout: 30_000 });
for (const label of labels) {
await expect(svg).toContainText(label);
}
}
export async function expectFullscreenDiagramClosed(page: Page): Promise<void> {
await expect(page.getByTestId(FULLSCREEN_VIEWPORT)).toHaveCount(0);
}
@@ -2,7 +2,7 @@ export {
classifyAssistantFileLink,
normalizeInlinePathTarget,
type InlinePathTarget,
} from "./paths";
} from "./parse";
export {
AssistantFileLinkResolverProvider,
useAssistantFileLinkEnvironment,
@@ -5,7 +5,7 @@ import {
parseAssistantFileLink,
parseFileProtocolUrl,
parseInlinePathToken,
} from "./index";
} from "./parse";
describe("parseInlinePathToken", () => {
it("returns null for plain paths without a line number", () => {
@@ -10,7 +10,7 @@ import {
import React from "react";
import type { ToastApi } from "@/components/toast-host";
import type { OpenFileDisposition } from "@/workspace/file-open";
import type { InlinePathTarget } from "./paths";
import type { InlinePathTarget } from "./parse";
import type { AssistantFileLinkContext, GetDirectorySuggestions } from "./resolver";
export interface AssistantFileLinkDaemonClient {
@@ -3,7 +3,7 @@ import {
isFileLookingAssistantToken,
type AssistantFileLinkClassification,
type InlinePathTarget,
} from "./paths";
} from "./parse";
import { i18n } from "@/i18n/i18next";
export interface AssistantFileLinkSource {
@@ -6,7 +6,7 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import React, { useCallback, useMemo, useState, type ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import type { ToastApi } from "@/components/toast-host";
import type { InlinePathTarget } from "./paths";
import type { InlinePathTarget } from "./parse";
import { AssistantFileLinkResolverProvider } from "./provider";
import type { DirectorySuggestionResult } from "./resolver";
import { useFileLink } from "./use-file-link";
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { openExternalUrl } from "@/utils/open-external-url";
import type { InlinePathTarget } from "./paths";
import type { InlinePathTarget } from "./parse";
import {
useAssistantFileLinkResolverContext,
type AssistantFileLinkResolverContextValue,
@@ -0,0 +1,81 @@
import esbuild from "esbuild";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const appRoot = path.resolve(__dirname, "../../../../..");
const repoRoot = path.resolve(appRoot, "../..");
const entry = path.join(__dirname, "runtime/entry.ts");
const output = path.join(__dirname, "runtime/html.gen.ts");
const result = await esbuild.build({
entryPoints: [entry],
bundle: true,
write: false,
format: "iife",
platform: "browser",
target: ["ios15", "chrome100"],
minify: true,
// The app's deployment floor is iOS 15.1 but structuredClone (used by
// mermaid) only exists from iOS 15.4; esbuild targets transform syntax,
// not runtime APIs. Mermaid only clones plain config objects, so a
// JSON-based fallback is sufficient.
banner: {
js: 'if(typeof globalThis.structuredClone!=="function"){globalThis.structuredClone=function(v){return v===undefined?v:JSON.parse(JSON.stringify(v));};}',
},
logLevel: "info",
});
const rawJs = result.outputFiles[0]?.text;
if (!rawJs) {
throw new Error("Mermaid runtime bundle produced no JavaScript");
}
// JSON.stringify below escapes the TypeScript representation, not the runtime
// HTML — an embedded closing-script sequence would still terminate the inline
// <script>. `<\/script` is identical to `</script` inside JS strings/regex.
const js = rawJs.replace(/<\/script/gi, "<\\/script");
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'none'; img-src data: blob:; font-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'"
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<style>
html,
body {
margin: 0;
padding: 0;
background: transparent;
height: 100%;
}
/* The host sizes this frame to the diagram, so the diagram fills the frame. */
#diagram {
height: 100%;
}
#diagram svg {
display: block;
}
</style>
</head>
<body>
<div id="diagram"></div>
<script>${js}</script>
</body>
</html>`;
const contents = `// Generated by packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs.
// Do not edit by hand.
export const mermaidRuntimeHtml = ${JSON.stringify(html)};
`;
await fs.writeFile(output, contents);
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
@@ -0,0 +1,140 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Modal, View } from "react-native";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ZoomableViewport } from "@/components/zoomable-viewport";
import { SPACING } from "@/styles/theme";
import { WindowChromeRootRegion } from "@/utils/desktop-window";
import { MermaidIframeRuntime, type MermaidRenderedMessage } from "./iframe-runtime.web";
import type { DiagramColorScheme } from "./render-model";
import { useMermaidRenderModel } from "./use-render-model";
interface MermaidFullscreenViewerProps {
source: string;
colorScheme: DiagramColorScheme;
onClose: () => void;
}
// The diagram is already in the render cache, so the viewport knows its size before the
// fullscreen iframe has re-rendered it.
const FALLBACK_SIZE = { width: 240, height: 240 };
const VIEWER_FIT = { padding: SPACING[6] };
export function MermaidFullscreenViewer({
source,
colorScheme,
onClose,
}: MermaidFullscreenViewerProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { state, request, rendered, renderFailed } = useMermaidRenderModel({
source,
phase: "complete",
colorScheme,
});
const handleRendered = useCallback(
(message: MermaidRenderedMessage) => {
rendered({
revision: message.revision,
source: message.source,
colorScheme: message.colorScheme,
dimensions: { height: message.height, width: message.width },
});
},
[rendered],
);
useEffect(() => {
function handleKeydown(event: KeyboardEvent): void {
if (event.key === "Escape") onClose();
}
window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
}, [onClose]);
// The Modal focus trap walks descendants and focuses the first one that takes it. Focus the
// content layer ourselves (this effect runs after the trap's) so keystrokes stay in this
// document instead of disappearing into a focused diagram iframe.
const contentLayerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
contentLayerRef.current?.focus();
}, []);
const visible = state.visible;
const contentSize = useMemo(
() => (visible ? { width: visible.width, height: visible.height } : FALLBACK_SIZE),
[visible],
);
const actions = useMemo(
() => [
{
icon: X,
label: t("common.actions.close"),
onPress: onClose,
testID: "mermaid-fullscreen-close",
},
],
[onClose, t],
);
const contentLayerStyle = useMemo(
() => [
styles.contentLayer,
{
paddingTop: insets.top,
paddingRight: insets.right,
paddingBottom: insets.bottom,
paddingLeft: insets.left,
},
],
[insets.bottom, insets.left, insets.right, insets.top],
);
return (
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
<WindowChromeRootRegion corners="both">
<View style={styles.root}>
<View style={styles.backdrop} />
<View style={contentLayerStyle}>
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: focus holder for the Escape shortcut */}
<div ref={contentLayerRef} style={focusLayerDomStyle} tabIndex={-1}>
<ZoomableViewport
accessibilityLabel={t("message.diagram.diagram")}
actions={actions}
contentSize={contentSize}
fit={VIEWER_FIT}
minScale={1}
onPressOutsideContent={onClose}
style={styles.viewport}
testID="mermaid-fullscreen-viewport"
wheelActivation="always"
>
<MermaidIframeRuntime
request={request}
onRendered={handleRendered}
onRenderFailed={renderFailed}
/>
</ZoomableViewport>
</div>
</View>
</View>
</WindowChromeRootRegion>
</Modal>
);
}
const focusLayerDomStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: 0,
outline: "none",
};
const styles = StyleSheet.create((theme) => ({
root: { flex: 1, minHeight: 0, minWidth: 0 },
backdrop: { position: "absolute", inset: 0, backgroundColor: theme.colors.surface0 },
contentLayer: { position: "absolute", inset: 0 },
viewport: { flex: 1 },
}));
@@ -0,0 +1,379 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
StyleSheet as RNStyleSheet,
ScrollView,
View,
type TextStyle,
type ViewStyle,
} from "react-native";
import { WebView, type WebViewMessageEvent } from "react-native-webview";
import { Code, Workflow, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import type { Theme } from "@/styles/theme";
import type { MarkdownFenceRendererProps } from "./types";
import type { MermaidRenderRequest } from "./render-model";
import { mermaidRuntimeHtml } from "./runtime/html.gen";
import {
parseMermaidRuntimeMessage,
serializeMermaidRuntimeRenderMessage,
type MermaidRuntimeRenderMessage,
} from "./runtime/messages";
import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
import { useMermaidRenderModel } from "./use-render-model";
import { getDiagramBoxStyle } from "./presentation";
interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
colorScheme?: "light" | "dark";
}
const WEBVIEW_SOURCE = { html: mermaidRuntimeHtml };
const WEBVIEW_ORIGIN_WHITELIST = ["*"];
const MAX_PREVIEW_HEIGHT = 480;
interface MermaidWebViewProps {
request: MermaidRenderRequest | null;
interactive: boolean;
onRendered: (message: {
revision: number;
source: string;
colorScheme: "light" | "dark";
height: number;
width: number;
}) => void;
onRenderFailed: (revision: number) => void;
style?: ViewStyle;
}
function MermaidWebView({
request,
interactive,
onRendered,
onRenderFailed,
style,
}: MermaidWebViewProps) {
const webViewRef = useRef<WebView | null>(null);
const driverRef = useRef<MermaidRuntimeRequestDriver | null>(null);
driverRef.current ??= new MermaidRuntimeRequestDriver();
const sendRequest = useCallback(
(current: MermaidRenderRequest | null) => {
if (!current || !webViewRef.current) {
return;
}
const message: MermaidRuntimeRenderMessage = {
type: "render",
revision: current.revision,
source: current.source,
colorScheme: current.colorScheme,
interactive,
};
const payload = serializeMermaidRuntimeRenderMessage(message);
webViewRef.current.injectJavaScript(
`window.__PASEO_MERMAID_RUNTIME_RECEIVE__ && window.__PASEO_MERMAID_RUNTIME_RECEIVE__(${payload}); true;`,
);
},
[interactive],
);
useEffect(() => {
sendRequest(driverRef.current?.update(request) ?? null);
}, [request, sendRequest]);
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
let value: unknown;
try {
value = JSON.parse(event.nativeEvent.data);
} catch {
return;
}
const message = parseMermaidRuntimeMessage(value);
if (!message) {
return;
}
if (message.type === "bridgeReady") {
sendRequest(driverRef.current?.ready() ?? null);
return;
}
if (message.type === "renderError") {
onRenderFailed(message.revision);
sendRequest(driverRef.current?.settled(message.revision, false) ?? null);
return;
}
onRendered(message);
sendRequest(driverRef.current?.settled(message.revision, true) ?? null);
},
[onRenderFailed, onRendered, sendRequest],
);
const handleShouldStartLoad = useCallback(
(load: { url: string }) => load.url === "about:blank" || load.url.startsWith("data:"),
[],
);
return (
<WebView
ref={webViewRef}
source={WEBVIEW_SOURCE}
originWhitelist={WEBVIEW_ORIGIN_WHITELIST}
style={[webViewStyles.webView, style]}
onMessage={handleMessage}
onShouldStartLoadWithRequest={handleShouldStartLoad}
scrollEnabled={interactive}
bounces={false}
scalesPageToFit={interactive}
setSupportMultipleWindows={false}
allowsLinkPreview={false}
javaScriptEnabled
/>
);
}
const webViewStyles = RNStyleSheet.create({
webView: { backgroundColor: "transparent" },
});
interface MermaidDiagramViewerProps {
code: string;
colorScheme: "light" | "dark";
onClose: () => void;
inheritedStyles: TextStyle;
textStyle: TextStyle;
}
function MermaidDiagramViewer({
code,
colorScheme,
onClose,
inheritedStyles,
textStyle,
}: MermaidDiagramViewerProps) {
const { t } = useTranslation();
const [showSource, setShowSource] = useState(false);
const toggleSource = useCallback(() => setShowSource((current) => !current), []);
const { request, rendered, renderFailed } = useMermaidRenderModel({
source: code,
phase: "complete",
colorScheme,
});
const handleRendered = useCallback(
(message: {
revision: number;
source: string;
colorScheme: "light" | "dark";
height: number;
width: number;
}) => {
rendered({
revision: message.revision,
source: message.source,
colorScheme: message.colorScheme,
dimensions: { height: message.height, width: message.width },
});
},
[rendered],
);
return (
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
<View style={viewerStyles.backdrop}>
<View style={viewerStyles.webView}>
<MermaidWebView
request={request}
interactive
onRendered={handleRendered}
onRenderFailed={renderFailed}
style={viewerStyles.runtime}
/>
{showSource ? (
<ScrollView
style={viewerStyles.sourceOverlay}
contentContainerStyle={viewerStyles.source}
>
<HighlightedCodeBlock
code={code}
language="mermaid"
inheritedStyles={inheritedStyles}
textStyle={textStyle}
/>
</ScrollView>
) : null}
</View>
<View style={viewerStyles.actions}>
<Pressable
onPress={toggleSource}
style={viewerStyles.actionButton}
accessibilityRole="button"
accessibilityLabel={
showSource ? t("message.diagram.viewDiagram") : t("message.diagram.viewSource")
}
hitSlop={12}
>
{showSource ? (
<ThemedDiagramIcon size={20} uniProps={closeIconColor} />
) : (
<ThemedSourceIcon size={20} uniProps={closeIconColor} />
)}
</Pressable>
<Pressable
onPress={onClose}
style={viewerStyles.actionButton}
accessibilityRole="button"
accessibilityLabel={t("common.actions.close")}
hitSlop={12}
>
<ThemedCloseIcon size={20} uniProps={closeIconColor} />
</Pressable>
</View>
</View>
</Modal>
);
}
const ThemedCloseIcon = withUnistyles(X);
const ThemedSourceIcon = withUnistyles(Code);
const ThemedDiagramIcon = withUnistyles(Workflow);
const closeIconColor = (theme: Theme) => ({ color: theme.colors.foreground });
const viewerStyles = StyleSheet.create((theme, runtime) => ({
backdrop: { flex: 1, backgroundColor: theme.colors.surface0 },
webView: {
flex: 1,
marginTop: runtime.insets.top,
marginBottom: runtime.insets.bottom,
},
runtime: { flex: 1 },
sourceOverlay: {
position: "absolute",
inset: 0,
backgroundColor: theme.colors.surface0,
},
source: {
paddingTop: theme.spacing[12],
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[4],
},
actions: {
position: "absolute",
top: runtime.insets.top + theme.spacing[3],
right: theme.spacing[4],
flexDirection: "row",
gap: theme.spacing[2],
},
actionButton: {
padding: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
},
}));
function MermaidFenceHostImpl({
code,
phase,
inheritedStyles,
textStyle,
colorScheme = "dark",
}: MermaidFenceHostImplProps) {
const { t } = useTranslation();
const { state, request, rendered, renderFailed } = useMermaidRenderModel({
source: code,
phase,
colorScheme,
});
const [hasRuntimeContent, setHasRuntimeContent] = useState(false);
const handleRendered = useCallback(
(message: {
revision: number;
source: string;
colorScheme: "light" | "dark";
height: number;
width: number;
}) => {
setHasRuntimeContent(true);
rendered({
revision: message.revision,
source: message.source,
colorScheme: message.colorScheme,
dimensions: { height: message.height, width: message.width },
});
},
[rendered],
);
const [viewerOpen, setViewerOpen] = useState(false);
const openViewer = useCallback(() => setViewerOpen(true), []);
const closeViewer = useCallback(() => setViewerOpen(false), []);
const visible = state.visible;
const canShowDiagram = visible !== null && hasRuntimeContent;
const previewInnerStyle = useMemo(
() =>
canShowDiagram && visible
? { height: Math.min(visible.height, MAX_PREVIEW_HEIGHT) }
: previewStyles.measuringInner,
[canShowDiagram, visible],
);
const diagramBoxStyle = getDiagramBoxStyle(textStyle);
return (
<>
{!canShowDiagram ? (
<HighlightedCodeBlock
code={code}
language="mermaid"
inheritedStyles={inheritedStyles}
textStyle={textStyle}
/>
) : null}
<Pressable
onPress={openViewer}
disabled={!canShowDiagram}
accessibilityRole={canShowDiagram ? "imagebutton" : undefined}
accessibilityLabel={t("message.diagram.diagram")}
style={
canShowDiagram
? [diagramBoxStyle, previewStyles.preview]
: [diagramBoxStyle, previewStyles.measuring]
}
>
<View style={previewInnerStyle} pointerEvents="none">
<MermaidWebView
request={request}
interactive={false}
onRendered={handleRendered}
onRenderFailed={renderFailed}
/>
</View>
</Pressable>
{viewerOpen && canShowDiagram && visible ? (
<MermaidDiagramViewer
code={visible.source}
colorScheme={visible.colorScheme}
onClose={closeViewer}
inheritedStyles={inheritedStyles}
textStyle={textStyle}
/>
) : null}
</>
);
}
const previewStyles = RNStyleSheet.create({
measuring: {
position: "absolute",
left: 0,
right: 0,
opacity: 0,
pointerEvents: "none",
},
measuringInner: { height: 240 },
preview: { overflow: "hidden" },
});
const mapColorScheme = (theme: Theme) => ({ colorScheme: theme.colorScheme });
const ThemedMermaidFenceHost = withUnistyles(MermaidFenceHostImpl);
export function MermaidFenceHost(props: MarkdownFenceRendererProps) {
return <ThemedMermaidFenceHost {...props} uniProps={mapColorScheme} />;
}
@@ -0,0 +1,13 @@
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import type { MarkdownFenceRendererProps } from "./types";
export function MermaidFenceHost({ code, inheritedStyles, textStyle }: MarkdownFenceRendererProps) {
return (
<HighlightedCodeBlock
code={code}
language="mermaid"
inheritedStyles={inheritedStyles}
textStyle={textStyle}
/>
);
}
@@ -0,0 +1,193 @@
import { useCallback, useMemo, useState } from "react";
import { Pressable, View, type TextStyle, type ViewStyle } from "react-native";
import { Code, Maximize2, Workflow } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { ZoomableViewport } from "@/components/zoomable-viewport";
import type { Theme } from "@/styles/theme";
import type { MarkdownFenceRendererProps } from "./types";
import { MermaidFullscreenViewer } from "./fullscreen-viewer.web";
import { MermaidIframeRuntime, type MermaidRenderedMessage } from "./iframe-runtime.web";
import { useMermaidRenderModel } from "./use-render-model";
import { getDiagramBoxStyle } from "./presentation";
interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
colorScheme?: "light" | "dark";
}
function MermaidFenceHostImpl({
code,
phase,
inheritedStyles,
textStyle,
colorScheme = "dark",
}: MermaidFenceHostImplProps) {
const { t } = useTranslation();
const { state, request, rendered, renderFailed } = useMermaidRenderModel({
source: code,
phase,
colorScheme,
});
const [hasRuntimeContent, setHasRuntimeContent] = useState(false);
const [showSource, setShowSource] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const showSourcePress = useCallback(() => setShowSource(true), []);
const showDiagramPress = useCallback(() => setShowSource(false), []);
const openFullscreen = useCallback(() => setIsFullscreen(true), []);
const closeFullscreen = useCallback(() => setIsFullscreen(false), []);
const handleRendered = useCallback(
(message: MermaidRenderedMessage) => {
setHasRuntimeContent(true);
rendered({
revision: message.revision,
source: message.source,
colorScheme: message.colorScheme,
dimensions: { height: message.height, width: message.width },
});
},
[rendered],
);
const visible = state.visible;
const canShowDiagram = visible !== null && hasRuntimeContent;
const diagramVisible = canShowDiagram && !showSource;
const runtimeHeight = Math.max(visible?.height ?? 240, 1);
const actions = useMemo(
() => [
{
icon: Code,
label: t("message.diagram.viewSource"),
onPress: showSourcePress,
},
{
icon: Maximize2,
label: t("message.diagram.fullscreen"),
onPress: openFullscreen,
testID: "mermaid-fullscreen",
},
],
[openFullscreen, showSourcePress, t],
);
const sourceView = useMemo(() => {
const { marginTop, marginBottom, marginVertical, ...sourceTextStyle } = textStyle;
const margins: ViewStyle = {
marginTop: marginTop ?? marginVertical,
marginBottom: marginBottom ?? marginVertical,
};
const text: TextStyle = sourceTextStyle;
return { container: [margins, sourceContainerStyle], text };
}, [textStyle]);
const diagramStyle = useMemo(
() => [
getDiagramBoxStyle(textStyle),
containerStyle,
{ height: runtimeHeight, minHeight: MIN_DIAGRAM_BOX_HEIGHT },
],
[runtimeHeight, textStyle],
);
const diagramSize = useMemo(
() => (visible ? { width: visible.width, height: visible.height } : MEASURING_SIZE),
[visible],
);
const sourceVisible = !diagramVisible;
const sourceContainer = showSource ? sourceView.container : sourceContainerStyle;
const sourceTextStyle = showSource ? sourceView.text : textStyle;
const viewportStyle = diagramVisible ? diagramStyle : measuringStyle;
return (
<>
{sourceVisible ? (
<View style={sourceContainer}>
<HighlightedCodeBlock
code={code}
language="mermaid"
inheritedStyles={inheritedStyles}
textStyle={sourceTextStyle}
/>
{showSource && canShowDiagram ? (
<Pressable
accessibilityLabel={t("message.diagram.viewDiagram")}
accessibilityRole="button"
hitSlop={4}
onPress={showDiagramPress}
style={controlStyles.sourceButton}
>
{({ hovered }) => (
<Workflow
size={14}
color={hovered ? controlStyles.iconHovered.color : controlStyles.icon.color}
/>
)}
</Pressable>
) : null}
</View>
) : null}
<ZoomableViewport
accessibilityLabel={t("message.diagram.diagram")}
actions={actions}
contentSize={diagramSize}
style={viewportStyle}
testID="mermaid-viewport"
wheelActivation="modifier"
>
<MermaidIframeRuntime
request={request}
onRendered={handleRendered}
onRenderFailed={renderFailed}
/>
</ZoomableViewport>
{isFullscreen && visible ? (
<MermaidFullscreenViewer
source={visible.source}
colorScheme={visible.colorScheme}
onClose={closeFullscreen}
/>
) : null}
</>
);
}
const MEASURING_SIZE = { width: 240, height: 240 };
/**
* The toolbar overlays the top of the box (8px offset + 32px compact buttons) and the box clips
* with `overflow: hidden`, so a shorter box leaves the buttons half-clipped and unclickable.
*/
const MIN_DIAGRAM_BOX_HEIGHT = 56;
const sourceContainerStyle: ViewStyle = { position: "relative" };
/**
* The viewport's own root is `flex: 1`, so as a flex item it has `flex-basis: 0%` and that basis
* replaces the height below. In the markdown column there is no free space to grow into, so the
* box collapses and the diagram is scaled down to fit a couple of pixels. Size it from the height.
*/
const containerStyle: ViewStyle = {
flexBasis: "auto",
flexGrow: 0,
flexShrink: 0,
overflow: "hidden",
position: "relative",
};
const measuringStyle: ViewStyle = {
position: "absolute",
left: 0,
right: 0,
top: 0,
height: 240,
opacity: 0,
pointerEvents: "none",
};
const controlStyles = StyleSheet.create((theme) => ({
sourceButton: {
position: "absolute",
top: theme.spacing[2],
right: theme.spacing[2],
padding: theme.spacing[1],
},
icon: { color: theme.colors.foregroundMuted },
iconHovered: { color: theme.colors.foreground },
}));
const mapColorScheme = (theme: Theme) => ({ colorScheme: theme.colorScheme });
const ThemedMermaidFenceHost = withUnistyles(MermaidFenceHostImpl);
export function MermaidFenceHost(props: MarkdownFenceRendererProps) {
return <ThemedMermaidFenceHost {...props} uniProps={mapColorScheme} />;
}
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useRef } from "react";
import type { DiagramColorScheme, MermaidRenderRequest } from "./render-model";
import { mermaidRuntimeHtml } from "./runtime/html.gen";
import { parseMermaidRuntimeMessage, type MermaidRuntimeRenderMessage } from "./runtime/messages";
import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
export interface MermaidRenderedMessage {
revision: number;
source: string;
colorScheme: DiagramColorScheme;
height: number;
width: number;
}
interface MermaidIframeRuntimeProps {
request: MermaidRenderRequest | null;
onRendered: (message: MermaidRenderedMessage) => void;
onRenderFailed: (revision: number) => void;
}
/** Sandboxed Mermaid renderer. Sizing and gestures belong to the surrounding viewport. */
export function MermaidIframeRuntime({
request,
onRendered,
onRenderFailed,
}: MermaidIframeRuntimeProps) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const driverRef = useRef<MermaidRuntimeRequestDriver | null>(null);
driverRef.current ??= new MermaidRuntimeRequestDriver();
const sendRequest = useCallback((current: MermaidRenderRequest | null) => {
const target = iframeRef.current?.contentWindow;
if (!current || !target) return;
const message: MermaidRuntimeRenderMessage = {
type: "render",
revision: current.revision,
source: current.source,
colorScheme: current.colorScheme,
interactive: false,
};
target.postMessage(message, "*");
}, []);
useEffect(() => {
sendRequest(driverRef.current?.update(request) ?? null);
}, [request, sendRequest]);
useEffect(() => {
function receiveMessage(event: MessageEvent): void {
if (event.source !== iframeRef.current?.contentWindow) return;
const message = parseMermaidRuntimeMessage(event.data);
if (!message) return;
if (message.type === "bridgeReady") {
sendRequest(driverRef.current?.ready() ?? null);
return;
}
if (message.type === "renderError") {
onRenderFailed(message.revision);
sendRequest(driverRef.current?.settled(message.revision, false) ?? null);
return;
}
onRendered(message);
sendRequest(driverRef.current?.settled(message.revision, true) ?? null);
}
window.addEventListener("message", receiveMessage);
return () => window.removeEventListener("message", receiveMessage);
}, [onRenderFailed, onRendered, sendRequest]);
// `inert` (not just tabIndex) because the Modal focus trap focuses descendants
// programmatically; a focused iframe swallows every keystroke, including Escape.
return (
<iframe
ref={iframeRef}
title=""
aria-hidden
inert
sandbox="allow-scripts"
srcDoc={mermaidRuntimeHtml}
tabIndex={-1}
style={iframeStyle}
/>
);
}
const iframeStyle: React.CSSProperties = {
display: "block",
width: "100%",
height: "100%",
border: 0,
pointerEvents: "none",
background: "transparent",
};
@@ -0,0 +1,19 @@
import { createContext, useContext } from "react";
import { withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { MermaidFenceHost } from "./host";
export { splitMermaidFences } from "./segments";
export const MermaidStreamingContext = createContext(false);
const ThemedHost = withUnistyles(MermaidFenceHost);
const styles = (theme: Theme) => ({
inheritedStyles: {
color: theme.colors.foreground,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
},
textStyle: { backgroundColor: theme.colors.surface2, padding: 12, borderRadius: 8 },
});
export function MermaidFence({ code }: { code: string }) {
const streaming = useContext(MermaidStreamingContext);
return <ThemedHost code={code} phase={streaming ? "streaming" : "complete"} uniProps={styles} />;
}
@@ -0,0 +1,14 @@
import type { TextStyle, ViewStyle } from "react-native";
export function getDiagramBoxStyle(style: TextStyle): ViewStyle {
return {
backgroundColor: style.backgroundColor,
borderColor: style.borderColor,
borderRadius: style.borderRadius,
borderWidth: style.borderWidth,
marginBottom: style.marginBottom,
marginTop: style.marginTop,
marginVertical: style.marginVertical,
padding: style.padding,
};
}
@@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import {
createMermaidRenderModel,
getMermaidRenderRequest,
reduceMermaidRenderModel,
type MermaidRenderInput,
type MermaidRenderModel,
} from "./render-model";
function input(overrides: Partial<MermaidRenderInput> = {}): MermaidRenderInput {
return {
source: "flowchart TD\nA --> B",
phase: "streaming",
colorScheme: "dark",
rejected: false,
cached: null,
...overrides,
};
}
function renderCurrent(state: MermaidRenderModel): MermaidRenderModel {
return reduceMermaidRenderModel(state, {
type: "rendered",
revision: state.revision,
source: state.source,
colorScheme: state.colorScheme,
dimensions: { height: 120, width: 240 },
});
}
describe("Mermaid render model", () => {
it("shows each valid streamed revision", () => {
const first = renderCurrent(createMermaidRenderModel(input()));
const secondPending = reduceMermaidRenderModel(first, {
type: "inputChanged",
input: input({ source: "flowchart TD\nA --> B\nB --> C" }),
});
const second = renderCurrent(secondPending);
expect(first.visible?.source).toBe("flowchart TD\nA --> B");
expect(second.visible?.source).toBe("flowchart TD\nA --> B\nB --> C");
});
it("keeps the last valid preview across a transient streaming failure", () => {
const rendered = renderCurrent(createMermaidRenderModel(input()));
const pending = reduceMermaidRenderModel(rendered, {
type: "inputChanged",
input: input({ source: "flowchart TD\nA --" }),
});
const failed = reduceMermaidRenderModel(pending, {
type: "renderFailed",
revision: pending.revision,
});
expect(failed.status).toBe("failed");
expect(failed.visible?.source).toBe("flowchart TD\nA --> B");
});
it("clears a stale preview when failed source becomes final", () => {
const rendered = renderCurrent(createMermaidRenderModel(input()));
const pending = reduceMermaidRenderModel(rendered, {
type: "inputChanged",
input: input({ source: "flowchart TD\nA --" }),
});
const failed = reduceMermaidRenderModel(pending, {
type: "renderFailed",
revision: pending.revision,
});
const complete = reduceMermaidRenderModel(failed, {
type: "inputChanged",
input: input({ source: "flowchart TD\nA --", phase: "complete" }),
});
expect(complete.status).toBe("failed");
expect(complete.visible).toBeNull();
});
it("uses the completion phase when an in-flight final render fails", () => {
const rendered = renderCurrent(createMermaidRenderModel(input()));
const pending = reduceMermaidRenderModel(rendered, {
type: "inputChanged",
input: input({ source: "flowchart TD\nA --" }),
});
const completePending = reduceMermaidRenderModel(pending, {
type: "inputChanged",
input: input({ source: "flowchart TD\nA --", phase: "complete" }),
});
const failed = reduceMermaidRenderModel(completePending, {
type: "renderFailed",
revision: completePending.revision,
});
expect(failed.visible).toBeNull();
});
it("ignores obsolete render results", () => {
const initial = createMermaidRenderModel(input());
const newer = reduceMermaidRenderModel(initial, {
type: "inputChanged",
input: input({ source: "flowchart TD\nA --> C" }),
});
const obsolete = reduceMermaidRenderModel(newer, {
type: "rendered",
revision: initial.revision,
source: initial.source,
colorScheme: initial.colorScheme,
dimensions: { height: 90, width: 180 },
});
expect(obsolete).toBe(newer);
expect(getMermaidRenderRequest(obsolete)).toEqual({
revision: newer.revision,
source: "flowchart TD\nA --> C",
colorScheme: "dark",
});
});
it("uses an obsolete successful prefix as the first streaming preview", () => {
const initial = createMermaidRenderModel(input());
const newer = reduceMermaidRenderModel(initial, {
type: "inputChanged",
input: input({ source: "flowchart TD\nA --> B\nB --" }),
});
const preview = reduceMermaidRenderModel(newer, {
type: "rendered",
revision: initial.revision,
source: initial.source,
colorScheme: initial.colorScheme,
dimensions: { height: 90, width: 180 },
});
expect(preview.status).toBe("pending");
expect(preview.visible?.source).toBe(initial.source);
expect(getMermaidRenderRequest(preview)?.source).toBe("flowchart TD\nA --> B\nB --");
});
it("keeps the last preview when source policy rejects a streaming revision", () => {
const rendered = renderCurrent(createMermaidRenderModel(input()));
const rejected = reduceMermaidRenderModel(rendered, {
type: "inputChanged",
input: input({ source: 'flowchart TD\nA@{ img: "https://example.com/x" }', rejected: true }),
});
expect(rejected.status).toBe("rejected");
expect(rejected.visible).toBe(rendered.visible);
expect(getMermaidRenderRequest(rejected)).toBeNull();
});
it("clears the last preview when the rejected revision is complete", () => {
const rendered = renderCurrent(createMermaidRenderModel(input()));
const rejected = reduceMermaidRenderModel(rendered, {
type: "inputChanged",
input: input({
source: 'flowchart TD\nA@{ img: "https://example.com/x" }',
phase: "complete",
rejected: true,
}),
});
expect(rejected.status).toBe("rejected");
expect(rejected.visible).toBeNull();
});
});
@@ -0,0 +1,170 @@
import type { MarkdownPhase } from "./types";
export type DiagramColorScheme = "light" | "dark";
export interface DiagramDimensions {
height: number;
width: number;
}
export interface RenderedDiagram extends DiagramDimensions {
source: string;
colorScheme: DiagramColorScheme;
}
export interface MermaidRenderInput {
source: string;
phase: MarkdownPhase;
colorScheme: DiagramColorScheme;
rejected: boolean;
cached: RenderedDiagram | null;
}
export interface MermaidRenderRequest {
revision: number;
source: string;
colorScheme: DiagramColorScheme;
}
export interface MermaidRenderModel {
revision: number;
source: string;
phase: MarkdownPhase;
colorScheme: DiagramColorScheme;
status: "pending" | "rendered" | "failed" | "rejected";
visible: RenderedDiagram | null;
}
export type MermaidRenderAction =
| { type: "inputChanged"; input: MermaidRenderInput }
| {
type: "rendered";
revision: number;
source: string;
colorScheme: DiagramColorScheme;
dimensions: DiagramDimensions;
}
| { type: "renderFailed"; revision: number };
function reduceRendered(
state: MermaidRenderModel,
action: Extract<MermaidRenderAction, { type: "rendered" }>,
): MermaidRenderModel {
if (action.colorScheme !== state.colorScheme) {
return state;
}
if (action.revision !== state.revision || action.source !== state.source) {
const isStreamingPrefix =
state.phase === "streaming" &&
action.revision < state.revision &&
state.source.startsWith(action.source);
const isNewerPreview =
state.visible === null || action.source.length > state.visible.source.length;
if (!isStreamingPrefix || !isNewerPreview) {
return state;
}
return {
...state,
visible: {
source: action.source,
colorScheme: action.colorScheme,
...action.dimensions,
},
};
}
return {
...state,
status: "rendered",
visible: {
source: action.source,
colorScheme: action.colorScheme,
...action.dimensions,
},
};
}
export function createMermaidRenderModel(input: MermaidRenderInput): MermaidRenderModel {
if (input.rejected) {
return {
revision: 1,
source: input.source,
phase: input.phase,
colorScheme: input.colorScheme,
status: "rejected",
visible: input.phase === "complete" ? null : input.cached,
};
}
return {
revision: 1,
source: input.source,
phase: input.phase,
colorScheme: input.colorScheme,
status: "pending",
visible: input.cached,
};
}
export function reduceMermaidRenderModel(
state: MermaidRenderModel,
action: MermaidRenderAction,
): MermaidRenderModel {
if (action.type === "rendered") {
return reduceRendered(state, action);
}
if (action.type === "renderFailed") {
if (action.revision !== state.revision) {
return state;
}
return {
...state,
status: "failed",
visible: state.phase === "complete" ? null : state.visible,
};
}
const { input } = action;
const isSameRender = input.source === state.source && input.colorScheme === state.colorScheme;
if (isSameRender) {
if (input.phase === state.phase) {
return state;
}
const finalFailure = input.phase === "complete" && state.status === "failed";
return {
...state,
phase: input.phase,
visible: finalFailure ? null : state.visible,
};
}
if (input.rejected) {
return {
revision: state.revision + 1,
source: input.source,
phase: input.phase,
colorScheme: input.colorScheme,
status: "rejected",
visible: input.phase === "complete" ? null : (input.cached ?? state.visible),
};
}
return {
revision: state.revision + 1,
source: input.source,
phase: input.phase,
colorScheme: input.colorScheme,
status: "pending",
visible: input.cached ?? state.visible,
};
}
export function getMermaidRenderRequest(state: MermaidRenderModel): MermaidRenderRequest | null {
if (state.status !== "pending") {
return null;
}
return {
revision: state.revision,
source: state.source,
colorScheme: state.colorScheme,
};
}
@@ -0,0 +1,170 @@
import mermaid from "mermaid";
import {
parseMermaidRuntimeRenderMessage,
type MermaidRuntimeMessage,
type MermaidRuntimeRenderMessage,
} from "./messages";
declare global {
interface Window {
ReactNativeWebView?: {
postMessage?: (data: string) => void;
};
__PASEO_MERMAID_RUNTIME_RECEIVE__?: (message: unknown) => void;
}
}
function sendToHost(message: MermaidRuntimeMessage): void {
window.ReactNativeWebView?.postMessage?.(JSON.stringify(message));
if (window.parent !== window) {
window.parent.postMessage(message, "*");
}
}
function initializeMermaid(colorScheme: "light" | "dark"): void {
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
theme: colorScheme === "dark" ? "dark" : "default",
secure: [
"secure",
"securityLevel",
"startOnLoad",
"maxTextSize",
"suppressErrorRendering",
"maxEdges",
"theme",
"themeVariables",
"themeCSS",
],
flowchart: { htmlLabels: false },
class: { htmlLabels: false },
});
}
function setViewport(interactive: boolean): void {
document
.querySelector('meta[name="viewport"]')
?.setAttribute(
"content",
interactive
? "width=device-width, initial-scale=1, maximum-scale=8"
: "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no",
);
}
let latestRevision = 0;
let pendingRender: MermaidRuntimeRenderMessage | null = null;
let isRendering = false;
let isDrainScheduled = false;
interface DiagramSize {
height: number;
width: number;
}
/**
* Mermaid emits `width="100%"` with an inline `max-width` in pixels, so the diagram renders at
* its natural width and never fills a larger box. The host sizes this frame, so let the diagram
* follow the frame in both directions instead.
*/
function stretchToFrame(svg: SVGSVGElement): void {
svg.style.maxWidth = "100%";
svg.style.maxHeight = "100%";
svg.style.width = "100%";
svg.style.height = "100%";
}
/**
* The reported size is the host's content size, and the host feeds it back as this frame's size.
* Measuring the rendered box would make that a loop: every re-render lands in a frame the last
* measurement shrank by the container's padding, so a streaming diagram ratchets down to nothing.
* The viewBox is the diagram's own size and doesn't move.
*/
function measureDiagram(host: HTMLElement, svg: SVGSVGElement | null): DiagramSize {
const viewBox = svg?.viewBox.baseVal;
if (viewBox && viewBox.width > 0 && viewBox.height > 0) {
return { height: Math.ceil(viewBox.height), width: Math.ceil(viewBox.width) };
}
const rect = svg?.getBoundingClientRect();
return {
height: Math.ceil(rect?.height ?? host.scrollHeight),
width: Math.ceil(rect?.width ?? host.scrollWidth),
};
}
async function render(message: MermaidRuntimeRenderMessage): Promise<void> {
try {
initializeMermaid(message.colorScheme);
const { svg } = await mermaid.render(`paseo-mermaid-${message.revision}`, message.source);
if (message.revision !== latestRevision) {
return;
}
setViewport(message.interactive);
const host = document.getElementById("diagram");
if (!host) {
return;
}
host.innerHTML = svg;
const element = host.querySelector("svg");
if (element) {
stretchToFrame(element);
}
const size = measureDiagram(host, element);
sendToHost({
type: "rendered",
revision: message.revision,
source: message.source,
colorScheme: message.colorScheme,
height: size.height,
width: size.width,
});
} catch {
if (message.revision === latestRevision) {
sendToHost({ type: "renderError", revision: message.revision });
}
}
}
async function drainRenderQueue(): Promise<void> {
if (isRendering) {
return;
}
isRendering = true;
try {
while (pendingRender) {
const next = pendingRender;
pendingRender = null;
await render(next);
}
} finally {
isRendering = false;
}
}
function receiveRender(value: unknown): void {
const message = parseMermaidRuntimeRenderMessage(value);
if (!message) {
return;
}
latestRevision = message.revision;
pendingRender = message;
if (isRendering || isDrainScheduled) {
return;
}
isDrainScheduled = true;
window.setTimeout(() => {
isDrainScheduled = false;
void drainRenderQueue();
}, 0);
}
window.__PASEO_MERMAID_RUNTIME_RECEIVE__ = receiveRender;
window.addEventListener("message", (event) => {
if (event.source === window.parent) {
receiveRender(event.data);
}
});
sendToHost({ type: "bridgeReady" });
File diff suppressed because one or more lines are too long
@@ -0,0 +1,91 @@
import type { DiagramColorScheme, DiagramDimensions } from "../render-model";
export interface MermaidRuntimeRenderMessage {
type: "render";
revision: number;
source: string;
colorScheme: DiagramColorScheme;
interactive: boolean;
}
export type MermaidRuntimeMessage =
| { type: "bridgeReady" }
| ({
type: "rendered";
revision: number;
source: string;
colorScheme: DiagramColorScheme;
} & DiagramDimensions)
| { type: "renderError"; revision: number };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isColorScheme(value: unknown): value is DiagramColorScheme {
return value === "light" || value === "dark";
}
export function parseMermaidRuntimeRenderMessage(
value: unknown,
): MermaidRuntimeRenderMessage | null {
if (
!isRecord(value) ||
value.type !== "render" ||
typeof value.revision !== "number" ||
!Number.isInteger(value.revision) ||
typeof value.source !== "string" ||
!isColorScheme(value.colorScheme) ||
typeof value.interactive !== "boolean"
) {
return null;
}
return {
type: "render",
revision: value.revision,
source: value.source,
colorScheme: value.colorScheme,
interactive: value.interactive,
};
}
export function parseMermaidRuntimeMessage(value: unknown): MermaidRuntimeMessage | null {
if (!isRecord(value) || typeof value.type !== "string") {
return null;
}
if (value.type === "bridgeReady") {
return { type: "bridgeReady" };
}
if (
value.type === "renderError" &&
typeof value.revision === "number" &&
Number.isInteger(value.revision)
) {
return { type: "renderError", revision: value.revision };
}
if (
value.type === "rendered" &&
typeof value.revision === "number" &&
Number.isInteger(value.revision) &&
typeof value.source === "string" &&
isColorScheme(value.colorScheme) &&
typeof value.height === "number" &&
Number.isFinite(value.height) &&
typeof value.width === "number" &&
Number.isFinite(value.width)
) {
return {
type: "rendered",
revision: value.revision,
source: value.source,
colorScheme: value.colorScheme,
height: value.height,
width: value.width,
};
}
return null;
}
export function serializeMermaidRuntimeRenderMessage(message: MermaidRuntimeRenderMessage): string {
return JSON.stringify(message).replace(/<\/script/gi, "<\\/script");
}
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import type { MermaidRenderRequest } from "../render-model";
import { MermaidRuntimeRequestDriver } from "./request-driver";
function request(revision: number, source: string): MermaidRenderRequest {
return { revision, source, colorScheme: "dark" };
}
describe("MermaidRuntimeRequestDriver", () => {
it("tries queued streaming prefixes in order until the runtime renders one", () => {
const driver = new MermaidRuntimeRequestDriver();
const first = request(1, "flow");
const valid = request(2, "flowchart TD\nA --> B\n");
const transient = request(3, "flowchart TD\nA --> B\nB --");
expect(driver.update(first)).toBeNull();
expect(driver.update(valid)).toBeNull();
expect(driver.update(transient)).toBeNull();
expect(driver.ready()).toBe(valid);
expect(driver.settled(valid.revision, true)).toBe(transient);
});
it("keeps completed lines but collapses incomplete token revisions before readiness", () => {
const driver = new MermaidRuntimeRequestDriver();
const partial = request(1, "flowchart TD\nA --");
const completed = request(2, "flowchart TD\nA --> B\n");
const nextPartial = request(3, "flowchart TD\nA --> B\nB --");
const latestPartial = request(4, "flowchart TD\nA --> B\nB -->");
driver.update(partial);
driver.update(completed);
driver.update(nextPartial);
driver.update(latestPartial);
expect(driver.ready()).toBe(completed);
expect(driver.settled(completed.revision, true)).toBe(latestPartial);
});
it("collapses later updates to the newest request after the first render", () => {
const driver = new MermaidRuntimeRequestDriver();
const first = request(1, "flowchart TD\nA --> B");
const second = request(2, "flowchart TD\nA --> B\nB --> C");
const latest = request(3, "flowchart TD\nA --> B\nB --> D");
driver.update(first);
expect(driver.ready()).toBe(first);
expect(driver.settled(first.revision, true)).toBeNull();
expect(driver.update(second)).toBe(second);
expect(driver.update(latest)).toBeNull();
expect(driver.settled(second.revision, false)).toBe(latest);
});
});
@@ -0,0 +1,63 @@
import type { MermaidRenderRequest } from "../render-model";
function isCompletedLineCandidate(source: string): boolean {
return source.endsWith("\n") && source.trim().includes("\n");
}
export class MermaidRuntimeRequestDriver {
private readyState = false;
private renderedOnce = false;
private inFlight: MermaidRenderRequest | null = null;
private queued: MermaidRenderRequest[] = [];
update(request: MermaidRenderRequest | null): MermaidRenderRequest | null {
if (!request || this.hasRevision(request.revision)) {
return null;
}
if (this.renderedOnce) {
this.queued = [request];
} else {
const lastIndex = this.queued.length - 1;
const last = this.queued[lastIndex];
if (last && !isCompletedLineCandidate(last.source)) {
this.queued[lastIndex] = request;
} else {
this.queued.push(request);
}
}
return this.takeNext();
}
ready(): MermaidRenderRequest | null {
this.readyState = true;
return this.takeNext();
}
settled(revision: number, rendered: boolean): MermaidRenderRequest | null {
if (this.inFlight?.revision !== revision) {
return null;
}
this.inFlight = null;
if (rendered) {
this.renderedOnce = true;
const latest = this.queued.at(-1);
this.queued = latest ? [latest] : [];
}
return this.takeNext();
}
private hasRevision(revision: number): boolean {
return (
this.inFlight?.revision === revision ||
this.queued.some((request) => request.revision === revision)
);
}
private takeNext(): MermaidRenderRequest | null {
if (!this.readyState || this.inFlight || this.queued.length === 0) {
return null;
}
this.inFlight = this.queued.shift() ?? null;
return this.inFlight;
}
}
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { mermaidRuntimeHtml } from "./html.gen";
describe("Mermaid runtime document", () => {
it("ships a closed network policy", () => {
expect(mermaidRuntimeHtml).toContain("default-src 'none'");
expect(mermaidRuntimeHtml).toContain("connect-src 'none'");
expect(mermaidRuntimeHtml).toContain("img-src data: blob:");
expect(mermaidRuntimeHtml).toContain("font-src 'none'");
expect(mermaidRuntimeHtml).toContain("frame-src 'none'");
});
});
@@ -0,0 +1,146 @@
import { afterEach, describe, expect, it } from "vitest";
import { mermaidRuntimeHtml } from "./html.gen";
import { parseMermaidRuntimeMessage, type MermaidRuntimeMessage } from "./messages";
const mountedFrames: HTMLIFrameElement[] = [];
function waitForRuntimeMessage(
frame: HTMLIFrameElement,
predicate: (message: MermaidRuntimeMessage) => boolean,
): Promise<MermaidRuntimeMessage> {
return new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => {
window.removeEventListener("message", receive);
reject(new Error("Timed out waiting for Mermaid runtime"));
}, 10_000);
function receive(event: MessageEvent): void {
if (event.source !== frame.contentWindow) {
return;
}
const message = parseMermaidRuntimeMessage(event.data);
if (!message || !predicate(message)) {
return;
}
window.clearTimeout(timeout);
window.removeEventListener("message", receive);
resolve(message);
}
window.addEventListener("message", receive);
});
}
async function mountRuntime(size?: { width: number; height: number }): Promise<HTMLIFrameElement> {
const frame = document.createElement("iframe");
frame.sandbox.add("allow-scripts");
if (size) {
frame.style.width = `${size.width}px`;
frame.style.height = `${size.height}px`;
}
const ready = waitForRuntimeMessage(frame, (message) => message.type === "bridgeReady");
frame.srcdoc = mermaidRuntimeHtml;
document.body.append(frame);
mountedFrames.push(frame);
await ready;
return frame;
}
function renderedSize(message: MermaidRuntimeMessage): { height: number; width: number } {
if (message.type !== "rendered") {
throw new Error(`Expected a rendered diagram, got ${message.type}`);
}
return { height: message.height, width: message.width };
}
function render(
frame: HTMLIFrameElement,
input: { revision: number; source: string },
): Promise<MermaidRuntimeMessage> {
const response = waitForRuntimeMessage(
frame,
(message) =>
message.type !== "bridgeReady" &&
"revision" in message &&
message.revision === input.revision,
);
frame.contentWindow?.postMessage(
{
type: "render",
revision: input.revision,
source: input.source,
colorScheme: "dark",
interactive: false,
},
"*",
);
return response;
}
afterEach(() => {
for (const frame of mountedFrames.splice(0)) {
frame.remove();
}
});
describe("Mermaid sandbox runtime", () => {
it("renders successive valid streaming prefixes and reports an invalid prefix", async () => {
const frame = await mountRuntime();
const firstSource = "flowchart TD\nA --> B";
const secondSource = `${firstSource}\nB --> C`;
const first = await render(frame, { revision: 1, source: firstSource });
const invalid = await render(frame, { revision: 2, source: "not a mermaid diagram" });
const second = await render(frame, { revision: 3, source: secondSource });
expect(first).toMatchObject({ type: "rendered", revision: 1, source: firstSource });
expect(invalid).toEqual({ type: "renderError", revision: 2 });
expect(second).toMatchObject({ type: "rendered", revision: 3, source: secondSource });
});
/**
* The host sizes this frame from the reported size, so a size that depends on the frame feeds
* back: every re-render measures inside a frame the previous measurement already shrank by the
* container's padding, and a streaming diagram ratchets down to a few pixels.
*/
it("reports the same size whatever frame the host gives it", async () => {
const source = "flowchart TD\nA[Start] --> B[Middle]\nB --> C[Ship]";
const narrowFrame = await mountRuntime({ height: 60, width: 60 });
const wideFrame = await mountRuntime({ height: 600, width: 900 });
const narrow = await render(narrowFrame, { revision: 1, source });
const wide = await render(wideFrame, { revision: 1, source });
expect(renderedSize(narrow)).toEqual(renderedSize(wide));
});
it("coalesces queued input and never reports an obsolete result", async () => {
const frame = await mountRuntime();
const obsoleteSource = `flowchart TD\n${Array.from({ length: 250 }, (_, index) => `A${index} --> A${index + 1}`).join("\n")}`;
const currentSource = "flowchart TD\nCurrent --> Result";
const obsoleteResponses: MermaidRuntimeMessage[] = [];
function collect(event: MessageEvent): void {
if (event.source !== frame.contentWindow) {
return;
}
const message = parseMermaidRuntimeMessage(event.data);
if (message && message.type !== "bridgeReady" && message.revision === 10) {
obsoleteResponses.push(message);
}
}
window.addEventListener("message", collect);
frame.contentWindow?.postMessage(
{
type: "render",
revision: 10,
source: obsoleteSource,
colorScheme: "dark",
interactive: false,
},
"*",
);
const current = await render(frame, { revision: 11, source: currentSource });
window.removeEventListener("message", collect);
expect(current).toMatchObject({ type: "rendered", revision: 11, source: currentSource });
expect(obsoleteResponses).toEqual([]);
});
});
@@ -0,0 +1,41 @@
import { fromMarkdown } from "mdast-util-from-markdown";
import type { RootContent } from "mdast";
interface Segment {
kind: "markdown" | "mermaid";
offset: number;
source: string;
}
/** Native enriched-markdown has no fenced-block override. Only diagrams leave its engine. */
export function splitMermaidFences(markdown: string): Segment[] {
if (!/mermaid/i.test(markdown)) return [{ kind: "markdown", offset: 0, source: markdown }];
const segments: Segment[] = [];
let cursor = 0;
function visit(node: RootContent): void {
if (node.type === "code" && node.lang?.toLowerCase() === "mermaid" && node.position) {
const start = node.position.start.offset!;
const end = node.position.end.offset!;
if (start > cursor)
segments.push({ kind: "markdown", offset: cursor, source: markdown.slice(cursor, start) });
segments.push({ kind: "mermaid", offset: start, source: node.value });
cursor = end;
} else if ("children" in node) node.children.forEach(visit);
}
const tree = fromMarkdown(markdown);
tree.children.forEach(visit);
if (cursor < markdown.length || segments.length === 0)
segments.push({ kind: "markdown", offset: cursor, source: markdown.slice(cursor) });
// Reference links keep their document-wide scope on both sides of a diagram.
if (segments.some((segment) => segment.kind === "mermaid")) {
const definitions = tree.children
.filter((node) => node.type === "definition")
.map((node) => markdown.slice(node.position!.start.offset, node.position!.end.offset))
.join("\n");
if (definitions)
for (const segment of segments) {
if (segment.kind === "markdown") segment.source += `\n\n${definitions}`;
}
}
return segments;
}
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { containsUnsafeMermaidSource } from "./source-policy";
describe("containsUnsafeMermaidSource", () => {
it("rejects resource-bearing constructs", () => {
expect(containsUnsafeMermaidSource('flowchart TD\n A@{ img: "https://x/y.png" }')).toBe(true);
expect(containsUnsafeMermaidSource("flowchart TD\n A@{ icon: 'pack:name' }")).toBe(true);
expect(
containsUnsafeMermaidSource('%%{init: {"themeCSS": "a { color: red }"}}%%\ngraph TD'),
).toBe(true);
expect(containsUnsafeMermaidSource("graph TD\n A[url(http://x)]")).toBe(true);
expect(containsUnsafeMermaidSource("graph TD\n A[@import 'x']")).toBe(true);
expect(containsUnsafeMermaidSource('graph TD\n A["<img src=x>"]')).toBe(true);
expect(containsUnsafeMermaidSource('graph TD\n A["<i class=x>styled</i>"]')).toBe(true);
expect(containsUnsafeMermaidSource('graph TD\n A["&#60;img src=x&#62;"]')).toBe(true);
expect(containsUnsafeMermaidSource('graph TD\n A["</b>"]')).toBe(true);
});
it("rejects all shape-data constructs, including yaml key evasions", () => {
expect(containsUnsafeMermaidSource('flowchart TD\n A@{ "img": "https://x/y.png" }')).toBe(
true,
);
expect(containsUnsafeMermaidSource("flowchart TD\n A@{ 'img': 'https://x/y.png' }")).toBe(
true,
);
expect(
containsUnsafeMermaidSource('flowchart TD\n A@{ "\\u0069mg": "https://x/y.png" }'),
).toBe(true);
expect(
containsUnsafeMermaidSource('flowchart TD\n A@{ "\\u{69}mg": "https://x/y.png" }'),
).toBe(true);
expect(containsUnsafeMermaidSource('flowchart TD\n A@{ "\\x69mg": "https://x/y.png" }')).toBe(
true,
);
expect(
containsUnsafeMermaidSource('flowchart TD\n A@{ "\\U00000069mg": "https://attacker/x" }'),
).toBe(true);
expect(containsUnsafeMermaidSource('flowchart TD\n A@{ "icon": "pack:name" }')).toBe(true);
expect(
containsUnsafeMermaidSource(
'flowchart TD\n A@{ ? img # comment\n : "https://attacker/x" }',
),
).toBe(true);
expect(
containsUnsafeMermaidSource(
'flowchart TD\n A@{ dummy: &k img\n ? *k # comment\n : "https://attacker/x" }',
),
).toBe(true);
});
it("fails closed for malformed or out-of-range escapes without throwing", () => {
expect(() =>
containsUnsafeMermaidSource('graph TD\n A["\\u{110000} disguised"]'),
).not.toThrow();
expect(containsUnsafeMermaidSource('graph TD\n A["\\u{110000} disguised"]')).toBe(true);
expect(() =>
containsUnsafeMermaidSource('graph TD\n A["\\u{FFFFFF} disguised"]'),
).not.toThrow();
expect(containsUnsafeMermaidSource('graph TD\n A["\\u{FFFFFF} disguised"]')).toBe(true);
});
it("allows ordinary diagrams including formatting-only labels", () => {
expect(containsUnsafeMermaidSource("flowchart TD\n A[Start] --> B{Choice}")).toBe(false);
expect(containsUnsafeMermaidSource('graph TD\n A["line one<br>line two"]')).toBe(false);
expect(containsUnsafeMermaidSource('graph TD\n A["line one<br/>line two"]')).toBe(false);
expect(containsUnsafeMermaidSource('graph TD\n A["<i>formatted</i>"]')).toBe(false);
expect(containsUnsafeMermaidSource("sequenceDiagram\n Alice->>Bob: a < b and x > y")).toBe(
false,
);
expect(containsUnsafeMermaidSource("flowchart TD\n A@{ shape: rect }")).toBe(true);
});
});
@@ -0,0 +1,52 @@
// Mermaid can fetch external resources while *rendering* — image shapes
// (`A@{ img: "url" }`) construct an Image and await decode before any output
// sanitization runs, and CSS can pull url()/@import — so a prompt-injected
// diagram could exfiltrate data in a request URL. securityLevel "strict" does
// not prevent this (mermaid-js/mermaid#7645). Mermaid 11.16 parses the shape
// metadata object with yaml.JSON_SCHEMA semantics, including aliases and
// explicit keys; matching those keys safely would require a real parser. We
// therefore reject any `@{ ... }` shape-data construct up front and fall back
// to the source code block. Formatting-only `<br>` and `<i>` tags are common
// in generated labels and carry no resource-bearing attributes; all other
// tags (and entity-encoded text that could smuggle one) are rejected.
const UNSAFE_MERMAID_SOURCE =
/@\s*\{|url\s*\(|@import\b|themeCSS|&#|<(?!\/?(?:br|i)\s*\/?>)[a-z!/]/i;
// Mermaid labels can contain escaped text. Decode the escape forms we care
// about before running the denylist so disguised HTML still gets caught.
// Invalid escape sequences fail closed and fall back to the source block.
function normalizeMermaidSource(code: string): string | null {
try {
return code
.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (_, hex: string) =>
decodeCodePointEscape(Number.parseInt(hex, 16)),
)
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16)),
)
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16)),
)
.replace(/["'`\\]/g, "");
} catch {
return null;
}
}
function decodeCodePointEscape(value: number): string {
if (!Number.isInteger(value) || value < 0 || value > 0x10ffff) {
throw new RangeError("Invalid Unicode code point");
}
return String.fromCodePoint(value);
}
export function containsUnsafeMermaidSource(code: string): boolean {
if (UNSAFE_MERMAID_SOURCE.test(code)) {
return true;
}
const normalized = normalizeMermaidSource(code);
if (normalized === null) {
return true;
}
return UNSAFE_MERMAID_SOURCE.test(normalized);
}
@@ -0,0 +1,8 @@
import type { TextStyle } from "react-native";
export type MarkdownPhase = "streaming" | "complete";
export interface MarkdownFenceRendererProps {
code: string;
phase: MarkdownPhase;
inheritedStyles: TextStyle;
textStyle: TextStyle;
}
@@ -0,0 +1,96 @@
import { useCallback, useEffect, useMemo, useReducer } from "react";
import {
createMermaidRenderModel,
getMermaidRenderRequest,
reduceMermaidRenderModel,
type DiagramColorScheme,
type DiagramDimensions,
type RenderedDiagram,
} from "./render-model";
import { containsUnsafeMermaidSource } from "./source-policy";
import type { MarkdownPhase } from "./types";
const renderCache = new Map<string, RenderedDiagram>();
const RENDER_CACHE_LIMIT = 50;
function cacheKey(source: string, colorScheme: DiagramColorScheme): string {
return `${colorScheme}\u0000${source}`;
}
function readCachedRender(source: string, colorScheme: DiagramColorScheme): RenderedDiagram | null {
return renderCache.get(cacheKey(source, colorScheme)) ?? null;
}
function cacheRender(rendered: RenderedDiagram): void {
if (renderCache.size >= RENDER_CACHE_LIMIT) {
const oldest = renderCache.keys().next().value;
if (oldest !== undefined) {
renderCache.delete(oldest);
}
}
renderCache.set(cacheKey(rendered.source, rendered.colorScheme), rendered);
}
export function useMermaidRenderModel({
source,
phase,
colorScheme,
}: {
source: string;
phase: MarkdownPhase;
colorScheme: DiagramColorScheme;
}) {
const renderInput = useMemo(
() => ({
source,
phase,
colorScheme,
rejected: containsUnsafeMermaidSource(source),
cached: readCachedRender(source, colorScheme),
}),
[colorScheme, phase, source],
);
const [state, dispatch] = useReducer(
reduceMermaidRenderModel,
renderInput,
createMermaidRenderModel,
);
useEffect(() => {
dispatch({ type: "inputChanged", input: renderInput });
}, [renderInput]);
const rendered = useCallback(
(response: {
revision: number;
source: string;
colorScheme: DiagramColorScheme;
dimensions: DiagramDimensions;
}) => {
const cached: RenderedDiagram = {
source: response.source,
colorScheme: response.colorScheme,
...response.dimensions,
};
cacheRender(cached);
dispatch({
type: "rendered",
revision: response.revision,
source: response.source,
colorScheme: response.colorScheme,
dimensions: response.dimensions,
});
},
[],
);
const renderFailed = useCallback((revision: number) => {
dispatch({ type: "renderFailed", revision });
}, []);
return {
state,
request: getMermaidRenderRequest(state),
rendered,
renderFailed,
};
}
@@ -34,10 +34,7 @@ export interface MarkdownImageContext {
export function useMarkdownSource(text: string, context: MarkdownImageContext) {
const occurrenceKey = useId();
const prepared = useMemo(
() => prepareMarkdownSource(text, context.workspaceRoot),
[text, context.workspaceRoot],
);
const prepared = useMemo(() => prepareMarkdownSource(text), [text]);
const sources = [...new Set(prepared.images.map((image) => image.source))];
const acquisitions = sources.map((source) => {
const resolved = context.filePath
@@ -95,7 +92,6 @@ export function useMarkdownSource(text: string, context: MarkdownImageContext) {
return {
markdown: prepared.render(resolved),
linkRecognition: prepared.linkRecognition,
linkPillContent: prepared.linkPillContent,
linkSource: prepared.linkSource,
imageSources: Object.fromEntries([...resolved].map(([source, url]) => [url, source])),
};
@@ -48,18 +48,64 @@ it.each([
"src/components/message.tsx",
"C:\\repo\\file.ts",
"docs/说明.md",
"my%20file.ts",
"src/file.ts:10-20",
"src/file.ts(10,2)",
])("recognizes inline paths through the file resolver: %s", (path) => {
const prepared = prepareMarkdownSource(`Open \`${path}\`.`, "/workspace");
const prepared = prepareMarkdownSource(`Open \`${path}\`.`);
expect(prepared.linkRecognition.inlineCode?.test(path)).toBe(true);
expect(prepared.linkSource(path)).toEqual({ href: path, text: path, sourceType: "inline-code" });
expect(prepared.linkSource(path, "inline-code")).toEqual({
href: path,
text: path,
sourceType: "inline-code",
});
});
it("recognizes bare Unicode filenames and preserves inline-code URLs", () => {
const prepared = prepareMarkdownSource("Read 说明.md, or `https://example.com`.", "/workspace");
const prepared = prepareMarkdownSource("Read 说明.md, or `https://example.com`.");
expect([..."说明.md,".matchAll(prepared.linkRecognition.text)].map(([text]) => text)).toEqual([
"说明.md",
]);
expect(prepared.linkRecognition.inlineCode?.test("https://example.com")).toBe(true);
});
it("keeps explicit and inline-code links distinct when their URL strings match", () => {
const url = "/tmp/my%20file.ts";
const prepared = prepareMarkdownSource(`[open](${url}) and \`${url}\``);
expect(prepared.linkSource(url, "markdown")).toEqual({ href: url, text: url });
expect(prepared.linkSource(url, "inline-code")).toEqual({
href: url,
text: url,
sourceType: "inline-code",
});
});
it("extracts only Mermaid fences for the native diagram host", async () => {
const { splitMermaidFences } = await import("./fence/mermaid/segments");
const source =
"First.\n\n```ts\nconst n = 1;\n```\n\n```mermaid\ngraph TD\nA-->B\n```\n\n> Last.";
expect(splitMermaidFences(source)).toEqual([
{ kind: "markdown", offset: 0, source: "First.\n\n```ts\nconst n = 1;\n```\n\n" },
{ kind: "mermaid", offset: 32, source: "graph TD\nA-->B" },
{ kind: "markdown", offset: 61, source: "\n\n> Last." },
]);
expect(splitMermaidFences("```md\n```mermaid\n```")).toEqual([
{ kind: "markdown", offset: 0, source: "```md\n```mermaid\n```" },
]);
});
it("keeps reference links available across native Mermaid boundaries", async () => {
const { splitMermaidFences } = await import("./fence/mermaid/segments");
const parts = splitMermaidFences(
"[Before][file]\n\n```mermaid\ngraph TD\nA-->B\n```\n\n[After][file]\n\n[file]: src/file.ts",
);
for (const part of parts.filter((candidate) => candidate.kind === "markdown"))
expect(part.source).toContain("[file]: src/file.ts");
});
it("recognizes mixed-case Mermaid fence info and incomplete streaming fences", async () => {
const { splitMermaidFences } = await import("./fence/mermaid/segments");
expect(splitMermaidFences("```Mermaid title\ngraph TD\nA-->B")).toEqual([
{ kind: "mermaid", offset: 0, source: "graph TD\nA-->B" },
]);
});
@@ -1,9 +1,10 @@
import { View } from "react-native";
import { markdownCopyImageSourcesDataSet } from "@/assistant-selection-copy/markup";
import { memo, useState } from "react";
import { memo, useMemo, useState } from "react";
import {
EnrichedMarkdownText,
type MarkdownStyle,
type LinkPressEvent,
type CodeBlockRendererProps,
} from "react-native-enriched-markdown";
import { withUnistyles } from "react-native-unistyles";
@@ -18,6 +19,8 @@ import type { Theme } from "@/styles/theme";
import { isWeb } from "@/constants/platform";
import { useMarkdownSource, type MarkdownImageContext } from "./images";
import { MermaidFence, MermaidStreamingContext, splitMermaidFences } from "./fence/mermaid";
const ThemedMarkdown = withUnistyles(EnrichedMarkdownText);
const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true };
@@ -109,6 +112,7 @@ const codeStyle = (theme: Theme) => ({
textStyle: { backgroundColor: theme.colors.surface2, padding: 12, borderRadius: 8 },
});
function WebCodeBlock({ code, language }: CodeBlockRendererProps) {
if (language?.toLowerCase() === "mermaid") return <MermaidFence code={code} />;
return <ThemedCodeBlock code={code} language={language} uniProps={codeStyle} />;
}
const WEB_CONTAINER = { userSelect: "text" as const };
@@ -117,6 +121,7 @@ const WEB_RENDERERS = { codeBlock: WebCodeBlock };
export interface MarkdownRendererProps extends MarkdownImageContext {
text: string;
compact?: boolean;
streaming?: boolean;
onLinkPress?: (url: string) => unknown;
}
@@ -124,6 +129,7 @@ export interface MarkdownRendererProps extends MarkdownImageContext {
export const MarkdownRenderer = memo(function MarkdownRenderer({
text,
compact = false,
streaming = false,
onLinkPress,
...imageContext
}: MarkdownRendererProps) {
@@ -134,16 +140,15 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
(environment.client?.readFile
? { readFile: environment.client.readFile.bind(environment.client) }
: undefined);
const { markdown, imageSources, linkRecognition, linkPillContent, linkSource } =
useMarkdownSource(text, {
workspaceRoot: environment.workspaceRoot,
serverId: environment.serverId,
...imageContext,
client,
});
const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(text, {
workspaceRoot: environment.workspaceRoot,
serverId: environment.serverId,
...imageContext,
client,
});
const [image, setImage] = useState<ImageLightboxSource | null>(null);
const pressLink = useStableEvent(({ url }: { url: string }) => {
const source = linkSource(url);
const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => {
const source = linkSource(url, sourceType);
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
else actions.open(source, "preferred");
});
@@ -151,27 +156,38 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
setImage({ type: "uri", uri: url }),
);
const closeImage = useStableEvent(() => setImage(null));
const segments = useMemo(
() =>
isWeb ? [{ kind: "markdown", offset: 0, source: markdown }] : splitMermaidFences(markdown),
[markdown],
);
const content = (
<>
<ThemedMarkdown
markdown={markdown}
containerStyle={isWeb ? WEB_CONTAINER : undefined}
uniProps={compact ? compactStyle : regularStyle}
flavor="github"
selectable
md4cFlags={MD4C_FLAGS}
linkRecognition={linkRecognition}
linkPillContent={linkPillContent}
enableTaskListItemToggle={false}
enableLinkPreview={false}
enableBlockContextMenu={false}
streamingAnimation={false}
onLinkPress={pressLink}
onImagePress={pressImage}
renderers={isWeb ? WEB_RENDERERS : undefined}
/>
<MermaidStreamingContext.Provider value={streaming}>
{segments.map((segment) =>
segment.kind === "mermaid" ? (
<MermaidFence key={segment.offset} code={segment.source} />
) : (
<ThemedMarkdown
key={segment.offset}
markdown={segment.source}
containerStyle={isWeb ? WEB_CONTAINER : undefined}
uniProps={compact ? compactStyle : regularStyle}
flavor="github"
selectable
md4cFlags={MD4C_FLAGS}
linkRecognition={linkRecognition}
enableTaskListItemToggle={false}
enableLinkPreview={false}
enableBlockContextMenu={false}
streamingAnimation={false}
onLinkPress={pressLink}
onImagePress={pressImage}
renderers={isWeb ? WEB_RENDERERS : undefined}
/>
),
)}
{image ? <AttachmentLightbox source={image} onClose={closeImage} /> : null}
</>
</MermaidStreamingContext.Provider>
);
return isWeb && Object.keys(imageSources).length > 0 ? (
<View dataSet={markdownCopyImageSourcesDataSet(imageSources)}>{content}</View>
+12 -25
View File
@@ -1,6 +1,5 @@
import { gfmTable } from "micromark-extension-gfm-table";
import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
import { classifyAssistantFileLink } from "@/assistant-file-links/paths";
import type { AssistantFileLinkSource } from "@/assistant-file-links";
import { decodeString } from "micromark-util-decode-string";
import { fromMarkdown } from "mdast-util-from-markdown";
@@ -9,35 +8,23 @@ import type { Nodes, Root } from "mdast";
// Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex.
// The Unicode flag makes Metro expand it to escapes rejected by native engines.
const FILE_PATTERN =
/(?:https?:\/\/[^\s<>]+|(?:[A-Za-z]:[\\/]|\.{0,2}\/|~\/)?[a-zA-Z0-9\u0080-\uFFFF_@.%+-]+(?:[\\/][a-zA-Z0-9\u0080-\uFFFF_@.%+-]+)+(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?|[a-zA-Z0-9\u0080-\uFFFF_@-]+\.[a-zA-Z0-9\u0080-\uFFFF_-]+(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?)/g;
/(?:https?:\/\/[^\s<>]+|(?:[A-Za-z]:[\\/]|\.{0,2}\/|~\/)?[a-zA-Z0-9\u0080-\uFFFF_@.%+-]+(?:[\\/][a-zA-Z0-9\u0080-\uFFFF_@.%+-]+)+(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?|[a-zA-Z0-9\u0080-\uFFFF_@%-]+\.[a-zA-Z0-9\u0080-\uFFFF_%-]+(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?)/g;
function linkMetadata(source: string, getTree: () => Root, workspaceRoot?: string) {
function linkMetadata() {
return {
linkPillContent: Object.fromEntries(
[...source.matchAll(FILE_PATTERN)]
.filter(([url]) => !/^https?:/i.test(url))
.map(([url]) => [url, { label: url }]),
),
linkRecognition: {
text: FILE_PATTERN,
inlineCode: new RegExp(
String.raw`^(?:${FILE_PATTERN.source})(?:\(\d+(?:,\d+)?(?:-\d+(?:,\d+)?)?\)| lines? \d+(?:-\d+)?)?$`,
),
},
linkSource(url: string): AssistantFileLinkSource {
function containsInlinePath(node: Nodes): boolean {
return (
(node.type === "inlineCode" && node.value === url) ||
("children" in node && node.children.some(containsInlinePath))
);
}
// The library emits only the URL. Recover raw inline-code semantics at tap time,
// without parsing every reveal frame merely to configure link recognition.
return source.includes("`") &&
classifyAssistantFileLink(url, { workspaceRoot }) &&
containsInlinePath(getTree())
? { href: url, text: url, sourceType: "inline-code" }
: { href: url, text: url };
linkSource(
url: string,
sourceType?: "markdown" | "text" | "inline-code",
): AssistantFileLinkSource {
if (sourceType === "inline-code") return { href: url, text: url, sourceType };
if (sourceType === "text") return { href: url, text: url, markup: "linkify" };
return { href: url, text: url };
},
};
}
@@ -51,7 +38,7 @@ export interface MarkdownImage {
}
/** Preserve source spelling; adapt workspace images, spoiler delimiters, and entity text. */
export function prepareMarkdownSource(source: string, workspaceRoot?: string) {
export function prepareMarkdownSource(source: string) {
const images: MarkdownImage[] = [];
let tree: Root | undefined;
const getTree = () =>
@@ -61,7 +48,7 @@ export function prepareMarkdownSource(source: string, workspaceRoot?: string) {
}));
const edits: { start: number; end: number; text: string }[] = [];
if (!source.includes("![") && !source.includes("||") && !source.includes("&")) {
return { images, ...linkMetadata(source, getTree, workspaceRoot), render: () => source };
return { images, ...linkMetadata(), render: () => source };
}
const definitions = new Map<string, { url: string; title?: string | null }>();
@@ -109,7 +96,7 @@ export function prepareMarkdownSource(source: string, workspaceRoot?: string) {
visit(getTree());
return {
images,
...linkMetadata(source, getTree, workspaceRoot),
...linkMetadata(),
render(urls: ReadonlyMap<string, string> = new Map()) {
const replacements = [...edits];
for (const image of images) {
+1
View File
@@ -1185,6 +1185,7 @@ export const AssistantMessage = memo(function AssistantMessage({
>
<MarkdownRenderer
text={revealedMessage}
streaming={phase !== "complete" || revealedMessage.length < renderedMessage.text.length}
client={client}
workspaceRoot={workspaceRoot}
serverId={serverId}