Erweiterung und CSS

- Code bekommt größere Schrift
- In einer Datei können neue Slides erstellt werden
This commit is contained in:
Tom 2026-07-16 22:46:03 +02:00
parent 291ee93b5b
commit 0fa66d9f48
10 changed files with 372 additions and 37 deletions

51
AGENTS.md Normal file
View file

@ -0,0 +1,51 @@
# AGENTS.md
Go-Tool zum Präsentieren von Markdown-Slides im Browser. Details siehe
[README.md](README.md) (Nutzung) und [docs/architektur.md](docs/architektur.md)
(Code-Aufbau).
## Setup
```sh
go build -o slidewalk ./cmd/slidewalk
```
## Tests
```sh
go test ./...
```
Vor jedem Commit: `go test ./...` und `gofmt -l .` (muss leer sein) laufen
lassen.
## Struktur
- `internal/parser` — Markdown-Dateien → `[]Slide`
- `internal/render``[]Slide` → eine HTML-Seite
- `internal/watch` — Dev-Server mit Live-Reload
- `internal/export` — statischer HTML-Export
- `web` — Templates/CSS/JS, per `embed.FS` eingebettet
- `cmd/slidewalk` — CLI-Einstiegspunkt
`watch` und `export` rufen ausschließlich `parser.ParseDir` und
`render.Render` auf — keine zweite Stelle, die Markdown parst oder HTML baut.
## Konventionen
- Reines Markdown als Slide-Quelle, eine Datei pro Slide, Reihenfolge über
Dateiname-Präfix (`010-intro.md`).
- Keine neuen externen Laufzeit-Abhängigkeiten ohne Rücksprache — Ziel ist
ein einziges, eingebettetes Binary ohne CDN-Requests.
- Table-driven Tests neben dem jeweiligen Package (`*_test.go`).
## Arbeitsweise
- Lean und minimalistisch: die einfachste Lösung, die das Problem löst.
Keine Abstraktionen, Konfigurierbarkeit oder Optionen für hypothetische
Zukunftsfälle.
- Keine Features, kein Refactoring, keine Aufräumarbeiten, die nicht
explizit gefragt wurden.
- Antworten knapp und auf das Wesentliche konzentriert — keine
Wiederholung des Offensichtlichen, keine ausschweifenden Erklärungen,
keine Zusammenfassungen am Ende, wenn der Diff für sich spricht.

View file

@ -113,6 +113,25 @@ Stichpunkten im Vortrag. Zusätzlich (oder alternativ) lässt sich jedes
beliebige Element per raw HTML mit `class="fragment"` einzeln als Fragment
markieren.
## Manueller Seitenumbruch
Eine Zeile mit `<!-- new slide -->` teilt eine einzelne Slide-Datei an
dieser Stelle in mehrere Slides auf — praktisch, wenn zusammengehöriger
Inhalt trotzdem auf mehrere Slides verteilt werden soll, ohne die Datei
selbst zu splitten. Alle so entstehenden Slides teilen sich das
Frontmatter der Datei (`title`, `class`, `notes`, `incremental`).
```markdown
# Erster Teil
<!-- new slide -->
# Zweiter Teil
```
Der Marker muss allein auf seiner Zeile stehen. Innerhalb eines Codeblocks
(```` ``` ```` oder `~~~`) wird er ignoriert und nicht als Umbruch gewertet.
## Frontmatter-Referenz
Jede Slide-Datei kann optional ein YAML-Frontmatter am Dateianfang haben:

View file

@ -47,8 +47,9 @@ machen.
| Datei | Enthält |
| --------------------- | ------- |
| `parser.go` | Öffentliche API: `Slide`-Struct, `ParseDir(dir)`, Datei-Discovery (`discoverSlideFiles`), Zusammenbau eines einzelnen Slides (`parseSlide`) |
| `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 |
@ -57,14 +58,19 @@ machen.
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.
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`)

View file

@ -9,8 +9,11 @@ Ein vollständiges, lauffähiges Beispiel-Deck liegt unter
## 1. Datei- und Ordnerstruktur
- **Eine Datei = ein Slide.** Jede `.md`-Datei in dem Ordner, den du an
`slidewalk` übergibst, wird zu genau einem Slide.
- **Eine Datei = ein Slide** — es sei denn, die Datei enthält
`<!-- new slide -->`-Markierungen, dann wird sie in mehrere Slides
aufgeteilt, siehe [Abschnitt 6a](#6a-manueller-seitenumbruch-in-einer-datei).
Jede `.md`-Datei in dem Ordner, den du an `slidewalk` übergibst, wird
standardmäßig 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:
@ -332,6 +335,39 @@ einzelne Listeneinträge etc.):
<p class="fragment">Dieser Absatz erscheint erst auf Tastendruck.</p>
```
## 6a. Manueller Seitenumbruch in einer Datei
Eine Zeile mit `<!-- new slide -->` teilt die aktuelle Datei an dieser
Stelle in mehrere Slides auf — praktisch, wenn zusammengehöriger Inhalt aus
Vortrags-Sicht trotzdem auf mehrere Slides verteilt werden soll, ohne die
Datei selbst in mehrere Dateien aufzuteilen:
```markdown
---
title: Ergebnisse
---
# Ergebnisse (1/2)
- Punkt eins
- Punkt zwei
<!-- new slide -->
# Ergebnisse (2/2)
- Punkt drei
```
Alle so entstehenden Slides teilen sich das Frontmatter der Datei
(`title`, `class`, `notes`, `incremental` gelten für jeden Teil gleich).
Der Marker muss allein auf seiner Zeile stehen (führende/nachfolgende
Leerzeichen sind egal). Innerhalb eines Codeblocks — egal ob mit
```` ``` ```` oder `~~~` eingezäunt — wird er als reiner Text behandelt und
löst keinen Umbruch aus; so lässt sich der Marker auch in Beispiel-Code
zeigen, ohne die Slide ungewollt zu splitten.
## 7. Übersichts-/Grid-Modus
Taste `O` schaltet einen Übersichts-Modus um, der alle Slides gleichzeitig

View file

@ -5,6 +5,8 @@
// - 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
// - eine Datei anhand von "<!-- new slide -->"-Markierungszeilen in
// mehrere Slides aufteilen, die sich das Frontmatter der Datei teilen
// - Markdown-Inhalt zu HTML rendern (inkl. GFM-Erweiterungen und
// Mermaid-Codeblöcken)
//

View file

@ -31,14 +31,16 @@ type Slide struct {
// 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.
// skip: true are omitted from the result. A file whose body contains one or
// more "<!-- new slide -->" marker lines yields one slide per part, all
// sharing that file's frontmatter.
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))
var slides []Slide
for _, name := range names {
path := filepath.Join(dir, name)
data, err := os.ReadFile(path)
@ -46,14 +48,11 @@ func ParseDir(dir string) ([]Slide, error) {
return nil, fmt.Errorf("reading slide file %s: %w", name, err)
}
slide, skip, err := parseSlide(name, data)
fileSlides, err := parseSlideFile(name, data)
if err != nil {
return nil, fmt.Errorf("parsing slide file %s: %w", name, err)
}
if skip {
continue
}
slides = append(slides, slide)
slides = append(slides, fileSlides...)
}
return slides, nil
}
@ -128,29 +127,34 @@ func trimLeadingZeros(digits string) string {
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) {
// parseSlideFile parses a single slide file's raw content into one or more
// Slides: one per "<!-- new slide -->"-separated part of its body, all
// sharing the file's frontmatter. It returns no Slides if the frontmatter
// sets skip: true.
func parseSlideFile(filename string, data []byte) ([]Slide, error) {
fm, body, err := parseFrontmatter(data)
if err != nil {
return Slide{}, false, err
return nil, err
}
if fm.Skip {
return Slide{}, true, nil
return nil, nil
}
htmlBody, err := renderMarkdown(body)
if err != nil {
return Slide{}, false, fmt.Errorf("rendering markdown: %w", err)
parts := splitSlideBreaks(body)
slides := make([]Slide, 0, len(parts))
for _, part := range parts {
htmlBody, err := renderMarkdown(part)
if err != nil {
return nil, fmt.Errorf("rendering markdown: %w", err)
}
slides = append(slides, 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.
})
}
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
return slides, nil
}

View file

@ -1,6 +1,7 @@
package parser
import (
"bytes"
"os"
"path/filepath"
"testing"
@ -111,6 +112,55 @@ func TestParseDir_Incremental(t *testing.T) {
}
}
func TestParseDir_NewSlideMarkerSplitsOneFileIntoMultipleSlides(t *testing.T) {
dir := t.TempDir()
writeFiles(t, dir, map[string]string{
"010-combo.md": "---\ntitle: Combo\nclass: center\n---\n# One\n\n<!-- new slide -->\n\n# Two\n",
"020-after.md": "# Three\n",
})
slides, err := ParseDir(dir)
if err != nil {
t.Fatalf("ParseDir() error = %v", err)
}
if len(slides) != 3 {
t.Fatalf("len(slides) = %d, want 3: %+v", len(slides), slides)
}
if !bytes.Contains([]byte(slides[0].HTML), []byte("One")) {
t.Errorf("slides[0].HTML = %q, want it to contain %q", slides[0].HTML, "One")
}
if !bytes.Contains([]byte(slides[1].HTML), []byte("Two")) {
t.Errorf("slides[1].HTML = %q, want it to contain %q", slides[1].HTML, "Two")
}
if !bytes.Contains([]byte(slides[2].HTML), []byte("Three")) {
t.Errorf("slides[2].HTML = %q, want it to contain %q", slides[2].HTML, "Three")
}
// Both parts of the split file share its frontmatter.
if slides[0].Title != "Combo" || slides[1].Title != "Combo" {
t.Errorf("slides[0..1].Title = %q, %q, want both %q", slides[0].Title, slides[1].Title, "Combo")
}
if slides[0].Class != "center" || slides[1].Class != "center" {
t.Errorf("slides[0..1].Class = %q, %q, want both %q", slides[0].Class, slides[1].Class, "center")
}
}
func TestParseDir_NewSlideMarkerIgnoredInsideFencedCodeBlock(t *testing.T) {
dir := t.TempDir()
writeFiles(t, dir, map[string]string{
"010-doc.md": "# Doc\n\n```\n<!-- new slide -->\n```\n",
})
slides, err := ParseDir(dir)
if err != nil {
t.Fatalf("ParseDir() error = %v", err)
}
if len(slides) != 1 {
t.Fatalf("len(slides) = %d, want 1 (marker inside fence must not split): %+v", len(slides), slides)
}
}
func writeFiles(t *testing.T, dir string, files map[string]string) {
t.Helper()
for name, content := range files {

92
internal/parser/split.go Normal file
View file

@ -0,0 +1,92 @@
package parser
import "bytes"
// slideBreakMarker is a line that, on its own, forces a new slide within a
// single source file, even though the surrounding content stays in one
// file (and shares its frontmatter).
var slideBreakMarker = []byte("<!-- new slide -->")
// splitSlideBreaks splits body into one or more parts at lines consisting
// solely of slideBreakMarker. Matches inside fenced code blocks (``` or
// ~~~) are ignored, so a marker shown as example text isn't mistaken for an
// actual break. If no marker is found, it returns body unchanged as the
// only part.
func splitSlideBreaks(body []byte) [][]byte {
lines := bytes.Split(body, []byte("\n"))
var parts [][]byte
var current [][]byte
var fence fenceState
for _, line := range lines {
trimmed := bytes.TrimSpace(line)
if !fence.active && bytes.Equal(trimmed, slideBreakMarker) {
parts = append(parts, bytes.TrimSpace(bytes.Join(current, []byte("\n"))))
current = nil
continue
}
fence.toggle(trimmed)
current = append(current, line)
}
parts = append(parts, bytes.TrimSpace(bytes.Join(current, []byte("\n"))))
if len(parts) == 1 {
return [][]byte{body}
}
nonEmpty := parts[:0]
for _, p := range parts {
if len(p) > 0 {
nonEmpty = append(nonEmpty, p)
}
}
return nonEmpty
}
// fenceState tracks whether the line currently being scanned lies inside a
// fenced code block, so a slide-break marker appearing as example text
// inside a fence isn't treated as an actual split point.
type fenceState struct {
active bool
char byte
count int
}
// toggle updates the fence state for one already-trimmed line.
func (f *fenceState) toggle(trimmed []byte) {
ch, count, ok := parseFenceLine(trimmed)
if !ok {
return
}
switch {
case !f.active:
f.active, f.char, f.count = true, ch, count
case ch == f.char && count >= f.count:
f.active = false
}
}
// parseFenceLine reports whether trimmed is a fenced-code-block delimiter
// line (a run of three or more backticks or tildes), returning the fence
// character and run length.
func parseFenceLine(trimmed []byte) (ch byte, count int, ok bool) {
if len(trimmed) < 3 {
return 0, 0, false
}
ch = trimmed[0]
if ch != '`' && ch != '~' {
return 0, 0, false
}
for count < len(trimmed) && trimmed[count] == ch {
count++
}
if count < 3 {
return 0, 0, false
}
// A backtick fence's info string cannot itself contain a backtick.
if ch == '`' && bytes.IndexByte(trimmed[count:], '`') != -1 {
return 0, 0, false
}
return ch, count, true
}

View file

@ -0,0 +1,75 @@
package parser
import (
"bytes"
"testing"
)
func TestSplitSlideBreaks_NoMarkerReturnsBodyUnchanged(t *testing.T) {
body := []byte("# A\n\nsome text\n")
got := splitSlideBreaks(body)
if len(got) != 1 || !bytes.Equal(got[0], body) {
t.Errorf("splitSlideBreaks() = %q, want [%q]", got, body)
}
}
func TestSplitSlideBreaks_SplitsOnMarkerLine(t *testing.T) {
body := []byte("# A\n\n<!-- new slide -->\n\n# B\n")
got := splitSlideBreaks(body)
want := [][]byte{[]byte("# A"), []byte("# B")}
if len(got) != len(want) {
t.Fatalf("splitSlideBreaks() = %q, want %q", got, want)
}
for i := range want {
if !bytes.Equal(got[i], want[i]) {
t.Errorf("part %d = %q, want %q", i, got[i], want[i])
}
}
}
func TestSplitSlideBreaks_HandlesMultipleMarkersAndSurroundingWhitespace(t *testing.T) {
body := []byte("<!-- new slide -->\n# A\n<!-- new slide -->\n# B\n<!-- new slide -->\n")
got := splitSlideBreaks(body)
want := [][]byte{[]byte("# A"), []byte("# B")}
if len(got) != len(want) {
t.Fatalf("splitSlideBreaks() = %q, want %q", got, want)
}
for i := range want {
if !bytes.Equal(got[i], want[i]) {
t.Errorf("part %d = %q, want %q", i, got[i], want[i])
}
}
}
func TestSplitSlideBreaks_IgnoresMarkerInsideBacktickFence(t *testing.T) {
body := []byte("# A\n\n```\n<!-- new slide -->\n```\n\n# B\n")
got := splitSlideBreaks(body)
if len(got) != 1 {
t.Fatalf("splitSlideBreaks() = %q, want a single part (marker is inside a fence)", got)
}
if !bytes.Equal(got[0], body) {
t.Errorf("splitSlideBreaks()[0] = %q, want unchanged body %q", got[0], body)
}
}
func TestSplitSlideBreaks_IgnoresMarkerInsideTildeFence(t *testing.T) {
body := []byte("# A\n\n~~~\n<!-- new slide -->\n~~~\n\n# B\n")
got := splitSlideBreaks(body)
if len(got) != 1 {
t.Fatalf("splitSlideBreaks() = %q, want a single part (marker is inside a fence)", got)
}
}
func TestSplitSlideBreaks_SplitsAfterFenceCloses(t *testing.T) {
body := []byte("```\ncode\n```\n\n<!-- new slide -->\n\n# B\n")
got := splitSlideBreaks(body)
want := [][]byte{[]byte("```\ncode\n```"), []byte("# B")}
if len(got) != len(want) {
t.Fatalf("splitSlideBreaks() = %q, want %q", got, want)
}
for i := range want {
if !bytes.Equal(got[i], want[i]) {
t.Errorf("part %d = %q, want %q", i, got[i], want[i])
}
}
}

View file

@ -161,7 +161,7 @@ code {
pre {
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
font-size: 0.9em;
background: var(--color-code-bg);
padding: var(--spacing-md);
border-radius: 0.5em;