Add the Mermaid reference plugin (#78)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 2m55s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m14s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 10s
CI / Import/export fidelity gate (push) Successful in 45s
CI / Auth e2e pack (push) Successful in 4m50s

The end-to-end proof of the code-block path: packages/plugins/mermaid
bundles the mermaid library (esbuild, ~3.4 MB unpacked — well under the
20 MiB install gate) so diagrams render entirely inside the sandbox; the
frame CSP forbids any network request (pinned by the e2e's off-origin
request assertion).

- Block data is `{ source, svg }`: the source text is the document of
  record, `svg` the last successfully rendered snapshot — persisted
  together on every good preview, so office/PDF exports can show the
  diagram without executing anything (#79).
- Edit mode: source textarea with a debounced live preview and inline
  error display; a failing source still persists (typed text never lost),
  paired with the last good snapshot.
- Render mode: renders the stored source; if that stops rendering, it
  falls back to the stored snapshot with a "stale" note — a bad edit
  never breaks render mode.
- mermaid leaves its scratch element (and, on parse errors, an error SVG)
  on document.body — the render helper removes both, so the surface only
  shows what the plugin inserts.
- e2e mermaid.spec.ts: flowchart renders + survives reload with zero
  off-origin requests, inline syntax errors with intact render mode, and
  a collaborator sees the diagram appear live. Wired into CI.
- seed.ts now heals a missing owner-admin grant on existing personal
  ponds: a dev database shared with the test suites can lose it to a
  cleanup, and the seed's contract is "idempotent", not "first run only".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Fable 5 2026-07-11 13:51:15 +02:00
parent 0003063c39
commit ef1c31dd2c
11 changed files with 1266 additions and 11 deletions

View File

@ -378,6 +378,17 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/page-tools.spec.ts
# Owner + admin contexts in the collab case → reset first.
- name: Reset login rate limit before mermaid pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run mermaid pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/mermaid.spec.ts
- name: Dump server logs on failure
if: failure()
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true

View File

@ -160,17 +160,22 @@ async function upsertFixtureUser(fixture: FixtureUser): Promise<string> {
where: { ownerId: user.id, type: 'PERSONAL' },
select: { id: true },
});
if (!existing) {
const pond = await prisma.pond.create({
const pondId =
existing?.id ??
(
await prisma.pond.create({
data: {
slug: slugify(fixture.displayName) || fixture.username,
name: fixture.displayName,
type: 'PERSONAL',
ownerId: user.id,
},
});
await ensureOwnerAdminGrant(pond.id, user.id);
}
})
).id;
// Also for a pre-existing pond: a dev database shared with test suites can
// lose the owner grant to a cleanup — re-seeding must heal it (the seed's
// documented contract is "idempotent", not "first run only").
await ensureOwnerAdminGrant(pondId, user.id);
// The instance default for additional_ponds is 0 (ADR 0011) — give
// the fixtures headroom so pond flows are exercisable in dev/e2e.
await prisma.quotaOverride.upsert({

View File

@ -0,0 +1,174 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test } from '@playwright/test';
import type { BrowserContext, Page } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Mermaid reference plugin end to end (issue #78): the code-block proof.
* Installs the real package (built into packages/plugins/mermaid/dist) and
* drives the acceptance criteria a flowchart renders and survives reload
* and collaboration, syntax errors stay inline without breaking render mode,
* and rendering triggers no request leaving the origin (the bundled library
* is the only executable code; the frame CSP forbids the rest).
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
function mermaidZip(): Buffer {
const here = dirname(fileURLToPath(import.meta.url));
return readFileSync(join(here, '../../../packages/plugins/mermaid/dist/mermaid-1.0.0.zip'));
}
async function installAsRequired(admin: BrowserContext): Promise<void> {
await admin.request.patch('/api/v1/admin/plugins/mermaid/mode', { data: { mode: 'disabled' } });
await admin.request.delete('/api/v1/admin/plugins/mermaid');
const installed = await admin.request.post('/api/v1/admin/plugins', {
multipart: {
file: { name: 'mermaid.zip', mimeType: 'application/zip', buffer: mermaidZip() },
},
});
expect(installed.status(), await installed.text()).toBe(201);
const mode = await admin.request.patch('/api/v1/admin/plugins/mermaid/mode', {
data: { mode: 'required' },
});
expect(mode.status(), await mode.text()).toBe(200);
}
async function personalPond(context: BrowserContext): Promise<{ id: string; slug: string }> {
const ponds = await context.request.get('/api/v1/ponds');
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
return { id: pond.id, slug: pond.slug };
}
async function openEditor(context: BrowserContext, pondSlug: string, slug: string): Promise<Page> {
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${slug}`);
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true');
await expect(page.locator('.editor-connection')).toHaveAttribute('data-status', 'connected', {
timeout: 15000,
});
return page;
}
function frameBody(page: Page) {
return page.frameLocator('.plugin-block iframe').locator('body');
}
async function insertDiagram(page: Page): Promise<void> {
await page.locator('.editor-toolbar__block-select').selectOption('mermaid/diagram');
await expect(page.locator('.plugin-block .plugin-block__surface')).toHaveAttribute(
'data-state',
'ready',
{ timeout: 15000 },
);
}
test('a flowchart renders, survives reload, and makes no off-origin request', async ({
browser,
}) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
await installAsRequired(admin);
const pond = await personalPond(admin);
const created = await (
await admin.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `E2E Mermaid ${Date.now()}` },
})
).json();
const page = await openEditor(admin, pond.slug, created.slug);
const origin = new URL(BASE_URL).origin;
const offOrigin: string[] = [];
page.on('request', (request) => {
const url = request.url();
if (!url.startsWith(origin) && !url.startsWith('data:') && !url.startsWith('blob:')) {
offOrigin.push(url);
}
});
await insertDiagram(page);
await expect(frameBody(page).locator('p')).toBeVisible({ timeout: 10000 }); // empty hint
// Enter edit mode, type a flowchart, watch the live preview render.
await page.locator('.plugin-block__bar button').click();
const textarea = frameBody(page).locator('textarea');
await textarea.fill('graph TD; Start-->End;');
await expect(frameBody(page).locator('.dt-mermaid__preview svg')).toBeVisible({
timeout: 10000,
});
// Back to render mode: the diagram SVG is the surface.
await page.locator('.plugin-block__bar button').click();
await expect(frameBody(page).locator('svg')).toBeVisible({ timeout: 10000 });
// Persisted through the document: a fresh load renders the diagram again.
await page.reload();
await expect(frameBody(page).locator('svg')).toBeVisible({ timeout: 15000 });
expect(offOrigin, `off-origin requests: ${offOrigin.join(', ')}`).toEqual([]);
await admin.close();
});
test('syntax errors show inline in edit mode and never break render mode', async ({ browser }) => {
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
await installAsRequired(admin);
const pond = await personalPond(admin);
const created = await (
await admin.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `E2E Mermaid Err ${Date.now()}` },
})
).json();
const page = await openEditor(admin, pond.slug, created.slug);
await insertDiagram(page);
await page.locator('.plugin-block__bar button').click();
// A valid diagram first, so a good snapshot exists.
const textarea = frameBody(page).locator('textarea');
await textarea.fill('graph TD; A-->B;');
await expect(frameBody(page).locator('.dt-mermaid__preview svg')).toBeVisible({
timeout: 10000,
});
// Break the source: the error shows inline, the last preview stays.
await textarea.fill('this is not mermaid at all');
await expect(frameBody(page).locator('.dt-mermaid__error')).toBeVisible({ timeout: 10000 });
await expect(frameBody(page).locator('.dt-mermaid__preview svg')).toBeVisible();
// Render mode falls back to the stored snapshot instead of breaking.
await page.locator('.plugin-block__bar button').click();
await expect(frameBody(page).locator('svg')).toBeVisible({ timeout: 10000 });
await admin.close();
});
test('a collaborator sees the diagram appear live', async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const admin = await contextForUser(browser, BASE_URL, 'fixture-admin');
await installAsRequired(admin);
const pond = await personalPond(owner);
const created = await (
await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `E2E Mermaid Collab ${Date.now()}` },
})
).json();
const pageA = await openEditor(owner, pond.slug, created.slug);
const pageB = await openEditor(admin, pond.slug, created.slug);
await insertDiagram(pageA);
await pageA.locator('.plugin-block__bar button').click();
await frameBody(pageA).locator('textarea').fill('graph TD; Live-->Sync;');
await expect(frameBody(pageA).locator('.dt-mermaid__preview svg')).toBeVisible({
timeout: 10000,
});
// The block data replicates through Yjs; the collaborator's frame renders.
await expect(frameBody(pageB).locator('svg')).toBeVisible({ timeout: 15000 });
await owner.close();
await admin.close();
});

View File

@ -0,0 +1,34 @@
// Builds the installable plugin (plugin-architecture.md §Package format):
// bundles src/plugin.ts (SDK + i18n inlined — the sandbox CSP forbids runtime
// fetches) into plugin.js as a single ES module, then packs the ZIP for the
// admin upload / dropzone watcher.
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'esbuild';
import { zipSync } from 'fflate';
const root = dirname(fileURLToPath(import.meta.url));
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
mkdirSync(join(root, 'dist'), { recursive: true });
await build({
entryPoints: [join(root, 'src/plugin.ts')],
bundle: true,
format: 'esm',
outfile: join(root, 'dist/plugin.js'),
minify: true,
});
const files = {
'manifest.json': readFileSync(join(root, 'manifest.json')),
'plugin.js': readFileSync(join(root, 'dist/plugin.js')),
};
for (const name of readdirSync(join(root, 'i18n'))) {
files[`i18n/${name}`] = readFileSync(join(root, 'i18n', name));
}
const target = join(root, 'dist', `${manifest.id}-${manifest.version}.zip`);
writeFileSync(target, zipSync(files));
console.log(`wrote ${target}`);

View File

@ -0,0 +1,6 @@
{
"placeholder": "Mermaid-Quelltext, z. B.: graph TD; A-->B;",
"empty": "Noch kein Diagramm — Quelltext im Bearbeiten-Modus eingeben.",
"error": "Mermaid-Fehler:",
"stale": "Zeigt den letzten gültigen Stand; der aktuelle Quelltext hat Fehler."
}

View File

@ -0,0 +1,6 @@
{
"placeholder": "Mermaid source, e.g.: graph TD; A-->B;",
"empty": "No diagram yet — enter source in edit mode.",
"error": "Mermaid error:",
"stale": "Showing the last valid state; the current source has errors."
}

View File

@ -0,0 +1,18 @@
{
"id": "mermaid",
"name": "Mermaid Diagrams",
"version": "1.0.0",
"apiVersion": "1",
"kind": "code",
"extensionPoints": [
{
"type": "block",
"id": "diagram",
"title": { "de": "Mermaid-Diagramm", "en": "Mermaid diagram" }
}
],
"permissions": ["blockData", "ui"],
"fallback": { "type": "text", "value": "[Mermaid diagram]" },
"license": "MIT",
"i18n": { "de": "i18n/de.json", "en": "i18n/en.json" }
}

View File

@ -0,0 +1,21 @@
{
"name": "@dorfteich/plugin-mermaid",
"version": "0.0.0",
"private": true,
"description": "Reference block plugin: Mermaid diagrams edited and rendered inside the sandbox (issue #78)",
"license": "MIT",
"scripts": {
"build": "node build.mjs",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"devDependencies": {
"@dorfteich/plugin-sdk": "workspace:*",
"@types/node": "^26.1.0",
"esbuild": "^0.24.0",
"fflate": "^0.8.2",
"mermaid": "^11.4.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

View File

@ -0,0 +1,157 @@
import mermaid from 'mermaid';
import { createPlugin, windowTransport, type RenderContext } from '@dorfteich/plugin-sdk';
import de from '../i18n/de.json';
import en from '../i18n/en.json';
/**
* Mermaid reference plugin (issue #78) the end-to-end proof of the code
* block path: the mermaid library is bundled into plugin.js and runs entirely
* inside the sandbox (the frame CSP forbids any network request).
*
* Block data: `{ source, svg }` the source text is the document of record;
* `svg` is the last successfully rendered snapshot, persisted alongside so
* office/PDF exports can show the diagram without executing anything (#79).
*
* Render mode shows the diagram (or, if the stored source no longer renders,
* the stored snapshot with a "stale" note a bad edit never breaks render
* mode). Edit mode is a source textarea with a live, debounced preview and
* inline error display; every successful preview persists source + snapshot
* through `blockData.setData`.
*/
const STRINGS: Record<string, Record<string, string>> = { de, en };
const PREVIEW_DEBOUNCE_MS = 400;
function labelFor(locale: string, key: string): string {
const base = locale.split('-')[0] ?? locale;
return STRINGS[base]?.[key] ?? STRINGS.en?.[key] ?? key;
}
interface DiagramData {
source?: string;
svg?: string;
}
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'neutral' });
let renderSeq = 0;
/** Renders mermaid source to SVG markup; throws on syntax errors. */
async function toSvg(source: string): Promise<string> {
renderSeq += 1;
const id = `dt-mermaid-${renderSeq}`;
try {
const { svg } = await mermaid.render(id, source);
return svg;
} finally {
// mermaid renders into a scratch element on `document.body` and, on a
// parse error, leaves its own error SVG behind — remove both so the
// surface only ever shows what this plugin inserts itself.
document.getElementById(id)?.remove();
document.getElementById(`d${id}`)?.remove();
}
}
function dataOf(context: RenderContext): DiagramData {
return context.data && typeof context.data === 'object' ? (context.data as DiagramData) : {};
}
const { host } = createPlugin({
transport: windowTransport({
target: { postMessage: (message) => window.parent.postMessage(message, '*') },
source: window,
}),
onRender: (context) => renderMode(context),
onEdit: (context) => editMode(context),
});
function resize(): void {
void host.ui.resize(Math.max(64, document.body.scrollHeight + 16));
}
async function renderMode(context: RenderContext): Promise<void> {
const data = dataOf(context);
document.body.textContent = '';
document.body.className = 'dt-mermaid dt-mermaid--render';
const source = (data.source ?? '').trim();
if (source === '') {
const hint = document.createElement('p');
hint.textContent = labelFor(context.locale, 'empty');
document.body.appendChild(hint);
resize();
return;
}
try {
document.body.innerHTML = await toSvg(source);
} catch {
// The stored source no longer renders (e.g. edited elsewhere with an
// error saved mid-typing): fall back to the last good snapshot.
if (data.svg) {
document.body.innerHTML = data.svg;
const note = document.createElement('p');
note.textContent = labelFor(context.locale, 'stale');
document.body.appendChild(note);
} else {
document.body.textContent = labelFor(context.locale, 'error');
}
}
resize();
}
function editMode(context: RenderContext): void {
const data = dataOf(context);
document.body.textContent = '';
document.body.className = 'dt-mermaid dt-mermaid--edit';
const textarea = document.createElement('textarea');
textarea.placeholder = labelFor(context.locale, 'placeholder');
textarea.value = data.source ?? '';
textarea.rows = 6;
textarea.style.width = '100%';
textarea.style.boxSizing = 'border-box';
textarea.style.fontFamily = 'monospace';
const error = document.createElement('p');
error.className = 'dt-mermaid__error';
error.style.color = '#b91c1c';
error.style.whiteSpace = 'pre-wrap';
error.hidden = true;
const preview = document.createElement('div');
preview.className = 'dt-mermaid__preview';
if (data.svg) preview.innerHTML = data.svg;
document.body.append(textarea, error, preview);
resize();
let debounce: number | undefined;
let lastGoodSvg = data.svg ?? '';
const refresh = async (): Promise<void> => {
const source = textarea.value;
try {
const svg = await toSvg(source);
lastGoodSvg = svg;
preview.innerHTML = svg;
error.hidden = true;
// Persist source + snapshot together — the snapshot is what office/PDF
// exports show (#79), so it must always match a source that rendered.
void host.blockData.setData({ source, svg });
} catch (cause) {
// Inline error, previous preview stays; the source is still persisted
// so a collaborator/reload never loses typed text.
error.textContent = `${labelFor(context.locale, 'error')} ${String(
(cause as Error)?.message ?? cause,
)}`;
error.hidden = false;
void host.blockData.setData({ source, svg: lastGoodSvg });
}
resize();
};
textarea.addEventListener('input', () => {
window.clearTimeout(debounce);
debounce = window.setTimeout(() => void refresh(), PREVIEW_DEBOUNCE_MS);
});
if (textarea.value.trim() !== '' && !data.svg) void refresh();
}

View File

@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"noEmit": true,
"lib": ["ES2022", "DOM"]
},
"include": ["src", "*.ts"]
}

812
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff