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 }