package scaffold import ( "bytes" "fmt" "image" "image/color" "image/jpeg" "os" "path" "strings" ) // Demo writes a site root that exercises every feature the engine has, and returns what it wrote. // // A generator rather than stored files: nothing in this repository is content (ADR-0011), so the prose here is // composed on the spot and is deliberately filler. It exists to be *served* — anything the engine can do that // this cannot express is a gap in the engine. // // Refuses a directory that already holds content, because a demo that overwrites somebody's site is worse than // no demo. func Demo(siteDir string) ([]string, error) { root, err := os.OpenRoot(siteDir) if err != nil { return nil, fmt.Errorf("open %s: %w", siteDir, err) } defer root.Close() if _, err := root.Stat("content"); err == nil { return nil, fmt.Errorf("%s already has content; point this at an empty directory", siteDir) } written := []string{} for _, f := range demoFiles() { if err := mkdirAll(root, path.Dir(f.name)); err != nil { return written, err } file, err := root.OpenFile(f.name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) if err != nil { return written, fmt.Errorf("create %s: %w", f.name, err) } _, err = file.Write(f.body) file.Close() if err != nil { return written, fmt.Errorf("write %s: %w", f.name, err) } written = append(written, f.name) } return written, nil } // file is one thing the demo writes. type file struct { name string body []byte } // demoFiles is the whole demo, in one list so what it covers can be read at a glance. // // Every feature appears at least once: two languages with a fallback, a series with ordered chapters, a gallery, // a figure, an include, tags crossing sections, a slug with an alias keeping the old URL, an undated page, a // draft, extras, a template override, static files, and a site declaration. func demoFiles() []file { files := []file{{"site.yaml", []byte("base: http://localhost:8080\ntitle: A Khosra Demo\n")}} files = append(files, demoPosts()...) files = append(files, demoSeries()...) files = append(files, demoGallery()...) files = append(files, demoWriting()...) return append(files, demoSiteFurniture()...) } // demoPosts covers languages, the fallback chain, a rename with an alias, and a draft. func demoPosts() []file { return []file{ // A post in both languages: the Bengali variant proves chrome, dates and digits localise. {"content/posts/first-light/index.en.md", post("First Light", "2026-03-01", []string{"monsoon", "beginnings"}, "The first demo post. Its Bengali twin sits beside it, so the language links in the footer go somewhere.\n\n"+ "Quotes become \"curly\", dashes -- like this -- become dashes, and an ellipsis... arrives as one character.\n")}, {"content/posts/first-light/index.bn.md", post("প্রথম আলো", "2026-03-01", []string{"monsoon"}, "এই লেখাটি বাংলায়। তারিখ, সংখ্যা আর পৃষ্ঠার নাম বাংলায় দেখা যাবে।\n")}, // English only: asking for it in Bengali falls back, and the canonical link says so. {"content/posts/only-english.en.md", post("Only in English", "2026-02-14", []string{"beginnings"}, "There is no Bengali version of this one. Ask for `/bn/posts/only-english/` and the engine serves this,\n"+ "with a canonical link naming the variant it actually gave you.\n")}, // A slug override with an alias, so the old address keeps working. {"content/posts/renamed-thing/index.en.md", []byte("---\ntitle: This Was Renamed\ndate: 2026-02-01\n" + "slug: a-better-name\naliases: [posts/renamed-thing]\n---\n" + "Served at `/posts/a-better-name/`. The path this file implies redirects here instead of breaking.\n")}, // A draft: not served at all until -dev on. {"content/posts/unfinished.en.md", []byte("---\ntitle: Unfinished\ndate: 2026-04-01\ndraft: true\n---\n" + "Invisible without `-dev on`, and so is anything beside it.\n")}, } } // demoSeries covers a landing page with ordered chapters: prev/next, first/last, and an archive. func demoSeries() []file { return []file{ // A series: landing page plus ordered chapters, driving prev/next/first/last and an archive. {"content/comics/the-long-monsoon/_index.en.md", []byte("---\ntitle: The Long Monsoon\ndate: 2026-03-01\n" + "tags: [monsoon]\n---\nA series in three parts. This page lists them in reading order.\n")}, {"content/comics/the-long-monsoon/first-rain.en.md", []byte("---\ntitle: First Rain\ndate: 2026-03-02\n" + "order: 10\n---\nChapter one. `order: 10` puts it first, and inserting a chapter later needs no renaming.\n")}, {"content/comics/the-long-monsoon/the-flood/index.en.md", []byte("---\ntitle: The Flood\ndate: 2026-03-09\n" + "order: 20\n---\nChapter two, a directory bundle so it can own a picture.\n\n" + "{{< figure src=\"water.jpg\" alt=\"A wall of grey water\" caption=\"Day three\" >}}\n")}, {"content/comics/the-long-monsoon/the-flood/water.jpg", photo(1800, 90, 110, 160)}, {"content/comics/the-long-monsoon/aftermath.en.md", []byte("---\ntitle: Aftermath\ndate: 2026-03-16\n" + "order: 30\ntags: [monsoon]\n---\nChapter three. The gaps between 10, 20 and 30 leave room to insert.\n")}, } } // demoGallery covers pictures beside a bundle: enumeration, resampling and a srcset. func demoGallery() []file { return []file{ // A gallery: every picture beside the bundle, sized and offered as a srcset. {"content/art/monsoon-studies/index.en.md", []byte("---\ntitle: Monsoon Studies\ndate: 2026-03-20\n" + "tags: [monsoon]\n---\nThree studies. The gallery below is every picture in this directory.\n\n" + "{{< gallery >}}\n")}, {"content/art/monsoon-studies/10-grey.jpg", photo(1600, 120, 130, 150)}, {"content/art/monsoon-studies/20-green.jpg", photo(1600, 80, 150, 90)}, {"content/art/monsoon-studies/30-blue.jpg", photo(1600, 70, 110, 190)}, } } // demoWriting covers an include and extras: a fragment with no URL, and supporting files that do have one. func demoWriting() []file { return []file{ // An include, and extras: supporting files published as artefacts of the process. {"content/writing/notes-on-water/index.en.md", []byte("---\ntitle: Notes on Water\ndate: 2026-03-25\n" + "tags: [monsoon, beginnings]\n---\nThe finished piece, assembled from a part beside it.\n\n" + "{{< include file=\"_method.md\" >}}\n")}, {"content/writing/notes-on-water/_method.md", []byte("## Method\n\nAn underscore keeps this out of the " + "scan, so it has no URL of its own and never appears in a listing.\n")}, {"content/writing/notes-on-water/extras/gauge.log", []byte("day one: 2m\nday two: 3m\nday three: 3m again\n")}, {"content/writing/notes-on-water/extras/research.md", []byte("## Research\n\nRendered as Markdown inside " + "the extras listing, *emphasis and all*.\n")}, {"content/writing/notes-on-water/extras/scan.jpg", photo(900, 160, 140, 100)}, } } // demoSiteFurniture covers what surrounds the content: an undated page, a template override, static files. func demoSiteFurniture() []file { return []file{ // An undated page: reachable, and correctly absent from every feed. {"content/pages/about.en.md", []byte("---\ntitle: About This Demo\n---\n" + "No date, so this page is not a feed item — which is how the engine decides what belongs in a feed.\n\n" + "Everything here was generated by `khosra demo`. Edit any file while the server runs and the change\n" + "appears within a couple of seconds.\n")}, // A template override: the same block the embedded theme defines, replaced. {"templates/list.html", []byte(`{{define "main" -}}` + "\n" + `

{{.Title}}

` + "\n" + `

This listing comes from the site's own template, not the embedded one.

` + "\n" + `{{- range .Items}}` + "\n" + `

{{if .Title}}{{.Title}}{{else}}{{.Key}}{{end}}

` + "\n" + `{{- if not .Date.IsZero}}

{{end}}` + "\n" + `
` + "\n" + `{{- end}}` + "\n" + `{{- if or .PrevURL .NextURL}}` + "\n" + `{{end}}` + "\n" + `{{- end}}` + "\n")}, {"static/robots-note.txt", []byte("Anything under static/ is served verbatim at /static/.\n")}, } } // post builds a bundle with the fields most posts carry. func post(title, date string, tags []string, body string) []byte { var out bytes.Buffer fmt.Fprintf(&out, "---\ntitle: %s\ndate: %s\n", title, date) if len(tags) > 0 { fmt.Fprintf(&out, "tags: [%s]\n", strings.Join(tags, ", ")) } fmt.Fprintf(&out, "---\n%s", body) return out.Bytes() } // photo is a real JPEG wide enough to earn derivatives, so the demo exercises resampling rather than describing // it. A gradient, because a placeholder should look like a placeholder. func photo(width int, r, g, b uint8) []byte { img := image.NewRGBA(image.Rect(0, 0, width, width*2/3)) for x := range width { for y := range width * 2 / 3 { shade := uint8((x + y) / 12 % 90) img.Set(x, y, color.RGBA{r + shade, g + shade, b - shade/2, 255}) } } var out bytes.Buffer if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 80}); err != nil { return nil } return out.Bytes() }