From e79aba2b11da041acd79cbcc139f5513ed7ca88a Mon Sep 17 00:00:00 2001 From: bdeshi Date: Fri, 31 Jul 2026 13:01:00 +0600 Subject: [PATCH] add `khosra new`, which writes the first bytes into a site root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolds a **directory** bundle — the only shape that can own local files, so the other kind would hand an author a page their pictures cannot live beside. What it writes is a draft: title, today's date, `draft: true`. A tool that publishes the moment it runs publishes by accident, and drafts are honoured now. This is the first thing that writes into somebody's content directory, so it goes through os.Root like every read does (ADR-0031), and it never overwrites: an existing bundle is an error. Two bugs found by running it rather than by testing it: A key of `../escape` did not fail. It never left the site root — path.Join collapses `..` first — but it wrote a real directory *inside* the root and outside content/, which is not an escape and not a bundle either. Refused outright now, the same guard the include path needed for the same reason. The test asserts what should be true — content/ is the only thing this creates — because the weaker assertion I wrote first would have passed. `khosra new posts/x -site dir` silently ignored -site, because Go's flag package stops at the first non-flag argument, and then failed complaining there was no site root. Parsed in rounds now, so either order works. main() crossed the function-length warning as a result, so it became a dispatch table with runServe beside it — the warning was right about the code. --- Makefile | 7 +- cmd/khosra/main.go | 25 +++++-- cmd/khosra/new.go | 42 ++++++++++++ docs/content-model.md | 11 +++ docs/state.md | 25 ++++--- internal/ext/scaffold/doc.go | 8 +++ internal/ext/scaffold/scaffold.go | 94 ++++++++++++++++++++++++++ internal/ext/scaffold/scaffold_test.go | 89 ++++++++++++++++++++++++ 8 files changed, 285 insertions(+), 16 deletions(-) create mode 100644 cmd/khosra/new.go create mode 100644 internal/ext/scaffold/doc.go create mode 100644 internal/ext/scaffold/scaffold.go create mode 100644 internal/ext/scaffold/scaffold_test.go diff --git a/Makefile b/Makefile index 417edd4..1b58681 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ SITE ?= $(KHOSRA_SITE) ADDR ?= localhost:8080 BIN := ./khosra -.PHONY: help build run check test verify fmt tidy clean +.PHONY: help build run check new test verify fmt tidy clean help: ## list targets @grep -hE '^[a-z]+:.*##' $(MAKEFILE_LIST) | sed 's/:[^#]*## /|/' | column -t -s '|' @@ -21,6 +21,11 @@ check: build ## validate a site root's content: make check SITE=/path/to/site @test -n "$(SITE)" || { echo "set SITE=/path/to/site, or export KHOSRA_SITE"; exit 1; } $(BIN) check -site "$(SITE)" +new: build ## scaffold a bundle: make new KEY=posts/hello-world SITE=/path/to/site + @test -n "$(SITE)" || { echo "set SITE=/path/to/site, or export KHOSRA_SITE"; exit 1; } + @test -n "$(KEY)" || { echo "set KEY=posts/hello-world"; exit 1; } + $(BIN) new -site "$(SITE)" "$(KEY)" + test: ## run tests with the race detector go test -race ./... diff --git a/cmd/khosra/main.go b/cmd/khosra/main.go index ebb26da..99e05f0 100644 --- a/cmd/khosra/main.go +++ b/cmd/khosra/main.go @@ -18,12 +18,27 @@ import ( ) func main() { - // One subcommand, matched before the flags are defined: `khosra check` validates a site root and exits, - // while a bare `khosra` serves one. A second subcommand is when this wants a table rather than an if. - if len(os.Args) > 1 && os.Args[1] == "check" { - runCheck(os.Args[2:]) - return + // Subcommands, matched before the flags are defined: `check` validates a site root, `new` scaffolds a + // bundle, and a bare `khosra` serves. A switch while there are two; a table when there is a third with + // flags worth sharing. + if len(os.Args) > 1 { + switch os.Args[1] { + case "check": + runCheck(os.Args[2:]) + return + case "new": + runNew(os.Args[2:]) + return + } } + runServe() +} + +// runServe is what a bare `khosra` does: assemble everything and listen. +// +// Its own function so main() stays a dispatch table. Everything is still wired in one place, which is the rule +// that matters (conventions.md). +func runServe() { site := flag.String("site", os.Getenv("KHOSRA_SITE"), "path to the site root (or KHOSRA_SITE)") addr := flag.String("addr", "localhost:8080", "address to listen on") base := flag.String("base", "", "canonical site origin, overriding site.yaml (e.g. https://khosra.example)") diff --git a/cmd/khosra/new.go b/cmd/khosra/new.go new file mode 100644 index 0000000..e4c8f44 --- /dev/null +++ b/cmd/khosra/new.go @@ -0,0 +1,42 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "khosra/internal/ext/scaffold" +) + +// runNew scaffolds a bundle into the site root. +func runNew(args []string) { + flags := flag.NewFlagSet("new", flag.ExitOnError) + site := flags.String("site", os.Getenv("KHOSRA_SITE"), "path to the site root (or KHOSRA_SITE)") + lang := flags.String("lang", "", "language suffix for the file; default locale when empty") + title := flags.String("title", "", "title; derived from the last path segment when empty") + // Parsed in rounds, because flag stops at the first non-flag argument: `new posts/x -site dir` would + // otherwise ignore -site entirely and fail complaining there was no site root, which is a confusing lie. + // Both orders work now. + var names []string + for rest := args; ; { + if err := flags.Parse(rest); err != nil { + fatal("cannot read the arguments", err) + } + if flags.NArg() == 0 { + break + } + names = append(names, flags.Arg(0)) + rest = flags.Args()[1:] + } + if *site == "" { + fatal("no site root: pass -site or set KHOSRA_SITE", nil) + } + if len(names) != 1 { + fatal("name exactly one bundle, like: khosra new posts/hello-world", nil) + } + written, err := scaffold.New(*site, names[0], *lang, *title) + if err != nil { + fatal("nothing was written", err) + } + fmt.Printf("wrote %s\nit is a draft: remove `draft: true` when it is ready\n", written) +} diff --git a/docs/content-model.md b/docs/content-model.md index f5fd472..8817cab 100644 --- a/docs/content-model.md +++ b/docs/content-model.md @@ -215,6 +215,17 @@ test: a tag is free-form and cross-cutting, a declared taxonomy has known terms Feeds follow the same shape: `/tags/{tag}/feed.xml` comes free from the same Query as the listing. +## Scaffolding + +`khosra new posts/hello-world` writes `content/posts/hello-world/index.en.md` — a **directory** bundle, because +only that shape can own local files, so scaffolding the other kind would hand an author a page their pictures +cannot live beside. `-lang` picks the language suffix and `-title` the title, which otherwise comes from the +last path segment. + +What it writes is a draft: `title`, today's `date`, and `draft: true`. A tool that publishes the moment it runs +publishes by accident. Nothing is ever overwritten, and a key containing `..` is refused — it names a place +under `content/`, not a path to walk. + ## Site settings `site.yaml` at the site root declares the site (ADR-0039). Declared keys only — absent is fine, since a bare diff --git a/docs/state.md b/docs/state.md index d4eb7c0..c0046b9 100644 --- a/docs/state.md +++ b/docs/state.md @@ -1,6 +1,6 @@ # State -**Verified against:** `8a7560a` on 2026-07-30 — update this line every change. +**Verified against:** `d7543a7` on 2026-07-30 — update this line every change. If this file disagrees with the code, the code is right and this file is a bug. ## Inventory @@ -17,6 +17,7 @@ If this file disagrees with the code, the code is right and this file is a bug. | `internal/render/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034) | 105 | | `internal/render/templates/` | reference theme: `base.html`, `page.html`, `list.html`, `shortcodes.html`, `theme.css` (ADR-0026) | — | | `internal/ext/shortcodes/` | first feature: `{{< name key="value" >}}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery`, `include`, plus the derivative pass and remembered picture inspection (ADR-0042, ADR-0044) | 564 | +| `internal/ext/scaffold/` | writes a new bundle into the site root through `os.Root`: a directory bundle, a draft, never an overwrite | 102 | | `internal/ext/check/` | third feature: validates a site root — what the engine worked around, broken internal links, missing titles and alt text, mixed series ordering | 216 | | `cmd/khosra/wire.go` | the only list of enabled features (`extensions.md`) | 20 | | `internal/web/resolve.go` | URL → (key, lang, page, tag) or a canonical redirect: language prefix, `/en/…` fork guard, pagination, tags, trailing slash | 112 | @@ -24,9 +25,10 @@ If this file disagrees with the code, the code is right and this file is a bug. | `internal/web/feed.go` | Atom for the site, a section or a tag, from dated bundles via one Query (ADR-0043) | 125 | | `internal/web/discover.go` | `/robots.txt` and `/sitemap.xml`, absolute and only with a declared base (ADR-0039) | 74 | | `internal/web/web.go` | handler: resolve, look up with fallback, section and tag listings, sequence, `/static/` (misses and refusals alike answer 404), degrade on failure | 152 | -| `cmd/khosra/main.go` | flags (`-site`, `-addr`, `-base`, `-cache`, `-dev`), wiring, startup including the derivative pass — the only place things are assembled | 92 | +| `cmd/khosra/main.go` | flags (`-site`, `-addr`, `-base`, `-cache`, `-dev`), wiring, startup including the derivative pass. `main` dispatches subcommands, `runServe` assembles the server — still the only place anything is wired | 116 | | `cmd/khosra/check.go` | the `check` subcommand: parse, print, exit code. What counts as a finding lives in the feature | 45 | -| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path and the checker, unpublished visibility, listing shapes | 2643 | +| `cmd/khosra/new.go` | the `new` subcommand: arguments in either order, then the feature does the writing | 42 | +| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path and the checker, unpublished visibility, listing shapes, scaffolding | 2732 | Serves a bundle at `/{section}/{slug}/` — the slug derived, or declared in frontmatter without moving the key (ADR-0035) — a paginated listing per section, tag listings global and @@ -35,7 +37,8 @@ URL, generated derivatives under `/derived/`, Atom feeds per site, section and tag, plus `/robots.txt` and `/sitemap.xml`. Chrome text, dates and digits render in English or Bengali; authored text is untouched but for typographic smoothing (ADR-0034); line breaking is left to CSS (ADR-0045). This repo holds engine source only — the site root is external and passed with -`khosra check` validates a site root and exits non-zero on anything that makes it wrong. A draft or +`khosra check` validates a site root and exits non-zero on anything that makes it wrong; `khosra new` +scaffolds a draft bundle into one. A draft or future-dated bundle is not served at all — nor is any file inside it (ADR-0024) — until `-dev on` reveals it and reloads templates per request. `-site` (ADR-0011). `site.yaml` declares `base` and `title`; with a base, canonical, hreflang and OpenGraph @@ -59,15 +62,17 @@ this change*. | Collection pages | 4 | **1** — done | Query primitive: `content.Query{Section, Tag, Lang}` + `Site.Run`. The fourth — a series archive — resolves through `Site.Sequence` instead: membership is structural and the sort ascends, so it shares the index but not the Query | | Views — **per-bundle selection only** | 0 | **2** | The View layer `architecture.md` describes: `view:` in frontmatter choosing a presentation, resolved through the cascade. Nothing selects a view yet. *Output formats* are counted separately and are not it: HTML, sitemap XML and Atom are three functions with nothing to share — an interface over them would have one member and no leverage | | Effects | 1 | **2** | Effect runner + trigger wiring (change / schedule / demand). The first is the derivative pass (ADR-0042), called straight from `cmd` at startup — one call needs no runner, and startup is the only change signal until queue 21 | -| Extensions | 2 | **3** | Extension registry (`extensions.md`). Was briefly 3; deleting the widows feature (ADR-0045) put it back to 2, which is the counter doing its job — a threshold reached by a feature that should not have existed was not a threshold | +| Extensions | 3 | **3** — due, and the answer is still no | Extension registry (`extensions.md`). It reached 3 once before and went back to 2 when the widows feature was deleted (ADR-0045) — a threshold reached by a feature that should not exist was never a threshold. It is 3 again with `scaffold`, and the note below the table says why a registry still buys nothing | | Interface implementations | — | **2** | The interface itself | | Non-stdlib dependencies | 4 direct | budget in `scripts/budgets.env` | — | -**When the Extensions counter comes due, look at how the features plug in.** The two that exist attach in -different ways — `shortcodes` is a goldmark extender in `extenders()`, `check` is a function `cmd` calls — so a -registry would have to abstract over "extends Markdown" and "validates content", which share nothing but the -word *feature*. Revisit when a third wants a *third* way in, or when one wants a route (the seam ADR-0042 -named). +**The Extensions counter is due, and a registry would still buy nothing.** The three features attach in two +unrelated ways: `shortcodes` is a goldmark extender listed in `extenders()`, while `check` and `scaffold` are +functions `cmd` calls for a subcommand. A registry would have to abstract over "extends Markdown", "validates +content" and "writes a file", which share nothing but the word *feature* — one member and no leverage. What the +counter is really detecting is that two of the three are commands, and commands compose fine as a switch in +`main`. Build the registry when a feature wants a **route** (the seam ADR-0042 named) or when two features need +to agree on an order. Allowlist, all four imported: `goldmark` (markdown), `golang.org/x/text` (NFC, ADR-0015), `gopkg.in/yaml.v3` (frontmatter, ADR-0020), `golang.org/x/image` (resampling and WebP, ADR-0040). diff --git a/internal/ext/scaffold/doc.go b/internal/ext/scaffold/doc.go new file mode 100644 index 0000000..295bebc --- /dev/null +++ b/internal/ext/scaffold/doc.go @@ -0,0 +1,8 @@ +// Package scaffold writes a new bundle into a site root. +// +// Contributes: the `new` subcommand (no request-path behaviour). +// Cascade keys: none. +// Contract fields: none. +// Not doing: per-type templates for the scaffold — an author who wants their own boilerplate wants archetypes, +// which is a site-root feature and a decision, not a flag. +package scaffold diff --git a/internal/ext/scaffold/scaffold.go b/internal/ext/scaffold/scaffold.go new file mode 100644 index 0000000..cbbed08 --- /dev/null +++ b/internal/ext/scaffold/scaffold.go @@ -0,0 +1,94 @@ +package scaffold + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path" + "strings" + + "khosra/internal/content" +) + +// New writes a bundle for key in lang, and returns the file it created. +// +// A **directory** bundle, always: only a directory bundle can own local files (content-model.md), so scaffolding +// the other shape would hand an author a page their pictures cannot live beside. The frontmatter says +// `draft: true`, because a tool that publishes the moment it is run is a tool that publishes by accident — and +// drafts are honoured now (ADR-0024). +// +// Every write goes through [os.Root], so a key with `..` in it cannot escape the site root any more than a +// request can (ADR-0031). Nothing is overwritten: an existing bundle is an error, never a silent replacement. +func New(siteDir, key, lang, title string) (string, error) { + key = content.Normalise(strings.Trim(strings.TrimSpace(key), "/")) + if key == "" { + return "", errors.New("no key: pass something like posts/hello-world") + } + // `..` is refused before anything is joined. os.Root stops an escape from the site root, but path.Join + // collapses `..` first — so `../outside` would have written a real directory *inside* the root and outside + // content/, which is not an escape but is not a bundle either. The include guard exists for the same reason. + for _, segment := range strings.Split(key, "/") { + if segment == ".." || segment == "." { + return "", fmt.Errorf("a key names a place under content/, so %q cannot contain %q", key, segment) + } + } + if lang == "" { + lang = content.DefaultLang + } + if title == "" { + title = titleFrom(key) + } + root, err := os.OpenRoot(siteDir) + if err != nil { + return "", fmt.Errorf("open site root %s: %w", siteDir, err) + } + defer root.Close() + + dir := path.Join("content", key) + if err := mkdirAll(root, dir); err != nil { + return "", err + } + name := path.Join(dir, "index."+lang+".md") + if _, err := root.Stat(name); err == nil { + return "", fmt.Errorf("%s already exists, and nothing here overwrites content", name) + } + file, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return "", fmt.Errorf("create %s: %w", name, err) + } + defer file.Close() + if _, err := file.WriteString(frontmatter(title)); err != nil { + return "", fmt.Errorf("write %s: %w", name, err) + } + return name, nil +} + +// frontmatter is the bundle a new page starts as: the one required field, today's date, and a draft flag the +// author removes when it is ready. +func frontmatter(title string) string { + return fmt.Sprintf("---\ntitle: %s\ndate: %s\ndraft: true\n---\n\n", + title, content.Now().Format("2006-01-02")) +} + +// titleFrom turns a slug into a plausible title: hyphens and underscores become spaces, and the first letter is +// capitalised. Only the first — the engine does not know which of an author's words are proper nouns. +func titleFrom(key string) string { + words := strings.NewReplacer("-", " ", "_", " ").Replace(path.Base(key)) + if words == "" { + return "" + } + return strings.ToUpper(words[:1]) + words[1:] +} + +// mkdirAll creates dir and any missing parent inside the root, since os.Root offers one level at a time. +func mkdirAll(root *os.Root, dir string) error { + built := "" + for _, segment := range strings.Split(dir, "/") { + built = path.Join(built, segment) + if err := root.Mkdir(built, 0o755); err != nil && !errors.Is(err, fs.ErrExist) { + return fmt.Errorf("create %s: %w", built, err) + } + } + return nil +} diff --git a/internal/ext/scaffold/scaffold_test.go b/internal/ext/scaffold/scaffold_test.go new file mode 100644 index 0000000..a89572a --- /dev/null +++ b/internal/ext/scaffold/scaffold_test.go @@ -0,0 +1,89 @@ +package scaffold + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "khosra/internal/content" +) + +func TestNewWritesADirectoryBundleThatScansAsADraft(t *testing.T) { + dir := t.TempDir() + written, err := New(dir, "posts/hello-world", "", "") + if err != nil { + t.Fatal(err) + } + // A directory bundle, because only that shape can own local files (content-model.md). + if written != "content/posts/hello-world/index.en.md" { + t.Errorf("wrote %q, want a directory bundle", written) + } + data, err := os.ReadFile(filepath.Join(dir, written)) + if err != nil { + t.Fatal(err) + } + // Parsed by the engine's own parser, not by eye: the scaffold has to be something khosra can read. + b, err := content.Parse(strings.TrimPrefix(written, "content/"), data) + if err != nil { + t.Fatalf("the engine cannot parse its own scaffold: %v\n%s", err, data) + } + if b.Title != "Hello world" { + t.Errorf("title = %q, want one derived from the slug", b.Title) + } + if !b.Draft { + t.Error("a scaffold must be a draft: a tool that publishes when it runs publishes by accident") + } + if b.Date.IsZero() { + t.Error("no date was written") + } + if b.Key != "posts/hello-world" { + t.Errorf("key = %q", b.Key) + } +} + +func TestNewHonoursLanguageAndTitle(t *testing.T) { + dir := t.TempDir() + written, err := New(dir, "pages/about", "bn", "পরিচিতি") + if err != nil { + t.Fatal(err) + } + if written != "content/pages/about/index.bn.md" { + t.Errorf("wrote %q, want the Bengali variant", written) + } + data, _ := os.ReadFile(filepath.Join(dir, written)) + if !strings.Contains(string(data), "title: পরিচিতি") { + t.Errorf("the given title should be used verbatim:\n%s", data) + } +} + +func TestNewNeverOverwritesAndNeverEscapes(t *testing.T) { + dir := t.TempDir() + if _, err := New(dir, "posts/twice", "", ""); err != nil { + t.Fatal(err) + } + if _, err := New(dir, "posts/twice", "", ""); err == nil { + t.Error("a second run must refuse rather than replace someone's writing") + } + // os.Root refuses an escape, and the key is normalised and trimmed before it is used at all (ADR-0031). + for _, key := range []string{"../outside", "posts/../../outside", "", "/"} { + if _, err := New(dir, key, "", ""); err == nil { + t.Errorf("New(%q) should have failed", key) + } + } + if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "outside")); err == nil { + t.Fatal("something was written outside the site root") + } + // The weaker check above is not enough: `../outside` does not escape the root, it lands *inside* it and + // outside content/, because path.Join collapses `..` before os.Root ever sees the name. So assert what + // should be true — content/ is the only thing this ever creates. + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if e.Name() != "content" { + t.Errorf("created %q at the site root; only content/ should ever appear", e.Name()) + } + } +}