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
158 lines
5.4 KiB
TypeScript
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();
|
|
}
|