dorfteich/packages/plugins/mermaid/src/plugin.ts
Claude Fable 5 ef1c31dd2c
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
Add the Mermaid reference plugin (#78)
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
2026-07-11 13:51:15 +02:00

158 lines
5.4 KiB
TypeScript

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();
}