#155: ChordPro-Plugin — Akkordblätter/Leadsheets als Block
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
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
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
This commit is contained in:
parent
58f175af32
commit
a879561ec7
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
34
packages/plugins/chordpro/build.mjs
Normal file
34
packages/plugins/chordpro/build.mjs
Normal 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}`);
|
||||||
6
packages/plugins/chordpro/i18n/de.json
Normal file
6
packages/plugins/chordpro/i18n/de.json
Normal 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."
|
||||||
|
}
|
||||||
6
packages/plugins/chordpro/i18n/en.json
Normal file
6
packages/plugins/chordpro/i18n/en.json
Normal 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."
|
||||||
|
}
|
||||||
18
packages/plugins/chordpro/manifest.json
Normal file
18
packages/plugins/chordpro/manifest.json
Normal 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" }
|
||||||
|
}
|
||||||
20
packages/plugins/chordpro/package.json
Normal file
20
packages/plugins/chordpro/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
59
packages/plugins/chordpro/src/chordpro.test.ts
Normal file
59
packages/plugins/chordpro/src/chordpro.test.ts
Normal 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('<img src=x>');
|
||||||
|
expect(svg).toContain('a & b < c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on an empty sheet so the plugin can show its hint', () => {
|
||||||
|
expect(() => chordProToSvg(' \n# nur Kommentar')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
191
packages/plugins/chordpro/src/chordpro.ts
Normal file
191
packages/plugins/chordpro/src/chordpro.ts
Normal 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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
124
packages/plugins/chordpro/src/plugin.ts
Normal file
124
packages/plugins/chordpro/src/plugin.ts
Normal 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();
|
||||||
|
}
|
||||||
11
packages/plugins/chordpro/tsconfig.json
Normal file
11
packages/plugins/chordpro/tsconfig.json
Normal 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
21
pnpm-lock.yaml
generated
@ -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':
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user