package parser
import (
"fmt"
"html/template"
"os"
"path/filepath"
"sort"
"unicode"
)
// Slide is a single parsed slide: its frontmatter metadata plus the
// rendered HTML of its Markdown body.
type Slide struct {
// Filename is the base name of the source file, e.g. "010-intro.md".
Filename string
// Title is the frontmatter "title" field.
Title string
// Class is the frontmatter "class" field, applied as a CSS class on the
// slide's element.
Class string
// Notes is the frontmatter "notes" field (speaker notes).
Notes string
// Incremental is the frontmatter "incremental" field. When true, the
// slide's top-level list items are revealed one at a time as
// navigation "fragments" instead of all at once.
Incremental bool
// HTML is the slide body rendered from Markdown to HTML.
HTML template.HTML
}
// 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.
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))
for _, name := range names {
path := filepath.Join(dir, name)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading slide file %s: %w", name, err)
}
slide, skip, err := parseSlide(name, data)
if err != nil {
return nil, fmt.Errorf("parsing slide file %s: %w", name, err)
}
if skip {
continue
}
slides = append(slides, slide)
}
return slides, nil
}
// discoverSlideFiles lists the ".md" files directly inside dir, sorted by
// filename so that a numeric filename prefix (e.g. "010-intro.md",
// "020-...") determines slide order.
func discoverSlideFiles(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var names []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
if filepath.Ext(entry.Name()) != ".md" {
continue
}
names = append(names, entry.Name())
}
sort.Slice(names, func(i, j int) bool { return lessNatural(names[i], names[j]) })
return names, nil
}
// lessNatural compares two filenames the way a human would: runs of digits
// are compared by numeric value rather than lexicographically, so
// "9-x.md" sorts before "10-x.md" and "999-x.md" before "1000-x.md"
// regardless of digit count. Non-digit runs are compared as-is.
func lessNatural(a, b string) bool {
for len(a) > 0 && len(b) > 0 {
aDigit, bDigit := unicode.IsDigit(rune(a[0])), unicode.IsDigit(rune(b[0]))
if aDigit && bDigit {
aNum, aRest := leadingDigits(a)
bNum, bRest := leadingDigits(b)
aVal, bVal := trimLeadingZeros(aNum), trimLeadingZeros(bNum)
if len(aVal) != len(bVal) {
return len(aVal) < len(bVal)
}
if aVal != bVal {
return aVal < bVal
}
a, b = aRest, bRest
continue
}
if a[0] != b[0] {
return a[0] < b[0]
}
a, b = a[1:], b[1:]
}
return len(a) < len(b)
}
// leadingDigits splits s into its leading run of ASCII digits and the rest.
func leadingDigits(s string) (digits, rest string) {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
return s[:i], s[i:]
}
// trimLeadingZeros strips leading zeros from a digit string, keeping at
// least one digit, so numeric values compare correctly by length first.
func trimLeadingZeros(digits string) string {
i := 0
for i < len(digits)-1 && digits[i] == '0' {
i++
}
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) {
fm, body, err := parseFrontmatter(data)
if err != nil {
return Slide{}, false, err
}
if fm.Skip {
return Slide{}, true, nil
}
htmlBody, err := renderMarkdown(body)
if err != nil {
return Slide{}, false, fmt.Errorf("rendering markdown: %w", err)
}
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
}