318 lines
18 KiB
Markdown
318 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 eines einzelnen Slides (`parseSlide`) |
|
|
| `frontmatter.go` | YAML-Frontmatter-Handling: `splitFrontmatter` trennt den `---`-Block vom Markdown-Body, `parseFrontmatter` parst ihn in das `frontmatter`-Struct (`title`, `class`, `notes`, `skip`, `incremental`) |
|
|
| `markdown.go` | Die `goldmark`-Instanz (`md`) mit allen Extensions, plus der custom Fenced-Code-Block-Renderer (Mermaid-Sonderfall + Chroma-Syntax-Highlighting) |
|
|
| `*_test.go` | Table-driven Tests je Datei |
|
|
|
|
### Ablauf von `ParseDir` (`parser.go:35`)
|
|
|
|
1. `discoverSlideFiles(dir)` listet alle `.md`-Dateien im Ordner (nicht
|
|
rekursiv) und sortiert sie alphabetisch — das ist der Mechanismus hinter
|
|
der Präfix-Reihenfolge (`010-...`, `020-...`).
|
|
2. Für jede Datei: `os.ReadFile` → `parseSlide(name, data)`.
|
|
3. `parseSlide` (`parser.go:134`) ruft zuerst `parseFrontmatter` auf. Ist
|
|
`skip: true` gesetzt, bricht es sofort ab (zweiter Rückgabewert `skip
|
|
bool`), ohne den Markdown-Body überhaupt zu rendern.
|
|
4. Sonst: `renderMarkdown(body)` aus `markdown.go` wandelt den Rest in HTML
|
|
um, das Ergebnis landet als `template.HTML` im `Slide`-Struct.
|
|
5. `ParseDir` filtert alle als "skip" markierten Slides aus der
|
|
zurückgegebenen Liste heraus.
|
|
|
|
### Fenced-Code-Blöcke: Mermaid & Syntax-Highlighting (`markdown.go`)
|
|
|
|
`goldmark` rendert Fenced-Code-Blöcke standardmäßig als
|
|
`<pre><code class="language-...">`. slidewalk braucht davon zwei
|
|
Sonderfälle, deshalb registriert `mermaidExtension` einen eigenen
|
|
`NodeRenderer` (`mermaidRenderer`) mit höherer Priorität als goldmarks
|
|
Standard-Renderer für `ast.KindFencedCodeBlock` — er überschreibt dessen
|
|
Registrierung (`renderFencedCodeBlock`, `markdown.go`):
|
|
|
|
- Sprache `mermaid` → `<pre class="mermaid">…</pre>` **ohne** `<code>`-Wrapper,
|
|
das Format, das `mermaid.js` erwartet.
|
|
- Sonstige Sprache, für die `lexers.Get` (Chroma) einen Lexer findet →
|
|
`renderHighlighted` tokenisiert den Codeblock-Inhalt und lässt ihn per
|
|
`chromahtml.Formatter` (Modus `WithClasses(true)`, also CSS-Klassen statt
|
|
Inline-Styles) als `<pre class="chroma">…</pre>` mit `<span>`-Tokens
|
|
rendern. Die eigentlichen Farben kommen aus der separat generierten
|
|
`web/assets/chroma.css` (siehe unten), nicht aus dem HTML selbst.
|
|
- Keine Sprache, oder eine, für die Chroma keinen Lexer kennt → fällt
|
|
zurück auf `renderDefaultFencedCodeBlock`, eine Kopie von goldmarks
|
|
Standardverhalten (`<pre><code class="language-xyz">`, funktioniert auch
|
|
ganz ohne Sprachangabe).
|
|
|
|
Zusätzlich aktiviert: `extension.GFM` (Tabellen, Strikethrough, Auto-Links,
|
|
Task-Listen), `extension.Footnote`, sowie `html.WithUnsafe()` — Letzteres
|
|
erlaubt rohes HTML inline/als Block im Markdown (siehe
|
|
[Markdown-Anleitung](markdown-guide.md#5-rohes-html-in-markdown)).
|
|
|
|
## 3. `internal/render` — aus Slides wird eine HTML-Seite
|
|
|
|
**Zuständig:** `[]parser.Slide` + ein paar Metadaten → ein vollständiges
|
|
HTML-Dokument. Das ist die **einzige** Stelle im Code, die das finale
|
|
Seiten-Markup erzeugt.
|
|
|
|
| Datei | Enthält |
|
|
| ------------- | ------- |
|
|
| `render.go` | `Page`-Struct (Input), `Render(w, page)` (Ausführung), interne `pageView`/`slideView`-Structs, die tatsächlich ans Template gehen |
|
|
|
|
`Render` (`render.go:50`) macht im Kern nur zwei Dinge:
|
|
|
|
1. Für jeden `parser.Slide` einen `slideView` bauen — dabei entsteht der
|
|
1-basierte `Index`, der später als `data-slide="n"` im HTML landet.
|
|
2. `pageTemplate.Execute(w, pageView{...})` — das eigentliche Template liegt
|
|
nicht in diesem Package, sondern wird über `web.Templates` (siehe unten)
|
|
eingebettet und beim Package-Init einmalig geparst
|
|
(`template.Must(template.ParseFS(...))`, `render.go:12`).
|
|
|
|
Wichtig: `Page.DevReload` (bool) ist der einzige Unterschied zwischen
|
|
Dev-Server- und Export-Ausgabe. Ist er `true`, bindet das Template
|
|
`live-reload.js` ein; beim Export bleibt er auf dem Zero-Value `false`.
|
|
Dadurch entsteht kein zweiter Codepfad — nur ein Flag.
|
|
|
|
`slideView.Incremental` reicht die Frontmatter-Einstellung `incremental`
|
|
unverändert durch: Ist sie gesetzt, bekommt das `<section>`-Element im
|
|
Template zusätzlich `data-incremental="true"`. Das ist die einzige
|
|
serverseitige Beteiligung an den Fragments — die eigentliche
|
|
Ein-/Ausblende-Logik läuft komplett im Browser (`nav.js`, siehe Abschnitt 6).
|
|
|
|
## 4. `web` — Templates und Assets
|
|
|
|
**Zuständig:** alles, was nicht Go-Code ist (HTML-Template, CSS, JS,
|
|
Mermaid-Library), so einbetten, dass am Ende ein einziges Binary ohne externe
|
|
Dateien entsteht.
|
|
|
|
| Datei/Ordner | Enthält |
|
|
| ------------------------------------ | ------- |
|
|
| `embed.go` | Zwei `embed.FS`-Variablen: `Templates` (bindet `templates/`) und `Assets` (bindet `assets/`) |
|
|
| `templates/page.html.tmpl` | Das eine HTML-Template für die ganze Seite: `<head>` mit Stylesheet-Links (`vendor/style.css`, `vendor/chroma.css`), `{{range .Slides}}` für die `<section>`-Elemente, Mermaid-Init-Script, Nav-Buttons/Klick-Zonen/Fortschrittsanzeige, `<script src="vendor/nav.js">`, optional `vendor/live-reload.js` |
|
|
| `assets/vendor/style.css` | CSS-Variablen (Farben/Spacing/Fonts), Dark-Mode via `prefers-color-scheme`, Basis-Styles, Slide-Layout (`section.slide` / `.active`), Nav-Styling |
|
|
| `assets/vendor/chroma.css` | Generierte Syntax-Highlighting-Farben für Codeblöcke (Chroma-Themes `github`/`github-dark`, hell/dunkel) — **wird nicht von Hand editiert**, siehe `gen_chroma_css.go` |
|
|
| `assets/vendor/nav.js` | Navigationslogik im Browser (siehe Abschnitt 6) |
|
|
| `assets/vendor/live-reload.js` | SSE-Client fürs Dev-Live-Reload (siehe Abschnitt 5) |
|
|
| `assets/vendor/mermaid.min.js` | Vendored Mermaid-Library, kein CDN nötig |
|
|
| `gen_chroma_css.go` | `//go:build ignore`-Generator-Script, das `assets/vendor/chroma.css` aus zwei Chroma-Styles erzeugt; läuft über `go generate ./web` (Direktive dazu in `embed.go`), nicht Teil des normalen Builds |
|
|
|
|
Alle Assets in `assets/vendor/` (eingebaute Styles/Scripts plus die
|
|
vendored Mermaid-Library) werden sowohl vom Dev-Server als auch vom
|
|
statischen Export unter dem URL-/Ordner-Präfix `vendor/` ausgeliefert bzw.
|
|
kopiert — dadurch landet bei `-build` außer der exportierten HTML-Datei
|
|
nichts von slidewalk lose im Zielordner.
|
|
|
|
`internal/render`, `internal/watch` und `internal/export` importieren dieses
|
|
Package, um an `Templates`/`Assets` zu kommen — es ist bewusst die einzige
|
|
Stelle, die `//go:embed` verwendet.
|
|
|
|
## 5. `internal/watch` — Dev-Server mit Live-Reload
|
|
|
|
**Zuständig:** die Seite über HTTP ausliefern und Browser automatisch neu
|
|
laden lassen, wenn sich eine Slide-Datei ändert.
|
|
|
|
| Datei | Enthält |
|
|
| ------------------ | ------- |
|
|
| `server.go` | `Server`-Struct (hält die aktuell gerenderte Seite im Speicher + den `fsnotify.Watcher`), `NewServer`, `reload`, `watchLoop`, `Handler`, `handleEvents`, `Serve` |
|
|
| `broker.go` | Minimaler Pub/Sub-Mechanismus (`broker`) für die SSE-Clients |
|
|
|
|
Ablauf beim Start (`NewServer`, `server.go:32`):
|
|
|
|
1. `fsnotify.NewWatcher()` + `watcher.Add(dir)` — beobachtet den
|
|
Content-Ordner auf Dateisystem-Ebene.
|
|
2. `s.reload()` (`server.go:65`) parst und rendert einmalig, damit ab dem
|
|
ersten Request sofort eine Seite im Speicher liegt (`s.page []byte`,
|
|
geschützt durch `s.mu sync.RWMutex`).
|
|
3. `go s.watchLoop()` — läuft im Hintergrund, solange der Prozess lebt.
|
|
|
|
`watchLoop` (`server.go:87`) reagiert nur auf
|
|
`Write|Create|Remove|Rename`-Events, ruft dann erneut `reload()` auf und
|
|
benachrichtigt via `s.broker.notify()` alle wartenden SSE-Clients. Schlägt
|
|
`reload()` fehl (z. B. kaputtes YAML mitten beim Tippen), wird der Fehler
|
|
verschluckt und die zuletzt funktionierende Version weiter ausgeliefert —
|
|
der Dev-Server stirbt nicht an einem Tippfehler.
|
|
|
|
`Handler()` (`server.go:114`) registriert:
|
|
|
|
- `/` → aktuelle `s.page` aus dem Speicher (kein Re-Parse pro Request nötig,
|
|
das übernimmt der Watcher)
|
|
- `/events` → SSE-Endpoint (`handleEvents`, `server.go:169`): abonniert einen
|
|
Kanal beim `broker`, schreibt bei jeder Benachrichtigung eine
|
|
`data: reload\n\n`-Zeile und flusht sofort. Wichtig: Header werden direkt
|
|
beim Verbindungsaufbau geflusht (`w.WriteHeader` + `flusher.Flush()`
|
|
**vor** der Warteschleife) — sonst hängt der Client, weil er auf die
|
|
Response-Header wartet, die sonst erst beim ersten Event geschrieben
|
|
würden.
|
|
- `/vendor/` → statische Assets (Styles, Scripts, vendored Mermaid-Library)
|
|
direkt aus `web.Assets` über `http.FileServer(http.FS(...))`.
|
|
- alles andere → Fallback im `/`-Handler: `isServableUserAsset` (`server.go`)
|
|
filtert `.md`-Dateien sowie Dotfiles/Dot-Ordner aus, alles übrige wird per
|
|
`http.FileServer(http.Dir(s.dir))` direkt aus dem Slide-Ordner ausgeliefert
|
|
— das macht z. B. `` im Dev-Server nutzbar, ohne das Bild
|
|
irgendwohin extra zu legen.
|
|
|
|
`Serve(addr, dir)` ist der einzige Einstiegspunkt, den `cmd/slidewalk`
|
|
kennt — er kapselt `NewServer` + `Handler` + `http.ListenAndServe`.
|
|
|
|
## 6. `web/assets/nav.js` — Navigation im Browser
|
|
|
|
Reines Vanilla-JS, IIFE, keine Abhängigkeiten außer der global geladenen
|
|
`mermaid`-Instanz. Kernstück ist die Funktion `show(index, fragment,
|
|
updateHash)`:
|
|
|
|
- setzt/entfernt die CSS-Klasse `active` auf dem jeweiligen
|
|
`section.slide`-Element (das eigentliche Ein-/Ausblenden übernimmt CSS,
|
|
siehe `section.slide.active` in `style.css`)
|
|
- ruft `applyFragments(index, fragment)` auf, das für den aktuellen Slide
|
|
die ersten `fragment` seiner `.fragment`-Elemente mit `fragment-visible`
|
|
markiert (Rest bleibt per CSS `opacity: 0` unsichtbar)
|
|
- aktualisiert die Fortschrittsanzeige (`.progress`-Element, Text `n / total`)
|
|
- schreibt den neuen Zustand in `location.hash` — `#3`, wenn kein Fragment
|
|
sichtbar ist, sonst `#3.2` (Slide 3, zweites Fragment sichtbar); außer bei
|
|
`updateHash: false`, das wird beim Reagieren auf `hashchange` verwendet,
|
|
um keine Endlosschleife zu erzeugen
|
|
- ruft `renderMermaid(index)` auf, das nur beim **ersten** Anzeigen eines
|
|
Slides `mermaid.run({ nodes: [...] })` gezielt für dessen
|
|
`pre.mermaid`-Elemente aufruft (Cache über `renderedMermaid`-Objekt) —
|
|
aus Performance-Gründen wird nicht das ganze Dokument beim Laden gerendert
|
|
|
|
### Fragments
|
|
|
|
`fragmentsFor(index)` ermittelt (und cached) die Fragment-Elemente eines
|
|
Slides in Dokumentreihenfolge: alle Elemente mit Klasse `fragment` (egal ob
|
|
per raw HTML vom Autor gesetzt, siehe
|
|
[Markdown-Anleitung](markdown-guide.md#6-fragments-bullet-points-einzeln-einblenden)),
|
|
plus — falls das `<section>` `data-incremental="true"` trägt (aus der
|
|
Frontmatter `incremental: true`) — automatisch dessen direkte
|
|
`<ul>`/`<ol>`-Listeneinträge, denen die Klasse `fragment` dafür zur
|
|
Laufzeit angehängt wird.
|
|
|
|
`next()`/`prev()` prüfen zuerst, ob der aktuelle Slide noch unsichtbare
|
|
bzw. sichtbare Fragmente hat, und blenden davon eines ein/aus, bevor sie
|
|
tatsächlich zum nächsten/vorherigen Slide wechseln (bei `prev()` startet
|
|
der vorherige Slide dabei mit **allen** Fragmenten sichtbar, wie man es aus
|
|
anderen Präsentations-Tools kennt).
|
|
|
|
### Übersichts-/Grid-Modus
|
|
|
|
`toggleGrid()`/`exitGrid()` schalten die Klasse `grid-mode` auf `<body>`
|
|
um; `style.css` übernimmt den Rest (Grid-Layout, alle Slides gleichzeitig
|
|
sichtbar und verkleinert, siehe `body.grid-mode` in `style.css`). Ein Klick
|
|
auf ein Slide-Element ruft, nur wenn `grid-mode` aktiv ist, `exitGrid()` und
|
|
`show()` für den jeweiligen Index auf. Taste `O` schaltet um, `Escape`
|
|
verlässt den Modus wieder; während `grid-mode` aktiv ist, werden Pfeiltasten
|
|
etc. ignoriert.
|
|
|
|
Event-Quellen, die alle letztlich `show()`/`next()`/`prev()` aufrufen:
|
|
`keydown` (Pfeile/Space/PageUp/PageDown/Home/End, plus `O`/`Escape` für den
|
|
Grid-Modus), Klicks auf `.nav-zone`/`.nav-button` sowie auf Slides im
|
|
Grid-Modus, und `hashchange` (Browser-Vor-/Zurück-Buttons oder manuelles
|
|
Editieren der URL, inkl. des `#slide.fragment`-Formats).
|
|
|
|
## 7. `internal/export` — statischer Export
|
|
|
|
**Zuständig:** dieselbe Seite wie der Dev-Server, aber einmalig als Dateien
|
|
auf die Platte geschrieben, ohne den Dev-only Live-Reload-Kram.
|
|
|
|
`Export(dir, outDir, outName)` (`export.go:29`):
|
|
|
|
1. `parser.ParseDir(dir)` — identischer Parse-Schritt wie im Dev-Server.
|
|
2. `writePage` (`export.go:103`) erzeugt die Ausgabedatei
|
|
(`outDir/outName`) und ruft `render.Render` mit einem `Page`, dessen
|
|
`DevReload` **nicht gesetzt** ist (Zero-Value `false`) — dadurch fehlt
|
|
das `<script src="vendor/live-reload.js">` automatisch im Export.
|
|
3. `exportedAssets` (`export.go:19`) listet explizit, welche Dateien aus
|
|
`web.Assets` mitkopiert werden: `vendor/style.css`, `vendor/chroma.css`,
|
|
`vendor/nav.js`, `vendor/mermaid.min.js` — alle landen dadurch unter
|
|
`outDir/vendor/`. `vendor/live-reload.js` steht bewusst **nicht** in
|
|
dieser Liste.
|
|
4. `copyAsset` (`export.go:119`) liest die Datei per `fs.ReadFile` aus dem
|
|
`embed.FS` und schreibt sie unverändert in den Zielordner (inkl.
|
|
`MkdirAll` für Unterordner wie `vendor/`).
|
|
5. `copyUserAssets` (`export.go:61`) durchläuft anschließend `dir` selbst per
|
|
`filepath.WalkDir` und kopiert alles außer `.md`-Dateien und
|
|
Dotfiles/Dot-Ordnern (`.git`, `.DS_Store`, …) 1:1 nach `outDir`, inklusive
|
|
Unterordnerstruktur. Dadurch landen z. B. per ``
|
|
referenzierte Bilder automatisch im Export, ohne dass sie irgendwo
|
|
fest aufgelistet werden müssen.
|
|
|
|
## 8. `cmd/slidewalk` — die CLI
|
|
|
|
**Zuständig:** Kommandozeilen-Argumente einlesen, validieren, und je nach
|
|
Flag `watch.Serve` oder `export.Export` aufrufen. Enthält bewusst keine
|
|
eigene Fachlogik.
|
|
|
|
`run(args)` (`main.go:21`) — als eigene Funktion getrennt von `main()`,
|
|
damit Fehler über einen normalen `error`-Rückgabewert statt über
|
|
verstreute `os.Exit`-Aufrufe behandelt werden:
|
|
|
|
1. `flag.NewFlagSet` mit `-build`, `-out`, `-addr`.
|
|
2. Erwartet genau **ein** positionales Argument (den Ordner-Pfad) —
|
|
`fs.NArg() != 1` ist ein Fehler. **Wichtig:** Go's `flag`-Paket stoppt das
|
|
Parsen beim ersten Nicht-Flag-Argument, Flags müssen also **vor** dem
|
|
Ordner stehen (`slidewalk -build ./dist ./ordner`, nicht umgekehrt).
|
|
3. `os.Stat(dir)` prüft, dass der Pfad existiert und ein Verzeichnis ist —
|
|
erst danach wird überhaupt geparst.
|
|
4. `*buildDir != ""` → `export.Export(...)`, sonst → `watch.Serve(...)`.
|
|
|
|
Alle Fehler werden mit `fmt.Errorf("...: %w", err)` gewrappt, sodass die
|
|
zugrunde liegende Ursache (z. B. das `os.Stat`- oder Parse-Error) beim
|
|
Ausgeben in `main()` erhalten bleibt.
|
|
|
|
## 9. Typischer Änderungs-Kompass
|
|
|
|
Kurzer Wegweiser, wo du ansetzt, je nachdem was du ändern willst:
|
|
|
|
| Ich will … | … ändere ich in |
|
|
| --------------------------------------------------------- | ---------------- |
|
|
| ein neues Frontmatter-Feld hinzufügen | `internal/parser/frontmatter.go` (Struct + Parsing), danach `internal/render/render.go` (falls es ins HTML soll) |
|
|
| das HTML-Grundgerüst der Seite ändern | `web/templates/page.html.tmpl` |
|
|
| Farben/Schriftgrößen/Spacing anpassen | `web/assets/style.css` (`:root`-Variablen) |
|
|
| Tastatur-/Maus-Navigation anpassen | `web/assets/nav.js` |
|
|
| ein weiteres Markdown-Feature aktivieren (z. B. Definition Lists) | `internal/parser/markdown.go`, `goldmark.WithExtensions(...)` |
|
|
| eine neue exportierte/servierte Asset-Datei hinzufügen | Datei nach `web/assets/` legen, dann in `internal/export/export.go` (`exportedAssets`) und ggf. `internal/watch/server.go` (`Handler`) eintragen |
|
|
| CLI-Flags ändern/ergänzen | `cmd/slidewalk/main.go` |
|