diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4222172..0be37bb 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -68,6 +68,34 @@ jobs: echo "version=${version}" >> "$GITHUB_OUTPUT" echo "slides ${slides}: documentation version ${version}" + # The gallery: the decks of go-slides/slides' examples/, rendered by + # go-slides itself (tools/gallery builds the slides command from the same + # checkout), as data/gallery.json and static/gallery/. The ref is + # params.gallery.ref in hugo.yaml. + - name: Gallery ref + id: gallery + run: echo "ref=$(hugo config --format json | jq -r '.params.gallery.ref')" >> "$GITHUB_OUTPUT" + + - name: Check out the examples + uses: actions/checkout@v7 + with: + repository: go-slides/slides + ref: ${{ steps.gallery.outputs.ref }} + path: slides-src + + - uses: actions/setup-go@v7 + with: + go-version: "1.27.2" + cache: false + + - name: Build the gallery + env: + GOWORK: "off" + working-directory: tools/gallery + run: | + go test ./... + go run . -src ../../slides-src -out ../.. + # Each page's creation (first commit, followed across renames) and its # author, for the page history at its foot (params.page.history). - name: Page history diff --git a/.gitignore b/.gitignore index 7cd05fc..a9fd687 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,9 @@ # Written by themes/hextra/scripts/page-history.sh before each build (CI and local). /data/pagehistory.json + +# Written by tools/gallery before each build (CI and local): the gallery's data +# and its thumbnails, sources and archives. +/data/gallery.json +/static/gallery/ +/slides-src/ diff --git a/README.md b/README.md index 1796e98..b79543a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ against that release (flags from its `slides -h`). | `assets/css/custom.css` | the brand indigo as Hextra's primary colour, with its contrast measured | | `themes/hextra` | submodule: tannevaled/hextra at tag v0.16.0 | | `branding` | submodule: [go-slides/brand](https://github.com/go-slides/brand) | +| `tools/gallery` | a Go program (stdlib only, with tests) that renders the gallery: it reads go-slides/slides' `examples/`, renders each deck's first slide with the slides command, and writes `data/gallery.json` and `static/gallery/` (both ignored by git) | +| `layouts/_shortcodes/gallery.html` | the gallery page's cards, from `data/gallery.json` | | `scripts/publish-version.sh` | puts one build into a `gh-pages` checkout and rewrites `versions.json`, `latest` and the root redirect | ## Working locally @@ -49,10 +51,14 @@ against that release (flags from its `slides -h`). git clone --recurse-submodules https://github.com/go-slides/docs.git cd docs mkdir -p data && sh themes/hextra/scripts/page-history.sh > data/pagehistory.json +git clone https://github.com/go-slides/slides ../slides # the gallery's decks +(cd tools/gallery && go run . -src ../../../slides -out ../..) # Go 1.27.2 or later hugo server --baseURL http://localhost:1313/docs/main/ ``` -Hugo 0.146 or later; no Python, no Node. +Hugo 0.146 or later; no Python, no Node. The gallery page fails the build when +`data/gallery.json` is missing, which is the `go run` line above. CI does it +before every build, from the ref `params.gallery.ref` names. ## Publication diff --git a/assets/css/custom.css b/assets/css/custom.css index ee9a99c..9f15e9c 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -29,6 +29,22 @@ * stays on screen at the end of the navbar. */ .fs-version-select .hextra-nav-menu-items { left: auto !important; right: 0; transform: none !important; } +/* The gallery (layouts/_shortcodes/gallery.html): a card per deck. */ +.gallery { display: grid; gap: 1.5rem; grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr)); margin-top: 1.5rem; } +.gallery-card { border: 1px solid #d1d5db; border-radius: 0.75rem; padding: 0.75rem; min-width: 0; } +.dark .gallery-card { border-color: #374151; } +.gallery-thumb img { width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 0.5rem; border: 1px solid #e5e7eb; } +.gallery-card h3 { margin: 0.75rem 0 0; font-size: 1.1rem; } +.gallery-card p { margin: 0.4rem 0 0; } +.gallery-meta { font-size: 0.8rem; opacity: 0.7; } +.gallery-actions { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; align-items: center; } +.gallery-open { background: #4F46E5; color: #fff; padding: 0.25rem 0.75rem; border-radius: 0.375rem; font-weight: 600; text-decoration: none; } +.gallery-open:hover { background: #4338ca; } +.gallery-note { font-size: 0.85rem; } +.gallery-card details { margin-top: 0.6rem; } +.gallery-card summary { cursor: pointer; font-weight: 600; } +.gallery-card pre { max-height: 24rem; overflow: auto; } + /* The outdated-version banner (layouts/_partials/custom/content-begin.html). */ .nd-outdated { margin-top: 1rem; padding: 0.75rem 1rem; border-radius: 0.5rem; border: 1px solid #f59e0b; background: #fffbeb; color: #78350f; } diff --git a/content/en/_index.md b/content/en/_index.md index 6213fb4..7e117e3 100644 --- a/content/en/_index.md +++ b/content/en/_index.md @@ -50,6 +50,7 @@ here that is done by Go libraries. ## Pages - [Getting started]({{< relref "getting-started" >}}) +- [Gallery]({{< relref "gallery" >}}): twelve example decks, to open in the playground - [Marp compatibility]({{< relref "marp" >}}) - [Templates]({{< relref "templates" >}}) - [Kinds]({{< relref "kinds" >}}) diff --git a/content/en/gallery.md b/content/en/gallery.md new file mode 100644 index 0000000..0eb1518 --- /dev/null +++ b/content/en/gallery.md @@ -0,0 +1,24 @@ +--- +title: "Gallery" +weight: 12 +description: "Twelve small decks, each showing one thing well: themes, kinds, templates, HCL, math, images, code, emoji, transitions and a complete talk. Open any of them in the playground." +tags: [gallery, examples] +--- + +Twelve small decks from the +[`examples/`](https://github.com/go-slides/slides/tree/main/examples) folder of +go-slides, each showing one thing well. The thumbnails are the first slide of +each deck, rendered by go-slides itself when this site is built; CI builds every +deck to HTML and PDF, so none of them breaks. + +**Open in the playground** loads the deck in your browser, in a link that sends +nothing to any server. A deck that has files besides `deck.md` (a kinds file, +included parts, images) cannot travel in a link: download its **.zip**, and +import it with the playground's file panel, or unzip it and build it with +`slides build --allow-local-files deck.md`. + +The pictures are original SVGs of the go-slides authors, released under +[CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/), and the decks +that use them say so. + +{{< gallery >}} diff --git a/hugo.yaml b/hugo.yaml index dfae5b2..0c6f69e 100644 --- a/hugo.yaml +++ b/hugo.yaml @@ -92,6 +92,12 @@ params: # being written, and say so. slides: version: "v0.9.0" + # The ref of go-slides/slides whose examples/ the gallery shows, and from + # which tools/gallery builds the slides command that renders the thumbnails + # (the CI reads it here). "main" until a release has an examples/ folder; + # then the release's tag, so that the gallery matches the pages. + gallery: + ref: "main" versions: # The version selector shows "main" with the development icon. development: "main" diff --git a/layouts/_shortcodes/gallery.html b/layouts/_shortcodes/gallery.html new file mode 100644 index 0000000..3237225 --- /dev/null +++ b/layouts/_shortcodes/gallery.html @@ -0,0 +1,39 @@ +{{- /* + The gallery of example decks, from data/gallery.json and static/gallery/, + which tools/gallery writes before every build (CI and local). +*/ -}} +{{- $items := hugo.Data.gallery -}} +{{- if not $items -}} + {{- errorf "gallery: data/gallery.json is missing. Run `go run ./tools/gallery -src ../slides` from the repository's root (see README.md)" -}} +{{- end -}} + diff --git a/tools/gallery/go.mod b/tools/gallery/go.mod new file mode 100644 index 0000000..bd5a194 --- /dev/null +++ b/tools/gallery/go.mod @@ -0,0 +1,3 @@ +module github.com/go-slides/docs/tools/gallery + +go 1.27.2 diff --git a/tools/gallery/main.go b/tools/gallery/main.go new file mode 100644 index 0000000..f9cf56b --- /dev/null +++ b/tools/gallery/main.go @@ -0,0 +1,329 @@ +// Command gallery prepares the documentation's gallery of example decks. +// +// It reads the decks of go-slides/slides' examples/ directory, renders the +// first slide of each with go-slides itself (the slides command, built from the +// same checkout), and writes what the Hugo page needs: +// +// data/gallery.json one entry per deck, in the order of its name +// static/gallery/NAME.jpg the first slide +// static/gallery/NAME.md the deck's source, shown on the page +// static/gallery/NAME.zip the deck's folder, when it has files besides deck.md +// +// A deck that is one file also gets an "Open in the playground" link: the +// playground's share format, "#deck=" and the deck, deflated and base64url +// encoded (playground/share.go in go-slides/go-slides.github.io). A deck with +// a media/, kinds/ or parts/ folder cannot travel in a link; it is offered as a +// .zip, which the playground's file panel imports. +// +// Usage, from the root of the documentation repository: +// +// go run ./tools/gallery -src ../slides +package main + +import ( + "archive/zip" + "bytes" + "compress/flate" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +// Limits of the playground's share link (playground/share.go there). +const ( + maxSource = 1 << 20 + maxShare = 64 << 10 +) + +// Entry is one deck of the gallery, as data/gallery.json holds it. +type Entry struct { + Name string `json:"name"` + Title string `json:"title"` + Description string `json:"description"` + Slides int `json:"slides"` + Source string `json:"source"` // the deck's text, relative to the site root + Thumbnail string `json:"thumbnail"` // relative to static/ + Playground string `json:"playground"` // the link, when the deck is one file that fits + Zip string `json:"zip"` // relative to static/, when the deck has other files + Files []string `json:"files"` // the files beside deck.md + Repo string `json:"repo"` // the deck's folder on GitHub +} + +func main() { + var ( + src = flag.String("src", "", "a checkout of go-slides/slides (its examples/ directory and cmd/slides)") + out = flag.String("out", ".", "the documentation repository's root") + bin = flag.String("slides", "", "the slides command; by default one is built from -src") + playground = flag.String("playground", "https://go-slides.github.io/playground/", "the playground's address") + repo = flag.String("repo", "https://github.com/go-slides/slides/tree/main/examples", "where the examples live") + ) + flag.Parse() + if *src == "" { + fatal(errors.New("-src is required")) + } + if *bin == "" { + tmp, err := os.MkdirTemp("", "gallery") + if err != nil { + fatal(err) + } + defer os.RemoveAll(tmp) + *bin = filepath.Join(tmp, "slides") + build := exec.Command("go", "build", "-o", *bin, "./cmd/slides") + build.Dir = *src + build.Env = append(os.Environ(), "GOWORK=off") + if b, err := build.CombinedOutput(); err != nil { + fatal(fmt.Errorf("building the slides command: %w\n%s", err, b)) + } + } + entries, err := Build(*src, *out, *bin, *playground, *repo) + if err != nil { + fatal(err) + } + fmt.Printf("gallery: %d decks\n", len(entries)) +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, "gallery:", err) + os.Exit(1) +} + +// Build writes the gallery's files under out, from the decks of src/examples. +func Build(src, out, slidesBin, playground, repo string) ([]Entry, error) { + decks, err := filepath.Glob(filepath.Join(src, "examples", "*", "deck.md")) + if err != nil { + return nil, err + } + if len(decks) == 0 { + return nil, fmt.Errorf("no deck in %s", filepath.Join(src, "examples")) + } + sortDecks(decks) + static := filepath.Join(out, "static", "gallery") + if err := os.RemoveAll(static); err != nil { + return nil, err + } + if err := os.MkdirAll(static, 0o755); err != nil { + return nil, err + } + var entries []Entry + for _, deck := range decks { + dir := filepath.Dir(deck) + name := filepath.Base(dir) + text, err := os.ReadFile(deck) + if err != nil { + return nil, err + } + title, desc := Meta(string(text)) + if title == "" || desc == "" { + return nil, fmt.Errorf("%s: the front matter needs a title and a description", deck) + } + files, err := Others(dir) + if err != nil { + return nil, err + } + e := Entry{ + Name: name, Title: title, Description: desc, Files: files, + Source: "static/gallery/" + name + ".md", + Thumbnail: "gallery/" + name + ".jpg", + Repo: repo + "/" + name, + } + if err := os.WriteFile(filepath.Join(out, e.Source), text, 0o644); err != nil { + return nil, err + } + n, err := render(slidesBin, deck, filepath.Join(static, name+".jpg")) + if err != nil { + return nil, fmt.Errorf("%s: %w", name, err) + } + e.Slides = n + if len(files) == 0 { + if frag, err := Share(string(text)); err == nil { + e.Playground = strings.TrimSuffix(playground, "/") + "/#" + frag + } + } + if len(files) > 0 || e.Playground == "" { + z, err := Zip(dir) + if err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(static, name+".zip"), z, 0o644); err != nil { + return nil, err + } + e.Zip = "gallery/" + name + ".zip" + } + entries = append(entries, e) + } + b, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Join(out, "data"), 0o755); err != nil { + return nil, err + } + return entries, os.WriteFile(filepath.Join(out, "data", "gallery.json"), append(b, '\n'), 0o644) +} + +// order is the gallery's order: the themes, then one feature at a time, then +// the complete talk. A deck it does not name follows, by name. +var order = []string{ + "theme-default", "theme-gaia", "theme-uncover", "kinds", "templates", "hcl", + "math", "images", "code", "emoji-and-fit", "presenting", "talk", +} + +func sortDecks(decks []string) { + rank := func(deck string) int { + name := filepath.Base(filepath.Dir(deck)) + for i, n := range order { + if n == name { + return i + } + } + return len(order) + } + sort.SliceStable(decks, func(i, j int) bool { + if ri, rj := rank(decks[i]), rank(decks[j]); ri != rj { + return ri < rj + } + return decks[i] < decks[j] + }) +} + +var slidesRE = regexp.MustCompile(`\((\d+) slides?\)`) + +// render writes the first slide of deck as a JPEG, with the slides command, +// and returns the deck's number of slides. +func render(bin, deck, jpg string) (int, error) { + // --image reports one slide: the count comes from a build of the page. + page := jpg + ".html" + defer os.Remove(page) + var count string + for _, args := range [][]string{ + {"build", "--allow-local-files", "-o", page, deck}, + {"build", "--allow-local-files", "--image", "jpeg", "--jpeg-quality", "80", "-o", jpg, deck}, + } { + cmd := exec.Command(bin, args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return 0, fmt.Errorf("slides %s: %w\n%s", args[0], err, stderr.String()) + } + if count == "" { + count = stderr.String() + } + } + m := slidesRE.FindStringSubmatch(count) + if m == nil { + return 0, fmt.Errorf("slides did not say how many slides: %q", count) + } + return strconv.Atoi(m[1]) +} + +// Meta returns a deck's title and description, from its front matter, in YAML +// ("title: x") or HCL ("title = "x""). +func Meta(deck string) (title, description string) { + front := frontMatter(deck) + return field(front, "title"), field(front, "description") +} + +func frontMatter(deck string) string { + lines := strings.Split(deck, "\n") + if len(lines) == 0 || !strings.HasPrefix(lines[0], "---") { + return "" + } + for i := 1; i < len(lines); i++ { + if strings.TrimRight(lines[i], " \r") == "---" { + return strings.Join(lines[1:i], "\n") + } + } + return "" +} + +func field(front, key string) string { + re := regexp.MustCompile(`(?m)^` + key + `\s*[:=]\s*(.+?)\s*$`) + m := re.FindStringSubmatch(front) + if m == nil { + return "" + } + v := m[1] + if u, err := strconv.Unquote(v); err == nil { + return u + } + return strings.Trim(v, `'"`) +} + +// Others lists the files of a deck's folder besides deck.md, slash-separated +// and sorted. +func Others(dir string) ([]string, error) { + var out []string + err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(dir, p) + if d.IsDir() || rel == "deck.md" || strings.HasPrefix(d.Name(), ".") { + return nil + } + out = append(out, filepath.ToSlash(rel)) + return nil + }) + sort.Strings(out) + return out, err +} + +// Share returns the playground's fragment for a deck, without the "#". +func Share(deck string) (string, error) { + if len(deck) > maxSource { + return "", errors.New("the deck is larger than the playground takes") + } + var buf bytes.Buffer + w, _ := flate.NewWriter(&buf, flate.BestCompression) + _, _ = w.Write([]byte(deck)) + _ = w.Close() + s := "deck=" + base64.RawURLEncoding.EncodeToString(buf.Bytes()) + if len(s) > maxShare { + return "", errors.New("the deck is too large to travel in a link") + } + return s, nil +} + +// Zip archives a deck's folder, deck.md at its top and the rest where it is, +// the files sorted and their times fixed, so that the same decks give the same +// bytes. +func Zip(dir string) ([]byte, error) { + files, err := Others(dir) + if err != nil { + return nil, err + } + names := append([]string{"deck.md"}, files...) + var buf bytes.Buffer + w := zip.NewWriter(&buf) + when := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for _, n := range names { + b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(n))) + if err != nil { + return nil, err + } + h := &zip.FileHeader{Name: n, Method: zip.Deflate, Modified: when} + h.SetMode(0o644) + f, err := w.CreateHeader(h) + if err != nil { + return nil, err + } + if _, err := f.Write(b); err != nil { + return nil, err + } + } + if err := w.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/tools/gallery/main_test.go b/tools/gallery/main_test.go new file mode 100644 index 0000000..5a89b0a --- /dev/null +++ b/tools/gallery/main_test.go @@ -0,0 +1,211 @@ +package main + +import ( + "archive/zip" + "bytes" + "compress/flate" + "encoding/base64" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestMeta(t *testing.T) { + for _, c := range []struct{ name, deck, title, desc string }{ + {"yaml", "---\nslides: true\ntitle: The default theme\ndescription: Clean and light.\n---\n\n# x\n", "The default theme", "Clean and light."}, + {"quoted", "---\ntitle: \"A: b\"\ndescription: 'It''s fine'\n---\n", "A: b", "It''s fine"}, + {"hcl", "---hcl\ntitle = \"HCL front matter\"\ndescription = \"In HCL2.\"\n---\n", "HCL front matter", "In HCL2."}, + {"none", "# no front matter\ntitle: not this\n", "", ""}, + {"body is not the front matter", "---\ntheme: x\n---\ntitle: in the body\n", "", ""}, + } { + t.Run(c.name, func(t *testing.T) { + title, desc := Meta(c.deck) + if title != c.title || desc != c.desc { + t.Errorf("got %q, %q; want %q, %q", title, desc, c.title, c.desc) + } + }) + } +} + +// decode is the playground's DecodeShare, to prove that Share is what it reads. +func decode(t *testing.T, frag string) string { + t.Helper() + data, ok := strings.CutPrefix(frag, "deck=") + if !ok { + t.Fatalf("no deck= prefix: %.20s", frag) + } + raw, err := base64.RawURLEncoding.DecodeString(data) + if err != nil { + t.Fatal(err) + } + b, err := io.ReadAll(flate.NewReader(bytes.NewReader(raw))) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func TestShareRoundTrip(t *testing.T) { + deck := "---\ntitle: é\n---\n\n# Hello $E=mc^2$ 😄\n" + frag, err := Share(deck) + if err != nil { + t.Fatal(err) + } + if got := decode(t, frag); got != deck { + t.Errorf("round trip: %q", got) + } + if strings.ContainsAny(strings.TrimPrefix(frag, "deck="), "+/=# ") { + t.Errorf("not URL-safe: %q", frag) + } +} + +func TestShareRefusesWhatCannotTravel(t *testing.T) { + // Random-looking text does not deflate: it is the case a link cannot carry. + var sb strings.Builder + x := uint32(1) + for sb.Len() < 200<<10 { + x = x*1664525 + 1013904223 + sb.WriteByte(byte('a' + (x>>24)%26)) + } + if _, err := Share(sb.String()); err == nil { + t.Error("a deck too large for a link was shared") + } + if _, err := Share(strings.Repeat("x", maxSource+1)); err == nil { + t.Error("a deck past the playground's bound was shared") + } +} + +func fixture(t *testing.T) string { + t.Helper() + src := t.TempDir() + write := func(p, s string) { + p = filepath.Join(src, "examples", filepath.FromSlash(p)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(s), 0o644); err != nil { + t.Fatal(err) + } + } + write("one/deck.md", "---\ntitle: One\ndescription: A single file.\n---\n\n# One\n") + write("two/deck.md", "---\ntitle: Two\ndescription: Two files.\n---\n\n![bg](media/a.svg)\n") + write("two/media/a.svg", "") + return src +} + +func TestOthers(t *testing.T) { + src := fixture(t) + got, err := Others(filepath.Join(src, "examples", "two")) + if err != nil { + t.Fatal(err) + } + if want := []string{"media/a.svg"}; !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } + got, _ = Others(filepath.Join(src, "examples", "one")) + if len(got) != 0 { + t.Errorf("a single file has no others: %v", got) + } +} + +func TestZipIsStableAndImportable(t *testing.T) { + dir := filepath.Join(fixture(t), "examples", "two") + a, err := Zip(dir) + if err != nil { + t.Fatal(err) + } + b, _ := Zip(dir) + if !bytes.Equal(a, b) { + t.Error("two archives of the same folder differ") + } + r, err := zip.NewReader(bytes.NewReader(a), int64(len(a))) + if err != nil { + t.Fatal(err) + } + var names []string + for _, f := range r.File { + names = append(names, f.Name) + } + if want := []string{"deck.md", "media/a.svg"}; !reflect.DeepEqual(names, want) { + t.Errorf("entries %v, want %v", names, want) + } +} + +// TestMain lets this test binary stand in for the slides command: a script +// would not be portable, so TestBuild runs the binary itself, with an +// environment variable that makes it act as `slides build ... -o OUT DECK`. +func TestMain(m *testing.M) { + if os.Getenv("GALLERY_FAKE_SLIDES") == "1" { + out := "" + for i, a := range os.Args { + if a == "-o" { + out = os.Args[i+1] + } + } + _ = os.WriteFile(out, []byte("\xff\xd8jpeg"), 0o644) + os.Stderr.WriteString("slides: deck => " + out + " (3 slides)\n") + os.Exit(0) + } + os.Exit(m.Run()) +} + +func TestBuild(t *testing.T) { + src := fixture(t) + out := t.TempDir() + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + t.Setenv("GALLERY_FAKE_SLIDES", "1") // the child process inherits it + entries, err := Build(src, out, self, "https://example.test/playground/", "https://example.test/repo") + if err != nil { + t.Fatal(err) + } + if len(entries) != 2 { + t.Fatalf("%d entries", len(entries)) + } + one, two := entries[0], entries[1] + if one.Name != "one" || two.Name != "two" { + t.Fatalf("order: %s, %s", one.Name, two.Name) + } + if !strings.HasPrefix(one.Playground, "https://example.test/playground/#deck=") || one.Zip != "" { + t.Errorf("a single file is shared by link: %+v", one) + } + if two.Playground != "" || two.Zip != "gallery/two.zip" { + t.Errorf("two files go as a zip: %+v", two) + } + if one.Slides != 3 || one.Title != "One" || one.Repo != "https://example.test/repo/one" { + t.Errorf("entry: %+v", one) + } + for _, p := range []string{"data/gallery.json", "static/gallery/one.jpg", "static/gallery/one.md", "static/gallery/two.zip"} { + if _, err := os.Stat(filepath.Join(out, p)); err != nil { + t.Error(err) + } + } + if _, err := os.Stat(filepath.Join(out, "static/gallery/one.zip")); err == nil { + t.Error("a single file needs no zip") + } +} + +func TestSortDecks(t *testing.T) { + d := func(n string) string { return filepath.Join("examples", n, "deck.md") } + got := []string{d("zeta"), d("talk"), d("alpha"), d("theme-default"), d("kinds")} + sortDecks(got) + want := []string{d("theme-default"), d("kinds"), d("talk"), d("alpha"), d("zeta")} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v", got) + } +} + +func TestBuildRefusesADeckWithoutMetadata(t *testing.T) { + src := t.TempDir() + p := filepath.Join(src, "examples", "x", "deck.md") + _ = os.MkdirAll(filepath.Dir(p), 0o755) + _ = os.WriteFile(p, []byte("# no front matter\n"), 0o644) + if _, err := Build(src, t.TempDir(), "/nonexistent", "", ""); err == nil || !strings.Contains(err.Error(), "title and a description") { + t.Errorf("got %v", err) + } +}