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
+5 -3
View File
@@ -6,7 +6,7 @@ Native selection belongs to the library's text view. Keep an assistant message i
## Links and images
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library emits a URL without syntax context, so the renderer recovers inline-code context from source when tapped to preserve suffix lookup and literal percent characters. Keep this parsing off reveal frames.
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct.
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory.
@@ -14,9 +14,11 @@ Resolve workspace image paths over the existing session RPC, then give the libra
The pinned nightly has the same React Native package and core source trees as upstream commit `73ac5085eb1fdc743973b3473d4dba65abad5261`. Compatibility was checked with React Native 0.81.5. Native dependencies must be rebuilt when changing the highlighting configuration; JavaScript refresh cannot enable compiled grammars.
Math and video are disabled. Task lists and admonitions are enabled; task checkboxes are read-only. Source adaptation spaces adjacent empty GFM table delimiters before escaping prose spoiler delimiters and decodes entity text before the engine can discard it. Code spans and fences keep their literal source. Mermaid renders as a code block. HTML and details collapsibles have no custom renderer.
Math and video are disabled. Task lists and admonitions are enabled; task checkboxes are read-only. Source adaptation spaces adjacent empty GFM table delimiters before escaping prose spoiler delimiters and decodes entity text before the engine can discard it. Code spans and fences keep their literal source. HTML and details collapsibles have no custom renderer.
Native code blocks use the library's copy control and compiled grammars. Web code blocks use `HighlightedCodeBlock` through the library's `renderers.codeBlock` override. The patch also preserves web list numbering and link titles, and marks pending parses with `aria-busy` so Find waits for rendered text. The package patch is tracked by [upstream PR #997](https://github.com/software-mansion/enriched-markdown/pull/997); remove the patch when the pinned release includes it.
Mermaid fences use the existing isolated diagram runtime and fullscreen viewer. Web dispatches them through the library’s code-block override. Native has no block-renderer hook, so the renderer extracts only Mermaid fences and places the existing WebView host between library-rendered segments. This is the only block exception; selection stops at a diagram boundary. Streaming keeps the last valid diagram visible while the next revision renders.
Native code blocks use the library's copy control and compiled grammars. Web code blocks use `HighlightedCodeBlock` through the library's `renderers.codeBlock` override. The patch also preserves web list numbering and link titles, marks pending parses with `aria-busy` so Find waits for rendered text, and carries link syntax through native and web tap events. The package patch is tracked by [upstream PR #997](https://github.com/software-mansion/enriched-markdown/pull/997); remove the patch when the pinned release includes it.
The library determines how incomplete Markdown appears during streaming. Completed text uses the same component and engine as history. Find and scroll-to-message address source message ids; selection-copy understands semantic HTML and the web code component's copy metadata.
@@ -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}
File diff suppressed because one or more lines are too long