slidewalk/docs/architektur.md
Tom 0fa66d9f48 Erweiterung und CSS
- Code bekommt größere Schrift
- In einer Datei können neue Slides erstellt werden
2026-07-16 22:46:03 +02:00

324 lines
18 KiB
Markdown

# 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 der Slides einer Datei (`parseSlideFile`) |
| `frontmatter.go` | YAML-Frontmatter-Handling: `splitFrontmatter` trennt den `---`-Block vom Markdown-Body, `parseFrontmatter` parst ihn in das `frontmatter`-Struct (`title`, `class`, `notes`, `skip`, `incremental`) |
| `split.go` | `splitSlideBreaks` teilt den Body einer Datei an `<!-- new slide -->`-Markierungszeilen in mehrere Teile, fence-bewusst (ignoriert Marker innerhalb von ```` ``` ````/`~~~`-Codeblöcken) |
| `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` → `parseSlideFile(name, data)`.
3. `parseSlideFile` (`parser.go:134`) ruft zuerst `parseFrontmatter` auf.
Ist `skip: true` gesetzt, bricht es sofort ab (leere Slide-Liste), ohne
den Markdown-Body überhaupt zu rendern.
4. Sonst: `splitSlideBreaks(body)` aus `split.go` teilt den Body an
`<!-- new slide -->`-Zeilen in einen oder mehrere Teile (ohne Marker: ein
Teil = der unveränderte Body). Für jeden Teil wandelt
`renderMarkdown(part)` aus `markdown.go` den Markdown-Text in HTML um;
das Ergebnis landet als `template.HTML` in einem eigenen `Slide`-Struct.
Alle Teile einer Datei teilen sich deren Frontmatter (`Title`, `Class`,
`Notes`, `Incremental`).
5. `ParseDir` reiht die Slides aller Dateien aneinander; als "skip"
markierte Dateien tragen keine Slides bei.
### 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` |