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)
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-...).- Für jede Datei:
os.ReadFile→parseSlide(name, data). parseSlide(parser.go:134) ruft zuerstparseFrontmatterauf. Istskip: truegesetzt, bricht es sofort ab (zweiter Rückgabewertskip bool), ohne den Markdown-Body überhaupt zu rendern.- Sonst:
renderMarkdown(body)ausmarkdown.gowandelt den Rest in HTML um, das Ergebnis landet alstemplate.HTMLimSlide-Struct. ParseDirfiltert 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, dasmermaid.jserwartet. - Sonstige Sprache, für die
lexers.Get(Chroma) einen Lexer findet →renderHighlightedtokenisiert den Codeblock-Inhalt und lässt ihn perchromahtml.Formatter(ModusWithClasses(true), also CSS-Klassen statt Inline-Styles) als<pre class="chroma">…</pre>mit<span>-Tokens rendern. Die eigentlichen Farben kommen aus der separat generiertenweb/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:
- Für jeden
parser.SlideeinenslideViewbauen — dabei entsteht der 1-basierteIndex, der später alsdata-slide="n"im HTML landet. pageTemplate.Execute(w, pageView{...})— das eigentliche Template liegt nicht in diesem Package, sondern wird überweb.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):
fsnotify.NewWatcher()+watcher.Add(dir)— beobachtet den Content-Ordner auf Dateisystem-Ebene.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 durchs.mu sync.RWMutex).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:
/→ aktuelles.pageaus dem Speicher (kein Re-Parse pro Request nötig, das übernimmt der Watcher)/events→ SSE-Endpoint (handleEvents,server.go:169): abonniert einen Kanal beimbroker, schreibt bei jeder Benachrichtigung einedata: 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 ausweb.Assetsüberhttp.FileServer(http.FS(...)).- alles andere → Fallback im
/-Handler:isServableUserAsset(server.go) filtert.md-Dateien sowie Dotfiles/Dot-Ordner aus, alles übrige wird perhttp.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
activeauf dem jeweiligensection.slide-Element (das eigentliche Ein-/Ausblenden übernimmt CSS, siehesection.slide.activeinstyle.css) - ruft
applyFragments(index, fragment)auf, das für den aktuellen Slide die erstenfragmentseiner.fragment-Elemente mitfragment-visiblemarkiert (Rest bleibt per CSSopacity: 0unsichtbar) - aktualisiert die Fortschrittsanzeige (
.progress-Element, Textn / total) - schreibt den neuen Zustand in
location.hash—#3, wenn kein Fragment sichtbar ist, sonst#3.2(Slide 3, zweites Fragment sichtbar); außer beiupdateHash: false, das wird beim Reagieren aufhashchangeverwendet, um keine Endlosschleife zu erzeugen - ruft
renderMermaid(index)auf, das nur beim ersten Anzeigen eines Slidesmermaid.run({ nodes: [...] })gezielt für dessenpre.mermaid-Elemente aufruft (Cache überrenderedMermaid-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):
parser.ParseDir(dir)— identischer Parse-Schritt wie im Dev-Server.writePage(export.go:103) erzeugt die Ausgabedatei (outDir/outName) und ruftrender.Rendermit einemPage, dessenDevReloadnicht gesetzt ist (Zero-Valuefalse) — dadurch fehlt das<script src="vendor/live-reload.js">automatisch im Export.exportedAssets(export.go:19) listet explizit, welche Dateien ausweb.Assetsmitkopiert werden:vendor/style.css,vendor/chroma.css,vendor/nav.js,vendor/mermaid.min.js— alle landen dadurch unteroutDir/vendor/.vendor/live-reload.jssteht bewusst nicht in dieser Liste.copyAsset(export.go:119) liest die Datei perfs.ReadFileaus demembed.FSund schreibt sie unverändert in den Zielordner (inkl.MkdirAllfür Unterordner wievendor/).copyUserAssets(export.go:61) durchläuft anschließenddirselbst perfilepath.WalkDirund kopiert alles außer.md-Dateien und Dotfiles/Dot-Ordnern (.git,.DS_Store, …) 1:1 nachoutDir, inklusive Unterordnerstruktur. Dadurch landen z. B. perreferenzierte 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:
flag.NewFlagSetmit-build,-out,-addr.- Erwartet genau ein positionales Argument (den Ordner-Pfad) —
fs.NArg() != 1ist ein Fehler. Wichtig: Go'sflag-Paket stoppt das Parsen beim ersten Nicht-Flag-Argument, Flags müssen also vor dem Ordner stehen (slidewalk -build ./dist ./ordner, nicht umgekehrt). os.Stat(dir)prüft, dass der Pfad existiert und ein Verzeichnis ist — erst danach wird überhaupt geparst.*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 |