M23: ChordPro-Plugin — Akkordblätter/Leadsheets (#155) #159

Merged
fable-5 merged 1 commits from m23-chordpro into main 2026-07-20 02:41:53 +02:00
13 changed files with 503 additions and 7 deletions
Showing only changes of commit a879561ec7 - Show all commits

View File

@ -128,6 +128,10 @@ person looking at it could.
build time into `vendor/`, gitignored) and runs fullscreen in the build time into `vendor/`, gitignored) and runs fullscreen in the
sandbox; blocks store `{ xml, svg }`, render mode and exports use the sandbox; blocks store `{ xml, svg }`, render mode and exports use the
SVG snapshot. SVG snapshot.
- `chordpro` (`block`): ChordPro leadsheets — a dependency-free code
block: its own minimal parser renders chords above lyrics into a static
SVG; blocks store `{ source, svg }`, render mode and exports use the
SVG snapshot.
- `excalidraw` (`block`): hand-drawn sketches — proves the npm-library - `excalidraw` (`block`): hand-drawn sketches — proves the npm-library
flavor of the bundled-app path: the Excalidraw React editor is bundled flavor of the bundled-app path: the Excalidraw React editor is bundled
straight into `plugin.js` (esbuild) with its font/locale assets shipped straight into `plugin.js` (esbuild) with its font/locale assets shipped

View File

@ -97,7 +97,8 @@ zeigen den Fallback-Text des Plugins, wenn es fehlt.
Mitgelieferte Referenz-Plugins: `toc` (Inhaltsverzeichnis), Mitgelieferte Referenz-Plugins: `toc` (Inhaltsverzeichnis),
`page-index` (label-gefilterte Seitenliste), `mermaid` `page-index` (label-gefilterte Seitenliste), `mermaid`
(Diagramm-Blöcke), `drawio` (vollwertiges draw.io-Bearbeiten), (Diagramm-Blöcke), `drawio` (vollwertiges draw.io-Bearbeiten),
`excalidraw` (handgezeichnete Skizzen), `section-styles-basic` `excalidraw` (handgezeichnete Skizzen), `chordpro` (ChordPro-
Akkordblätter — Akkorde über dem Text), `section-styles-basic`
(farbige Hinweiskästen). (farbige Hinweiskästen).
**Woher kommen die ZIPs der Referenz-Plugins?** Sie liegen den **Woher kommen die ZIPs der Referenz-Plugins?** Sie liegen den
@ -105,14 +106,14 @@ Server-Images nicht bei — sie werden aus dem Repository gebaut (Node 22
und pnpm, einmalig `pnpm install` im Repo-Wurzelverzeichnis): und pnpm, einmalig `pnpm install` im Repo-Wurzelverzeichnis):
```sh ```sh
cd packages/plugins/<name> # toc | page-index | mermaid | drawio | excalidraw | section-styles-basic cd packages/plugins/<name> # toc | page-index | mermaid | drawio | excalidraw | chordpro | section-styles-basic
pnpm build # → dist/<id>-<version>.zip — diese Datei hochladen pnpm build # → dist/<id>-<version>.zip — diese Datei hochladen
``` ```
Der `drawio`-Build lädt beim ersten Lauf sein gepinntes Editor-Bundle Der `drawio`-Build lädt beim ersten Lauf sein gepinntes Editor-Bundle
von GitHub und packt ein ~27-MiB-ZIP (innerhalb des 64-MiB-Limits für von GitHub und packt ein ~27-MiB-ZIP (innerhalb des 64-MiB-Limits für
Plugin-Uploads); `excalidraw` bündelt seinen Editor aus npm in ein Plugin-Uploads); `excalidraw` bündelt seinen Editor aus npm in ein
~16-MiB-ZIP; die übrigen vier bauen offline in Sekunden. ~16-MiB-ZIP; die übrigen fünf bauen offline in Sekunden.
**Typischer Ablauf:** ZIP hochladen, die abgeschottete Vorschau prüfen, **Typischer Ablauf:** ZIP hochladen, die abgeschottete Vorschau prüfen,
den Instanz-Modus auf `optional` stellen und das Plugin dann je Teich den Instanz-Modus auf `optional` stellen und das Plugin dann je Teich

View File

@ -88,22 +88,23 @@ fallback text when it is missing.
Shipped reference plugins: `toc` (table of contents), `page-index` Shipped reference plugins: `toc` (table of contents), `page-index`
(label-filtered page list), `mermaid` (diagram blocks), `drawio` (label-filtered page list), `mermaid` (diagram blocks), `drawio`
(full draw.io editing), `excalidraw` (hand-drawn sketches), (full draw.io editing), `excalidraw` (hand-drawn sketches), `chordpro`
`section-styles-basic` (colored callouts). (ChordPro leadsheets — chords above lyrics), `section-styles-basic`
(colored callouts).
**Getting the reference plugin ZIPs.** They are not bundled with the **Getting the reference plugin ZIPs.** They are not bundled with the
server images — build them from the repository (Node 22 + pnpm, one-time server images — build them from the repository (Node 22 + pnpm, one-time
`pnpm install` at the repo root): `pnpm install` at the repo root):
```sh ```sh
cd packages/plugins/<name> # toc | page-index | mermaid | drawio | excalidraw | section-styles-basic cd packages/plugins/<name> # toc | page-index | mermaid | drawio | excalidraw | chordpro | section-styles-basic
pnpm build # → dist/<id>-<version>.zip — upload that file pnpm build # → dist/<id>-<version>.zip — upload that file
``` ```
The `drawio` build downloads its pinned editor bundle from GitHub on the The `drawio` build downloads its pinned editor bundle from GitHub on the
first run and packs a ~27 MiB ZIP (within the 64 MiB plugin upload first run and packs a ~27 MiB ZIP (within the 64 MiB plugin upload
limit); `excalidraw` bundles its editor from npm into a ~16 MiB ZIP; the limit); `excalidraw` bundles its editor from npm into a ~16 MiB ZIP; the
remaining four build offline in seconds. remaining five build offline in seconds.
**Typical rollout:** upload the ZIP, check the sandboxed preview, switch **Typical rollout:** upload the ZIP, check the sandboxed preview, switch
the instance mode to `optional`, then enable the plugin per pond (pond the instance mode to `optional`, then enable the plugin per pond (pond

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": "ChordPro-Quelle, z. B.: {title: Mein Lied}\n[C]Hallo [G]Welt",
"empty": "Noch kein Akkordblatt — gib im Bearbeiten-Modus ChordPro-Quelltext ein.",
"error": "Das Blatt ist leer oder konnte nicht gerendert werden.",
"stale": "Zeigt den letzten gültigen Stand; die aktuelle Quelle ist leer."
}

View File

@ -0,0 +1,6 @@
{
"placeholder": "ChordPro source, e.g.: {title: My Song}\n[C]Hello [G]world",
"empty": "No chord sheet yet — enter ChordPro source in edit mode.",
"error": "The sheet is empty or could not be rendered.",
"stale": "Showing the last valid state; the current source is empty."
}

View File

@ -0,0 +1,18 @@
{
"id": "chordpro",
"name": "ChordPro Leadsheets",
"version": "1.0.0",
"apiVersion": "1",
"kind": "code",
"extensionPoints": [
{
"type": "block",
"id": "sheet",
"title": { "de": "Akkordblatt (ChordPro)", "en": "Chord sheet (ChordPro)" }
}
],
"permissions": ["blockData", "ui"],
"fallback": { "type": "text", "value": "[Chord sheet]" },
"license": "MIT",
"i18n": { "de": "i18n/de.json", "en": "i18n/en.json" }
}

View File

@ -0,0 +1,20 @@
{
"name": "@dorfteich/plugin-chordpro",
"version": "0.0.0",
"private": true,
"description": "Block plugin: ChordPro leadsheets — chords above lyrics, rendered to a static SVG (issue #155)",
"license": "MIT",
"scripts": {
"build": "node build.mjs",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"@dorfteich/plugin-sdk": "workspace:*",
"@types/node": "^26.1.0",
"esbuild": "^0.24.0",
"fflate": "^0.8.2",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

View File

@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { chordProToSvg, parseChordPro } from './chordpro';
const SHEET = `{title: Bühnenlied}
{artist: Nadia Morgenstern}
{key: C}
# internes Kommentar unsichtbar
[C]Hallo du [G]schöne [Am]Welt
Ohne Akkorde geht es [F]auch
{start_of_chorus}
{comment: Refrain}
[C]La la [G]la
{end_of_chorus}`;
describe('chordpro parser + svg formatter (issue #155)', () => {
it('parses directives, chords with columns, comments, and chorus', () => {
const sheet = parseChordPro(SHEET);
expect(sheet.meta).toMatchObject({
title: 'Bühnenlied',
subtitle: 'Nadia Morgenstern',
key: 'C',
});
const first = sheet.lines[0]!;
expect(first.lyrics).toBe('Hallo du schöne Welt');
expect(first.chords).toEqual([
{ at: 0, chord: 'C' },
{ at: 9, chord: 'G' },
{ at: 16, chord: 'Am' },
]);
const chorusLine = sheet.lines.find((l) => l.chorus && l.kind === 'pair')!;
expect(chorusLine.lyrics).toBe('La la la');
const comment = sheet.lines.find((l) => l.kind === 'comment')!;
expect(comment.lyrics).toBe('Refrain');
// `#` lines never show up.
expect(sheet.lines.some((l) => l.lyrics.includes('internes'))).toBe(false);
});
it('renders a self-contained SVG with title, chords, and umlauts escaped safely', () => {
const svg = chordProToSvg(SHEET);
expect(svg).toMatch(/^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/);
expect(svg).toContain('Bühnenlied');
expect(svg).toContain('>Am</text>');
expect(svg).toContain('Hallo du schöne Welt');
expect(svg).not.toContain('<script');
});
it('escapes XML-hostile input', () => {
const svg = chordProToSvg('{title: <img src=x>}\n[C]a & b < c');
expect(svg).toContain('&lt;img src=x&gt;');
expect(svg).toContain('a &amp; b &lt; c');
});
it('throws on an empty sheet so the plugin can show its hint', () => {
expect(() => chordProToSvg(' \n# nur Kommentar')).toThrow();
});
});

View File

@ -0,0 +1,191 @@
/**
* Minimal ChordPro parser + SVG leadsheet formatter (issue #155).
*
* Deliberately hand-rolled instead of pulling in a chord-sheet library: the
* subset a leadsheet needs is small (directives, `[chord]` markers, comments,
* chorus fences), and the SVG layout would be custom code either way this
* keeps the plugin dependency-free (supply-chain lesson from #136).
*
* Supported ChordPro:
* - `{title: …}` / `{t: …}`, `{subtitle: …}`/`{st: …}`/`{artist: …}`,
* `{key: …}`, `{capo: …}`, `{tempo: …}`
* - `{comment: …}` / `{c: …}` shown as an italic line
* - `{start_of_chorus}`/`{soc}` `{end_of_chorus}`/`{eoc}` indented block
* - lyric lines with inline `[C]`/`[Am7]`/ chord markers
* - `#` comment lines (ignored), blank lines (verse separation)
*/
export interface SheetMeta {
title: string;
subtitle: string;
key: string;
capo: string;
tempo: string;
}
export interface SheetLine {
kind: 'pair' | 'comment' | 'blank';
/** Lyric text with the chord markers removed. */
lyrics: string;
/** Chords with the lyric column (character index) they sit above. */
chords: { at: number; chord: string }[];
chorus: boolean;
}
export interface Sheet {
meta: SheetMeta;
lines: SheetLine[];
}
const DIRECTIVE = /^\{\s*([a-z_]+)\s*(?::\s*(.*?))?\s*\}\s*$/i;
const CHORD = /\[([^\][\n]+)\]/g;
export function parseChordPro(source: string): Sheet {
const meta: SheetMeta = { title: '', subtitle: '', key: '', capo: '', tempo: '' };
const lines: SheetLine[] = [];
let chorus = false;
for (const raw of source.split('\n')) {
const line = raw.replace(/\s+$/, '');
if (line.trimStart().startsWith('#')) continue;
const directive = DIRECTIVE.exec(line.trim());
if (directive) {
const name = directive[1]!.toLowerCase();
const value = (directive[2] ?? '').trim();
if (name === 'title' || name === 't') meta.title = value;
else if (name === 'subtitle' || name === 'st' || name === 'artist') meta.subtitle = value;
else if (name === 'key') meta.key = value;
else if (name === 'capo') meta.capo = value;
else if (name === 'tempo') meta.tempo = value;
else if (name === 'start_of_chorus' || name === 'soc') chorus = true;
else if (name === 'end_of_chorus' || name === 'eoc') chorus = false;
else if (name === 'comment' || name === 'c') {
lines.push({ kind: 'comment', lyrics: value, chords: [], chorus });
}
// Unknown directives are ignored on purpose (forward compatibility).
continue;
}
if (line.trim() === '') {
lines.push({ kind: 'blank', lyrics: '', chords: [], chorus });
continue;
}
let lyrics = '';
const chords: { at: number; chord: string }[] = [];
let last = 0;
for (const match of line.matchAll(CHORD)) {
lyrics += line.slice(last, match.index);
chords.push({ at: lyrics.length, chord: match[1]! });
last = match.index! + match[0].length;
}
lyrics += line.slice(last);
lines.push({ kind: 'pair', lyrics, chords, chorus });
}
// Trim leading/trailing blank lines.
while (lines[0]?.kind === 'blank') lines.shift();
while (lines[lines.length - 1]?.kind === 'blank') lines.pop();
return { meta, lines };
}
/** Monospace metrics fixed so the static SVG lays out identically wherever
* it renders (editor sandbox, public view, PDF export). */
const CHAR_W = 8.4;
const LINE_H = 18;
const PAD = 12;
const CHORUS_INDENT = 2;
function escapeXml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
/** The sheet as a self-contained SVG: chords above lyrics, monospace grid. */
export function sheetToSvg(sheet: Sheet): string {
const parts: string[] = [];
let y = PAD + 4;
let maxCols = 0;
const push = (text: string, x: number, options: string): void => {
parts.push(`<text x="${x}" y="${y}" ${options}>${escapeXml(text)}</text>`);
};
if (sheet.meta.title) {
y += 20;
push(sheet.meta.title, PAD, 'font-size="18" font-weight="bold" font-family="sans-serif"');
maxCols = Math.max(maxCols, (sheet.meta.title.length * 11) / CHAR_W);
}
const subtitleBits = [
sheet.meta.subtitle,
sheet.meta.key && `Key: ${sheet.meta.key}`,
sheet.meta.capo && `Capo: ${sheet.meta.capo}`,
sheet.meta.tempo && `${sheet.meta.tempo}`,
].filter(Boolean) as string[];
if (subtitleBits.length > 0) {
y += LINE_H;
push(subtitleBits.join(' — '), PAD, 'font-size="13" fill="#555" font-family="sans-serif"');
maxCols = Math.max(maxCols, subtitleBits.join(' — ').length);
}
if (sheet.meta.title || subtitleBits.length > 0) y += 8;
for (const line of sheet.lines) {
const indent = line.chorus ? CHORUS_INDENT : 0;
const x = PAD + indent * CHAR_W;
if (line.kind === 'blank') {
y += LINE_H * 0.7;
continue;
}
if (line.kind === 'comment') {
y += LINE_H;
push(
line.lyrics,
x,
'font-size="13" font-style="italic" fill="#555" font-family="sans-serif"',
);
maxCols = Math.max(maxCols, indent + line.lyrics.length);
continue;
}
if (line.chords.length > 0) {
y += LINE_H;
for (const { at, chord } of line.chords) {
parts.push(
`<text x="${PAD + (indent + at) * CHAR_W}" y="${y}" font-size="13" font-weight="bold"` +
` fill="#1d4ed8" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">` +
`${escapeXml(chord)}</text>`,
);
maxCols = Math.max(maxCols, indent + at + chord.length);
}
}
y += LINE_H;
push(
line.lyrics,
x,
'font-size="14" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" xml:space="preserve"',
);
maxCols = Math.max(maxCols, indent + line.lyrics.length);
}
const width = Math.max(240, Math.ceil(PAD * 2 + maxCols * CHAR_W));
const height = y + PAD;
return (
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}"` +
` width="${width}" height="${height}" role="img">` +
parts.join('') +
`</svg>`
);
}
/** Parse + render in one step; throws on an empty sheet so callers can show
* their empty-state hint instead of a blank box. */
export function chordProToSvg(source: string): string {
const sheet = parseChordPro(source);
if (sheet.lines.length === 0 && !sheet.meta.title) {
throw new Error('empty sheet');
}
return sheetToSvg(sheet);
}

View File

@ -0,0 +1,124 @@
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();
}

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"]
}

21
pnpm-lock.yaml generated
View File

@ -392,6 +392,27 @@ importers:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0) version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0)
packages/plugins/chordpro:
devDependencies:
'@dorfteich/plugin-sdk':
specifier: workspace:*
version: link:../../plugin-sdk
'@types/node':
specifier: ^26.1.0
version: 26.1.0
esbuild:
specifier: ^0.24.0
version: 0.24.2
fflate:
specifier: ^0.8.2
version: 0.8.3
typescript:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0)
packages/plugins/drawio: packages/plugins/drawio:
devDependencies: devDependencies:
'@dorfteich/plugin-sdk': '@dorfteich/plugin-sdk':