add khosra demo, and give the site a front page
The demo writes a whole site root that exercises every feature: two languages with a fallback, a series with ordered chapters, a gallery, a figure, an include, extras, tags across sections, a slug with an alias, an undated page, a draft, a template override, static files and site.yaml. It generates its filler rather than copying stored files, because nothing in this repository is content (ADR-0011) — and that makes it a test of the engine rather than a fixture: anything khosra can do that the demo cannot express is a gap. Two things found by generating and then serving it, which is the whole point: `khosra check` reported the demo's own series as mixing ordered and unordered members. It was right — the chapter bodies *described* `order: 10` while the frontmatter never carried it. The checker caught its own author. And `/` was a **404**. ADR-0008 leaves the root engine-owned, which is right, but "engine-owned" was never given an answer, so a visitor to the site's own address got nothing. The root now lists every bundle, newest first, paginated like any other listing, and 404s only when nothing is published. A hand-written home page stays a separate decision, recorded as such. Verified end to end: 23 files written, 12 bundles, 12 derivatives, `check` clean, and every URL the demo promises answers — including the alias redirecting, the draft hidden, and the front page rendering through the site's *own* template override.
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
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" +
|
||||
`<h1>{{.Title}}</h1>` + "\n" +
|
||||
`<p><em>This listing comes from the site's own template, not the embedded one.</em></p>` + "\n" +
|
||||
`{{- range .Items}}` + "\n" +
|
||||
`<article class="entry"><h2><a href="{{.URL}}">{{if .Title}}{{.Title}}{{else}}{{.Key}}{{end}}</a></h2>` + "\n" +
|
||||
`{{- if not .Date.IsZero}}<p><time datetime="{{.Date.Format "2006-01-02"}}">{{day $.Lang .Date}}</time></p>{{end}}` + "\n" +
|
||||
`</article>` + "\n" +
|
||||
`{{- end}}` + "\n" +
|
||||
`{{- if or .PrevURL .NextURL}}` + "\n" +
|
||||
`<nav class="pagination">` + "\n" +
|
||||
`{{- if .PrevURL}}<a rel="prev" href="{{.PrevURL}}">{{t .Lang "newer"}}</a>{{end}}` + "\n" +
|
||||
`<span>{{t .Lang "page-of" (num .Lang .Page) (num .Lang .Pages)}}</span>` + "\n" +
|
||||
`{{- if .NextURL}}<a rel="next" href="{{.NextURL}}">{{t .Lang "older"}}</a>{{end}}` + "\n" +
|
||||
`</nav>{{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()
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package scaffold
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -87,3 +88,51 @@ func TestNewNeverOverwritesAndNeverEscapes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDemoIsAWholeSiteThatTheEngineAccepts(t *testing.T) {
|
||||
// The demo's real assertion is that khosra can serve what khosra wrote, so this checks the shape and leaves
|
||||
// the serving to the web tests. Anything the engine can do that the demo cannot express is a gap.
|
||||
dir := t.TempDir()
|
||||
written, err := Demo(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(written) < 20 {
|
||||
t.Errorf("wrote %d files, which is too few to exercise the engine", len(written))
|
||||
}
|
||||
// Every feature is represented, named by the file that carries it.
|
||||
for _, want := range []string{
|
||||
"site.yaml",
|
||||
"content/posts/first-light/index.en.md", "content/posts/first-light/index.bn.md", // two languages
|
||||
"content/posts/renamed-thing/index.en.md", // slug plus alias
|
||||
"content/posts/unfinished.en.md", // a draft
|
||||
"content/comics/the-long-monsoon/_index.en.md", // a series landing
|
||||
"content/comics/the-long-monsoon/the-flood/water.jpg", // a figure's picture
|
||||
"content/art/monsoon-studies/10-grey.jpg", // a gallery
|
||||
"content/writing/notes-on-water/_method.md", // an include's fragment
|
||||
"content/writing/notes-on-water/extras/research.md", // extras
|
||||
"content/pages/about.en.md", // undated
|
||||
"templates/list.html", "static/robots-note.txt",
|
||||
} {
|
||||
if !slices.Contains(written, want) {
|
||||
t.Errorf("the demo does not cover %s", want)
|
||||
}
|
||||
}
|
||||
|
||||
// The bundles it wrote are bundles: parsed by the engine's own scanner, not by eye.
|
||||
bundles, problems, err := content.ScanReport(os.DirFS(dir))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(problems) != 0 {
|
||||
t.Errorf("the engine cannot read its own demo: %v", problems)
|
||||
}
|
||||
if len(bundles) < 10 {
|
||||
t.Errorf("scanned %d bundles, want the whole demo", len(bundles))
|
||||
}
|
||||
|
||||
// And it refuses to write over a site that already has content.
|
||||
if _, err := Demo(dir); err == nil {
|
||||
t.Error("a demo that overwrites somebody's site is worse than no demo")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ var chrome = map[string]map[string]string{
|
||||
"position": {"en": "%s of %s", "bn": "%s / %s"},
|
||||
"extras": {"en": "Extras", "bn": "অতিরিক্ত"},
|
||||
"back-to-page": {"en": "Back to the page", "bn": "পৃষ্ঠায় ফিরুন"},
|
||||
"everything": {"en": "Everything", "bn": "সবকিছু"},
|
||||
"first": {"en": "First", "bn": "প্রথম"},
|
||||
"last": {"en": "Last", "bn": "শেষ"},
|
||||
}
|
||||
|
||||
@@ -346,7 +346,14 @@ func (r *Renderer) Listing(section, lang string, all []content.Bundle, page int)
|
||||
if err := r.fresh(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l, window := r.paginate(section, lang, content.PageURL(section, lang, page), all, page,
|
||||
// The root has no section to name itself after, so it borrows the site's title, or says what it is.
|
||||
title := section
|
||||
if title == "" {
|
||||
if title = r.settings.Title; title == "" {
|
||||
title = text(lang, "everything")
|
||||
}
|
||||
}
|
||||
l, window := r.paginate(title, lang, content.PageURL(section, lang, page), all, page,
|
||||
func(p int) string { return content.PageURL(section, lang, p) })
|
||||
for _, b := range window {
|
||||
l.Items = append(l.Items, r.item(b, lang))
|
||||
|
||||
@@ -41,9 +41,11 @@ func resolve(path string, site *content.Site) (resolution, bool) {
|
||||
if path == "" || path[0] != '/' {
|
||||
return resolution{}, false
|
||||
}
|
||||
// The root is a listing of everything, not a miss: the engine owns "/" (ADR-0008), so it answers with the
|
||||
// one thing it can — every bundle, newest first (ADR-0050).
|
||||
trimmed := strings.Trim(path, "/")
|
||||
if trimmed == "" {
|
||||
return resolution{}, false
|
||||
return resolution{lang: content.DefaultLang, page: 1}, true
|
||||
}
|
||||
|
||||
lang, key, redirect := cutLang(content.Normalise(trimmed), site)
|
||||
|
||||
+3
-1
@@ -74,7 +74,9 @@ func serveStatic(sub fs.FS) http.Handler {
|
||||
// A section is not a bundle, so this runs only after the bundle lookup misses. A page number past the
|
||||
// end is a 404 rather than an empty page, because an empty page is a URL that means nothing.
|
||||
func serveListing(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, res resolution) bool {
|
||||
if res.key == "" || strings.Contains(res.key, "/") {
|
||||
// An empty key is the site root, which lists everything. Anything with a slash in it is a bundle path that
|
||||
// missed, not a section.
|
||||
if strings.Contains(res.key, "/") {
|
||||
return false
|
||||
}
|
||||
items := site.Run(content.Query{Section: res.key, Lang: res.lang})
|
||||
|
||||
@@ -50,9 +50,39 @@ func TestServeBundleAtItsPermalink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRootListsEverything(t *testing.T) {
|
||||
// The engine owns "/" (ADR-0008), so it answers with the one thing it can: every bundle, newest first
|
||||
// (ADR-0050). Found by serving the demo, where the front page was a 404.
|
||||
h := testHandler(t)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET / = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"About", "Hello"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the root should list every section's bundles, missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
|
||||
// A site with nothing published has no front page rather than an empty one, which is the same rule every
|
||||
// listing follows.
|
||||
empty, err := render.New(nil, content.Settings{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bare := Handler(Fixed(content.NewSite(nil)), empty, nil, nil, content.Settings{})
|
||||
rec = httptest.NewRecorder()
|
||||
bare.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("an empty site's root = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownPathsAre404(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
for _, path := range []string{"/", "/nope/", "/pages/nope", "/pages/about/deeper/"} {
|
||||
for _, path := range []string{"/nope/", "/pages/nope", "/pages/about/deeper/"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
|
||||
Reference in New Issue
Block a user