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 }