initial commit

This commit is contained in:
Tom 2026-07-15 20:14:07 +02:00
commit 5593d825b6
41 changed files with 7708 additions and 0 deletions

View 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: 12 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 (`![Alt](img/datei.png)`) 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
View 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

View 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: 12 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 (`![Alt](img/datei.png)`) 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
View 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
View 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
View 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. `![](img/foto.png)` 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 `![](img/foto.png)`
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
View 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
![Alt-Text](./bild.png)
```
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
`![Alt-Text](img/bild.png)`. 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/).

View 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

View 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.

View 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.
```

View 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
View 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
![slidewalk-Logo](img/logo.svg)
```
![slidewalk-Logo](img/logo.svg)

View file

@ -0,0 +1,8 @@
---
title: Danke
class: outro
---
# Danke
Quellcode und Frontmatter-Referenz: siehe [README](../../README.md).

View 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
View 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
View 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
View 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
View 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 "![](img/foto.png)")
// 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
}

View 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![Bild](img/foto.png)\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
View 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

View 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
}

View 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
View 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)))
}
}

View 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 --&gt; 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
View 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
}

View 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
View 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
View 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
}

View 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
View 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
View 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
View 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
}

View 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
View 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
View 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

File diff suppressed because one or more lines are too long

181
web/assets/vendor/nav.js vendored Normal file
View 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
View 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
View 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
View 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)
}

View 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">&#8592;</button>
<button type="button" class="nav-button next" aria-label="Nächster Slide">&#8594;</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>