diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..263cc39 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index cacec68..96aca85 100644 --- a/README.md +++ b/README.md @@ -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 `` 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 + + + +# 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: diff --git a/docs/architektur.md b/docs/architektur.md index fc5103e..4b78351 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -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 ``-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 + ``-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`) diff --git a/docs/markdown-guide.md b/docs/markdown-guide.md index db8609b..0388f6b 100644 --- a/docs/markdown-guide.md +++ b/docs/markdown-guide.md @@ -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 + ``-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.):

Dieser Absatz erscheint erst auf Tastendruck.

``` +## 6a. Manueller Seitenumbruch in einer Datei + +Eine Zeile mit `` 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 + + + +# 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 diff --git a/internal/parser/doc.go b/internal/parser/doc.go index e3615ab..6e44930 100644 --- a/internal/parser/doc.go +++ b/internal/parser/doc.go @@ -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 ""-Markierungszeilen in +// mehrere Slides aufteilen, die sich das Frontmatter der Datei teilen // - Markdown-Inhalt zu HTML rendern (inkl. GFM-Erweiterungen und // Mermaid-Codeblöcken) // diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 86b02b2..f1bd25d 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -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 "" 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 ""-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 } diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go index 2d9477d..aeb6d46 100644 --- a/internal/parser/parser_test.go +++ b/internal/parser/parser_test.go @@ -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\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\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 { diff --git a/internal/parser/split.go b/internal/parser/split.go new file mode 100644 index 0000000..2735b82 --- /dev/null +++ b/internal/parser/split.go @@ -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("") + +// 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 +} diff --git a/internal/parser/split_test.go b/internal/parser/split_test.go new file mode 100644 index 0000000..2f4d765 --- /dev/null +++ b/internal/parser/split_test.go @@ -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\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("\n# A\n\n# B\n\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\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\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\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]) + } + } +} diff --git a/web/assets/vendor/style.css b/web/assets/vendor/style.css index 9c5a1b0..389af43 100644 --- a/web/assets/vendor/style.css +++ b/web/assets/vendor/style.css @@ -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;