initial commit
This commit is contained in:
commit
5593d825b6
41 changed files with 7708 additions and 0 deletions
151
.claude/skills/praesentation-erstellen/SKILL.md
Normal file
151
.claude/skills/praesentation-erstellen/SKILL.md
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
---
|
||||||
|
name: praesentation-erstellen
|
||||||
|
description: Hilft interaktiv beim Aufbau einer neuen slidewalk-Präsentation aus Markdown-Slides – von Themenklärung über Gliederung bis zu den fertigen, nummerierten .md-Dateien mit Frontmatter. Funktioniert sowohl von Grund auf (Thema im Dialog klären) als auch ausgehend von einem bestehenden Markdown-/Text-Dokument, das in ein Deck umgewandelt werden soll. Themenneutral: funktioniert für jeden Vortrag, jede Zielgruppe. Nutzen, wenn ein neues Slide-Deck für slidewalk entstehen soll (nicht für das Reviewen bestehender Decks).
|
||||||
|
---
|
||||||
|
|
||||||
|
# Präsentation erstellen
|
||||||
|
|
||||||
|
Baut gemeinsam mit dem Nutzer ein neues slidewalk-Deck: ein Ordner mit
|
||||||
|
nummerierten `.md`-Dateien, die slidewalks Konventionen folgen. Das Thema ist
|
||||||
|
dabei beliebig (Business-Pitch, technischer Vortrag, Schulung, Statusupdate,
|
||||||
|
…) — dieser Skill trifft keine Annahmen über den Inhalt, sondern erfragt die
|
||||||
|
Struktur.
|
||||||
|
|
||||||
|
Zwei Startpunkte, je nachdem was der Nutzer mitbringt:
|
||||||
|
|
||||||
|
- **Modus A — von Grund auf**: Nutzer hat noch kein Ausgangsmaterial, nur ein
|
||||||
|
Thema im Kopf. Struktur entsteht im Dialog (Schritt 1a).
|
||||||
|
- **Modus B — aus bestehendem Dokument**: Nutzer hat schon einen Text
|
||||||
|
(Report, Notizen, README, Konzeptpapier, …) und will daraus ein Deck
|
||||||
|
ableiten. Struktur entsteht aus dem Dokument, nicht aus offenen Fragen
|
||||||
|
(Schritt 1b).
|
||||||
|
|
||||||
|
Ab Schritt 2 (Gliederung abstimmen) laufen beide Modi gleich weiter.
|
||||||
|
|
||||||
|
Maßgeblich für alle Markdown-/Frontmatter-Details ist
|
||||||
|
[`docs/markdown-guide.md`](../../../docs/markdown-guide.md); bei
|
||||||
|
Unsicherheit dort nachschlagen statt zu raten. Ein lauffähiges
|
||||||
|
Referenzbeispiel liegt unter [`examples/demo/`](../../../examples/demo/).
|
||||||
|
|
||||||
|
## Ablauf
|
||||||
|
|
||||||
|
### 1a. Rahmen klären (Modus A — von Grund auf)
|
||||||
|
|
||||||
|
Bevor irgendetwas geschrieben wird, per Rückfrage klären (nicht alles auf
|
||||||
|
einmal abfragen, sondern nur was für die Struktur nötig ist):
|
||||||
|
|
||||||
|
- **Thema & Kernbotschaft**: Worum geht es, was soll das Publikum am Ende
|
||||||
|
mitnehmen?
|
||||||
|
- **Zielgruppe & Anlass**: Wer sitzt im Publikum, wie viel Vorwissen ist da,
|
||||||
|
formell oder informell?
|
||||||
|
- **Ungefährer Umfang**: Wie viele Slides / wie lange soll der Vortrag
|
||||||
|
dauern? (Faustregel: 1–2 Minuten Sprechzeit pro Slide.)
|
||||||
|
- **Zielordner**: Wo sollen die Dateien liegen? Falls nicht genannt,
|
||||||
|
sinnvollen Vorschlag machen (z. B. `content/` im aktuellen Projekt) und
|
||||||
|
bestätigen lassen — nicht ungefragt einen bestehenden Ordner überschreiben.
|
||||||
|
- **Neu oder Erweiterung**: Entsteht ein komplett neues Deck, oder werden
|
||||||
|
Slides in einen bestehenden Ordner eingeschoben? Falls Erweiterung: erst
|
||||||
|
vorhandene Dateien lesen, um Nummerierungslücken und Stil zu übernehmen.
|
||||||
|
|
||||||
|
### 1b. Gliederung aus bestehendem Dokument ableiten (Modus B)
|
||||||
|
|
||||||
|
Statt Thema und Kernbotschaft zu erfragen, wird das Dokument komplett
|
||||||
|
gelesen und als Materialgrundlage genommen:
|
||||||
|
|
||||||
|
- **Nicht mechanisch an Überschriften entlang splitten.** Eine `#`/`##` pro
|
||||||
|
Slide ergibt fast immer schlechte Decks: manche Abschnitte sind zu lang
|
||||||
|
für eine Slide, manche zu kurz und sollten zusammengelegt werden, und
|
||||||
|
Fließtext-Absätze müssen ohnehin zu Stichpunkten verdichtet werden.
|
||||||
|
Überschriften sind ein Anhaltspunkt für die grobe Reihenfolge, nicht die
|
||||||
|
Schnittkante.
|
||||||
|
- **Verdichten statt kopieren.** Prosa-Abschnitte in Kernaussagen/Stichpunkte
|
||||||
|
übersetzen (siehe Schritt 3, Body-Konventionen); Prozesse, Vergleiche oder
|
||||||
|
Zeitpläne, die im Dokument in Textform stehen, als Kandidaten für ein
|
||||||
|
Mermaid-Diagramm oder eine Tabelle vormerken statt sie in Prosa zu
|
||||||
|
belassen.
|
||||||
|
- **Nur das übernehmen, was im Dokument steht.** Keine Fakten ergänzen oder
|
||||||
|
interpretieren, die dort nicht stehen — bei Unklarheiten (z. B. ein Absatz
|
||||||
|
lässt sich nicht eindeutig kürzen ohne Bedeutung zu verlieren) nachfragen
|
||||||
|
statt zu raten.
|
||||||
|
- **Trotzdem kurz nachfragen, was sich nicht aus dem Dokument ergibt**:
|
||||||
|
ungefährer Zielumfang (ganzes Dokument in eine Kurzfassung pressen oder
|
||||||
|
eher 1:1 in der Tiefe abbilden?), Zielordner, neu oder Erweiterung eines
|
||||||
|
bestehenden Decks — wie in Modus A.
|
||||||
|
- Das Ergebnis dieses Schritts ist eine Gliederung (Liste von Slide-Titeln
|
||||||
|
plus grober Inhaltsangabe je Slide), die genau wie in Modus A in Schritt 2
|
||||||
|
zur Bestätigung vorgelegt wird — noch keine fertigen Dateien.
|
||||||
|
|
||||||
|
### 2. Gliederung vorschlagen und abstimmen
|
||||||
|
|
||||||
|
Aus den Antworten (Modus A) bzw. aus dem Dokument (Modus B) eine Liste von
|
||||||
|
Slide-Titeln (Gliederung) ableiten und dem Nutzer **vor** dem Schreiben der
|
||||||
|
Dateien zur Bestätigung vorlegen. Typische Grobstruktur, an die Situation
|
||||||
|
anpassen (in Modus B ergibt sich diese meist schon aus dem Dokument):
|
||||||
|
|
||||||
|
1. Titel-/Intro-Slide
|
||||||
|
2. Kontext/Problem
|
||||||
|
3. Kernteil (mehrere Slides, ein Gedanke pro Slide)
|
||||||
|
4. Beleg/Details (Daten, Vergleich, Architektur, Zeitplan — je nach Thema)
|
||||||
|
5. Zusammenfassung/Call-to-Action
|
||||||
|
6. Danke/Fragen
|
||||||
|
|
||||||
|
Erst nach Zustimmung des Nutzers mit Schritt 3 weitermachen. Bei Unsicherheit
|
||||||
|
über den Detailgrad lieber kurz nachfragen als zu viele Slides auf Verdacht
|
||||||
|
zu erzeugen.
|
||||||
|
|
||||||
|
### 3. Dateien anlegen
|
||||||
|
|
||||||
|
Für jede Slide der Gliederung:
|
||||||
|
|
||||||
|
- **Dateiname**: Lücken-Nummerierung, drei Stellen als Standard
|
||||||
|
(`010-intro.md`, `020-kontext.md`, `025-nachtrag.md`, …), damit später
|
||||||
|
Slides eingeschoben werden können, ohne umzubenennen. Bei einer
|
||||||
|
Erweiterung eines bestehenden Decks: bestehendes Nummernschema
|
||||||
|
übernehmen.
|
||||||
|
- **Frontmatter**: `title` immer setzen (informativ, auch wenn nicht
|
||||||
|
automatisch im HTML sichtbar). `class` nur setzen, wenn eine
|
||||||
|
Layout-Variante gebraucht wird, die auch in `web/assets/style.css`
|
||||||
|
existiert oder gemeinsam mit dem Nutzer ergänzt wird — nicht auf Verdacht
|
||||||
|
erfinden. `notes` nur füllen, wenn der Nutzer tatsächlich Sprechernotizen
|
||||||
|
will. `skip` nur bei explizit als Entwurf markierten Slides. `incremental:
|
||||||
|
true` setzen, wenn Stichpunkte einer Slide im Vortrag einzeln
|
||||||
|
nacheinander aufgedeckt werden sollen (Bullet-Listen als "Fragments";
|
||||||
|
navigiert wird dann automatisch erst Fragment für Fragment, dann zum
|
||||||
|
nächsten Slide) — nicht standardmäßig auf jede Liste anwenden, sondern
|
||||||
|
nur wenn der Nutzer das so vorträgt.
|
||||||
|
- **Body**: Stichpunkte statt Fließtext, eine Kernaussage pro Slide. Tabellen
|
||||||
|
für Vergleiche/Status, Blockquotes für Zitate/Hervorhebungen, Mermaid-Codeblöcke
|
||||||
|
(`graph`, `sequenceDiagram`, `gantt`, `pie`, …) für Prozesse, Architektur
|
||||||
|
oder Zeitpläne statt sie in Prosa zu beschreiben. Codeblöcke mit
|
||||||
|
Sprachangabe (```go, ```js, …) werden automatisch Syntax-highlighted,
|
||||||
|
ohne weiteres Zutun. Bilder (``) einfach neben die
|
||||||
|
Slide-Dateien legen (auch in Unterordnern) — sie werden von Dev-Server
|
||||||
|
und Export automatisch mitgeliefert, kein manuelles Kopieren nötig. Rohes
|
||||||
|
HTML nur einsetzen, wenn reines Markdown eine Anforderung wirklich nicht
|
||||||
|
abdeckt (z. B. `class="fragment"` auf einem einzelnen Element für ein
|
||||||
|
gezieltes Fragment außerhalb einer Liste).
|
||||||
|
|
||||||
|
### 4. Ergebnis prüfen lassen
|
||||||
|
|
||||||
|
Nach dem Schreiben der Dateien:
|
||||||
|
|
||||||
|
- Vorschau anbieten: `slidewalk <ordner>` (Dev-Server mit Live-Reload). Bei
|
||||||
|
größeren Decks auf den Übersichts-/Grid-Modus hinweisen (Taste `O` im
|
||||||
|
Browser zeigt alle Slides als Miniaturansicht, Klick springt hin).
|
||||||
|
- Falls gewünscht, statischen Export anbieten: `slidewalk -build <ziel> <ordner>`.
|
||||||
|
- Kurz zusammenfassen, welche Dateien mit welcher Absicht entstanden sind,
|
||||||
|
damit der Nutzer gezielt Feedback geben kann (z. B. "Slide 3 ist noch zu
|
||||||
|
textlastig").
|
||||||
|
|
||||||
|
## Leitplanken
|
||||||
|
|
||||||
|
- Keine Inhalte erfinden, die der Nutzer nicht genannt hat oder die nicht aus
|
||||||
|
bereitgestelltem Material (Notizen, bestehende Dokumente) stammen — bei
|
||||||
|
fehlenden Fakten nachfragen statt zu raten. In Modus B gilt das
|
||||||
|
besonders für das Verdichten: kürzen und in Stichpunkte übersetzen ist
|
||||||
|
erlaubt und gewollt, inhaltlich umdeuten oder ergänzen nicht.
|
||||||
|
- Immer die im Projekt vorhandenen Konventionen respektieren: Lücken-Nummerierung,
|
||||||
|
Frontmatter-Felder aus `docs/markdown-guide.md`, vorhandene `class`-Werte in
|
||||||
|
`web/assets/style.css` wiederverwenden statt neue zu erfinden.
|
||||||
|
- Zielordner nicht ungefragt leeren oder überschreiben; bei Konflikten mit
|
||||||
|
bestehenden Dateien nachfragen.
|
||||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Build-Artefakte (go build -o slidewalk, Cross-Compile- und -build-Exports
|
||||||
|
# landen laut README typischerweise in ./dist)
|
||||||
|
/slidewalk
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
# Editoren/IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Go test/coverage
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
coverage.html
|
||||||
|
|
||||||
|
# lokale Umgebungsvariablen, falls mal welche dazukommen
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
151
.opencode/skills/praesentation-erstellen/SKILL.md
Normal file
151
.opencode/skills/praesentation-erstellen/SKILL.md
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
---
|
||||||
|
name: praesentation-erstellen
|
||||||
|
description: Hilft interaktiv beim Aufbau einer neuen slidewalk-Präsentation aus Markdown-Slides – von Themenklärung über Gliederung bis zu den fertigen, nummerierten .md-Dateien mit Frontmatter. Funktioniert sowohl von Grund auf (Thema im Dialog klären) als auch ausgehend von einem bestehenden Markdown-/Text-Dokument, das in ein Deck umgewandelt werden soll. Themenneutral: funktioniert für jeden Vortrag, jede Zielgruppe. Nutzen, wenn ein neues Slide-Deck für slidewalk entstehen soll (nicht für das Reviewen bestehender Decks).
|
||||||
|
---
|
||||||
|
|
||||||
|
# Präsentation erstellen
|
||||||
|
|
||||||
|
Baut gemeinsam mit dem Nutzer ein neues slidewalk-Deck: ein Ordner mit
|
||||||
|
nummerierten `.md`-Dateien, die slidewalks Konventionen folgen. Das Thema ist
|
||||||
|
dabei beliebig (Business-Pitch, technischer Vortrag, Schulung, Statusupdate,
|
||||||
|
…) — dieser Skill trifft keine Annahmen über den Inhalt, sondern erfragt die
|
||||||
|
Struktur.
|
||||||
|
|
||||||
|
Zwei Startpunkte, je nachdem was der Nutzer mitbringt:
|
||||||
|
|
||||||
|
- **Modus A — von Grund auf**: Nutzer hat noch kein Ausgangsmaterial, nur ein
|
||||||
|
Thema im Kopf. Struktur entsteht im Dialog (Schritt 1a).
|
||||||
|
- **Modus B — aus bestehendem Dokument**: Nutzer hat schon einen Text
|
||||||
|
(Report, Notizen, README, Konzeptpapier, …) und will daraus ein Deck
|
||||||
|
ableiten. Struktur entsteht aus dem Dokument, nicht aus offenen Fragen
|
||||||
|
(Schritt 1b).
|
||||||
|
|
||||||
|
Ab Schritt 2 (Gliederung abstimmen) laufen beide Modi gleich weiter.
|
||||||
|
|
||||||
|
Maßgeblich für alle Markdown-/Frontmatter-Details ist
|
||||||
|
[`docs/markdown-guide.md`](../../../docs/markdown-guide.md); bei
|
||||||
|
Unsicherheit dort nachschlagen statt zu raten. Ein lauffähiges
|
||||||
|
Referenzbeispiel liegt unter [`examples/demo/`](../../../examples/demo/).
|
||||||
|
|
||||||
|
## Ablauf
|
||||||
|
|
||||||
|
### 1a. Rahmen klären (Modus A — von Grund auf)
|
||||||
|
|
||||||
|
Bevor irgendetwas geschrieben wird, per Rückfrage klären (nicht alles auf
|
||||||
|
einmal abfragen, sondern nur was für die Struktur nötig ist):
|
||||||
|
|
||||||
|
- **Thema & Kernbotschaft**: Worum geht es, was soll das Publikum am Ende
|
||||||
|
mitnehmen?
|
||||||
|
- **Zielgruppe & Anlass**: Wer sitzt im Publikum, wie viel Vorwissen ist da,
|
||||||
|
formell oder informell?
|
||||||
|
- **Ungefährer Umfang**: Wie viele Slides / wie lange soll der Vortrag
|
||||||
|
dauern? (Faustregel: 1–2 Minuten Sprechzeit pro Slide.)
|
||||||
|
- **Zielordner**: Wo sollen die Dateien liegen? Falls nicht genannt,
|
||||||
|
sinnvollen Vorschlag machen (z. B. `content/` im aktuellen Projekt) und
|
||||||
|
bestätigen lassen — nicht ungefragt einen bestehenden Ordner überschreiben.
|
||||||
|
- **Neu oder Erweiterung**: Entsteht ein komplett neues Deck, oder werden
|
||||||
|
Slides in einen bestehenden Ordner eingeschoben? Falls Erweiterung: erst
|
||||||
|
vorhandene Dateien lesen, um Nummerierungslücken und Stil zu übernehmen.
|
||||||
|
|
||||||
|
### 1b. Gliederung aus bestehendem Dokument ableiten (Modus B)
|
||||||
|
|
||||||
|
Statt Thema und Kernbotschaft zu erfragen, wird das Dokument komplett
|
||||||
|
gelesen und als Materialgrundlage genommen:
|
||||||
|
|
||||||
|
- **Nicht mechanisch an Überschriften entlang splitten.** Eine `#`/`##` pro
|
||||||
|
Slide ergibt fast immer schlechte Decks: manche Abschnitte sind zu lang
|
||||||
|
für eine Slide, manche zu kurz und sollten zusammengelegt werden, und
|
||||||
|
Fließtext-Absätze müssen ohnehin zu Stichpunkten verdichtet werden.
|
||||||
|
Überschriften sind ein Anhaltspunkt für die grobe Reihenfolge, nicht die
|
||||||
|
Schnittkante.
|
||||||
|
- **Verdichten statt kopieren.** Prosa-Abschnitte in Kernaussagen/Stichpunkte
|
||||||
|
übersetzen (siehe Schritt 3, Body-Konventionen); Prozesse, Vergleiche oder
|
||||||
|
Zeitpläne, die im Dokument in Textform stehen, als Kandidaten für ein
|
||||||
|
Mermaid-Diagramm oder eine Tabelle vormerken statt sie in Prosa zu
|
||||||
|
belassen.
|
||||||
|
- **Nur das übernehmen, was im Dokument steht.** Keine Fakten ergänzen oder
|
||||||
|
interpretieren, die dort nicht stehen — bei Unklarheiten (z. B. ein Absatz
|
||||||
|
lässt sich nicht eindeutig kürzen ohne Bedeutung zu verlieren) nachfragen
|
||||||
|
statt zu raten.
|
||||||
|
- **Trotzdem kurz nachfragen, was sich nicht aus dem Dokument ergibt**:
|
||||||
|
ungefährer Zielumfang (ganzes Dokument in eine Kurzfassung pressen oder
|
||||||
|
eher 1:1 in der Tiefe abbilden?), Zielordner, neu oder Erweiterung eines
|
||||||
|
bestehenden Decks — wie in Modus A.
|
||||||
|
- Das Ergebnis dieses Schritts ist eine Gliederung (Liste von Slide-Titeln
|
||||||
|
plus grober Inhaltsangabe je Slide), die genau wie in Modus A in Schritt 2
|
||||||
|
zur Bestätigung vorgelegt wird — noch keine fertigen Dateien.
|
||||||
|
|
||||||
|
### 2. Gliederung vorschlagen und abstimmen
|
||||||
|
|
||||||
|
Aus den Antworten (Modus A) bzw. aus dem Dokument (Modus B) eine Liste von
|
||||||
|
Slide-Titeln (Gliederung) ableiten und dem Nutzer **vor** dem Schreiben der
|
||||||
|
Dateien zur Bestätigung vorlegen. Typische Grobstruktur, an die Situation
|
||||||
|
anpassen (in Modus B ergibt sich diese meist schon aus dem Dokument):
|
||||||
|
|
||||||
|
1. Titel-/Intro-Slide
|
||||||
|
2. Kontext/Problem
|
||||||
|
3. Kernteil (mehrere Slides, ein Gedanke pro Slide)
|
||||||
|
4. Beleg/Details (Daten, Vergleich, Architektur, Zeitplan — je nach Thema)
|
||||||
|
5. Zusammenfassung/Call-to-Action
|
||||||
|
6. Danke/Fragen
|
||||||
|
|
||||||
|
Erst nach Zustimmung des Nutzers mit Schritt 3 weitermachen. Bei Unsicherheit
|
||||||
|
über den Detailgrad lieber kurz nachfragen als zu viele Slides auf Verdacht
|
||||||
|
zu erzeugen.
|
||||||
|
|
||||||
|
### 3. Dateien anlegen
|
||||||
|
|
||||||
|
Für jede Slide der Gliederung:
|
||||||
|
|
||||||
|
- **Dateiname**: Lücken-Nummerierung, drei Stellen als Standard
|
||||||
|
(`010-intro.md`, `020-kontext.md`, `025-nachtrag.md`, …), damit später
|
||||||
|
Slides eingeschoben werden können, ohne umzubenennen. Bei einer
|
||||||
|
Erweiterung eines bestehenden Decks: bestehendes Nummernschema
|
||||||
|
übernehmen.
|
||||||
|
- **Frontmatter**: `title` immer setzen (informativ, auch wenn nicht
|
||||||
|
automatisch im HTML sichtbar). `class` nur setzen, wenn eine
|
||||||
|
Layout-Variante gebraucht wird, die auch in `web/assets/style.css`
|
||||||
|
existiert oder gemeinsam mit dem Nutzer ergänzt wird — nicht auf Verdacht
|
||||||
|
erfinden. `notes` nur füllen, wenn der Nutzer tatsächlich Sprechernotizen
|
||||||
|
will. `skip` nur bei explizit als Entwurf markierten Slides. `incremental:
|
||||||
|
true` setzen, wenn Stichpunkte einer Slide im Vortrag einzeln
|
||||||
|
nacheinander aufgedeckt werden sollen (Bullet-Listen als "Fragments";
|
||||||
|
navigiert wird dann automatisch erst Fragment für Fragment, dann zum
|
||||||
|
nächsten Slide) — nicht standardmäßig auf jede Liste anwenden, sondern
|
||||||
|
nur wenn der Nutzer das so vorträgt.
|
||||||
|
- **Body**: Stichpunkte statt Fließtext, eine Kernaussage pro Slide. Tabellen
|
||||||
|
für Vergleiche/Status, Blockquotes für Zitate/Hervorhebungen, Mermaid-Codeblöcke
|
||||||
|
(`graph`, `sequenceDiagram`, `gantt`, `pie`, …) für Prozesse, Architektur
|
||||||
|
oder Zeitpläne statt sie in Prosa zu beschreiben. Codeblöcke mit
|
||||||
|
Sprachangabe (```go, ```js, …) werden automatisch Syntax-highlighted,
|
||||||
|
ohne weiteres Zutun. Bilder (``) einfach neben die
|
||||||
|
Slide-Dateien legen (auch in Unterordnern) — sie werden von Dev-Server
|
||||||
|
und Export automatisch mitgeliefert, kein manuelles Kopieren nötig. Rohes
|
||||||
|
HTML nur einsetzen, wenn reines Markdown eine Anforderung wirklich nicht
|
||||||
|
abdeckt (z. B. `class="fragment"` auf einem einzelnen Element für ein
|
||||||
|
gezieltes Fragment außerhalb einer Liste).
|
||||||
|
|
||||||
|
### 4. Ergebnis prüfen lassen
|
||||||
|
|
||||||
|
Nach dem Schreiben der Dateien:
|
||||||
|
|
||||||
|
- Vorschau anbieten: `slidewalk <ordner>` (Dev-Server mit Live-Reload). Bei
|
||||||
|
größeren Decks auf den Übersichts-/Grid-Modus hinweisen (Taste `O` im
|
||||||
|
Browser zeigt alle Slides als Miniaturansicht, Klick springt hin).
|
||||||
|
- Falls gewünscht, statischen Export anbieten: `slidewalk -build <ziel> <ordner>`.
|
||||||
|
- Kurz zusammenfassen, welche Dateien mit welcher Absicht entstanden sind,
|
||||||
|
damit der Nutzer gezielt Feedback geben kann (z. B. "Slide 3 ist noch zu
|
||||||
|
textlastig").
|
||||||
|
|
||||||
|
## Leitplanken
|
||||||
|
|
||||||
|
- Keine Inhalte erfinden, die der Nutzer nicht genannt hat oder die nicht aus
|
||||||
|
bereitgestelltem Material (Notizen, bestehende Dokumente) stammen — bei
|
||||||
|
fehlenden Fakten nachfragen statt zu raten. In Modus B gilt das
|
||||||
|
besonders für das Verdichten: kürzen und in Stichpunkte übersetzen ist
|
||||||
|
erlaubt und gewollt, inhaltlich umdeuten oder ergänzen nicht.
|
||||||
|
- Immer die im Projekt vorhandenen Konventionen respektieren: Lücken-Nummerierung,
|
||||||
|
Frontmatter-Felder aus `docs/markdown-guide.md`, vorhandene `class`-Werte in
|
||||||
|
`web/assets/style.css` wiederverwenden statt neue zu erfinden.
|
||||||
|
- Zielordner nicht ungefragt leeren oder überschreiben; bei Konflikten mit
|
||||||
|
bestehenden Dateien nachfragen.
|
||||||
180
README.md
Normal file
180
README.md
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
# slidewalk
|
||||||
|
|
||||||
|
Ein minimalistisches, selbstgebautes Tool zum Präsentieren von Markdown-Slides
|
||||||
|
im Browser, mit Mermaid-Support. Geschrieben in Go, das Ergebnis ist ein
|
||||||
|
einziges Binary.
|
||||||
|
|
||||||
|
- Nur Markdown als Quelle (rohes HTML ist bei Bedarf inline erlaubt)
|
||||||
|
- Eine Datei = ein Slide, Reihenfolge über Dateiname-Präfix (`010-intro.md`, `020-...`)
|
||||||
|
- Vendored Mermaid, kein CDN nötig
|
||||||
|
- Syntax-Highlighting für Codeblöcke (serverseitig via Chroma, kein Client-JS)
|
||||||
|
- Tastatur- und Maus-Navigation, URL-Hash spiegelt den aktuellen Slide
|
||||||
|
- Dev-Server mit Live-Reload oder statischer Export als ein HTML-Dokument
|
||||||
|
- Dark Mode von Anfang an
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Voraussetzung ist ein installiertes Go (siehe `go.mod` für die Mindestversion).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go build -o slidewalk ./cmd/slidewalk
|
||||||
|
```
|
||||||
|
|
||||||
|
Das erzeugt ein einziges Binary `slidewalk` ohne weitere Laufzeit-Abhängigkeiten
|
||||||
|
(alle Assets sind über `embed.FS` eingebettet).
|
||||||
|
|
||||||
|
## Nutzung
|
||||||
|
|
||||||
|
Ein Beispiel-Deck liegt unter `examples/demo/`.
|
||||||
|
|
||||||
|
### Dev-Server (Standard)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
slidewalk ./examples/demo
|
||||||
|
```
|
||||||
|
|
||||||
|
Startet einen lokalen Server (Standard-Adresse `127.0.0.1:8080`) und liefert die
|
||||||
|
Präsentation aus. Änderungen an den Markdown-Dateien lösen automatisch ein
|
||||||
|
Neuladen im Browser aus (Live-Reload über Server-Sent Events).
|
||||||
|
|
||||||
|
Flags müssen **vor** dem Ordner-Pfad stehen:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
slidewalk [-addr <host:port>] <ordner>
|
||||||
|
```
|
||||||
|
|
||||||
|
Während der Entwicklung von slidewalk selbst (ohne vorheriges `go build`)
|
||||||
|
geht das auch direkt mit `go run`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go run ./cmd/slidewalk ./examples/demo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Statischer Export
|
||||||
|
|
||||||
|
```sh
|
||||||
|
slidewalk -build ./dist ./examples/demo
|
||||||
|
```
|
||||||
|
|
||||||
|
Schreibt die gerenderte Präsentation als eigenständiges HTML-Dokument nach
|
||||||
|
`./dist`. Alle benötigten Assets (`style.css`, `chroma.css`, `nav.js`,
|
||||||
|
`mermaid.min.js`) landen dabei in `./dist/vendor/` — außer der exportierten
|
||||||
|
HTML-Datei selbst liegt also nichts von slidewalk lose im Zielordner. Eigene
|
||||||
|
Dateien (Bilder etc.) werden unverändert an ihrem relativen Pfad mitkopiert.
|
||||||
|
Das Ergebnis lässt sich ohne Server direkt im Browser öffnen oder auf
|
||||||
|
beliebigem Static Hosting ausliefern.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
slidewalk [-build <zielordner>] [-out <datei.html>] <ordner>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Default | Bedeutung |
|
||||||
|
| --------- | -------------- | ------------------------------------------------------------ |
|
||||||
|
| `-build` | – | Zielordner für den statischen Export; ohne diesen Flag startet der Dev-Server |
|
||||||
|
| `-out` | `index.html` | Name der exportierten HTML-Datei (nur mit `-build`) |
|
||||||
|
| `-addr` | `127.0.0.1:8080` | Adresse, auf der der Dev-Server lauscht |
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
| Eingabe | Aktion |
|
||||||
|
| --------------------------------- | ----------------- |
|
||||||
|
| `→`, `Space`, `PageDown` | nächstes Fragment, dann nächster Slide |
|
||||||
|
| `←`, `PageUp` | vorheriges Fragment, dann vorheriger Slide |
|
||||||
|
| `Home` | erster Slide |
|
||||||
|
| `End` | letzter Slide |
|
||||||
|
| `O` | Übersichts-/Grid-Modus an/aus |
|
||||||
|
| `Escape` (im Grid-Modus) | Grid-Modus verlassen, ohne Slide zu wechseln |
|
||||||
|
| Klick links/rechts, Pfeil-Buttons | Slide wechseln |
|
||||||
|
| Klick auf Slide (im Grid-Modus) | zu diesem Slide springen, Grid-Modus verlassen |
|
||||||
|
|
||||||
|
Der aktuelle Slide (und ggf. Fragment-Fortschritt) wird im URL-Hash
|
||||||
|
gespiegelt (z. B. `#3` oder `#3.2` für Slide 3, zweites Fragment sichtbar)
|
||||||
|
und beim Laden daraus wiederhergestellt.
|
||||||
|
|
||||||
|
## Fragments (Bullet-Points einzeln einblenden)
|
||||||
|
|
||||||
|
Setzt eine Slide-Datei `incremental: true` im Frontmatter, werden ihre
|
||||||
|
Top-Level-Listeneinträge (`<ul>`/`<ol>`) einzeln nacheinander eingeblendet,
|
||||||
|
statt alle auf einmal zu erscheinen — praktisch beim Durchgehen von
|
||||||
|
Stichpunkten im Vortrag. Zusätzlich (oder alternativ) lässt sich jedes
|
||||||
|
beliebige Element per raw HTML mit `class="fragment"` einzeln als Fragment
|
||||||
|
markieren.
|
||||||
|
|
||||||
|
## Frontmatter-Referenz
|
||||||
|
|
||||||
|
Jede Slide-Datei kann optional ein YAML-Frontmatter am Dateianfang haben:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: Meine Überschrift
|
||||||
|
class: intro
|
||||||
|
notes: Nur für die Sprechernotizen gedacht.
|
||||||
|
skip: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Der eigentliche Slide-Inhalt
|
||||||
|
```
|
||||||
|
|
||||||
|
| Feld | Typ | Bedeutung |
|
||||||
|
| -------- | ------- | -------------------------------------------------------------------------- |
|
||||||
|
| `title` | string | Titel des Slides; wird beim Anzeigen des Slides als Browser-Tab-Titel gesetzt (`Slide-Titel · Deck-Titel`) |
|
||||||
|
| `class` | string | Wird als zusätzliche CSS-Klasse auf das `<section>`-Element angewendet |
|
||||||
|
| `notes` | string | Sprechernotizen; werden gerendert, aber per CSS standardmäßig versteckt |
|
||||||
|
| `skip` | bool | Wenn `true`, wird die Datei beim Rendern/Export komplett übersprungen |
|
||||||
|
| `incremental` | bool | Wenn `true`, werden Top-Level-Listeneinträge der Slide einzeln nacheinander eingeblendet (Fragments) |
|
||||||
|
|
||||||
|
Mermaid-Diagramme werden über normale Codeblöcke mit der Sprache `mermaid`
|
||||||
|
eingebunden:
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A --> B
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
Eine ausführliche Anleitung mit Beispielen für alle unterstützten
|
||||||
|
Markdown-Features (Tabellen, Task-Listen, Fußnoten, Mermaid-Diagrammtypen,
|
||||||
|
rohes HTML als Fallback etc.) findest du unter
|
||||||
|
[`docs/markdown-guide.md`](docs/markdown-guide.md).
|
||||||
|
|
||||||
|
## Code-Dokumentation
|
||||||
|
|
||||||
|
Eine Übersicht über die Architektur, welche Datei wofür zuständig ist und
|
||||||
|
wie die Packages zusammenspielen, findest du unter
|
||||||
|
[`docs/architektur.md`](docs/architektur.md).
|
||||||
|
|
||||||
|
## Cross-Compiling
|
||||||
|
|
||||||
|
Go kompiliert ohne zusätzliche Werkzeuge für andere Betriebssysteme/
|
||||||
|
Architekturen, gesteuert über die Umgebungsvariablen `GOOS`/`GOARCH`. Da alle
|
||||||
|
Assets über `embed.FS` eingebettet sind, entsteht in jedem Fall ein einziges,
|
||||||
|
eigenständiges Binary.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# macOS (Apple Silicon)
|
||||||
|
GOOS=darwin GOARCH=arm64 go build -o dist/slidewalk-darwin-arm64 ./cmd/slidewalk
|
||||||
|
|
||||||
|
# macOS (Intel)
|
||||||
|
GOOS=darwin GOARCH=amd64 go build -o dist/slidewalk-darwin-amd64 ./cmd/slidewalk
|
||||||
|
|
||||||
|
# Linux (x86_64)
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o dist/slidewalk-linux-amd64 ./cmd/slidewalk
|
||||||
|
|
||||||
|
# Linux (ARM64, z. B. Raspberry Pi 4/5, AWS Graviton)
|
||||||
|
GOOS=linux GOARCH=arm64 go build -o dist/slidewalk-linux-arm64 ./cmd/slidewalk
|
||||||
|
|
||||||
|
# Windows (x86_64)
|
||||||
|
GOOS=windows GOARCH=amd64 go build -o dist/slidewalk-windows-amd64.exe ./cmd/slidewalk
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle Zielsysteme lassen sich von einer einzigen Maschine aus bauen (kein
|
||||||
|
Cross-Compiler nötig, da reines Go ohne CGO). `go tool dist list` zeigt alle
|
||||||
|
unterstützten `GOOS`/`GOARCH`-Kombinationen.
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go vet ./...
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
61
cmd/slidewalk/main.go
Normal file
61
cmd/slidewalk/main.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// Command slidewalk presents a folder of Markdown slides in the browser, or,
|
||||||
|
// with -build, exports them as a static HTML page.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"slidewalk/internal/export"
|
||||||
|
"slidewalk/internal/watch"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(os.Args[1:]); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "slidewalk:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("slidewalk", flag.ContinueOnError)
|
||||||
|
buildDir := fs.String("build", "", "Zielordner für den statischen Export (wenn gesetzt, kein Dev-Server)")
|
||||||
|
outName := fs.String("out", "index.html", "Name der exportierten HTML-Datei (nur mit -build)")
|
||||||
|
addr := fs.String("addr", "127.0.0.1:8080", "Adresse, auf der der Dev-Server lauscht")
|
||||||
|
fs.Usage = func() {
|
||||||
|
fmt.Fprintln(fs.Output(), "Nutzung: slidewalk [-build <zielordner>] [-out <datei.html>] [-addr <host:port>] <ordner>")
|
||||||
|
fs.PrintDefaults()
|
||||||
|
}
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if fs.NArg() != 1 {
|
||||||
|
fs.Usage()
|
||||||
|
return fmt.Errorf("erwarte genau einen Ordner-Pfad als Argument, habe %d erhalten", fs.NArg())
|
||||||
|
}
|
||||||
|
dir := fs.Arg(0)
|
||||||
|
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ungültiger Ordner-Pfad %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return fmt.Errorf("ungültiger Ordner-Pfad %q: ist kein Verzeichnis", dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *buildDir != "" {
|
||||||
|
if err := export.Export(dir, *buildDir, *outName); err != nil {
|
||||||
|
return fmt.Errorf("export fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Präsentation nach %s exportiert (%s)\n", *buildDir, *outName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Dev-Server läuft auf %s (Ordner: %s)\n", *addr, dir)
|
||||||
|
if err := watch.Serve(*addr, dir); err != nil {
|
||||||
|
return fmt.Errorf("dev-server fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
318
docs/architektur.md
Normal file
318
docs/architektur.md
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
# Architektur & Code-Übersicht
|
||||||
|
|
||||||
|
Diese Dokumentation erklärt, wie der Code von slidewalk aufgebaut ist: welche
|
||||||
|
Datei wofür zuständig ist, wie die Packages zusammenspielen und wo du
|
||||||
|
ansetzen musst, wenn du etwas ändern willst. Für die Autoring-Seite (wie man
|
||||||
|
Slides schreibt) siehe [`docs/markdown-guide.md`](markdown-guide.md); für
|
||||||
|
Installation/Nutzung siehe [`README.md`](../README.md).
|
||||||
|
|
||||||
|
## 1. Der große Überblick
|
||||||
|
|
||||||
|
Alles dreht sich um einen einzigen Datenfluss: **Ordner mit `.md`-Dateien
|
||||||
|
rein, eine HTML-Seite raus.** Dev-Server und statischer Export sind nur zwei
|
||||||
|
verschiedene "Auslässe" für denselben Kern:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
A["Ordner mit<br/>010-x.md, 020-y.md, ..."] --> B["internal/parser<br/>ParseDir"]
|
||||||
|
B -->|"[]Slide"| C["internal/render<br/>Render"]
|
||||||
|
C -->|"HTML-Bytes"| D{{"cmd/slidewalk"}}
|
||||||
|
D -->|"ohne -build"| E["internal/watch<br/>Server"]
|
||||||
|
D -->|"mit -build"| F["internal/export<br/>Export"]
|
||||||
|
E --> G["Browser<br/>(HTTP + SSE)"]
|
||||||
|
F --> H["Ordner mit<br/>index.html + Assets"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Vier Packages, klare Verantwortung, jedes einzeln testbar:
|
||||||
|
|
||||||
|
| Package | Beantwortet die Frage |
|
||||||
|
| ------------------ | --------------------------------------------------------------- |
|
||||||
|
| `internal/parser` | Wie wird aus einer `.md`-Datei ein `Slide` (Struct mit HTML)? |
|
||||||
|
| `internal/render` | Wie werden mehrere `Slide`s zu **einer** HTML-Seite? |
|
||||||
|
| `internal/watch` | Wie liefere ich die Seite über HTTP aus und melde Änderungen? |
|
||||||
|
| `internal/export` | Wie schreibe ich die Seite + Assets als statische Dateien? |
|
||||||
|
| `web` | Wo liegen Templates/CSS/JS, und wie kommen sie ins Binary? |
|
||||||
|
| `cmd/slidewalk` | Wie übersetze ich CLI-Flags in einen Aufruf von `watch` oder `export`? |
|
||||||
|
|
||||||
|
Sowohl `internal/watch` als auch `internal/export` rufen **ausschließlich**
|
||||||
|
`parser.ParseDir` und `render.Render` auf — es gibt keine zweite Stelle im
|
||||||
|
Code, die Markdown parst oder HTML zusammenbaut. Wenn sich am
|
||||||
|
HTML-Slide-Markup etwas ändern soll, reicht eine Änderung in
|
||||||
|
`internal/render`.
|
||||||
|
|
||||||
|
## 2. `internal/parser` — Markdown-Dateien einlesen
|
||||||
|
|
||||||
|
**Zuständig:** aus einem Ordner-Pfad eine sortierte Liste von `Slide`-Structs
|
||||||
|
machen.
|
||||||
|
|
||||||
|
| Datei | Enthält |
|
||||||
|
| --------------------- | ------- |
|
||||||
|
| `parser.go` | Öffentliche API: `Slide`-Struct, `ParseDir(dir)`, Datei-Discovery (`discoverSlideFiles`), Zusammenbau eines einzelnen Slides (`parseSlide`) |
|
||||||
|
| `frontmatter.go` | YAML-Frontmatter-Handling: `splitFrontmatter` trennt den `---`-Block vom Markdown-Body, `parseFrontmatter` parst ihn in das `frontmatter`-Struct (`title`, `class`, `notes`, `skip`, `incremental`) |
|
||||||
|
| `markdown.go` | Die `goldmark`-Instanz (`md`) mit allen Extensions, plus der custom Fenced-Code-Block-Renderer (Mermaid-Sonderfall + Chroma-Syntax-Highlighting) |
|
||||||
|
| `*_test.go` | Table-driven Tests je Datei |
|
||||||
|
|
||||||
|
### Ablauf von `ParseDir` (`parser.go:35`)
|
||||||
|
|
||||||
|
1. `discoverSlideFiles(dir)` listet alle `.md`-Dateien im Ordner (nicht
|
||||||
|
rekursiv) und sortiert sie alphabetisch — das ist der Mechanismus hinter
|
||||||
|
der Präfix-Reihenfolge (`010-...`, `020-...`).
|
||||||
|
2. Für jede Datei: `os.ReadFile` → `parseSlide(name, data)`.
|
||||||
|
3. `parseSlide` (`parser.go:134`) ruft zuerst `parseFrontmatter` auf. Ist
|
||||||
|
`skip: true` gesetzt, bricht es sofort ab (zweiter Rückgabewert `skip
|
||||||
|
bool`), ohne den Markdown-Body überhaupt zu rendern.
|
||||||
|
4. Sonst: `renderMarkdown(body)` aus `markdown.go` wandelt den Rest in HTML
|
||||||
|
um, das Ergebnis landet als `template.HTML` im `Slide`-Struct.
|
||||||
|
5. `ParseDir` filtert alle als "skip" markierten Slides aus der
|
||||||
|
zurückgegebenen Liste heraus.
|
||||||
|
|
||||||
|
### Fenced-Code-Blöcke: Mermaid & Syntax-Highlighting (`markdown.go`)
|
||||||
|
|
||||||
|
`goldmark` rendert Fenced-Code-Blöcke standardmäßig als
|
||||||
|
`<pre><code class="language-...">`. slidewalk braucht davon zwei
|
||||||
|
Sonderfälle, deshalb registriert `mermaidExtension` einen eigenen
|
||||||
|
`NodeRenderer` (`mermaidRenderer`) mit höherer Priorität als goldmarks
|
||||||
|
Standard-Renderer für `ast.KindFencedCodeBlock` — er überschreibt dessen
|
||||||
|
Registrierung (`renderFencedCodeBlock`, `markdown.go`):
|
||||||
|
|
||||||
|
- Sprache `mermaid` → `<pre class="mermaid">…</pre>` **ohne** `<code>`-Wrapper,
|
||||||
|
das Format, das `mermaid.js` erwartet.
|
||||||
|
- Sonstige Sprache, für die `lexers.Get` (Chroma) einen Lexer findet →
|
||||||
|
`renderHighlighted` tokenisiert den Codeblock-Inhalt und lässt ihn per
|
||||||
|
`chromahtml.Formatter` (Modus `WithClasses(true)`, also CSS-Klassen statt
|
||||||
|
Inline-Styles) als `<pre class="chroma">…</pre>` mit `<span>`-Tokens
|
||||||
|
rendern. Die eigentlichen Farben kommen aus der separat generierten
|
||||||
|
`web/assets/chroma.css` (siehe unten), nicht aus dem HTML selbst.
|
||||||
|
- Keine Sprache, oder eine, für die Chroma keinen Lexer kennt → fällt
|
||||||
|
zurück auf `renderDefaultFencedCodeBlock`, eine Kopie von goldmarks
|
||||||
|
Standardverhalten (`<pre><code class="language-xyz">`, funktioniert auch
|
||||||
|
ganz ohne Sprachangabe).
|
||||||
|
|
||||||
|
Zusätzlich aktiviert: `extension.GFM` (Tabellen, Strikethrough, Auto-Links,
|
||||||
|
Task-Listen), `extension.Footnote`, sowie `html.WithUnsafe()` — Letzteres
|
||||||
|
erlaubt rohes HTML inline/als Block im Markdown (siehe
|
||||||
|
[Markdown-Anleitung](markdown-guide.md#5-rohes-html-in-markdown)).
|
||||||
|
|
||||||
|
## 3. `internal/render` — aus Slides wird eine HTML-Seite
|
||||||
|
|
||||||
|
**Zuständig:** `[]parser.Slide` + ein paar Metadaten → ein vollständiges
|
||||||
|
HTML-Dokument. Das ist die **einzige** Stelle im Code, die das finale
|
||||||
|
Seiten-Markup erzeugt.
|
||||||
|
|
||||||
|
| Datei | Enthält |
|
||||||
|
| ------------- | ------- |
|
||||||
|
| `render.go` | `Page`-Struct (Input), `Render(w, page)` (Ausführung), interne `pageView`/`slideView`-Structs, die tatsächlich ans Template gehen |
|
||||||
|
|
||||||
|
`Render` (`render.go:50`) macht im Kern nur zwei Dinge:
|
||||||
|
|
||||||
|
1. Für jeden `parser.Slide` einen `slideView` bauen — dabei entsteht der
|
||||||
|
1-basierte `Index`, der später als `data-slide="n"` im HTML landet.
|
||||||
|
2. `pageTemplate.Execute(w, pageView{...})` — das eigentliche Template liegt
|
||||||
|
nicht in diesem Package, sondern wird über `web.Templates` (siehe unten)
|
||||||
|
eingebettet und beim Package-Init einmalig geparst
|
||||||
|
(`template.Must(template.ParseFS(...))`, `render.go:12`).
|
||||||
|
|
||||||
|
Wichtig: `Page.DevReload` (bool) ist der einzige Unterschied zwischen
|
||||||
|
Dev-Server- und Export-Ausgabe. Ist er `true`, bindet das Template
|
||||||
|
`live-reload.js` ein; beim Export bleibt er auf dem Zero-Value `false`.
|
||||||
|
Dadurch entsteht kein zweiter Codepfad — nur ein Flag.
|
||||||
|
|
||||||
|
`slideView.Incremental` reicht die Frontmatter-Einstellung `incremental`
|
||||||
|
unverändert durch: Ist sie gesetzt, bekommt das `<section>`-Element im
|
||||||
|
Template zusätzlich `data-incremental="true"`. Das ist die einzige
|
||||||
|
serverseitige Beteiligung an den Fragments — die eigentliche
|
||||||
|
Ein-/Ausblende-Logik läuft komplett im Browser (`nav.js`, siehe Abschnitt 6).
|
||||||
|
|
||||||
|
## 4. `web` — Templates und Assets
|
||||||
|
|
||||||
|
**Zuständig:** alles, was nicht Go-Code ist (HTML-Template, CSS, JS,
|
||||||
|
Mermaid-Library), so einbetten, dass am Ende ein einziges Binary ohne externe
|
||||||
|
Dateien entsteht.
|
||||||
|
|
||||||
|
| Datei/Ordner | Enthält |
|
||||||
|
| ------------------------------------ | ------- |
|
||||||
|
| `embed.go` | Zwei `embed.FS`-Variablen: `Templates` (bindet `templates/`) und `Assets` (bindet `assets/`) |
|
||||||
|
| `templates/page.html.tmpl` | Das eine HTML-Template für die ganze Seite: `<head>` mit Stylesheet-Links (`vendor/style.css`, `vendor/chroma.css`), `{{range .Slides}}` für die `<section>`-Elemente, Mermaid-Init-Script, Nav-Buttons/Klick-Zonen/Fortschrittsanzeige, `<script src="vendor/nav.js">`, optional `vendor/live-reload.js` |
|
||||||
|
| `assets/vendor/style.css` | CSS-Variablen (Farben/Spacing/Fonts), Dark-Mode via `prefers-color-scheme`, Basis-Styles, Slide-Layout (`section.slide` / `.active`), Nav-Styling |
|
||||||
|
| `assets/vendor/chroma.css` | Generierte Syntax-Highlighting-Farben für Codeblöcke (Chroma-Themes `github`/`github-dark`, hell/dunkel) — **wird nicht von Hand editiert**, siehe `gen_chroma_css.go` |
|
||||||
|
| `assets/vendor/nav.js` | Navigationslogik im Browser (siehe Abschnitt 6) |
|
||||||
|
| `assets/vendor/live-reload.js` | SSE-Client fürs Dev-Live-Reload (siehe Abschnitt 5) |
|
||||||
|
| `assets/vendor/mermaid.min.js` | Vendored Mermaid-Library, kein CDN nötig |
|
||||||
|
| `gen_chroma_css.go` | `//go:build ignore`-Generator-Script, das `assets/vendor/chroma.css` aus zwei Chroma-Styles erzeugt; läuft über `go generate ./web` (Direktive dazu in `embed.go`), nicht Teil des normalen Builds |
|
||||||
|
|
||||||
|
Alle Assets in `assets/vendor/` (eingebaute Styles/Scripts plus die
|
||||||
|
vendored Mermaid-Library) werden sowohl vom Dev-Server als auch vom
|
||||||
|
statischen Export unter dem URL-/Ordner-Präfix `vendor/` ausgeliefert bzw.
|
||||||
|
kopiert — dadurch landet bei `-build` außer der exportierten HTML-Datei
|
||||||
|
nichts von slidewalk lose im Zielordner.
|
||||||
|
|
||||||
|
`internal/render`, `internal/watch` und `internal/export` importieren dieses
|
||||||
|
Package, um an `Templates`/`Assets` zu kommen — es ist bewusst die einzige
|
||||||
|
Stelle, die `//go:embed` verwendet.
|
||||||
|
|
||||||
|
## 5. `internal/watch` — Dev-Server mit Live-Reload
|
||||||
|
|
||||||
|
**Zuständig:** die Seite über HTTP ausliefern und Browser automatisch neu
|
||||||
|
laden lassen, wenn sich eine Slide-Datei ändert.
|
||||||
|
|
||||||
|
| Datei | Enthält |
|
||||||
|
| ------------------ | ------- |
|
||||||
|
| `server.go` | `Server`-Struct (hält die aktuell gerenderte Seite im Speicher + den `fsnotify.Watcher`), `NewServer`, `reload`, `watchLoop`, `Handler`, `handleEvents`, `Serve` |
|
||||||
|
| `broker.go` | Minimaler Pub/Sub-Mechanismus (`broker`) für die SSE-Clients |
|
||||||
|
|
||||||
|
Ablauf beim Start (`NewServer`, `server.go:32`):
|
||||||
|
|
||||||
|
1. `fsnotify.NewWatcher()` + `watcher.Add(dir)` — beobachtet den
|
||||||
|
Content-Ordner auf Dateisystem-Ebene.
|
||||||
|
2. `s.reload()` (`server.go:65`) parst und rendert einmalig, damit ab dem
|
||||||
|
ersten Request sofort eine Seite im Speicher liegt (`s.page []byte`,
|
||||||
|
geschützt durch `s.mu sync.RWMutex`).
|
||||||
|
3. `go s.watchLoop()` — läuft im Hintergrund, solange der Prozess lebt.
|
||||||
|
|
||||||
|
`watchLoop` (`server.go:87`) reagiert nur auf
|
||||||
|
`Write|Create|Remove|Rename`-Events, ruft dann erneut `reload()` auf und
|
||||||
|
benachrichtigt via `s.broker.notify()` alle wartenden SSE-Clients. Schlägt
|
||||||
|
`reload()` fehl (z. B. kaputtes YAML mitten beim Tippen), wird der Fehler
|
||||||
|
verschluckt und die zuletzt funktionierende Version weiter ausgeliefert —
|
||||||
|
der Dev-Server stirbt nicht an einem Tippfehler.
|
||||||
|
|
||||||
|
`Handler()` (`server.go:114`) registriert:
|
||||||
|
|
||||||
|
- `/` → aktuelle `s.page` aus dem Speicher (kein Re-Parse pro Request nötig,
|
||||||
|
das übernimmt der Watcher)
|
||||||
|
- `/events` → SSE-Endpoint (`handleEvents`, `server.go:169`): abonniert einen
|
||||||
|
Kanal beim `broker`, schreibt bei jeder Benachrichtigung eine
|
||||||
|
`data: reload\n\n`-Zeile und flusht sofort. Wichtig: Header werden direkt
|
||||||
|
beim Verbindungsaufbau geflusht (`w.WriteHeader` + `flusher.Flush()`
|
||||||
|
**vor** der Warteschleife) — sonst hängt der Client, weil er auf die
|
||||||
|
Response-Header wartet, die sonst erst beim ersten Event geschrieben
|
||||||
|
würden.
|
||||||
|
- `/vendor/` → statische Assets (Styles, Scripts, vendored Mermaid-Library)
|
||||||
|
direkt aus `web.Assets` über `http.FileServer(http.FS(...))`.
|
||||||
|
- alles andere → Fallback im `/`-Handler: `isServableUserAsset` (`server.go`)
|
||||||
|
filtert `.md`-Dateien sowie Dotfiles/Dot-Ordner aus, alles übrige wird per
|
||||||
|
`http.FileServer(http.Dir(s.dir))` direkt aus dem Slide-Ordner ausgeliefert
|
||||||
|
— das macht z. B. `` im Dev-Server nutzbar, ohne das Bild
|
||||||
|
irgendwohin extra zu legen.
|
||||||
|
|
||||||
|
`Serve(addr, dir)` ist der einzige Einstiegspunkt, den `cmd/slidewalk`
|
||||||
|
kennt — er kapselt `NewServer` + `Handler` + `http.ListenAndServe`.
|
||||||
|
|
||||||
|
## 6. `web/assets/nav.js` — Navigation im Browser
|
||||||
|
|
||||||
|
Reines Vanilla-JS, IIFE, keine Abhängigkeiten außer der global geladenen
|
||||||
|
`mermaid`-Instanz. Kernstück ist die Funktion `show(index, fragment,
|
||||||
|
updateHash)`:
|
||||||
|
|
||||||
|
- setzt/entfernt die CSS-Klasse `active` auf dem jeweiligen
|
||||||
|
`section.slide`-Element (das eigentliche Ein-/Ausblenden übernimmt CSS,
|
||||||
|
siehe `section.slide.active` in `style.css`)
|
||||||
|
- ruft `applyFragments(index, fragment)` auf, das für den aktuellen Slide
|
||||||
|
die ersten `fragment` seiner `.fragment`-Elemente mit `fragment-visible`
|
||||||
|
markiert (Rest bleibt per CSS `opacity: 0` unsichtbar)
|
||||||
|
- aktualisiert die Fortschrittsanzeige (`.progress`-Element, Text `n / total`)
|
||||||
|
- schreibt den neuen Zustand in `location.hash` — `#3`, wenn kein Fragment
|
||||||
|
sichtbar ist, sonst `#3.2` (Slide 3, zweites Fragment sichtbar); außer bei
|
||||||
|
`updateHash: false`, das wird beim Reagieren auf `hashchange` verwendet,
|
||||||
|
um keine Endlosschleife zu erzeugen
|
||||||
|
- ruft `renderMermaid(index)` auf, das nur beim **ersten** Anzeigen eines
|
||||||
|
Slides `mermaid.run({ nodes: [...] })` gezielt für dessen
|
||||||
|
`pre.mermaid`-Elemente aufruft (Cache über `renderedMermaid`-Objekt) —
|
||||||
|
aus Performance-Gründen wird nicht das ganze Dokument beim Laden gerendert
|
||||||
|
|
||||||
|
### Fragments
|
||||||
|
|
||||||
|
`fragmentsFor(index)` ermittelt (und cached) die Fragment-Elemente eines
|
||||||
|
Slides in Dokumentreihenfolge: alle Elemente mit Klasse `fragment` (egal ob
|
||||||
|
per raw HTML vom Autor gesetzt, siehe
|
||||||
|
[Markdown-Anleitung](markdown-guide.md#6-fragments-bullet-points-einzeln-einblenden)),
|
||||||
|
plus — falls das `<section>` `data-incremental="true"` trägt (aus der
|
||||||
|
Frontmatter `incremental: true`) — automatisch dessen direkte
|
||||||
|
`<ul>`/`<ol>`-Listeneinträge, denen die Klasse `fragment` dafür zur
|
||||||
|
Laufzeit angehängt wird.
|
||||||
|
|
||||||
|
`next()`/`prev()` prüfen zuerst, ob der aktuelle Slide noch unsichtbare
|
||||||
|
bzw. sichtbare Fragmente hat, und blenden davon eines ein/aus, bevor sie
|
||||||
|
tatsächlich zum nächsten/vorherigen Slide wechseln (bei `prev()` startet
|
||||||
|
der vorherige Slide dabei mit **allen** Fragmenten sichtbar, wie man es aus
|
||||||
|
anderen Präsentations-Tools kennt).
|
||||||
|
|
||||||
|
### Übersichts-/Grid-Modus
|
||||||
|
|
||||||
|
`toggleGrid()`/`exitGrid()` schalten die Klasse `grid-mode` auf `<body>`
|
||||||
|
um; `style.css` übernimmt den Rest (Grid-Layout, alle Slides gleichzeitig
|
||||||
|
sichtbar und verkleinert, siehe `body.grid-mode` in `style.css`). Ein Klick
|
||||||
|
auf ein Slide-Element ruft, nur wenn `grid-mode` aktiv ist, `exitGrid()` und
|
||||||
|
`show()` für den jeweiligen Index auf. Taste `O` schaltet um, `Escape`
|
||||||
|
verlässt den Modus wieder; während `grid-mode` aktiv ist, werden Pfeiltasten
|
||||||
|
etc. ignoriert.
|
||||||
|
|
||||||
|
Event-Quellen, die alle letztlich `show()`/`next()`/`prev()` aufrufen:
|
||||||
|
`keydown` (Pfeile/Space/PageUp/PageDown/Home/End, plus `O`/`Escape` für den
|
||||||
|
Grid-Modus), Klicks auf `.nav-zone`/`.nav-button` sowie auf Slides im
|
||||||
|
Grid-Modus, und `hashchange` (Browser-Vor-/Zurück-Buttons oder manuelles
|
||||||
|
Editieren der URL, inkl. des `#slide.fragment`-Formats).
|
||||||
|
|
||||||
|
## 7. `internal/export` — statischer Export
|
||||||
|
|
||||||
|
**Zuständig:** dieselbe Seite wie der Dev-Server, aber einmalig als Dateien
|
||||||
|
auf die Platte geschrieben, ohne den Dev-only Live-Reload-Kram.
|
||||||
|
|
||||||
|
`Export(dir, outDir, outName)` (`export.go:29`):
|
||||||
|
|
||||||
|
1. `parser.ParseDir(dir)` — identischer Parse-Schritt wie im Dev-Server.
|
||||||
|
2. `writePage` (`export.go:103`) erzeugt die Ausgabedatei
|
||||||
|
(`outDir/outName`) und ruft `render.Render` mit einem `Page`, dessen
|
||||||
|
`DevReload` **nicht gesetzt** ist (Zero-Value `false`) — dadurch fehlt
|
||||||
|
das `<script src="vendor/live-reload.js">` automatisch im Export.
|
||||||
|
3. `exportedAssets` (`export.go:19`) listet explizit, welche Dateien aus
|
||||||
|
`web.Assets` mitkopiert werden: `vendor/style.css`, `vendor/chroma.css`,
|
||||||
|
`vendor/nav.js`, `vendor/mermaid.min.js` — alle landen dadurch unter
|
||||||
|
`outDir/vendor/`. `vendor/live-reload.js` steht bewusst **nicht** in
|
||||||
|
dieser Liste.
|
||||||
|
4. `copyAsset` (`export.go:119`) liest die Datei per `fs.ReadFile` aus dem
|
||||||
|
`embed.FS` und schreibt sie unverändert in den Zielordner (inkl.
|
||||||
|
`MkdirAll` für Unterordner wie `vendor/`).
|
||||||
|
5. `copyUserAssets` (`export.go:61`) durchläuft anschließend `dir` selbst per
|
||||||
|
`filepath.WalkDir` und kopiert alles außer `.md`-Dateien und
|
||||||
|
Dotfiles/Dot-Ordnern (`.git`, `.DS_Store`, …) 1:1 nach `outDir`, inklusive
|
||||||
|
Unterordnerstruktur. Dadurch landen z. B. per ``
|
||||||
|
referenzierte Bilder automatisch im Export, ohne dass sie irgendwo
|
||||||
|
fest aufgelistet werden müssen.
|
||||||
|
|
||||||
|
## 8. `cmd/slidewalk` — die CLI
|
||||||
|
|
||||||
|
**Zuständig:** Kommandozeilen-Argumente einlesen, validieren, und je nach
|
||||||
|
Flag `watch.Serve` oder `export.Export` aufrufen. Enthält bewusst keine
|
||||||
|
eigene Fachlogik.
|
||||||
|
|
||||||
|
`run(args)` (`main.go:21`) — als eigene Funktion getrennt von `main()`,
|
||||||
|
damit Fehler über einen normalen `error`-Rückgabewert statt über
|
||||||
|
verstreute `os.Exit`-Aufrufe behandelt werden:
|
||||||
|
|
||||||
|
1. `flag.NewFlagSet` mit `-build`, `-out`, `-addr`.
|
||||||
|
2. Erwartet genau **ein** positionales Argument (den Ordner-Pfad) —
|
||||||
|
`fs.NArg() != 1` ist ein Fehler. **Wichtig:** Go's `flag`-Paket stoppt das
|
||||||
|
Parsen beim ersten Nicht-Flag-Argument, Flags müssen also **vor** dem
|
||||||
|
Ordner stehen (`slidewalk -build ./dist ./ordner`, nicht umgekehrt).
|
||||||
|
3. `os.Stat(dir)` prüft, dass der Pfad existiert und ein Verzeichnis ist —
|
||||||
|
erst danach wird überhaupt geparst.
|
||||||
|
4. `*buildDir != ""` → `export.Export(...)`, sonst → `watch.Serve(...)`.
|
||||||
|
|
||||||
|
Alle Fehler werden mit `fmt.Errorf("...: %w", err)` gewrappt, sodass die
|
||||||
|
zugrunde liegende Ursache (z. B. das `os.Stat`- oder Parse-Error) beim
|
||||||
|
Ausgeben in `main()` erhalten bleibt.
|
||||||
|
|
||||||
|
## 9. Typischer Änderungs-Kompass
|
||||||
|
|
||||||
|
Kurzer Wegweiser, wo du ansetzt, je nachdem was du ändern willst:
|
||||||
|
|
||||||
|
| Ich will … | … ändere ich in |
|
||||||
|
| --------------------------------------------------------- | ---------------- |
|
||||||
|
| ein neues Frontmatter-Feld hinzufügen | `internal/parser/frontmatter.go` (Struct + Parsing), danach `internal/render/render.go` (falls es ins HTML soll) |
|
||||||
|
| das HTML-Grundgerüst der Seite ändern | `web/templates/page.html.tmpl` |
|
||||||
|
| Farben/Schriftgrößen/Spacing anpassen | `web/assets/style.css` (`:root`-Variablen) |
|
||||||
|
| Tastatur-/Maus-Navigation anpassen | `web/assets/nav.js` |
|
||||||
|
| ein weiteres Markdown-Feature aktivieren (z. B. Definition Lists) | `internal/parser/markdown.go`, `goldmark.WithExtensions(...)` |
|
||||||
|
| eine neue exportierte/servierte Asset-Datei hinzufügen | Datei nach `web/assets/` legen, dann in `internal/export/export.go` (`exportedAssets`) und ggf. `internal/watch/server.go` (`Handler`) eintragen |
|
||||||
|
| CLI-Flags ändern/ergänzen | `cmd/slidewalk/main.go` |
|
||||||
383
docs/markdown-guide.md
Normal file
383
docs/markdown-guide.md
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
# Anleitung: Slides schreiben
|
||||||
|
|
||||||
|
Diese Anleitung beschreibt den Aufbau der Markdown-Dateien für slidewalk im
|
||||||
|
Detail — von der Datei- und Ordnerstruktur über Frontmatter bis zu allen
|
||||||
|
unterstützten Markdown-Features, Mermaid-Diagrammen und rohem HTML.
|
||||||
|
|
||||||
|
Ein vollständiges, lauffähiges Beispiel-Deck liegt unter
|
||||||
|
[`examples/demo/`](../examples/demo/).
|
||||||
|
|
||||||
|
## 1. Datei- und Ordnerstruktur
|
||||||
|
|
||||||
|
- **Eine Datei = ein Slide.** Jede `.md`-Datei in dem Ordner, den du an
|
||||||
|
`slidewalk` übergibst, wird zu genau einem Slide.
|
||||||
|
- **Reihenfolge über Dateiname-Präfix.** Die Dateien werden alphabetisch
|
||||||
|
sortiert; ein numerischer Präfix mit Lücken macht das Einschieben späterer
|
||||||
|
Slides einfach, ohne bestehende Dateien umbenennen zu müssen:
|
||||||
|
|
||||||
|
```
|
||||||
|
content/
|
||||||
|
010-intro.md
|
||||||
|
020-features.md
|
||||||
|
025-nachtrag.md <- später eingeschoben, keine Umbenennung nötig
|
||||||
|
030-diagramm.md
|
||||||
|
040-outro.md
|
||||||
|
```
|
||||||
|
|
||||||
|
- Als Slides werden nur Dateien mit der Endung `.md` **direkt** im übergebenen
|
||||||
|
Ordner berücksichtigt (nicht rekursiv) — Unterordner-Inhalte werden nie als
|
||||||
|
eigene Slides eingelesen.
|
||||||
|
- Alle anderen Dateien im Ordner (Bilder, sonstige Assets), auch in
|
||||||
|
Unterordnern, werden sowohl vom Dev-Server als auch beim `-build`-Export
|
||||||
|
automatisch mit ausgeliefert bzw. mitkopiert — siehe [Abschnitt
|
||||||
|
„Bilder"](#bilder). Ausgenommen sind Dotfiles/Dot-Ordner (`.git`,
|
||||||
|
`.DS_Store`, …), die werden ignoriert.
|
||||||
|
|
||||||
|
## 2. Frontmatter
|
||||||
|
|
||||||
|
Jede Slide-Datei kann optional mit einem YAML-Frontmatter-Block beginnen,
|
||||||
|
eingerahmt von `---`-Zeilen:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: Meine Überschrift
|
||||||
|
class: intro
|
||||||
|
notes: Nur für die Sprechernotizen gedacht.
|
||||||
|
skip: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Der eigentliche Slide-Inhalt
|
||||||
|
```
|
||||||
|
|
||||||
|
Fehlt das Frontmatter komplett, ist das kein Fehler — die Datei wird einfach
|
||||||
|
komplett als Markdown-Body behandelt.
|
||||||
|
|
||||||
|
| Feld | Typ | Standard | Bedeutung |
|
||||||
|
| ------- | ------ | -------- | -------------------------------------------------------------------------- |
|
||||||
|
| `title` | string | leer | Titel des Slides. Aktuell informativ, wird nicht automatisch ins Slide-HTML gerendert. |
|
||||||
|
| `class` | string | leer | Wird als zusätzliche CSS-Klasse auf das `<section>`-Element des Slides angewendet (z. B. für eigene Layout-Varianten in `style.css`). |
|
||||||
|
| `notes` | string | leer | Sprechernotizen. Werden mitgerendert (`<div class="notes">`), aber per CSS standardmäßig ausgeblendet (`display: none`). |
|
||||||
|
| `skip` | bool | `false` | Bei `true` wird die Datei komplett übersprungen — taucht weder im Dev-Server noch im Export auf. Praktisch, um Entwürfe im Ordner liegen zu lassen. |
|
||||||
|
| `incremental` | bool | `false` | Bei `true` werden die Top-Level-Listeneinträge (`<ul>`/`<ol>`) der Slide einzeln nacheinander eingeblendet ("Fragments"), siehe [Abschnitt 6](#6-fragments-bullet-points-einzeln-einblenden). |
|
||||||
|
|
||||||
|
### Eigene `class`-Werte nutzen
|
||||||
|
|
||||||
|
Der Wert von `class` landet 1:1 als zusätzliche CSS-Klasse im HTML:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
class: intro
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
erzeugt:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<section class="slide intro" data-slide="1">…</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
Damit du eigene Slide-Varianten stylen kannst, ergänze `web/assets/style.css`
|
||||||
|
um passende Regeln, z. B.:
|
||||||
|
|
||||||
|
```css
|
||||||
|
section.slide.intro h1 {
|
||||||
|
font-size: var(--font-size-xxl);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Markdown-Grundlagen
|
||||||
|
|
||||||
|
Der Markdown-Body jeder Datei wird mit [`goldmark`](https://github.com/yuin/goldmark)
|
||||||
|
inklusive GitHub-Flavored-Markdown-Erweiterungen gerendert. Es steht das
|
||||||
|
komplette gängige Markdown-Set zur Verfügung:
|
||||||
|
|
||||||
|
### Überschriften
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# H1
|
||||||
|
## H2
|
||||||
|
### H3
|
||||||
|
```
|
||||||
|
|
||||||
|
`h1`–`h3` haben eigene Größenstufen in `style.css` (`--font-size-xxl`,
|
||||||
|
`--font-size-xl`, `--font-size-lg`); `h4`–`h6` erben die Basisgröße.
|
||||||
|
|
||||||
|
### Absätze, Betonung, Durchstreichen
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
Normaler Text mit *kursiv*, **fett** und ~~durchgestrichen~~.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Listen
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- Punkt eins
|
||||||
|
- Punkt zwei
|
||||||
|
- verschachtelt
|
||||||
|
|
||||||
|
1. Erster Schritt
|
||||||
|
2. Zweiter Schritt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task-Listen (GFM)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- [x] Erledigt
|
||||||
|
- [ ] Offen
|
||||||
|
```
|
||||||
|
|
||||||
|
Rendert als Checkbox-Liste (die Checkboxen sind nicht interaktiv, rein
|
||||||
|
optisch).
|
||||||
|
|
||||||
|
### Links und Auto-Links
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
[slidewalk auf GitHub](https://github.com/) — expliziter Link.
|
||||||
|
|
||||||
|
https://example.com — nackte URLs werden automatisch verlinkt (Linkify).
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bilder
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
Bildpfade sind relativ zur ausgelieferten HTML-Datei zu verstehen, z. B. legst
|
||||||
|
du das Bild unter `img/bild.png` im Slide-Ordner ab und referenzierst es mit
|
||||||
|
``. Sowohl der Dev-Server als auch der
|
||||||
|
`-build`-Export liefern bzw. kopieren alle Dateien aus dem Slide-Ordner
|
||||||
|
automatisch mit (auch aus Unterordnern) — außer den `.md`-Dateien selbst und
|
||||||
|
Dotfiles/Dot-Ordnern (`.git`, `.DS_Store`, …). Ein manuelles Kopieren ist
|
||||||
|
nicht mehr nötig.
|
||||||
|
|
||||||
|
### Zitate
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
> Ein Blockquote für Zitate oder Hervorhebungen.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tabellen (GFM)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| Feature | Status |
|
||||||
|
| ------------ | ------ |
|
||||||
|
| Markdown | ✅ |
|
||||||
|
| Mermaid | ✅ |
|
||||||
|
| Live-Reload | ✅ |
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inline-Code und Codeblöcke
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
Ein `inline`-Code-Ausschnitt.
|
||||||
|
|
||||||
|
```go
|
||||||
|
func main() {
|
||||||
|
fmt.Println("hallo")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
Codeblöcke mit Sprachangabe (```go, ```js, …) werden automatisch
|
||||||
|
Syntax-highlighted, sofern [Chroma](https://github.com/alecthomas/chroma)
|
||||||
|
(die Highlighting-Bibliothek, die slidewalk dafür einbindet) die Sprache
|
||||||
|
kennt — das Highlighting passiert beim Parsen in Go, es ist also kein
|
||||||
|
zusätzliches JavaScript im Browser nötig. Farben kommen aus der
|
||||||
|
mitausgelieferten `chroma.css` und passen sich automatisch dem Dark Mode an
|
||||||
|
(Theme `github` hell / `github-dark` dunkel).
|
||||||
|
|
||||||
|
Kennt Chroma die angegebene Sprache nicht (oder es wurde gar keine
|
||||||
|
Sprache angegeben), fällt der Codeblock automatisch auf einfaches
|
||||||
|
`<pre><code class="language-<sprache>">` ohne Highlighting zurück —
|
||||||
|
funktioniert also in jedem Fall, nur eben ohne Farben.
|
||||||
|
|
||||||
|
### Fußnoten (GFM/PHP-Markdown-Extra-Stil)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
Eine Aussage mit Beleg.[^1]
|
||||||
|
|
||||||
|
[^1]: Die Fußnote selbst, wird am Ende des Slides gesammelt dargestellt.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Mermaid-Diagramme
|
||||||
|
|
||||||
|
Ein Codeblock mit der Sprache `mermaid` wird nicht als Code dargestellt,
|
||||||
|
sondern als Diagramm gerendert:
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Start] --> B{Frage?}
|
||||||
|
B -->|Ja| C[Weiter]
|
||||||
|
B -->|Nein| D[Stop]
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
Details dazu, wie das technisch funktioniert:
|
||||||
|
|
||||||
|
- Der Codeblock wird zu `<pre class="mermaid">…</pre>` statt des normalen
|
||||||
|
`<pre><code class="language-mermaid">…</code></pre>` — das ist das Format,
|
||||||
|
das `mermaid.js` erwartet.
|
||||||
|
- Mermaid wird lokal aus `web/assets/vendor/mermaid.min.js` geladen, kein
|
||||||
|
CDN-Zugriff nötig.
|
||||||
|
- Das Theme (`mermaid.initialize({ theme: …, themeVariables: … })`) wird
|
||||||
|
automatisch an die CSS-Variablen deines `style.css` angeglichen
|
||||||
|
(Hintergrund-, Text- und Akzentfarbe, Schriftart) und reagiert auf
|
||||||
|
Dark-Mode (`prefers-color-scheme`).
|
||||||
|
- Aus Performance-Gründen wird ein Diagramm erst gerendert, wenn der Slide,
|
||||||
|
auf dem es liegt, tatsächlich aktiv wird (`mermaid.run({ nodes: […] })`
|
||||||
|
gezielt pro Slide, nicht für das gesamte Dokument beim Laden).
|
||||||
|
|
||||||
|
Alle gängigen Mermaid-Diagrammtypen funktionieren, z. B.:
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
Alice->>Bob: Hallo Bob
|
||||||
|
Bob-->>Alice: Hallo Alice
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
```mermaid
|
||||||
|
pie title Anteile
|
||||||
|
"A" : 40
|
||||||
|
"B" : 35
|
||||||
|
"C" : 25
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
## 5. Rohes HTML in Markdown
|
||||||
|
|
||||||
|
Für Fälle, die reines Markdown nicht abdeckt (z. B. Layout-`<div>`s,
|
||||||
|
`<iframe>`s, Inline-Styles), ist rohes HTML — sowohl block- als auch
|
||||||
|
inline-artig — direkt im Markdown erlaubt. Das ist Markdown-Standardverhalten
|
||||||
|
und in slidewalk explizit aktiviert (`goldmark` läuft mit `html.WithUnsafe()`,
|
||||||
|
d. h. HTML wird **nicht** escaped, sondern unverändert übernommen).
|
||||||
|
|
||||||
|
### Block-HTML
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
<div class="side-by-side">
|
||||||
|
<div>
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
Normaler Markdown-Text funktioniert auch **innerhalb** von HTML-Blöcken,
|
||||||
|
solange eine Leerzeile davor steht.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
|
||||||
|
## Rechts
|
||||||
|
|
||||||
|
Zweite Spalte.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inline-HTML
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
Ein Wort mit <span style="color: var(--color-accent)">Akzentfarbe</span>
|
||||||
|
mitten im Satz, oder ein <br> für einen manuellen Zeilenumbruch.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Eingebettete Inhalte
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
<iframe src="https://example.com" width="100%" height="400" style="border:0"></iframe>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinweis:** Da rohes HTML ungefiltert übernommen wird, solltest du nur
|
||||||
|
Markdown-Dateien aus vertrauenswürdiger Quelle präsentieren (z. B. deinem
|
||||||
|
eigenen Repository) — genau wie bei jedem anderen selbst geschriebenen
|
||||||
|
HTML-Dokument.
|
||||||
|
|
||||||
|
## 6. Fragments (Bullet-Points einzeln einblenden)
|
||||||
|
|
||||||
|
Standardmäßig erscheint der komplette Inhalt einer Slide auf einmal. Für
|
||||||
|
Stichpunktlisten, die im Vortrag einzeln nacheinander aufgedeckt werden
|
||||||
|
sollen, gibt es zwei Wege:
|
||||||
|
|
||||||
|
**Automatisch für die ganze Liste**, per Frontmatter:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
incremental: true
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agenda
|
||||||
|
|
||||||
|
- Punkt eins
|
||||||
|
- Punkt zwei
|
||||||
|
- Punkt drei
|
||||||
|
```
|
||||||
|
|
||||||
|
Jeder Top-Level-Listeneintrag (`<ul>`/`<ol>` direkt in der Slide) wird dann
|
||||||
|
einzeln als "Fragment" behandelt: `→`/`Space` blendet zunächst das nächste
|
||||||
|
Fragment ein, statt sofort zum nächsten Slide zu springen; erst wenn alle
|
||||||
|
Fragmente sichtbar sind, geht es zum nächsten Slide weiter. `←` funktioniert
|
||||||
|
spiegelbildlich rückwärts. Der URL-Hash spiegelt den Fortschritt (z. B.
|
||||||
|
`#4.2` für Slide 4, zweites Fragment sichtbar).
|
||||||
|
|
||||||
|
**Gezielt für einzelne Elemente**, per raw HTML mit der Klasse `fragment`
|
||||||
|
(funktioniert unabhängig von `incremental`, auch für Absätze, Bilder,
|
||||||
|
einzelne Listeneinträge etc.):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
<p class="fragment">Dieser Absatz erscheint erst auf Tastendruck.</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Übersichts-/Grid-Modus
|
||||||
|
|
||||||
|
Taste `O` schaltet einen Übersichts-Modus um, der alle Slides gleichzeitig
|
||||||
|
als Miniaturansicht zeigt (nützlich, um in einem größeren Deck schnell zu
|
||||||
|
einem bestimmten Slide zu springen). Ein Klick auf ein Slide springt direkt
|
||||||
|
dorthin und verlässt den Modus; `Escape` oder erneutes `O` verlassen ihn
|
||||||
|
ohne Slide-Wechsel. Der Modus ist reines Client-JS/CSS (`.grid-mode` auf
|
||||||
|
`<body>`), es gibt keinen zusätzlichen Server-Endpunkt dafür.
|
||||||
|
|
||||||
|
## 8. Vollständiges Beispiel
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: Roadmap
|
||||||
|
class: roadmap
|
||||||
|
notes: Vor diesem Slide kurz auf offene Fragen aus dem letzten Meeting eingehen.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap Q3
|
||||||
|
|
||||||
|
- [x] Parser & Renderer
|
||||||
|
- [x] Mermaid-Support
|
||||||
|
- [ ] Presenter-Notizen-Ansicht
|
||||||
|
|
||||||
|
| Meilenstein | Status |
|
||||||
|
| --------------- | ----------- |
|
||||||
|
| MVP | ✅ erledigt |
|
||||||
|
| Live-Reload | ✅ erledigt |
|
||||||
|
| v1.0 | 🚧 in Arbeit |
|
||||||
|
|
||||||
|
> Details zur Timeline gerne im Anschluss.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
gantt
|
||||||
|
title Grobe Timeline
|
||||||
|
dateFormat YYYY-MM-DD
|
||||||
|
section MVP
|
||||||
|
Parser & Renderer :done, 2026-06-01, 14d
|
||||||
|
Navigation & CSS :done, 2026-06-15, 10d
|
||||||
|
section v1.0
|
||||||
|
Presenter View :active, 2026-07-01, 10d
|
||||||
|
```
|
||||||
|
|
||||||
|
<div style="font-size: var(--font-size-sm); color: var(--color-muted)">
|
||||||
|
Letzte Aktualisierung: 2026-07-08
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Weitere lauffähige Beispiele findest du in [`examples/demo/`](../examples/demo/).
|
||||||
12
examples/demo/010-intro.md
Normal file
12
examples/demo/010-intro.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
---
|
||||||
|
title: Willkommen
|
||||||
|
class: intro
|
||||||
|
---
|
||||||
|
|
||||||
|
# slidewalk
|
||||||
|
|
||||||
|
Ein schlankes, selbstgebautes Tool zum Präsentieren von Markdown-Slides im Browser.
|
||||||
|
|
||||||
|
- Nur Markdown als Quelle
|
||||||
|
- Ein einziges Binary
|
||||||
|
- Mermaid-Support ohne CDN
|
||||||
15
examples/demo/020-features.md
Normal file
15
examples/demo/020-features.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
---
|
||||||
|
title: Features
|
||||||
|
notes: Kurz auf Frontmatter und Navigation eingehen, bevor es zum Diagramm geht.
|
||||||
|
incremental: true
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
1. Datei = ein Slide, sortiert nach Dateiname-Präfix
|
||||||
|
2. YAML-Frontmatter für `title`, `class`, `notes`, `skip`, `incremental`
|
||||||
|
3. Tastatur- und Maus-Navigation, URL-Hash spiegelt den aktuellen Slide
|
||||||
|
4. Live-Reload im Dev-Server über Server-Sent Events
|
||||||
|
5. Übersichts-Modus (Taste `O`) und Fragments wie diese Liste hier
|
||||||
|
|
||||||
|
> Speaker-Notes wie diese werden mitgerendert, aber standardmäßig per CSS versteckt.
|
||||||
28
examples/demo/025-code-beispiel.md
Normal file
28
examples/demo/025-code-beispiel.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
title: Codebeispiele
|
||||||
|
notes: Zeigt automatisches Syntax-Highlighting via Chroma, komplett ohne Client-JavaScript.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Codebeispiele
|
||||||
|
|
||||||
|
Fenced Codeblöcke mit Sprachangabe werden automatisch farbig hervorgehoben —
|
||||||
|
das passiert direkt beim Parsen in Go, im Browser läuft dafür kein
|
||||||
|
zusätzliches JavaScript:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func main() {
|
||||||
|
fmt.Println("Hallo, slidewalk!")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const total = slides.length;
|
||||||
|
console.log(`${total} Slides geladen`);
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne (oder mit unbekannter) Sprachangabe bleibt es bei schlichtem
|
||||||
|
Monospace-Text, ganz ohne Highlighting:
|
||||||
|
|
||||||
|
```
|
||||||
|
Einfacher Text ohne Sprachangabe.
|
||||||
|
```
|
||||||
14
examples/demo/030-ablauf.md
Normal file
14
examples/demo/030-ablauf.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
---
|
||||||
|
title: Ablauf
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vom Ordner zur Präsentation
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Markdown-Ordner] --> B[Parser: Frontmatter + goldmark]
|
||||||
|
B --> C[Renderer: eine HTML-Seite]
|
||||||
|
C --> D{--build gesetzt?}
|
||||||
|
D -->|Nein| E[Dev-Server mit Live-Reload]
|
||||||
|
D -->|Ja| F[Statischer Export]
|
||||||
|
```
|
||||||
15
examples/demo/035-bild.md
Normal file
15
examples/demo/035-bild.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
---
|
||||||
|
title: Bilder
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bilder einbinden
|
||||||
|
|
||||||
|
Bilder liegen einfach im Slide-Ordner (auch in Unterordnern) und werden von
|
||||||
|
Dev-Server und `-build`-Export automatisch mitgeliefert — kein manuelles
|
||||||
|
Kopieren nötig:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
8
examples/demo/040-danke.md
Normal file
8
examples/demo/040-danke.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
title: Danke
|
||||||
|
class: outro
|
||||||
|
---
|
||||||
|
|
||||||
|
# Danke
|
||||||
|
|
||||||
|
Quellcode und Frontmatter-Referenz: siehe [README](../../README.md).
|
||||||
5
examples/demo/img/logo.svg
Normal file
5
examples/demo/img/logo.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 140" width="200" height="140" role="img" aria-label="slidewalk-Logo">
|
||||||
|
<rect x="4" y="4" width="192" height="132" rx="12" fill="none" stroke="#3b5bdb" stroke-width="6"/>
|
||||||
|
<circle cx="45" cy="70" r="14" fill="#3b5bdb"/>
|
||||||
|
<path d="M75 40 L160 70 L75 100 Z" fill="#3b5bdb"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 339 B |
15
go.mod
Normal file
15
go.mod
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
module slidewalk
|
||||||
|
|
||||||
|
go 1.26.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/alecthomas/chroma/v2 v2.27.0
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1
|
||||||
|
github.com/yuin/goldmark v1.8.2
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||||
|
golang.org/x/sys v0.13.0 // indirect
|
||||||
|
)
|
||||||
20
go.sum
Normal file
20
go.sum
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||||
|
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||||
|
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
|
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||||
|
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||||
|
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
14
internal/export/doc.go
Normal file
14
internal/export/doc.go
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// Package export schreibt eine über internal/render erzeugte Präsentation
|
||||||
|
// als statische Dateien auf die Platte.
|
||||||
|
//
|
||||||
|
// Zuständigkeiten:
|
||||||
|
// - Die gerenderte HTML-Seite sowie style.css und vendor/mermaid.min.js in
|
||||||
|
// einen Zielordner kopieren
|
||||||
|
// - Den Dateinamen der exportierten HTML-Datei konfigurierbar machen
|
||||||
|
// - Sicherstellen, dass das im Dev-Modus eingebundene SSE-Client-Script
|
||||||
|
// nicht in den Export gelangt
|
||||||
|
//
|
||||||
|
// Nicht Zuständigkeit dieses Packages: das Parsen oder Rendern der Slides
|
||||||
|
// (siehe internal/parser, internal/render) sowie der Dev-Server-Betrieb
|
||||||
|
// (siehe internal/watch).
|
||||||
|
package export
|
||||||
131
internal/export/export.go
Normal file
131
internal/export/export.go
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
package export
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"slidewalk/internal/parser"
|
||||||
|
"slidewalk/internal/render"
|
||||||
|
"slidewalk/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// exportedAssets are the static assets (relative to web.Assets' "assets"
|
||||||
|
// root) copied into every export. The dev-only live-reload client script is
|
||||||
|
// deliberately not part of this list.
|
||||||
|
var exportedAssets = []string{
|
||||||
|
"vendor/style.css",
|
||||||
|
"vendor/chroma.css",
|
||||||
|
"vendor/nav.js",
|
||||||
|
"vendor/mermaid.min.js",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export renders the slide deck in dir and writes it, along with its
|
||||||
|
// required assets, into outDir. outName is the filename of the exported
|
||||||
|
// HTML document, e.g. "index.html".
|
||||||
|
func Export(dir, outDir, outName string) error {
|
||||||
|
slides, err := parser.ParseDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing slides in %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("creating output directory %s: %w", outDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writePage(outDir, outName, slides); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range exportedAssets {
|
||||||
|
dest := filepath.Join(outDir, filepath.FromSlash(name))
|
||||||
|
if err := copyAsset(name, dest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := copyUserAssets(dir, outDir); err != nil {
|
||||||
|
return fmt.Errorf("copying assets from %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyUserAssets copies every file in dir other than the slide ".md" files
|
||||||
|
// themselves (e.g. images referenced via Markdown like "")
|
||||||
|
// into outDir, preserving relative paths and subdirectory structure.
|
||||||
|
// Dotfiles and dot-directories (".git", ".DS_Store", ...) are skipped.
|
||||||
|
func copyUserAssets(dir, outDir string) error {
|
||||||
|
return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(d.Name(), ".") {
|
||||||
|
if d.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.EqualFold(filepath.Ext(d.Name()), ".md") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(dir, path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("computing relative path for %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return copyFile(path, filepath.Join(outDir, rel))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyFile copies the file at src to dest, creating any missing parent
|
||||||
|
// directories.
|
||||||
|
func copyFile(src, dest string) error {
|
||||||
|
data, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading %s: %w", src, err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("creating directory for %s: %w", dest, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dest, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("writing %s: %w", dest, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePage(outDir, outName string, slides []parser.Slide) error {
|
||||||
|
htmlPath := filepath.Join(outDir, outName)
|
||||||
|
f, err := os.Create(htmlPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating %s: %w", htmlPath, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
page := render.Page{Title: filepath.Base(filepath.Clean(outDir)), Slides: slides}
|
||||||
|
if err := render.Render(f, page); err != nil {
|
||||||
|
return fmt.Errorf("rendering page to %s: %w", htmlPath, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyAsset copies the embedded asset at "assets/<name>" to dest.
|
||||||
|
func copyAsset(name, dest string) error {
|
||||||
|
data, err := fs.ReadFile(web.Assets, path.Join("assets", name))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading embedded asset %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("creating directory for %s: %w", dest, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dest, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("writing %s: %w", dest, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
98
internal/export/export_test.go
Normal file
98
internal/export/export_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package export
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExport_WritesPageAndAssets(t *testing.T) {
|
||||||
|
srcDir := t.TempDir()
|
||||||
|
writeFile(t, srcDir, "010-intro.md", "---\ntitle: Intro\n---\n# Hallo\n")
|
||||||
|
writeFile(t, srcDir, "020-hidden.md", "---\nskip: true\n---\n# Hidden\n")
|
||||||
|
|
||||||
|
outDir := t.TempDir()
|
||||||
|
if err := Export(srcDir, outDir, "deck.html"); err != nil {
|
||||||
|
t.Fatalf("Export() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
wantContains string
|
||||||
|
wantNotContain string
|
||||||
|
}{
|
||||||
|
{name: "html page", path: "deck.html", wantContains: "<h1>Hallo</h1>"},
|
||||||
|
{name: "html excludes skipped slide", path: "deck.html", wantNotContain: "Hidden"},
|
||||||
|
{name: "html excludes dev live-reload script", path: "deck.html", wantNotContain: "live-reload.js"},
|
||||||
|
{name: "style.css copied", path: filepath.Join("vendor", "style.css"), wantContains: ":root"},
|
||||||
|
{name: "chroma.css copied", path: filepath.Join("vendor", "chroma.css"), wantContains: ".chroma"},
|
||||||
|
{name: "nav.js copied", path: filepath.Join("vendor", "nav.js"), wantContains: "mermaid.run"},
|
||||||
|
{name: "vendor mermaid copied", path: filepath.Join("vendor", "mermaid.min.js")},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(filepath.Join(outDir, tt.path))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading %s: %v", tt.path, err)
|
||||||
|
}
|
||||||
|
if tt.wantContains != "" && !strings.Contains(string(data), tt.wantContains) {
|
||||||
|
t.Errorf("%s does not contain %q", tt.path, tt.wantContains)
|
||||||
|
}
|
||||||
|
if tt.wantNotContain != "" && strings.Contains(string(data), tt.wantNotContain) {
|
||||||
|
t.Errorf("%s unexpectedly contains %q", tt.path, tt.wantNotContain)
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
t.Errorf("%s is empty", tt.path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(outDir, "vendor", "live-reload.js")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("live-reload.js should not be part of a static export, stat err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExport_CopiesUserAssets(t *testing.T) {
|
||||||
|
srcDir := t.TempDir()
|
||||||
|
writeFile(t, srcDir, "010-intro.md", "# Hallo\n\n")
|
||||||
|
if err := os.MkdirAll(filepath.Join(srcDir, "img"), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(srcDir, "img"), "foto.png", "fake-png-bytes")
|
||||||
|
writeFile(t, srcDir, ".DS_Store", "should-not-be-copied")
|
||||||
|
|
||||||
|
outDir := t.TempDir()
|
||||||
|
if err := Export(srcDir, outDir, "deck.html"); err != nil {
|
||||||
|
t.Fatalf("Export() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filepath.Join(outDir, "img", "foto.png"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading exported image: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "fake-png-bytes" {
|
||||||
|
t.Errorf("exported image content = %q, want %q", data, "fake-png-bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(outDir, ".DS_Store")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf(".DS_Store should not be exported, stat err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExport_InvalidSourceDir(t *testing.T) {
|
||||||
|
outDir := t.TempDir()
|
||||||
|
err := Export(filepath.Join(outDir, "does-not-exist"), outDir, "deck.html")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Export() error = nil, want error for missing source dir")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, dir, name, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(%s): %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
15
internal/parser/doc.go
Normal file
15
internal/parser/doc.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Package parser liest ein Verzeichnis mit Markdown-Slide-Dateien ein und
|
||||||
|
// wandelt sie in strukturierte Slide-Daten um.
|
||||||
|
//
|
||||||
|
// Zuständigkeiten:
|
||||||
|
// - Datei-Discovery: Slide-Dateien in einem Ordner finden und anhand ihres
|
||||||
|
// Dateiname-Präfixes (z. B. "010-intro.md") sortieren
|
||||||
|
// - YAML-Frontmatter der Felder title, class, notes und skip parsen
|
||||||
|
// - Markdown-Inhalt zu HTML rendern (inkl. GFM-Erweiterungen und
|
||||||
|
// Mermaid-Codeblöcken)
|
||||||
|
//
|
||||||
|
// Nicht Zuständigkeit dieses Packages: das Zusammenfügen mehrerer Slides zu
|
||||||
|
// einer HTML-Gesamtseite (siehe internal/render) sowie das Ausliefern oder
|
||||||
|
// Exportieren der gerenderten Präsentation (siehe internal/watch und
|
||||||
|
// internal/export).
|
||||||
|
package parser
|
||||||
73
internal/parser/frontmatter.go
Normal file
73
internal/parser/frontmatter.go
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// frontmatterDelim is the delimiter line that opens and closes a YAML
|
||||||
|
// frontmatter block at the top of a slide file.
|
||||||
|
var frontmatterDelim = []byte("---")
|
||||||
|
|
||||||
|
// frontmatter holds the metadata fields recognized in a slide's YAML
|
||||||
|
// frontmatter block.
|
||||||
|
type frontmatter struct {
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
Class string `yaml:"class"`
|
||||||
|
Notes string `yaml:"notes"`
|
||||||
|
Skip bool `yaml:"skip"`
|
||||||
|
Incremental bool `yaml:"incremental"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitFrontmatter separates a leading YAML frontmatter block (delimited by
|
||||||
|
// "---" lines) from the remaining Markdown body. If data has no frontmatter
|
||||||
|
// block, it returns a nil frontmatter slice and the original data as body.
|
||||||
|
func splitFrontmatter(data []byte) (fm []byte, body []byte) {
|
||||||
|
rest := bytes.TrimLeft(data, "\r\n")
|
||||||
|
if !bytes.HasPrefix(rest, frontmatterDelim) {
|
||||||
|
return nil, data
|
||||||
|
}
|
||||||
|
afterOpen := rest[len(frontmatterDelim):]
|
||||||
|
afterOpen = bytes.TrimLeft(afterOpen, " \t")
|
||||||
|
if len(afterOpen) > 0 && afterOpen[0] != '\n' && afterOpen[0] != '\r' {
|
||||||
|
// The "---" is followed by other content on the same line, so it is
|
||||||
|
// not a frontmatter delimiter.
|
||||||
|
return nil, data
|
||||||
|
}
|
||||||
|
nlIdx := bytes.IndexByte(afterOpen, '\n')
|
||||||
|
if nlIdx == -1 {
|
||||||
|
return nil, data
|
||||||
|
}
|
||||||
|
remainder := afterOpen[nlIdx+1:]
|
||||||
|
|
||||||
|
closeIdx := bytes.Index(remainder, []byte("\n---"))
|
||||||
|
if closeIdx == -1 {
|
||||||
|
return nil, data
|
||||||
|
}
|
||||||
|
fm = remainder[:closeIdx]
|
||||||
|
|
||||||
|
after := remainder[closeIdx+len("\n---"):]
|
||||||
|
after = bytes.TrimLeft(after, "\r")
|
||||||
|
if nl := bytes.IndexByte(after, '\n'); nl != -1 {
|
||||||
|
after = after[nl+1:]
|
||||||
|
} else {
|
||||||
|
after = nil
|
||||||
|
}
|
||||||
|
return fm, after
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFrontmatter extracts and parses the frontmatter block from data,
|
||||||
|
// returning the parsed metadata and the remaining Markdown body.
|
||||||
|
func parseFrontmatter(data []byte) (frontmatter, []byte, error) {
|
||||||
|
raw, body := splitFrontmatter(data)
|
||||||
|
var fm frontmatter
|
||||||
|
if raw == nil {
|
||||||
|
return fm, body, nil
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(raw, &fm); err != nil {
|
||||||
|
return fm, body, fmt.Errorf("parsing frontmatter: %w", err)
|
||||||
|
}
|
||||||
|
return fm, body, nil
|
||||||
|
}
|
||||||
91
internal/parser/frontmatter_test.go
Normal file
91
internal/parser/frontmatter_test.go
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSplitFrontmatter(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantFM string
|
||||||
|
wantBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no frontmatter",
|
||||||
|
input: "# Just markdown\n",
|
||||||
|
wantFM: "",
|
||||||
|
wantBody: "# Just markdown\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with frontmatter",
|
||||||
|
input: "---\ntitle: Hello\n---\n# Body\n",
|
||||||
|
wantFM: "title: Hello",
|
||||||
|
wantBody: "# Body\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "frontmatter with no trailing body",
|
||||||
|
input: "---\ntitle: Hello\n---\n",
|
||||||
|
wantFM: "title: Hello",
|
||||||
|
wantBody: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unterminated frontmatter treated as body",
|
||||||
|
input: "---\ntitle: Hello\n# Body\n",
|
||||||
|
wantFM: "",
|
||||||
|
wantBody: "---\ntitle: Hello\n# Body\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
fm, body := splitFrontmatter([]byte(tt.input))
|
||||||
|
if string(fm) != tt.wantFM {
|
||||||
|
t.Errorf("frontmatter = %q, want %q", fm, tt.wantFM)
|
||||||
|
}
|
||||||
|
if string(body) != tt.wantBody {
|
||||||
|
t.Errorf("body = %q, want %q", body, tt.wantBody)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFrontmatter(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want frontmatter
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "all fields",
|
||||||
|
input: "---\ntitle: Intro\nclass: center\nnotes: speaker notes\nskip: true\nincremental: true\n---\nbody\n",
|
||||||
|
want: frontmatter{Title: "Intro", Class: "center", Notes: "speaker notes", Skip: true, Incremental: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no frontmatter defaults to zero values",
|
||||||
|
input: "just body\n",
|
||||||
|
want: frontmatter{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid yaml",
|
||||||
|
input: "---\ntitle: [unterminated\n---\nbody\n",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, _, err := parseFrontmatter([]byte(tt.input))
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("frontmatter = %+v, want %+v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
159
internal/parser/markdown.go
Normal file
159
internal/parser/markdown.go
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/alecthomas/chroma/v2"
|
||||||
|
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
|
||||||
|
"github.com/alecthomas/chroma/v2/lexers"
|
||||||
|
"github.com/alecthomas/chroma/v2/styles"
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
"github.com/yuin/goldmark/ast"
|
||||||
|
"github.com/yuin/goldmark/extension"
|
||||||
|
"github.com/yuin/goldmark/renderer"
|
||||||
|
"github.com/yuin/goldmark/renderer/html"
|
||||||
|
"github.com/yuin/goldmark/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mermaidLang is the fenced-code-block language tag that triggers Mermaid
|
||||||
|
// rendering.
|
||||||
|
var mermaidLang = []byte("mermaid")
|
||||||
|
|
||||||
|
// chromaFormatter renders tokenized code as HTML using CSS classes (rather
|
||||||
|
// than inline styles), so the actual colors live in web/assets/chroma.css
|
||||||
|
// (see web/gen_chroma_css.go) and can vary between light and dark mode.
|
||||||
|
var chromaFormatter = chromahtml.New(chromahtml.WithClasses(true))
|
||||||
|
|
||||||
|
// chromaBaseStyle is passed to chromaFormatter.Format as required by its
|
||||||
|
// signature. In WithClasses mode its colors are unused (they live in the
|
||||||
|
// generated stylesheet instead); only its background-vs-foreground
|
||||||
|
// classification matters, which is the same across chroma styles.
|
||||||
|
var chromaBaseStyle = styles.Get("github")
|
||||||
|
|
||||||
|
// md is the shared goldmark instance used to convert slide Markdown bodies
|
||||||
|
// to HTML. It enables GFM (tables, strikethrough, autolinks, task lists),
|
||||||
|
// footnotes, raw inline/block HTML passthrough, and Mermaid code-block
|
||||||
|
// rendering.
|
||||||
|
var md = goldmark.New(
|
||||||
|
goldmark.WithExtensions(extension.GFM, extension.Footnote, mermaidExtension),
|
||||||
|
goldmark.WithRendererOptions(html.WithUnsafe()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// renderMarkdown converts a Markdown slide body to HTML.
|
||||||
|
func renderMarkdown(body []byte) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := md.Convert(body, &buf); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mermaidExtender registers a NodeRenderer that intercepts ```mermaid fenced
|
||||||
|
// code blocks so they render as <pre class="mermaid">...</pre> instead of
|
||||||
|
// the default <pre><code class="language-mermaid">...</code></pre>, since
|
||||||
|
// mermaid.js expects the diagram source as the direct text content of a
|
||||||
|
// <pre class="mermaid"> element.
|
||||||
|
type mermaidExtender struct{}
|
||||||
|
|
||||||
|
var mermaidExtension goldmark.Extender = mermaidExtender{}
|
||||||
|
|
||||||
|
func (mermaidExtender) Extend(m goldmark.Markdown) {
|
||||||
|
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
||||||
|
util.Prioritized(&mermaidRenderer{}, 100),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// mermaidRenderer renders fenced code blocks, special-casing the "mermaid"
|
||||||
|
// language and falling back to goldmark's standard rendering otherwise.
|
||||||
|
type mermaidRenderer struct{}
|
||||||
|
|
||||||
|
func (r *mermaidRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||||
|
reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *mermaidRenderer) renderFencedCodeBlock(
|
||||||
|
w util.BufWriter, source []byte, node ast.Node, entering bool,
|
||||||
|
) (ast.WalkStatus, error) {
|
||||||
|
n := node.(*ast.FencedCodeBlock)
|
||||||
|
language := n.Language(source)
|
||||||
|
|
||||||
|
if bytes.Equal(language, mermaidLang) {
|
||||||
|
if entering {
|
||||||
|
_, _ = w.WriteString(`<pre class="mermaid">`)
|
||||||
|
writeLinesEscaped(w, source, n)
|
||||||
|
} else {
|
||||||
|
_, _ = w.WriteString("</pre>\n")
|
||||||
|
}
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if lexer := lexers.Get(string(language)); lexer != nil {
|
||||||
|
if entering {
|
||||||
|
if err := renderHighlighted(w, source, n, lexer); err != nil {
|
||||||
|
return ast.WalkStop, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderDefaultFencedCodeBlock(w, source, n, entering)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderHighlighted writes a fenced code block as syntax-highlighted HTML
|
||||||
|
// using chroma, given a lexer already matched to the block's language tag.
|
||||||
|
// It writes the complete "<pre>...</pre>" in one call (on the "entering"
|
||||||
|
// walk step); the corresponding "!entering" step is a no-op, mirroring how
|
||||||
|
// renderDefaultFencedCodeBlock already writes the opening tag and content
|
||||||
|
// together and only the closing tag separately.
|
||||||
|
func renderHighlighted(w util.BufWriter, source []byte, n *ast.FencedCodeBlock, lexer chroma.Lexer) error {
|
||||||
|
var code bytes.Buffer
|
||||||
|
lines := n.Lines()
|
||||||
|
for i := 0; i < lines.Len(); i++ {
|
||||||
|
line := lines.At(i)
|
||||||
|
code.Write(line.Value(source))
|
||||||
|
}
|
||||||
|
|
||||||
|
iterator, err := lexer.Tokenise(nil, code.String())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tokenizing code block for syntax highlighting: %w", err)
|
||||||
|
}
|
||||||
|
if err := chromaFormatter.Format(w, chromaBaseStyle, iterator); err != nil {
|
||||||
|
return fmt.Errorf("formatting syntax-highlighted code block: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDefaultFencedCodeBlock reproduces goldmark's standard
|
||||||
|
// <pre><code class="language-...">...</code></pre> rendering for
|
||||||
|
// non-Mermaid fenced code blocks.
|
||||||
|
func renderDefaultFencedCodeBlock(
|
||||||
|
w util.BufWriter, source []byte, n *ast.FencedCodeBlock, entering bool,
|
||||||
|
) (ast.WalkStatus, error) {
|
||||||
|
if entering {
|
||||||
|
_, _ = w.WriteString("<pre><code")
|
||||||
|
if language := n.Language(source); language != nil {
|
||||||
|
_, _ = w.WriteString(` class="language-`)
|
||||||
|
_, _ = w.Write(util.EscapeHTML(language))
|
||||||
|
_, _ = w.WriteString(`"`)
|
||||||
|
}
|
||||||
|
_ = w.WriteByte('>')
|
||||||
|
writeLinesEscaped(w, source, n)
|
||||||
|
} else {
|
||||||
|
_, _ = w.WriteString("</code></pre>\n")
|
||||||
|
}
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeLinesEscaped writes a node's source lines HTML-escaped, matching
|
||||||
|
// goldmark's own code-block output. Escaping is safe here (and required, to
|
||||||
|
// prevent literal HTML injection from slide content) because a browser
|
||||||
|
// decodes entities back to their original characters before Mermaid reads
|
||||||
|
// an element's text content.
|
||||||
|
func writeLinesEscaped(w util.BufWriter, source []byte, n ast.Node) {
|
||||||
|
lines := n.Lines()
|
||||||
|
for i := 0; i < lines.Len(); i++ {
|
||||||
|
line := lines.At(i)
|
||||||
|
_, _ = w.Write(util.EscapeHTML(line.Value(source)))
|
||||||
|
}
|
||||||
|
}
|
||||||
101
internal/parser/markdown_test.go
Normal file
101
internal/parser/markdown_test.go
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderMarkdown_Mermaid(t *testing.T) {
|
||||||
|
input := "```mermaid\ngraph TD\n A --> B\n```\n"
|
||||||
|
|
||||||
|
got, err := renderMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderMarkdown() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := `<pre class="mermaid">graph TD
|
||||||
|
A --> B
|
||||||
|
</pre>
|
||||||
|
`
|
||||||
|
if string(got) != want {
|
||||||
|
t.Errorf("renderMarkdown() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMarkdown_KnownLanguageGetsSyntaxHighlighted(t *testing.T) {
|
||||||
|
input := "```go\nfunc main() {}\n```\n"
|
||||||
|
|
||||||
|
got, err := renderMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderMarkdown() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(got), `class="chroma"`) {
|
||||||
|
t.Errorf("renderMarkdown() = %q, want chroma-highlighted output for a known language", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(got), `class="mermaid"`) {
|
||||||
|
t.Errorf("renderMarkdown() = %q, non-mermaid block should not get mermaid class", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMarkdown_UnknownLanguageFallsBackToPlain(t *testing.T) {
|
||||||
|
input := "```this-language-does-not-exist-anywhere\nsome text\n```\n"
|
||||||
|
|
||||||
|
got, err := renderMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderMarkdown() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(got), `<pre><code class="language-this-language-does-not-exist-anywhere">`) {
|
||||||
|
t.Errorf("renderMarkdown() = %q, want standard fenced code block rendering for an unrecognized language", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMarkdown_NoLanguageFallsBackToPlain(t *testing.T) {
|
||||||
|
input := "```\nplain text\n```\n"
|
||||||
|
|
||||||
|
got, err := renderMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderMarkdown() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(string(got), `<pre><code>`) {
|
||||||
|
t.Errorf("renderMarkdown() = %q, want standard fenced code block rendering without a language", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMarkdown_GFMTable(t *testing.T) {
|
||||||
|
input := "| A | B |\n|---|---|\n| 1 | 2 |\n"
|
||||||
|
|
||||||
|
got, err := renderMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderMarkdown() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(got), "<table>") {
|
||||||
|
t.Errorf("renderMarkdown() = %q, want a rendered table", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMarkdown_Footnote(t *testing.T) {
|
||||||
|
input := "Text with a footnote.[^1]\n\n[^1]: The footnote body.\n"
|
||||||
|
|
||||||
|
got, err := renderMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderMarkdown() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(got), `class="footnotes"`) {
|
||||||
|
t.Errorf("renderMarkdown() = %q, want rendered footnotes section", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMarkdown_RawHTMLAllowed(t *testing.T) {
|
||||||
|
input := "<div class=\"custom\">raw html</div>\n"
|
||||||
|
|
||||||
|
got, err := renderMarkdown([]byte(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderMarkdown() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(got), `<div class="custom">raw html</div>`) {
|
||||||
|
t.Errorf("renderMarkdown() = %q, want raw HTML passed through unescaped", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
156
internal/parser/parser.go
Normal file
156
internal/parser/parser.go
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Slide is a single parsed slide: its frontmatter metadata plus the
|
||||||
|
// rendered HTML of its Markdown body.
|
||||||
|
type Slide struct {
|
||||||
|
// Filename is the base name of the source file, e.g. "010-intro.md".
|
||||||
|
Filename string
|
||||||
|
// Title is the frontmatter "title" field.
|
||||||
|
Title string
|
||||||
|
// Class is the frontmatter "class" field, applied as a CSS class on the
|
||||||
|
// slide's <section> element.
|
||||||
|
Class string
|
||||||
|
// Notes is the frontmatter "notes" field (speaker notes).
|
||||||
|
Notes string
|
||||||
|
// Incremental is the frontmatter "incremental" field. When true, the
|
||||||
|
// slide's top-level list items are revealed one at a time as
|
||||||
|
// navigation "fragments" instead of all at once.
|
||||||
|
Incremental bool
|
||||||
|
// HTML is the slide body rendered from Markdown to HTML.
|
||||||
|
HTML template.HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDir discovers Markdown slide files in dir, sorted by filename, parses
|
||||||
|
// each one, and returns the resulting slides. Files whose frontmatter sets
|
||||||
|
// skip: true are omitted from the result.
|
||||||
|
func ParseDir(dir string) ([]Slide, error) {
|
||||||
|
names, err := discoverSlideFiles(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("discovering slide files in %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slides := make([]Slide, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading slide file %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slide, skip, err := parseSlide(name, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing slide file %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slides = append(slides, slide)
|
||||||
|
}
|
||||||
|
return slides, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverSlideFiles lists the ".md" files directly inside dir, sorted by
|
||||||
|
// filename so that a numeric filename prefix (e.g. "010-intro.md",
|
||||||
|
// "020-...") determines slide order.
|
||||||
|
func discoverSlideFiles(dir string) ([]string, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var names []string
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filepath.Ext(entry.Name()) != ".md" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names = append(names, entry.Name())
|
||||||
|
}
|
||||||
|
sort.Slice(names, func(i, j int) bool { return lessNatural(names[i], names[j]) })
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lessNatural compares two filenames the way a human would: runs of digits
|
||||||
|
// are compared by numeric value rather than lexicographically, so
|
||||||
|
// "9-x.md" sorts before "10-x.md" and "999-x.md" before "1000-x.md"
|
||||||
|
// regardless of digit count. Non-digit runs are compared as-is.
|
||||||
|
func lessNatural(a, b string) bool {
|
||||||
|
for len(a) > 0 && len(b) > 0 {
|
||||||
|
aDigit, bDigit := unicode.IsDigit(rune(a[0])), unicode.IsDigit(rune(b[0]))
|
||||||
|
if aDigit && bDigit {
|
||||||
|
aNum, aRest := leadingDigits(a)
|
||||||
|
bNum, bRest := leadingDigits(b)
|
||||||
|
aVal, bVal := trimLeadingZeros(aNum), trimLeadingZeros(bNum)
|
||||||
|
if len(aVal) != len(bVal) {
|
||||||
|
return len(aVal) < len(bVal)
|
||||||
|
}
|
||||||
|
if aVal != bVal {
|
||||||
|
return aVal < bVal
|
||||||
|
}
|
||||||
|
a, b = aRest, bRest
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a[0] != b[0] {
|
||||||
|
return a[0] < b[0]
|
||||||
|
}
|
||||||
|
a, b = a[1:], b[1:]
|
||||||
|
}
|
||||||
|
return len(a) < len(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// leadingDigits splits s into its leading run of ASCII digits and the rest.
|
||||||
|
func leadingDigits(s string) (digits, rest string) {
|
||||||
|
i := 0
|
||||||
|
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return s[:i], s[i:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimLeadingZeros strips leading zeros from a digit string, keeping at
|
||||||
|
// least one digit, so numeric values compare correctly by length first.
|
||||||
|
func trimLeadingZeros(digits string) string {
|
||||||
|
i := 0
|
||||||
|
for i < len(digits)-1 && digits[i] == '0' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return digits[i:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSlide parses a single slide file's raw content into a Slide. The
|
||||||
|
// second return value reports whether the slide's frontmatter sets
|
||||||
|
// skip: true, in which case the Slide should be discarded by the caller.
|
||||||
|
func parseSlide(filename string, data []byte) (Slide, bool, error) {
|
||||||
|
fm, body, err := parseFrontmatter(data)
|
||||||
|
if err != nil {
|
||||||
|
return Slide{}, false, err
|
||||||
|
}
|
||||||
|
if fm.Skip {
|
||||||
|
return Slide{}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlBody, err := renderMarkdown(body)
|
||||||
|
if err != nil {
|
||||||
|
return Slide{}, false, fmt.Errorf("rendering markdown: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Slide{
|
||||||
|
Filename: filename,
|
||||||
|
Title: fm.Title,
|
||||||
|
Class: fm.Class,
|
||||||
|
Notes: fm.Notes,
|
||||||
|
Incremental: fm.Incremental,
|
||||||
|
HTML: template.HTML(htmlBody), //nolint:gosec // slide content is trusted local input, not user-supplied.
|
||||||
|
}, false, nil
|
||||||
|
}
|
||||||
137
internal/parser/parser_test.go
Normal file
137
internal/parser/parser_test.go
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDiscoverSlideFiles_SortsByPrefixAndIgnoresNonMarkdown(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeFiles(t, dir, map[string]string{
|
||||||
|
"020-second.md": "body",
|
||||||
|
"010-first.md": "body",
|
||||||
|
"030-third.md": "body",
|
||||||
|
"README.txt": "not a slide",
|
||||||
|
"assets/img.png": "binary",
|
||||||
|
})
|
||||||
|
|
||||||
|
got, err := discoverSlideFiles(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("discoverSlideFiles() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"010-first.md", "020-second.md", "030-third.md"}
|
||||||
|
if !equalStrings(got, want) {
|
||||||
|
t.Errorf("discoverSlideFiles() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverSlideFiles_SortsNumericallyAcrossDigitCounts(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeFiles(t, dir, map[string]string{
|
||||||
|
"10-b.md": "body",
|
||||||
|
"9-a.md": "body",
|
||||||
|
"1000-d.md": "body",
|
||||||
|
"999-c.md": "body",
|
||||||
|
})
|
||||||
|
|
||||||
|
got, err := discoverSlideFiles(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("discoverSlideFiles() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"9-a.md", "10-b.md", "999-c.md", "1000-d.md"}
|
||||||
|
if !equalStrings(got, want) {
|
||||||
|
t.Errorf("discoverSlideFiles() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDir_RespectsSkip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeFiles(t, dir, map[string]string{
|
||||||
|
"010-intro.md": "---\ntitle: Intro\n---\n# Intro\n",
|
||||||
|
"020-hidden.md": "---\ntitle: Hidden\nskip: true\n---\n# Hidden\n",
|
||||||
|
"030-outro.md": "---\ntitle: Outro\n---\n# Outro\n",
|
||||||
|
})
|
||||||
|
|
||||||
|
slides, err := ParseDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseDir() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(slides) != 2 {
|
||||||
|
t.Fatalf("len(slides) = %d, want 2", len(slides))
|
||||||
|
}
|
||||||
|
if slides[0].Title != "Intro" || slides[1].Title != "Outro" {
|
||||||
|
t.Errorf("slides = %+v, want Intro then Outro (Hidden skipped)", slides)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDir_OrderAndFields(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeFiles(t, dir, map[string]string{
|
||||||
|
"020-second.md": "---\ntitle: Second\nclass: center\nnotes: speak here\n---\n# Second\n",
|
||||||
|
"010-first.md": "---\ntitle: First\n---\n# First\n",
|
||||||
|
})
|
||||||
|
|
||||||
|
slides, err := ParseDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseDir() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(slides) != 2 {
|
||||||
|
t.Fatalf("len(slides) = %d, want 2", len(slides))
|
||||||
|
}
|
||||||
|
if slides[0].Filename != "010-first.md" || slides[1].Filename != "020-second.md" {
|
||||||
|
t.Fatalf("slides in wrong order: %+v", slides)
|
||||||
|
}
|
||||||
|
if slides[1].Class != "center" || slides[1].Notes != "speak here" {
|
||||||
|
t.Errorf("slides[1] = %+v, want Class=center Notes=\"speak here\"", slides[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDir_Incremental(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeFiles(t, dir, map[string]string{
|
||||||
|
"010-a.md": "---\nincremental: true\n---\n# A\n",
|
||||||
|
"020-b.md": "# B\n",
|
||||||
|
})
|
||||||
|
|
||||||
|
slides, err := ParseDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseDir() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slides[0].Incremental {
|
||||||
|
t.Errorf("slides[0].Incremental = false, want true")
|
||||||
|
}
|
||||||
|
if slides[1].Incremental {
|
||||||
|
t.Errorf("slides[1].Incremental = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFiles(t *testing.T, dir string, files map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
for name, content := range files {
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(%s) error = %v", filepath.Dir(path), err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(%s) error = %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalStrings(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
15
internal/render/doc.go
Normal file
15
internal/render/doc.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Package render fügt die von internal/parser gelieferten Slides zu einer
|
||||||
|
// einzigen HTML-Seite zusammen.
|
||||||
|
//
|
||||||
|
// Zuständigkeiten:
|
||||||
|
// - Ein Go-Template ausführen, das pro Slide ein <section data-slide="n">
|
||||||
|
// erzeugt
|
||||||
|
// - Frontmatter-Felder (z. B. class) als HTML-Attribute durchreichen
|
||||||
|
// - Als gemeinsame Basis für Dev-Server (internal/watch) und statischen
|
||||||
|
// Export (internal/export) dienen, damit es keinen doppelten
|
||||||
|
// Rendering-Code gibt
|
||||||
|
//
|
||||||
|
// Nicht Zuständigkeit dieses Packages: das Parsen von Markdown/Frontmatter
|
||||||
|
// (siehe internal/parser) sowie das Ausliefern über HTTP oder das Schreiben
|
||||||
|
// von Dateien auf die Platte.
|
||||||
|
package render
|
||||||
69
internal/render/render.go
Normal file
69
internal/render/render.go
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"slidewalk/internal/parser"
|
||||||
|
"slidewalk/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pageTemplate = template.Must(template.ParseFS(web.Templates, "templates/page.html.tmpl"))
|
||||||
|
|
||||||
|
// Page is the data needed to render a full slide deck as one HTML page.
|
||||||
|
type Page struct {
|
||||||
|
// Title is used as the document's <title>.
|
||||||
|
Title string
|
||||||
|
// Slides are the deck's slides, in presentation order.
|
||||||
|
Slides []parser.Slide
|
||||||
|
// DevReload, when true, includes the dev-only live-reload client script
|
||||||
|
// that listens for change notifications from the dev server. It must
|
||||||
|
// stay false for static exports.
|
||||||
|
DevReload bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// slideView is the per-slide data exposed to the page template. Index is
|
||||||
|
// the slide's 1-based position, used as the data-slide attribute.
|
||||||
|
type slideView struct {
|
||||||
|
Index int
|
||||||
|
Title string
|
||||||
|
Class string
|
||||||
|
Notes string
|
||||||
|
Incremental bool
|
||||||
|
HTML template.HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageView is the top-level data exposed to the page template.
|
||||||
|
type pageView struct {
|
||||||
|
Title string
|
||||||
|
Slides []slideView
|
||||||
|
DevReload bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render writes page as a single self-contained HTML document to w, with
|
||||||
|
// one <section data-slide="n"> per slide in presentation order.
|
||||||
|
//
|
||||||
|
// Render is the shared assembly step behind both the dev server
|
||||||
|
// (internal/watch) and the static exporter (internal/export): both call it
|
||||||
|
// to turn parsed slides into the final page, so the markup is only ever
|
||||||
|
// built in one place.
|
||||||
|
func Render(w io.Writer, page Page) error {
|
||||||
|
views := make([]slideView, len(page.Slides))
|
||||||
|
for i, s := range page.Slides {
|
||||||
|
views[i] = slideView{
|
||||||
|
Index: i + 1,
|
||||||
|
Title: s.Title,
|
||||||
|
Class: s.Class,
|
||||||
|
Notes: s.Notes,
|
||||||
|
Incremental: s.Incremental,
|
||||||
|
HTML: s.HTML,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := pageView{Title: page.Title, Slides: views, DevReload: page.DevReload}
|
||||||
|
if err := pageTemplate.Execute(w, data); err != nil {
|
||||||
|
return fmt.Errorf("rendering page: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
133
internal/render/render_test.go
Normal file
133
internal/render/render_test.go
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"slidewalk/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRender_OrderAndStructure(t *testing.T) {
|
||||||
|
page := Page{
|
||||||
|
Title: "My Deck",
|
||||||
|
Slides: []parser.Slide{
|
||||||
|
{Filename: "010-intro.md", Title: "Intro", HTML: template.HTML("<h1>Intro</h1>")},
|
||||||
|
{Filename: "020-diagram.md", Class: "center", Notes: "remember the punchline", HTML: template.HTML("<h1>Diagram</h1>")},
|
||||||
|
{Filename: "030-outro.md", HTML: template.HTML("<h1>Outro</h1>")},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := Render(&buf, page); err != nil {
|
||||||
|
t.Fatalf("Render() error = %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
|
||||||
|
if !strings.Contains(out, "<title>My Deck</title>") {
|
||||||
|
t.Errorf("output missing document title: %s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
idxFirst := strings.Index(out, `data-slide="1"`)
|
||||||
|
idxSecond := strings.Index(out, `data-slide="2"`)
|
||||||
|
idxThird := strings.Index(out, `data-slide="3"`)
|
||||||
|
if idxFirst == -1 || idxSecond == -1 || idxThird == -1 {
|
||||||
|
t.Fatalf("output missing expected data-slide attributes: %s", out)
|
||||||
|
}
|
||||||
|
if !(idxFirst < idxSecond && idxSecond < idxThird) {
|
||||||
|
t.Errorf("slides not in presentation order: %s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(out, `class="slide center"`) {
|
||||||
|
t.Errorf("output missing merged slide+custom class: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `<div class="notes">remember the punchline</div>`) {
|
||||||
|
t.Errorf("output missing rendered notes: %s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Count(out, "<h1>Intro</h1>") != 1 ||
|
||||||
|
strings.Count(out, "<h1>Diagram</h1>") != 1 ||
|
||||||
|
strings.Count(out, "<h1>Outro</h1>") != 1 {
|
||||||
|
t.Errorf("output missing one or more slide bodies: %s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(out, `<link rel="stylesheet" href="vendor/style.css">`) {
|
||||||
|
t.Errorf("output missing stylesheet link: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "mermaid.initialize") {
|
||||||
|
t.Errorf("output missing mermaid.initialize call: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `<script src="vendor/nav.js">`) {
|
||||||
|
t.Errorf("output missing nav.js script tag: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_IncrementalSlideGetsDataAttribute(t *testing.T) {
|
||||||
|
page := Page{
|
||||||
|
Slides: []parser.Slide{
|
||||||
|
{Filename: "010-intro.md", Incremental: true, HTML: template.HTML("<ul><li>A</li></ul>")},
|
||||||
|
{Filename: "020-outro.md", HTML: template.HTML("<h1>Outro</h1>")},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := Render(&buf, page); err != nil {
|
||||||
|
t.Fatalf("Render() error = %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
|
||||||
|
if !strings.Contains(out, `data-slide="1" data-incremental="true"`) {
|
||||||
|
t.Errorf("output missing data-incremental attribute on incremental slide: %s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, `data-slide="2" data-incremental`) {
|
||||||
|
t.Errorf("non-incremental slide should not get data-incremental attribute: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_NoNotesOmitsNotesElement(t *testing.T) {
|
||||||
|
page := Page{
|
||||||
|
Slides: []parser.Slide{
|
||||||
|
{Filename: "010-intro.md", HTML: template.HTML("<h1>Intro</h1>")},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := Render(&buf, page); err != nil {
|
||||||
|
t.Fatalf("Render() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(buf.String(), `class="notes"`) {
|
||||||
|
t.Errorf("output should omit notes element when Notes is empty: %s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_DevReloadScript(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
devReload bool
|
||||||
|
}{
|
||||||
|
{name: "omitted for static export", devReload: false},
|
||||||
|
{name: "included for dev server", devReload: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
page := Page{
|
||||||
|
Slides: []parser.Slide{
|
||||||
|
{Filename: "010-intro.md", HTML: template.HTML("<h1>Intro</h1>")},
|
||||||
|
},
|
||||||
|
DevReload: tt.devReload,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := Render(&buf, page); err != nil {
|
||||||
|
t.Fatalf("Render() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := strings.Contains(buf.String(), `<script src="vendor/live-reload.js">`)
|
||||||
|
if got != tt.devReload {
|
||||||
|
t.Errorf("live-reload script present = %v, want %v", got, tt.devReload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
44
internal/watch/broker.go
Normal file
44
internal/watch/broker.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package watch
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// broker fans out change notifications to any number of subscribed SSE
|
||||||
|
// clients.
|
||||||
|
type broker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
clients map[chan struct{}]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBroker() *broker {
|
||||||
|
return &broker{clients: make(map[chan struct{}]struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribe registers a new client and returns the channel it will receive
|
||||||
|
// notifications on. The caller must call unsubscribe when done.
|
||||||
|
func (b *broker) subscribe() chan struct{} {
|
||||||
|
ch := make(chan struct{}, 1)
|
||||||
|
b.mu.Lock()
|
||||||
|
b.clients[ch] = struct{}{}
|
||||||
|
b.mu.Unlock()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *broker) unsubscribe(ch chan struct{}) {
|
||||||
|
b.mu.Lock()
|
||||||
|
delete(b.clients, ch)
|
||||||
|
b.mu.Unlock()
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify wakes every subscribed client. Clients that are not currently
|
||||||
|
// waiting are skipped rather than blocked on.
|
||||||
|
func (b *broker) notify() {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
for ch := range b.clients {
|
||||||
|
select {
|
||||||
|
case ch <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
internal/watch/doc.go
Normal file
13
internal/watch/doc.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// Package watch stellt den Dev-Server samt Live-Reload für slidewalk bereit.
|
||||||
|
//
|
||||||
|
// Zuständigkeiten:
|
||||||
|
// - Einen net/http-Server betreiben, der die über internal/render erzeugte
|
||||||
|
// Seite sowie die Assets ausliefert
|
||||||
|
// - Den Content-Ordner per fsnotify auf Änderungen überwachen und bei
|
||||||
|
// Bedarf neu parsen/rendern (internal/parser, internal/render)
|
||||||
|
// - Einen SSE-Endpoint bereitstellen, über den Änderungen an verbundene
|
||||||
|
// Clients gemeldet werden
|
||||||
|
//
|
||||||
|
// Nicht Zuständigkeit dieses Packages: statischer Export ohne Server (siehe
|
||||||
|
// internal/export) sowie das eigentliche Parsen oder Rendern der Slides.
|
||||||
|
package watch
|
||||||
213
internal/watch/server.go
Normal file
213
internal/watch/server.go
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
package watch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
|
||||||
|
"slidewalk/internal/parser"
|
||||||
|
"slidewalk/internal/render"
|
||||||
|
"slidewalk/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server serves a slide deck over HTTP and pushes browser reloads via
|
||||||
|
// Server-Sent Events whenever a slide file changes on disk.
|
||||||
|
type Server struct {
|
||||||
|
dir string
|
||||||
|
watcher *fsnotify.Watcher
|
||||||
|
broker *broker
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
page []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer parses and renders the slide deck in dir, then starts watching
|
||||||
|
// dir for changes so the in-memory page stays up to date.
|
||||||
|
func NewServer(dir string) (*Server, error) {
|
||||||
|
watcher, err := fsnotify.NewWatcher()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating file watcher: %w", err)
|
||||||
|
}
|
||||||
|
if err := watcher.Add(dir); err != nil {
|
||||||
|
watcher.Close()
|
||||||
|
return nil, fmt.Errorf("watching %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
dir: dir,
|
||||||
|
watcher: watcher,
|
||||||
|
broker: newBroker(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.reload(); err != nil {
|
||||||
|
watcher.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
go s.watchLoop()
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the file watcher.
|
||||||
|
func (s *Server) Close() error {
|
||||||
|
return s.watcher.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// reload re-parses and re-renders the slide deck, replacing the in-memory
|
||||||
|
// page.
|
||||||
|
func (s *Server) reload() error {
|
||||||
|
slides, err := parser.ParseDir(s.dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing slides in %s: %w", s.dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
page := render.Page{
|
||||||
|
Title: filepath.Base(filepath.Clean(s.dir)),
|
||||||
|
Slides: slides,
|
||||||
|
DevReload: true,
|
||||||
|
}
|
||||||
|
if err := render.Render(&buf, page); err != nil {
|
||||||
|
return fmt.Errorf("rendering page: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.page = buf.Bytes()
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) watchLoop() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case event, ok := <-s.watcher.Events:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := s.reload(); err != nil {
|
||||||
|
// The edit left the deck in a state that fails to parse or
|
||||||
|
// render (e.g. invalid frontmatter); keep serving the last
|
||||||
|
// good version instead of crashing the dev server.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.broker.notify()
|
||||||
|
case _, ok := <-s.watcher.Errors:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler returns the HTTP handler serving the current rendered page, its
|
||||||
|
// static assets, and the SSE endpoint used for live reload.
|
||||||
|
func (s *Server) Handler() (http.Handler, error) {
|
||||||
|
assets, err := fs.Sub(web.Assets, "assets")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("preparing embedded assets: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userAssets := http.FileServer(http.Dir(s.dir))
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/" {
|
||||||
|
s.mu.RLock()
|
||||||
|
page := s.page
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write(page)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isServableUserAsset(r.URL.Path) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userAssets.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/events", s.handleEvents)
|
||||||
|
mux.Handle("/vendor/", http.FileServer(http.FS(assets)))
|
||||||
|
|
||||||
|
return mux, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isServableUserAsset reports whether urlPath may be served from the slide
|
||||||
|
// source directory as a user asset (e.g. an image referenced by a slide).
|
||||||
|
// Slide Markdown files and dotfiles/dot-directories (".git", ".DS_Store",
|
||||||
|
// ...) are excluded.
|
||||||
|
func isServableUserAsset(urlPath string) bool {
|
||||||
|
if strings.EqualFold(filepath.Ext(urlPath), ".md") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, part := range strings.Split(urlPath, "/") {
|
||||||
|
if strings.HasPrefix(part, ".") && part != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEvents serves the SSE endpoint that live-reload.js connects to. It
|
||||||
|
// pushes one event per change notification and otherwise blocks until the
|
||||||
|
// client disconnects.
|
||||||
|
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
ch := s.broker.subscribe()
|
||||||
|
defer s.broker.unsubscribe(ch)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
case _, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, "data: reload\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve starts an HTTP server on addr that serves the slide deck in dir,
|
||||||
|
// live-reloading connected browsers whenever a slide file changes.
|
||||||
|
func Serve(addr, dir string) error {
|
||||||
|
srv, err := NewServer(dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
handler, err := srv.Handler()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := http.ListenAndServe(addr, handler); err != nil {
|
||||||
|
return fmt.Errorf("dev server: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
212
internal/watch/server_test.go
Normal file
212
internal/watch/server_test.go
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
package watch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestServer(t *testing.T, dir string) (*Server, *httptest.Server) {
|
||||||
|
t.Helper()
|
||||||
|
srv, err := NewServer(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewServer() error = %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { srv.Close() })
|
||||||
|
|
||||||
|
handler, err := srv.Handler()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Handler() error = %v", err)
|
||||||
|
}
|
||||||
|
httpSrv := httptest.NewServer(handler)
|
||||||
|
t.Cleanup(httpSrv.Close)
|
||||||
|
|
||||||
|
return srv, httpSrv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "010-intro.md"), []byte("# Hallo\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, httpSrv := newTestServer(t, dir)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
wantStatus int
|
||||||
|
wantContains string
|
||||||
|
}{
|
||||||
|
{name: "index renders slides", path: "/", wantStatus: http.StatusOK, wantContains: "<h1>Hallo</h1>"},
|
||||||
|
{name: "index includes live-reload script", path: "/", wantStatus: http.StatusOK, wantContains: `<script src="vendor/live-reload.js">`},
|
||||||
|
{name: "style.css served", path: "/vendor/style.css", wantStatus: http.StatusOK, wantContains: ":root"},
|
||||||
|
{name: "chroma.css served", path: "/vendor/chroma.css", wantStatus: http.StatusOK, wantContains: ".chroma"},
|
||||||
|
{name: "nav.js served", path: "/vendor/nav.js", wantStatus: http.StatusOK, wantContains: "mermaid.run"},
|
||||||
|
{name: "live-reload.js served", path: "/vendor/live-reload.js", wantStatus: http.StatusOK, wantContains: "EventSource"},
|
||||||
|
{name: "vendor mermaid served", path: "/vendor/mermaid.min.js", wantStatus: http.StatusOK},
|
||||||
|
{name: "unknown path 404s", path: "/does-not-exist", wantStatus: http.StatusNotFound},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
resp, err := http.Get(httpSrv.URL + tt.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET %s: %v", tt.path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != tt.wantStatus {
|
||||||
|
t.Errorf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
|
||||||
|
}
|
||||||
|
if tt.wantContains != "" {
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading body: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), tt.wantContains) {
|
||||||
|
t.Errorf("body does not contain %q: %s", tt.wantContains, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_ServesUserAssets(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "010-intro.md"), []byte("# Hallo\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "img"), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "img", "foto.png"), []byte("fake-png-bytes"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "notes.md"), []byte("geheim"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, httpSrv := newTestServer(t, dir)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{name: "user image served", path: "/img/foto.png", wantStatus: http.StatusOK},
|
||||||
|
{name: "markdown file not served", path: "/notes.md", wantStatus: http.StatusNotFound},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
resp, err := http.Get(httpSrv.URL + tt.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET %s: %v", tt.path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != tt.wantStatus {
|
||||||
|
t.Errorf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_ReloadsOnFileChange(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
slidePath := filepath.Join(dir, "010-intro.md")
|
||||||
|
if err := os.WriteFile(slidePath, []byte("# First\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, httpSrv := newTestServer(t, dir)
|
||||||
|
|
||||||
|
get := func() string {
|
||||||
|
resp, err := http.Get(httpSrv.URL + "/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading body: %v", err)
|
||||||
|
}
|
||||||
|
return string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(get(), "<h1>First</h1>") {
|
||||||
|
t.Fatal("expected first version of slide content")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(slidePath, []byte("# Second\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for {
|
||||||
|
if strings.Contains(get(), "<h1>Second</h1>") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("timed out waiting for in-memory page to reflect file change")
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_NotifiesSSEClientsOnChange(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
slidePath := filepath.Join(dir, "010-intro.md")
|
||||||
|
if err := os.WriteFile(slidePath, []byte("# First\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, httpSrv := newTestServer(t, dir)
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, httpSrv.URL+"/events", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRequest: %v", err)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET /events: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
|
||||||
|
t.Fatalf("Content-Type = %q, want text/event-stream", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(slidePath, []byte("# Second\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
line string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
lineCh := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
reader := bufio.NewReader(resp.Body)
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
lineCh <- result{line, err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case res := <-lineCh:
|
||||||
|
if res.err != nil {
|
||||||
|
t.Fatalf("reading SSE stream: %v", res.err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.line, "reload") {
|
||||||
|
t.Errorf("SSE event = %q, want it to mention reload", res.line)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for SSE change notification")
|
||||||
|
}
|
||||||
|
}
|
||||||
160
web/assets/vendor/chroma.css
vendored
Normal file
160
web/assets/vendor/chroma.css
vendored
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
/* Generated by web/gen_chroma_css.go via `go generate ./web` — do not edit by hand. */
|
||||||
|
|
||||||
|
/* Background */ .bg { background-color: #f7f7f7; }
|
||||||
|
/* PreWrapper */ .chroma { background-color: #f7f7f7; -webkit-text-size-adjust: none; }
|
||||||
|
/* Error */ .chroma .err { color: #f6f8fa; background-color: #82071e }
|
||||||
|
/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit }
|
||||||
|
/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
|
||||||
|
/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
|
||||||
|
/* LineHighlight */ .chroma .hl { background-color: #dedede }
|
||||||
|
/* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f }
|
||||||
|
/* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f }
|
||||||
|
/* Line */ .chroma .line { display: flex; }
|
||||||
|
/* Keyword */ .chroma .k { color: #cf222e }
|
||||||
|
/* KeywordConstant */ .chroma .kc { color: #cf222e }
|
||||||
|
/* KeywordDeclaration */ .chroma .kd { color: #cf222e }
|
||||||
|
/* KeywordNamespace */ .chroma .kn { color: #cf222e }
|
||||||
|
/* KeywordPseudo */ .chroma .kp { color: #cf222e }
|
||||||
|
/* KeywordReserved */ .chroma .kr { color: #cf222e }
|
||||||
|
/* KeywordType */ .chroma .kt { color: #cf222e }
|
||||||
|
/* NameAttribute */ .chroma .na { color: #1f2328 }
|
||||||
|
/* NameClass */ .chroma .nc { color: #1f2328 }
|
||||||
|
/* NameConstant */ .chroma .no { color: #0550ae }
|
||||||
|
/* NameDecorator */ .chroma .nd { color: #0550ae }
|
||||||
|
/* NameEntity */ .chroma .ni { color: #6639ba }
|
||||||
|
/* NameLabel */ .chroma .nl { color: #990000; font-weight: bold }
|
||||||
|
/* NameNamespace */ .chroma .nn { color: #24292e }
|
||||||
|
/* NameOther */ .chroma .nx { color: #1f2328 }
|
||||||
|
/* NameTag */ .chroma .nt { color: #0550ae }
|
||||||
|
/* NameBuiltin */ .chroma .nb { color: #6639ba }
|
||||||
|
/* NameBuiltinPseudo */ .chroma .bp { color: #6a737d }
|
||||||
|
/* NameVariable */ .chroma .nv { color: #953800 }
|
||||||
|
/* NameVariableClass */ .chroma .vc { color: #953800 }
|
||||||
|
/* NameVariableGlobal */ .chroma .vg { color: #953800 }
|
||||||
|
/* NameVariableInstance */ .chroma .vi { color: #953800 }
|
||||||
|
/* NameVariableMagic */ .chroma .vm { color: #953800 }
|
||||||
|
/* NameFunction */ .chroma .nf { color: #6639ba }
|
||||||
|
/* NameFunctionMagic */ .chroma .fm { color: #6639ba }
|
||||||
|
/* LiteralString */ .chroma .s { color: #0a3069 }
|
||||||
|
/* LiteralStringAffix */ .chroma .sa { color: #0a3069 }
|
||||||
|
/* LiteralStringBacktick */ .chroma .sb { color: #0a3069 }
|
||||||
|
/* LiteralStringChar */ .chroma .sc { color: #0a3069 }
|
||||||
|
/* LiteralStringDelimiter */ .chroma .dl { color: #0a3069 }
|
||||||
|
/* LiteralStringDoc */ .chroma .sd { color: #0a3069 }
|
||||||
|
/* LiteralStringDouble */ .chroma .s2 { color: #0a3069 }
|
||||||
|
/* LiteralStringEscape */ .chroma .se { color: #0a3069 }
|
||||||
|
/* LiteralStringHeredoc */ .chroma .sh { color: #0a3069 }
|
||||||
|
/* LiteralStringInterpol */ .chroma .si { color: #0a3069 }
|
||||||
|
/* LiteralStringOther */ .chroma .sx { color: #0a3069 }
|
||||||
|
/* LiteralStringRegex */ .chroma .sr { color: #0a3069 }
|
||||||
|
/* LiteralStringSingle */ .chroma .s1 { color: #0a3069 }
|
||||||
|
/* LiteralStringSymbol */ .chroma .ss { color: #032f62 }
|
||||||
|
/* LiteralNumber */ .chroma .m { color: #0550ae }
|
||||||
|
/* LiteralNumberBin */ .chroma .mb { color: #0550ae }
|
||||||
|
/* LiteralNumberFloat */ .chroma .mf { color: #0550ae }
|
||||||
|
/* LiteralNumberHex */ .chroma .mh { color: #0550ae }
|
||||||
|
/* LiteralNumberInteger */ .chroma .mi { color: #0550ae }
|
||||||
|
/* LiteralNumberIntegerLong */ .chroma .il { color: #0550ae }
|
||||||
|
/* LiteralNumberOct */ .chroma .mo { color: #0550ae }
|
||||||
|
/* Operator */ .chroma .o { color: #0550ae }
|
||||||
|
/* OperatorWord */ .chroma .ow { color: #0550ae }
|
||||||
|
/* OperatorReserved */ .chroma .or { color: #0550ae }
|
||||||
|
/* Punctuation */ .chroma .p { color: #1f2328 }
|
||||||
|
/* Comment */ .chroma .c { color: #57606a }
|
||||||
|
/* CommentHashbang */ .chroma .ch { color: #57606a }
|
||||||
|
/* CommentMultiline */ .chroma .cm { color: #57606a }
|
||||||
|
/* CommentSingle */ .chroma .c1 { color: #57606a }
|
||||||
|
/* CommentSpecial */ .chroma .cs { color: #57606a }
|
||||||
|
/* CommentPreproc */ .chroma .cp { color: #57606a }
|
||||||
|
/* CommentPreprocFile */ .chroma .cpf { color: #57606a }
|
||||||
|
/* GenericDeleted */ .chroma .gd { color: #82071e; background-color: #ffebe9 }
|
||||||
|
/* GenericEmph */ .chroma .ge { color: #1f2328 }
|
||||||
|
/* GenericInserted */ .chroma .gi { color: #116329; background-color: #dafbe1 }
|
||||||
|
/* GenericOutput */ .chroma .go { color: #1f2328 }
|
||||||
|
/* GenericUnderline */ .chroma .gl { text-decoration: underline }
|
||||||
|
/* TextWhitespace */ .chroma .w { color: #ffffff }
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
/* Background */ .bg { color: #e6edf3; background-color: #0d1117; }
|
||||||
|
/* PreWrapper */ .chroma { color: #e6edf3; background-color: #0d1117; -webkit-text-size-adjust: none; }
|
||||||
|
/* Error */ .chroma .err { color: #f85149 }
|
||||||
|
/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit }
|
||||||
|
/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
|
||||||
|
/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
|
||||||
|
/* LineHighlight */ .chroma .hl { background-color: #6e7681 }
|
||||||
|
/* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #737679 }
|
||||||
|
/* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #6e7681 }
|
||||||
|
/* Line */ .chroma .line { display: flex; }
|
||||||
|
/* Keyword */ .chroma .k { color: #ff7b72 }
|
||||||
|
/* KeywordConstant */ .chroma .kc { color: #79c0ff }
|
||||||
|
/* KeywordDeclaration */ .chroma .kd { color: #ff7b72 }
|
||||||
|
/* KeywordNamespace */ .chroma .kn { color: #ff7b72 }
|
||||||
|
/* KeywordPseudo */ .chroma .kp { color: #79c0ff }
|
||||||
|
/* KeywordReserved */ .chroma .kr { color: #ff7b72 }
|
||||||
|
/* KeywordType */ .chroma .kt { color: #ff7b72 }
|
||||||
|
/* NameClass */ .chroma .nc { color: #f0883e; font-weight: bold }
|
||||||
|
/* NameConstant */ .chroma .no { color: #79c0ff; font-weight: bold }
|
||||||
|
/* NameDecorator */ .chroma .nd { color: #d2a8ff; font-weight: bold }
|
||||||
|
/* NameEntity */ .chroma .ni { color: #ffa657 }
|
||||||
|
/* NameException */ .chroma .ne { color: #f0883e; font-weight: bold }
|
||||||
|
/* NameLabel */ .chroma .nl { color: #79c0ff; font-weight: bold }
|
||||||
|
/* NameNamespace */ .chroma .nn { color: #ff7b72 }
|
||||||
|
/* NameProperty */ .chroma .py { color: #79c0ff }
|
||||||
|
/* NameTag */ .chroma .nt { color: #7ee787 }
|
||||||
|
/* NameVariable */ .chroma .nv { color: #79c0ff }
|
||||||
|
/* NameVariableClass */ .chroma .vc { color: #79c0ff }
|
||||||
|
/* NameVariableGlobal */ .chroma .vg { color: #79c0ff }
|
||||||
|
/* NameVariableInstance */ .chroma .vi { color: #79c0ff }
|
||||||
|
/* NameVariableMagic */ .chroma .vm { color: #79c0ff }
|
||||||
|
/* NameFunction */ .chroma .nf { color: #d2a8ff; font-weight: bold }
|
||||||
|
/* NameFunctionMagic */ .chroma .fm { color: #d2a8ff; font-weight: bold }
|
||||||
|
/* Literal */ .chroma .l { color: #a5d6ff }
|
||||||
|
/* LiteralDate */ .chroma .ld { color: #79c0ff }
|
||||||
|
/* LiteralString */ .chroma .s { color: #a5d6ff }
|
||||||
|
/* LiteralStringAffix */ .chroma .sa { color: #79c0ff }
|
||||||
|
/* LiteralStringBacktick */ .chroma .sb { color: #a5d6ff }
|
||||||
|
/* LiteralStringChar */ .chroma .sc { color: #a5d6ff }
|
||||||
|
/* LiteralStringDelimiter */ .chroma .dl { color: #79c0ff }
|
||||||
|
/* LiteralStringDoc */ .chroma .sd { color: #a5d6ff }
|
||||||
|
/* LiteralStringDouble */ .chroma .s2 { color: #a5d6ff }
|
||||||
|
/* LiteralStringEscape */ .chroma .se { color: #79c0ff }
|
||||||
|
/* LiteralStringHeredoc */ .chroma .sh { color: #79c0ff }
|
||||||
|
/* LiteralStringInterpol */ .chroma .si { color: #a5d6ff }
|
||||||
|
/* LiteralStringOther */ .chroma .sx { color: #a5d6ff }
|
||||||
|
/* LiteralStringRegex */ .chroma .sr { color: #79c0ff }
|
||||||
|
/* LiteralStringSingle */ .chroma .s1 { color: #a5d6ff }
|
||||||
|
/* LiteralStringSymbol */ .chroma .ss { color: #a5d6ff }
|
||||||
|
/* LiteralNumber */ .chroma .m { color: #a5d6ff }
|
||||||
|
/* LiteralNumberBin */ .chroma .mb { color: #a5d6ff }
|
||||||
|
/* LiteralNumberFloat */ .chroma .mf { color: #a5d6ff }
|
||||||
|
/* LiteralNumberHex */ .chroma .mh { color: #a5d6ff }
|
||||||
|
/* LiteralNumberInteger */ .chroma .mi { color: #a5d6ff }
|
||||||
|
/* LiteralNumberIntegerLong */ .chroma .il { color: #a5d6ff }
|
||||||
|
/* LiteralNumberOct */ .chroma .mo { color: #a5d6ff }
|
||||||
|
/* Operator */ .chroma .o { color: #ff7b72; font-weight: bold }
|
||||||
|
/* OperatorWord */ .chroma .ow { color: #ff7b72; font-weight: bold }
|
||||||
|
/* OperatorReserved */ .chroma .or { color: #ff7b72; font-weight: bold }
|
||||||
|
/* Comment */ .chroma .c { color: #8b949e; font-style: italic }
|
||||||
|
/* CommentHashbang */ .chroma .ch { color: #8b949e; font-style: italic }
|
||||||
|
/* CommentMultiline */ .chroma .cm { color: #8b949e; font-style: italic }
|
||||||
|
/* CommentSingle */ .chroma .c1 { color: #8b949e; font-style: italic }
|
||||||
|
/* CommentSpecial */ .chroma .cs { color: #8b949e; font-weight: bold; font-style: italic }
|
||||||
|
/* CommentPreproc */ .chroma .cp { color: #8b949e; font-weight: bold; font-style: italic }
|
||||||
|
/* CommentPreprocFile */ .chroma .cpf { color: #8b949e; font-weight: bold; font-style: italic }
|
||||||
|
/* GenericDeleted */ .chroma .gd { color: #ffa198; background-color: #490202 }
|
||||||
|
/* GenericEmph */ .chroma .ge { font-style: italic }
|
||||||
|
/* GenericError */ .chroma .gr { color: #ffa198 }
|
||||||
|
/* GenericHeading */ .chroma .gh { color: #79c0ff; font-weight: bold }
|
||||||
|
/* GenericInserted */ .chroma .gi { color: #56d364; background-color: #0f5323 }
|
||||||
|
/* GenericOutput */ .chroma .go { color: #8b949e }
|
||||||
|
/* GenericPrompt */ .chroma .gp { color: #8b949e }
|
||||||
|
/* GenericStrong */ .chroma .gs { font-weight: bold }
|
||||||
|
/* GenericSubheading */ .chroma .gu { color: #79c0ff }
|
||||||
|
/* GenericTraceback */ .chroma .gt { color: #ff7b72 }
|
||||||
|
/* GenericUnderline */ .chroma .gl { text-decoration: underline }
|
||||||
|
/* TextWhitespace */ .chroma .w { color: #6e7681 }
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.chroma, pre.chroma code {
|
||||||
|
background: var(--color-code-bg);
|
||||||
|
}
|
||||||
12
web/assets/vendor/live-reload.js
vendored
Normal file
12
web/assets/vendor/live-reload.js
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
if (typeof EventSource === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var source = new EventSource("/events");
|
||||||
|
source.onmessage = function () {
|
||||||
|
location.reload();
|
||||||
|
};
|
||||||
|
})();
|
||||||
3843
web/assets/vendor/mermaid.min.js
vendored
Normal file
3843
web/assets/vendor/mermaid.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
181
web/assets/vendor/nav.js
vendored
Normal file
181
web/assets/vendor/nav.js
vendored
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var slides = Array.prototype.slice.call(document.querySelectorAll("section.slide"));
|
||||||
|
var total = slides.length;
|
||||||
|
var progressEl = document.querySelector(".progress");
|
||||||
|
var baseTitle = document.title;
|
||||||
|
var renderedMermaid = {};
|
||||||
|
var fragmentsCache = {};
|
||||||
|
var current = 1;
|
||||||
|
var fragmentIndex = 0;
|
||||||
|
var gridMode = false;
|
||||||
|
|
||||||
|
function clamp(n) {
|
||||||
|
if (n < 1) return 1;
|
||||||
|
if (n > total) return total;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fragmentsFor returns the "fragment" elements of the given 1-based slide
|
||||||
|
// index, in document order, computed and cached once per slide. A slide
|
||||||
|
// marked with data-incremental="true" gets its top-level list items
|
||||||
|
// auto-tagged as fragments in addition to any explicitly marked via raw
|
||||||
|
// HTML (class="fragment").
|
||||||
|
function fragmentsFor(index) {
|
||||||
|
if (fragmentsCache[index]) return fragmentsCache[index];
|
||||||
|
var slide = slides[index - 1];
|
||||||
|
if (!slide) return [];
|
||||||
|
|
||||||
|
if (slide.dataset.incremental === "true") {
|
||||||
|
var autoItems = slide.querySelectorAll(":scope > ul > li, :scope > ol > li");
|
||||||
|
Array.prototype.forEach.call(autoItems, function (li) {
|
||||||
|
li.classList.add("fragment");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var frags = Array.prototype.slice.call(slide.querySelectorAll(".fragment"));
|
||||||
|
frags.forEach(function (el, i) {
|
||||||
|
el.setAttribute("data-fragment-index", String(i + 1));
|
||||||
|
});
|
||||||
|
fragmentsCache[index] = frags;
|
||||||
|
return frags;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFragments(index, count) {
|
||||||
|
fragmentsFor(index).forEach(function (el, i) {
|
||||||
|
el.classList.toggle("fragment-visible", i < count);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHash() {
|
||||||
|
var raw = location.hash.replace("#", "");
|
||||||
|
var parts = raw.split(".");
|
||||||
|
var slide = parseInt(parts[0], 10);
|
||||||
|
var fragment = parseInt(parts[1], 10);
|
||||||
|
return {
|
||||||
|
slide: isNaN(slide) ? 1 : clamp(slide),
|
||||||
|
fragment: isNaN(fragment) ? 0 : Math.max(0, fragment)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMermaid(index) {
|
||||||
|
if (renderedMermaid[index]) return;
|
||||||
|
var slide = slides[index - 1];
|
||||||
|
if (!slide) return;
|
||||||
|
var nodes = slide.querySelectorAll("pre.mermaid");
|
||||||
|
if (nodes.length === 0) return;
|
||||||
|
renderedMermaid[index] = true;
|
||||||
|
mermaid.run({ nodes: Array.prototype.slice.call(nodes) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(index, fragment, updateHash) {
|
||||||
|
current = clamp(index);
|
||||||
|
var frags = fragmentsFor(current);
|
||||||
|
fragmentIndex = typeof fragment === "number" ? Math.min(Math.max(fragment, 0), frags.length) : 0;
|
||||||
|
|
||||||
|
slides.forEach(function (slide, i) {
|
||||||
|
slide.classList.toggle("active", i === current - 1);
|
||||||
|
});
|
||||||
|
applyFragments(current, fragmentIndex);
|
||||||
|
|
||||||
|
if (progressEl) {
|
||||||
|
progressEl.textContent = current + " / " + total;
|
||||||
|
}
|
||||||
|
var slideTitle = slides[current - 1].dataset.title;
|
||||||
|
document.title = slideTitle ? slideTitle + " · " + baseTitle : baseTitle;
|
||||||
|
if (updateHash !== false) {
|
||||||
|
location.hash = fragmentIndex > 0 ? current + "." + fragmentIndex : String(current);
|
||||||
|
}
|
||||||
|
renderMermaid(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
var frags = fragmentsFor(current);
|
||||||
|
if (fragmentIndex < frags.length) {
|
||||||
|
show(current, fragmentIndex + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
show(current + 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prev() {
|
||||||
|
if (fragmentIndex > 0) {
|
||||||
|
show(current, fragmentIndex - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var target = current - 1;
|
||||||
|
if (target < 1) return;
|
||||||
|
show(target, fragmentsFor(target).length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGrid() {
|
||||||
|
gridMode = !gridMode;
|
||||||
|
document.body.classList.toggle("grid-mode", gridMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitGrid() {
|
||||||
|
if (!gridMode) return;
|
||||||
|
gridMode = false;
|
||||||
|
document.body.classList.remove("grid-mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("hashchange", function () {
|
||||||
|
var h = parseHash();
|
||||||
|
show(h.slide, h.fragment, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "o" || e.key === "O") {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleGrid();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (gridMode) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
exitGrid();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowRight":
|
||||||
|
case " ":
|
||||||
|
case "PageDown":
|
||||||
|
e.preventDefault();
|
||||||
|
next();
|
||||||
|
break;
|
||||||
|
case "ArrowLeft":
|
||||||
|
case "PageUp":
|
||||||
|
e.preventDefault();
|
||||||
|
prev();
|
||||||
|
break;
|
||||||
|
case "Home":
|
||||||
|
e.preventDefault();
|
||||||
|
show(1, 0);
|
||||||
|
break;
|
||||||
|
case "End":
|
||||||
|
e.preventDefault();
|
||||||
|
show(total, 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".nav-zone.prev, .nav-button.prev").forEach(function (el) {
|
||||||
|
el.addEventListener("click", prev);
|
||||||
|
});
|
||||||
|
document.querySelectorAll(".nav-zone.next, .nav-button.next").forEach(function (el) {
|
||||||
|
el.addEventListener("click", next);
|
||||||
|
});
|
||||||
|
|
||||||
|
slides.forEach(function (slide, i) {
|
||||||
|
slide.addEventListener("click", function () {
|
||||||
|
if (!gridMode) return;
|
||||||
|
exitGrid();
|
||||||
|
show(i + 1, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var initial = parseHash();
|
||||||
|
show(initial.slide, initial.fragment, true);
|
||||||
|
})();
|
||||||
312
web/assets/vendor/style.css
vendored
Normal file
312
web/assets/vendor/style.css
vendored
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
:root {
|
||||||
|
--color-bg: #ffffff;
|
||||||
|
--color-fg: #1a1a1a;
|
||||||
|
--color-accent: #3b5bdb;
|
||||||
|
--color-muted: #6b7280;
|
||||||
|
--color-border: #e2e2e2;
|
||||||
|
--color-code-bg: #f4f4f5;
|
||||||
|
|
||||||
|
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
Helvetica, Arial, sans-serif;
|
||||||
|
--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Consolas,
|
||||||
|
"Liberation Mono", monospace;
|
||||||
|
|
||||||
|
--font-size-sm: 0.85rem;
|
||||||
|
--font-size-base: 1.25rem;
|
||||||
|
--font-size-lg: 1.75rem;
|
||||||
|
--font-size-xl: 2.5rem;
|
||||||
|
--font-size-xxl: 3.25rem;
|
||||||
|
|
||||||
|
--spacing-xs: 0.25rem;
|
||||||
|
--spacing-sm: 0.5rem;
|
||||||
|
--spacing-md: 1rem;
|
||||||
|
--spacing-lg: 2rem;
|
||||||
|
--spacing-xl: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-bg: #16171a;
|
||||||
|
--color-fg: #f2f2f2;
|
||||||
|
--color-accent: #7c9bff;
|
||||||
|
--color-muted: #9ca3af;
|
||||||
|
--color-border: #33353a;
|
||||||
|
--color-code-bg: #222327;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-fg);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.slide {
|
||||||
|
display: flex;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
overflow: auto;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 200ms ease, visibility 0s linear 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.slide.active {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: opacity 200ms ease, visibility 0s linear 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
section.slide {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fragment {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fragment.fragment-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fragment {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section.slide > * {
|
||||||
|
max-width: 50rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0 0 var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: var(--font-size-xxl);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
table,
|
||||||
|
blockquote,
|
||||||
|
pre {
|
||||||
|
margin: 0 0 var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
padding-left: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin-left: 0;
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border-left: 0.25rem solid var(--color-accent);
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: var(--font-family-mono);
|
||||||
|
font-size: 0.85em;
|
||||||
|
background: var(--color-code-bg);
|
||||||
|
padding: 0.15em 0.4em;
|
||||||
|
border-radius: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-family: var(--font-family-mono);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
background: var(--color-code-bg);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-radius: 0.5em;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.mermaid {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: var(--color-code-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-zone {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 15%;
|
||||||
|
z-index: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-zone.prev {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-zone.next {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-code-bg);
|
||||||
|
color: var(--color-fg);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button.prev {
|
||||||
|
left: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button.next {
|
||||||
|
right: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--spacing-md);
|
||||||
|
right: var(--spacing-md);
|
||||||
|
z-index: 2;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.grid-mode {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
height: auto;
|
||||||
|
min-height: 100%;
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.grid-mode .nav-zone,
|
||||||
|
body.grid-mode .nav-button,
|
||||||
|
body.grid-mode .progress {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.grid-mode section.slide {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: none;
|
||||||
|
width: auto;
|
||||||
|
height: 12rem;
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.grid-mode section.slide.active {
|
||||||
|
outline: 3px solid var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.grid-mode section.slide::before {
|
||||||
|
content: attr(data-slide);
|
||||||
|
position: absolute;
|
||||||
|
top: 0.4rem;
|
||||||
|
left: 0.4rem;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.2em 0.5em;
|
||||||
|
border-radius: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.grid-mode section.slide > * {
|
||||||
|
transform: scale(0.35);
|
||||||
|
transform-origin: top left;
|
||||||
|
max-width: none;
|
||||||
|
width: 285%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.grid-mode .fragment {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
20
web/embed.go
Normal file
20
web/embed.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Package web embeds the Go templates and static assets (CSS, vendored JS)
|
||||||
|
// that internal/render, internal/watch, and internal/export share to
|
||||||
|
// assemble and serve a slidewalk presentation.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
// Templates embeds the Go templates used to assemble the combined slide
|
||||||
|
// deck page.
|
||||||
|
//
|
||||||
|
//go:embed templates
|
||||||
|
var Templates embed.FS
|
||||||
|
|
||||||
|
// Assets embeds the static assets shipped alongside a rendered
|
||||||
|
// presentation: style.css, chroma.css, and the vendored Mermaid script.
|
||||||
|
//
|
||||||
|
//go:embed assets
|
||||||
|
var Assets embed.FS
|
||||||
|
|
||||||
|
//go:generate go run gen_chroma_css.go
|
||||||
65
web/gen_chroma_css.go
Normal file
65
web/gen_chroma_css.go
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
// Command gen_chroma_css regenerates assets/chroma.css, the syntax-highlighting
|
||||||
|
// stylesheet for fenced code blocks (see internal/parser/markdown.go). Run it
|
||||||
|
// via `go generate ./web` whenever the chosen Chroma styles should change.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
|
||||||
|
"github.com/alecthomas/chroma/v2/styles"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lightStyleName and darkStyleName are the Chroma styles used for syntax
|
||||||
|
// highlighting in, respectively, light and dark mode. "github"/"github-dark"
|
||||||
|
// is a matched light/dark pair, mirroring the codebase's own light/dark
|
||||||
|
// palette in web/assets/style.css.
|
||||||
|
const (
|
||||||
|
lightStyleName = "github"
|
||||||
|
darkStyleName = "github-dark"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
formatter := chromahtml.New(chromahtml.WithClasses(true))
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
out.WriteString("/* Generated by web/gen_chroma_css.go via `go generate ./web` — do not edit by hand. */\n\n")
|
||||||
|
|
||||||
|
if err := writeStyle(&out, formatter, lightStyleName); err != nil {
|
||||||
|
fail(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out.WriteString("\n@media (prefers-color-scheme: dark) {\n")
|
||||||
|
if err := writeStyle(&out, formatter, darkStyleName); err != nil {
|
||||||
|
fail(err)
|
||||||
|
}
|
||||||
|
out.WriteString("}\n")
|
||||||
|
|
||||||
|
// pre already carries the slide deck's own code background
|
||||||
|
// (--color-code-bg, matching plain, non-highlighted code blocks); this
|
||||||
|
// overrides Chroma's own background class so highlighted blocks look
|
||||||
|
// consistent with the rest of the deck instead of introducing a second
|
||||||
|
// background color.
|
||||||
|
out.WriteString("\npre.chroma, pre.chroma code {\n background: var(--color-code-bg);\n}\n")
|
||||||
|
|
||||||
|
if err := os.WriteFile("assets/vendor/chroma.css", out.Bytes(), 0o644); err != nil {
|
||||||
|
fail(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeStyle(w *bytes.Buffer, formatter *chromahtml.Formatter, name string) error {
|
||||||
|
style := styles.Get(name)
|
||||||
|
if style == nil {
|
||||||
|
return fmt.Errorf("unknown chroma style %q", name)
|
||||||
|
}
|
||||||
|
return formatter.WriteCSS(w, style)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(err error) {
|
||||||
|
fmt.Fprintln(os.Stderr, "gen_chroma_css:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
42
web/templates/page.html.tmpl
Normal file
42
web/templates/page.html.tmpl
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link rel="stylesheet" href="vendor/style.css">
|
||||||
|
<link rel="stylesheet" href="vendor/chroma.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{range .Slides}}<section class="slide{{if .Class}} {{.Class}}{{end}}" data-slide="{{.Index}}"{{if .Title}} data-title="{{.Title}}"{{end}}{{if .Incremental}} data-incremental="true"{{end}}>
|
||||||
|
{{.HTML}}{{if .Notes}}<div class="notes">{{.Notes}}</div>{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}<div class="nav-zone prev" aria-hidden="true"></div>
|
||||||
|
<div class="nav-zone next" aria-hidden="true"></div>
|
||||||
|
<button type="button" class="nav-button prev" aria-label="Vorheriger Slide">←</button>
|
||||||
|
<button type="button" class="nav-button next" aria-label="Nächster Slide">→</button>
|
||||||
|
<div class="progress"></div>
|
||||||
|
<script src="vendor/mermaid.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var styles = getComputedStyle(document.documentElement);
|
||||||
|
var cssVar = function (name) { return styles.getPropertyValue(name).trim(); };
|
||||||
|
var isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
theme: isDark ? "dark" : "default",
|
||||||
|
themeVariables: {
|
||||||
|
background: cssVar("--color-bg"),
|
||||||
|
primaryColor: cssVar("--color-bg"),
|
||||||
|
primaryTextColor: cssVar("--color-fg"),
|
||||||
|
primaryBorderColor: cssVar("--color-accent"),
|
||||||
|
lineColor: cssVar("--color-accent"),
|
||||||
|
fontFamily: cssVar("--font-family")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script src="vendor/nav.js"></script>
|
||||||
|
{{if .DevReload}}<script src="vendor/live-reload.js"></script>
|
||||||
|
{{end}}</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Reference in a new issue