add khosra new, which writes the first bytes into a site root

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.
This commit is contained in:
Claude Opus 5
2026-07-31 13:01:00 +06:00
committed by bdeshi
parent bc8c26d8f4
commit f3e54b1849
8 changed files with 285 additions and 16 deletions
+6 -1
View File
@@ -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 ./...
+20 -5
View File
@@ -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)")
+42
View File
@@ -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)
}
+11
View File
@@ -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
+15 -10
View File
@@ -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).
+8
View File
@@ -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
+94
View File
@@ -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
}
+89
View File
@@ -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())
}
}
}