Erweiterung und CSS
- Code bekommt größere Schrift - In einer Datei können neue Slides erstellt werden
This commit is contained in:
parent
291ee93b5b
commit
0fa66d9f48
10 changed files with 372 additions and 37 deletions
51
AGENTS.md
Normal file
51
AGENTS.md
Normal 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.
|
||||||
19
README.md
19
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
|
beliebige Element per raw HTML mit `class="fragment"` einzeln als Fragment
|
||||||
markieren.
|
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
|
## Frontmatter-Referenz
|
||||||
|
|
||||||
Jede Slide-Datei kann optional ein YAML-Frontmatter am Dateianfang haben:
|
Jede Slide-Datei kann optional ein YAML-Frontmatter am Dateianfang haben:
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,9 @@ machen.
|
||||||
|
|
||||||
| Datei | Enthält |
|
| 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`) |
|
| `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) |
|
| `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 |
|
| `*_test.go` | Table-driven Tests je Datei |
|
||||||
|
|
||||||
|
|
@ -57,14 +58,19 @@ machen.
|
||||||
1. `discoverSlideFiles(dir)` listet alle `.md`-Dateien im Ordner (nicht
|
1. `discoverSlideFiles(dir)` listet alle `.md`-Dateien im Ordner (nicht
|
||||||
rekursiv) und sortiert sie alphabetisch — das ist der Mechanismus hinter
|
rekursiv) und sortiert sie alphabetisch — das ist der Mechanismus hinter
|
||||||
der Präfix-Reihenfolge (`010-...`, `020-...`).
|
der Präfix-Reihenfolge (`010-...`, `020-...`).
|
||||||
2. Für jede Datei: `os.ReadFile` → `parseSlide(name, data)`.
|
2. Für jede Datei: `os.ReadFile` → `parseSlideFile(name, data)`.
|
||||||
3. `parseSlide` (`parser.go:134`) ruft zuerst `parseFrontmatter` auf. Ist
|
3. `parseSlideFile` (`parser.go:134`) ruft zuerst `parseFrontmatter` auf.
|
||||||
`skip: true` gesetzt, bricht es sofort ab (zweiter Rückgabewert `skip
|
Ist `skip: true` gesetzt, bricht es sofort ab (leere Slide-Liste), ohne
|
||||||
bool`), ohne den Markdown-Body überhaupt zu rendern.
|
den Markdown-Body überhaupt zu rendern.
|
||||||
4. Sonst: `renderMarkdown(body)` aus `markdown.go` wandelt den Rest in HTML
|
4. Sonst: `splitSlideBreaks(body)` aus `split.go` teilt den Body an
|
||||||
um, das Ergebnis landet als `template.HTML` im `Slide`-Struct.
|
`<!-- new slide -->`-Zeilen in einen oder mehrere Teile (ohne Marker: ein
|
||||||
5. `ParseDir` filtert alle als "skip" markierten Slides aus der
|
Teil = der unveränderte Body). Für jeden Teil wandelt
|
||||||
zurückgegebenen Liste heraus.
|
`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`)
|
### Fenced-Code-Blöcke: Mermaid & Syntax-Highlighting (`markdown.go`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,11 @@ Ein vollständiges, lauffähiges Beispiel-Deck liegt unter
|
||||||
|
|
||||||
## 1. Datei- und Ordnerstruktur
|
## 1. Datei- und Ordnerstruktur
|
||||||
|
|
||||||
- **Eine Datei = ein Slide.** Jede `.md`-Datei in dem Ordner, den du an
|
- **Eine Datei = ein Slide** — es sei denn, die Datei enthält
|
||||||
`slidewalk` übergibst, wird zu genau einem Slide.
|
`<!-- 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
|
- **Reihenfolge über Dateiname-Präfix.** Die Dateien werden alphabetisch
|
||||||
sortiert; ein numerischer Präfix mit Lücken macht das Einschieben späterer
|
sortiert; ein numerischer Präfix mit Lücken macht das Einschieben späterer
|
||||||
Slides einfach, ohne bestehende Dateien umbenennen zu müssen:
|
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>
|
<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
|
## 7. Übersichts-/Grid-Modus
|
||||||
|
|
||||||
Taste `O` schaltet einen Übersichts-Modus um, der alle Slides gleichzeitig
|
Taste `O` schaltet einen Übersichts-Modus um, der alle Slides gleichzeitig
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
// - Datei-Discovery: Slide-Dateien in einem Ordner finden und anhand ihres
|
// - Datei-Discovery: Slide-Dateien in einem Ordner finden und anhand ihres
|
||||||
// Dateiname-Präfixes (z. B. "010-intro.md") sortieren
|
// Dateiname-Präfixes (z. B. "010-intro.md") sortieren
|
||||||
// - YAML-Frontmatter der Felder title, class, notes und skip parsen
|
// - 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
|
// - Markdown-Inhalt zu HTML rendern (inkl. GFM-Erweiterungen und
|
||||||
// Mermaid-Codeblöcken)
|
// Mermaid-Codeblöcken)
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -31,14 +31,16 @@ type Slide struct {
|
||||||
|
|
||||||
// ParseDir discovers Markdown slide files in dir, sorted by filename, parses
|
// ParseDir discovers Markdown slide files in dir, sorted by filename, parses
|
||||||
// each one, and returns the resulting slides. Files whose frontmatter sets
|
// 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) {
|
func ParseDir(dir string) ([]Slide, error) {
|
||||||
names, err := discoverSlideFiles(dir)
|
names, err := discoverSlideFiles(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("discovering slide files in %s: %w", dir, err)
|
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 {
|
for _, name := range names {
|
||||||
path := filepath.Join(dir, name)
|
path := filepath.Join(dir, name)
|
||||||
data, err := os.ReadFile(path)
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parsing slide file %s: %w", name, err)
|
return nil, fmt.Errorf("parsing slide file %s: %w", name, err)
|
||||||
}
|
}
|
||||||
if skip {
|
slides = append(slides, fileSlides...)
|
||||||
continue
|
|
||||||
}
|
|
||||||
slides = append(slides, slide)
|
|
||||||
}
|
}
|
||||||
return slides, nil
|
return slides, nil
|
||||||
}
|
}
|
||||||
|
|
@ -128,29 +127,34 @@ func trimLeadingZeros(digits string) string {
|
||||||
return digits[i:]
|
return digits[i:]
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSlide parses a single slide file's raw content into a Slide. The
|
// parseSlideFile parses a single slide file's raw content into one or more
|
||||||
// second return value reports whether the slide's frontmatter sets
|
// Slides: one per "<!-- new slide -->"-separated part of its body, all
|
||||||
// skip: true, in which case the Slide should be discarded by the caller.
|
// sharing the file's frontmatter. It returns no Slides if the frontmatter
|
||||||
func parseSlide(filename string, data []byte) (Slide, bool, error) {
|
// sets skip: true.
|
||||||
|
func parseSlideFile(filename string, data []byte) ([]Slide, error) {
|
||||||
fm, body, err := parseFrontmatter(data)
|
fm, body, err := parseFrontmatter(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Slide{}, false, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if fm.Skip {
|
if fm.Skip {
|
||||||
return Slide{}, true, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
htmlBody, err := renderMarkdown(body)
|
parts := splitSlideBreaks(body)
|
||||||
|
slides := make([]Slide, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
htmlBody, err := renderMarkdown(part)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Slide{}, false, fmt.Errorf("rendering markdown: %w", err)
|
return nil, fmt.Errorf("rendering markdown: %w", err)
|
||||||
}
|
}
|
||||||
|
slides = append(slides, Slide{
|
||||||
return Slide{
|
|
||||||
Filename: filename,
|
Filename: filename,
|
||||||
Title: fm.Title,
|
Title: fm.Title,
|
||||||
Class: fm.Class,
|
Class: fm.Class,
|
||||||
Notes: fm.Notes,
|
Notes: fm.Notes,
|
||||||
Incremental: fm.Incremental,
|
Incremental: fm.Incremental,
|
||||||
HTML: template.HTML(htmlBody), //nolint:gosec // slide content is trusted local input, not user-supplied.
|
HTML: template.HTML(htmlBody), //nolint:gosec // slide content is trusted local input, not user-supplied.
|
||||||
}, false, nil
|
})
|
||||||
|
}
|
||||||
|
return slides, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package parser
|
package parser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"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) {
|
func writeFiles(t *testing.T, dir string, files map[string]string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for name, content := range files {
|
for name, content := range files {
|
||||||
|
|
|
||||||
92
internal/parser/split.go
Normal file
92
internal/parser/split.go
Normal 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
|
||||||
|
}
|
||||||
75
internal/parser/split_test.go
Normal file
75
internal/parser/split_test.go
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
web/assets/vendor/style.css
vendored
2
web/assets/vendor/style.css
vendored
|
|
@ -161,7 +161,7 @@ code {
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
font-family: var(--font-family-mono);
|
font-family: var(--font-family-mono);
|
||||||
font-size: var(--font-size-sm);
|
font-size: 0.9em;
|
||||||
background: var(--color-code-bg);
|
background: var(--color-code-bg);
|
||||||
padding: var(--spacing-md);
|
padding: var(--spacing-md);
|
||||||
border-radius: 0.5em;
|
border-radius: 0.5em;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue