dorfteich/packages/plugins/chordpro/src/plugin.ts
Claude Fable 5 a879561ec7
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 5m30s
CI / Build container images (pull_request) Successful in 10s
CI / Auth e2e pack (pull_request) Successful in 7m39s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 17s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 4m46s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m1s
CI / Import/export fidelity gate (push) Successful in 54s
Release / Build release images and notes (push) Successful in 1m11s
Release / Release-candidate operations QA (push) Successful in 44s
Prod deploy / Deploy the released images to Prod (push) Successful in 15s
#155: ChordPro-Plugin — Akkordblätter/Leadsheets als Block
Neues Referenz-Plugin packages/plugins/chordpro nach dem
mermaid-Muster: bewusst ohne Fremdbibliothek (Supply-Chain-Lehre aus
#136) — eigener minimaler ChordPro-Parser (Direktiven title/subtitle/
artist/key/capo/tempo/comment, Chorus-Fences, [Akkord]-Marker,
#-Kommentare) plus SVG-Formatter mit Monospace-Raster: Akkorde über dem
Text, Titelkopf, Chorus-Einrückung, XML-escaped. Persistenz {source,
svg} — der Snapshot bedient Lesemodus, Public-Ansicht und Exporte über
den PluginFallbackRenderer. Edit-Modus: Textarea + debounced
Live-Preview. ZIP 20 KB (Limits 64/256 MiB), 4 Parser-/Formatter-Tests,
i18n de+en, Doku-Listen (site-admin en+de, plugin-architecture)
ergänzt. Prod-Installation wie üblich per Site-Admin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-20 02:11:36 +02:00

125 lines
3.9 KiB
TypeScript

import { createPlugin, windowTransport, type RenderContext } from '@dorfteich/plugin-sdk';
import de from '../i18n/de.json';
import en from '../i18n/en.json';
import { chordProToSvg } from './chordpro';
/**
* ChordPro leadsheet plugin (issue #155), following the mermaid reference
* plugin's shape: the source text is the document of record, `svg` is the
* last successfully rendered snapshot — persisted together so the read view,
* public view, and office/PDF exports show the sheet without executing
* anything (#79). Edit mode is a source textarea with a live, debounced
* preview; render mode shows the rendered sheet.
*/
const STRINGS: Record<string, Record<string, string>> = { de, en };
const PREVIEW_DEBOUNCE_MS = 300;
function labelFor(locale: string, key: string): string {
const base = locale.split('-')[0] ?? locale;
return STRINGS[base]?.[key] ?? STRINGS.en?.[key] ?? key;
}
interface SheetData {
source?: string;
svg?: string;
}
function dataOf(context: RenderContext): SheetData {
return context.data && typeof context.data === 'object' ? (context.data as SheetData) : {};
}
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));
}
function renderMode(context: RenderContext): void {
const data = dataOf(context);
document.body.textContent = '';
document.body.className = 'dt-chordpro dt-chordpro--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 = chordProToSvg(source);
} catch {
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-chordpro dt-chordpro--edit';
const textarea = document.createElement('textarea');
textarea.placeholder = labelFor(context.locale, 'placeholder');
textarea.value = data.source ?? '';
textarea.rows = 10;
textarea.style.width = '100%';
textarea.style.boxSizing = 'border-box';
textarea.style.fontFamily = 'monospace';
const error = document.createElement('p');
error.className = 'dt-chordpro__error';
error.style.color = '#b91c1c';
error.hidden = true;
const preview = document.createElement('div');
preview.className = 'dt-chordpro__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 = (): void => {
const source = textarea.value;
try {
const svg = chordProToSvg(source);
lastGoodSvg = svg;
preview.innerHTML = svg;
error.hidden = true;
// Persist source + snapshot together — the snapshot carries the read
// view, public view, and exports (#79).
void host.blockData.setData({ source, svg });
} catch {
error.textContent = labelFor(context.locale, 'error');
error.hidden = false;
void host.blockData.setData({ source, svg: lastGoodSvg });
}
resize();
};
textarea.addEventListener('input', () => {
window.clearTimeout(debounce);
debounce = window.setTimeout(refresh, PREVIEW_DEBOUNCE_MS);
});
if (textarea.value.trim() !== '' && !data.svg) refresh();
}