slidewalk/docs/architektur.md
2026-07-15 20:14:07 +02:00

18 KiB

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; für Installation/Nutzung siehe 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:

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 Slides 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.ReadFileparseSlide(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).

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), 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