160 lines
4.6 KiB
Go
160 lines
4.6 KiB
Go
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 <section> 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. 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)
|
|
}
|
|
|
|
var slides []Slide
|
|
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)
|
|
}
|
|
|
|
fileSlides, err := parseSlideFile(name, data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing slide file %s: %w", name, err)
|
|
}
|
|
slides = append(slides, fileSlides...)
|
|
}
|
|
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:]
|
|
}
|
|
|
|
// 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 nil, err
|
|
}
|
|
if fm.Skip {
|
|
return nil, nil
|
|
}
|
|
|
|
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 slides, nil
|
|
}
|