From e9f29247b0cf877083f31539c237522e9ec7d033 Mon Sep 17 00:00:00 2001 From: draw me an elephant <68925779+drawmeanelephant@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:07:18 -0400 Subject: [PATCH 1/2] feat(wrap): oliver wrap --template --meta-json --assets-root --body (Phase 6 S2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the 7-token template dialect for `oliver wrap`: - $title$/$description$/$author$/$date$/$palette$ → html_escaped meta - $assets_root$/$body$ → literal - $if(name)$...$endif$ → conditional blocks (first $endif$ closes, no nesting, verbatim unknown) - Unknown $word$ → verbatim passthrough Closes #108 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- docs/PHASE6-PLAN.md | 292 ++++++++++++++++++++++++++++ src/main.zig | 161 ++++++++++++++-- src/wrap.zig | 459 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 896 insertions(+), 16 deletions(-) create mode 100644 docs/PHASE6-PLAN.md create mode 100644 src/wrap.zig diff --git a/docs/PHASE6-PLAN.md b/docs/PHASE6-PLAN.md new file mode 100644 index 0000000..0aaf192 --- /dev/null +++ b/docs/PHASE6-PLAN.md @@ -0,0 +1,292 @@ +# Phase 6 — Rotkeeper Boundary Rationalization (S1–S5) — Plan & Notes + +**Status:** planning only, no code changes yet +**Date:** 2026-08-21 +**Source of truth:** `home/content/docs/oliver-contract.md:160` (rotkeeper, v1.10) + `bones/scripts/rc-oliver-adapter.sh` + `bones/scripts/rc-render.sh` + `bones/scripts/rc-test.sh` +**Pin:** `OLIVER_PIN=6edb520cabb31220995e676a95bf59cfb0e1ce4b` (`rotkeeper/scripts/setup.sh:76`) +**Open issues:** #107 (S1 meta), #108 (S2 wrap), #109 (S3 link rewrite), #110 (S4+S5 plan+manifest) + +This doc records the pre-implementation review for the four open issues so the slices can land in one bump with no churn. It does not change behavior; it is the planning gate before any `src/` edit. + +--- + +## 1. What the contract says today (no-OLIVER on current pin) + +| Slice | What moves to Oliver | Fallback on `6edb520c` | Contract line | File that owns fallback today | +|-------|----------------------|------------------------|---------------|-------------------------------| +| S1 | `oliver meta --from --format json < file > meta.json` (stdin → stdout JSON) + auto-strip in `oliver render` | `yq --front-matter extract` + `awk 'NR==1=="---" {skip; next} skip && $0=="---"{skip=0; next} !skip'` | `oliver-contract.md:79-103` + `rc-oliver-adapter.sh:91-129` | `rc-oliver-adapter.sh:99-120` (probe `oliver meta --help`) | +| S2 | `oliver wrap --template --meta-json --assets-root --body > page.html` (7-token dialect) | GAWK `literal_replace` + `evaluate_if` + `html_escape` | `oliver-contract.md:115-145` | `rc-oliver-adapter.sh:356-482` | +| S3 | `oliver render --from [--to xhtml]` rewrites `href/src` `.md/.textile/.cook → .html` at AST level | GAWK regex `/(href|src)=("|\x27)([^"\x27]+)("|\x27)/` + suffix replace | `oliver-contract.md:160` + `rc-oliver-adapter.sh:77-89, 288-353` | `rc-oliver-adapter.sh:296-352` + `rc-test.sh:391-414` | +| S4 | `oliver plan --content-dir … --output-dir … --template-dir … --meta-dir … --default-template --oliver-bin --root-dir --dry-run --verbose > batch.tsv` (13 cols, collision abort, `ASSETS_ROOT` depth) | Bash `find` + `strip_source_ext` + `rk_up_dirs` + `declare -A EXPECTED_OUTPUTS` collision check | `oliver-contract.md:162` | `rc-render.sh:289-358` | +| S5 | `oliver manifest --manifest --add ` (dedup) + `oliver manifest --manifest --verify` | `grep -Fxq` / `echo >> manifest` | `oliver-contract.md:164` | `rc-render.sh:136-153` | + +Harness shape (`rc-test.sh`): + +* Hermetic (always): 3 layout passes `crypt`/`busy`/`sterile` through a `fake_oliver` that already mimics S1–S5 (so the adapter pipeline is green on the current pin). Checks: ugly-edge-case.md 8 link cases, `smoke-fixture-expected.html` golden body, `v051-test` escaping/sidecar, S1 multiline+scalar-only+line-1 rule, S2 custom template + `$if$` empty-removal + unknown-token verbatim, S3 rewrites, S4 `plan --help`, S5 manifest dedup. +* Real-`oliver` (when present, `RK_STRICT=1` gates it green in CI): re-renders same sources through the real binary; on S1–S5 the probe skips with `"binary lacks (expected on pin 6edb520c, SX will bump)"` until performance is succeeded. +* Contract corpus: `bones/scripts/tests/fixtures/oliver-contract/{contract-inline.md,contract-blocks.md,contract-table.md}` rendered through real Oliver; asserts GFM tables, bold/autolink/entity/code-span, blockquote/ol/hr, etc. `RK_STRICT=1` + `xmllint` gates the XHTML profile. + +--- + +## 2. Sequencing — why one bump, strict order, but S1→S2→S3→S4→S5 + +* The contract orders S1 first for a reason: without `oliver meta`, `oliver render` can’t reliably auto-strip frontmatter and the adapter’s `awk` remains authoritative. S1 unlocks the invariant “render never sees frontmatter bytes.” +* S2 depends on S1’s `meta.json` shape (the 7 fields). GAWK currently builds `wrap_meta` from 5 fields via `jq`; the contract wants 7 → wrap must read the S1 JSON. Implementing wrap before meta would force a second change. +* S3 is independent of S1/S2 at the CLI surface, but the contract-corpus harness asserts rewritten links (`href="my-first-page.html"`). If S1/S2 ship without S3, the corpus moves from “hermetic-only” to “real-OLIVER-only” and `RK_STRICT=1` would fail. +* S4 (`plan`) determines where `soul` lives (`meta_dir/$(strip_source_ext rel).soul.md`) and what `ASSETS_ROOT` is. S5 (`manifest`) logs each `outfile`. S4 must be correct before S5’s verify matters. Both are pure filesystem planning — no markup — so they can be built last, but they must ship together so the `rc-render.sh` batch TSV (13 columns) is the single wire. + +**Recommended branch order:** one branch, 4 commits (S1, S2, S3, S4+S5) → one PR, one pin bump in `rotkeeper/scripts/setup.sh:76`. Do not bump the pin per slice; each intermediate pin would be green only in hermetic mode and would confuse `RK_STRICT=1` bisect. + +--- + +## 3. Cross-cutting risks & invariants to preserve + +1. **No filesystem in the core** (`docs/ARCHITECTURE.md: "core has no host dependencies: no filesystem, clock, network, or threads — ready for later WASM embedding"`). S4/S5 read directories and write `manifest.txt`, S2 reads `template`/`meta-json`/`body` files. These must live in `src/main.zig` (the CLI adapter), not in `src/oliver.zig`/`src/frontmatter.zig`/`src/html.zig`. Library stays embeddable; CLI stays a thin adapter. +2. **BOM / line-1 rule:** S1 issue body says “line 1 `---`, no BOM, `...` not honored”. The shared `frontmatter.zig:preprocess` already handles CRLF via `source.Lines` and rejects BOM implicitly (first three bytes are `0xEF 0xBB 0xBF`, not `---`), but it currently allows a BOM-free file with a leading blank line to fail open (no block) — which matches the harness’s `frontmatter-s1b.md` expectation. Keep that; add a test that `"\xEF\xBB\xBF---\ntitle: x\n---\n"` yields `metadata==null` and body is the raw bytes. +3. **Scalar-only & `null`→"" contract:** Existing `frontmatter.zig` supports nested maps/lists/double-quoted decoding. S1 wants “scalar strings only, lists/maps ignored, `null`/empty → `""`” for the 7 fields. Do **not** restrict `frontmatter.zig` itself; instead gate inside the `oliver meta` command: parse via `frontmatter.preprocess(... parse=true ...)` then project only `title/description/author/date/template/palette/render_profile` where `Value == .scalar`, else `""`. `Value.list`/`Value.map` → `""`. +4. **`...` not honored:** Current `preprocess` closes only at the same fence (`---` ↔ `---`, `+++` ↔ `+++`). It does not treat `...` as a close, which already matches the contract. Add a pinning test: `"---\ntitle: x\n...\n---\nbody"` → block is `"title: x\n...\n"` + body is empty string, not `"title: x"` + body `"...\n---\nbody"`. +5. **`html_escape` is Oliver’s renderer escape, not shell escape:** Contract says `& < > " '` → `& < > " '` for 5 fields, `$assets_root$`/`$body$` literal. Reuse `html.zig:writeEscaped` (text policy: `&`/`/"/NUL`) plus the single-quote `'` the GAWK `html_escape` uses. Verify `title="Cats & Dogs "` → `Cats & Dogs <v0.5.1>`. +6. **Link rewriting must be AST-level, not regex.** The GAWK fallback regex leaks `<`/`>`/`%3C` stripping. At AST level the link’s `href`/`src` is already percent-decoded and entity-decoded (`src/markdown.zig` link dest/title, `src/html.zig:writeEscapedHref` re-encodes). The transform should be applied between `oliver.parse` and `oliver.html.render` by rewriting the document’s `link.href`/`link.src`/`image.src` leaves (and possibly `autolink.href` already contains `foo.md` text) — do not mangle rendered HTML bytes. +7. **`OLIVER_PIN` bump must re-run `rk-test.sh` under `RK_STRICT=1` and `xmllint`.** Issue bodies explicitly say “Pin: bump → re-run harness, update contract table.” Plan a CI matrix leg that builds Oliver `zig build`, installs it, then `bash rotkeeper.sh test` in rotkeeper’s checkout. + +--- + +## 4. Issue #107 — `oliver meta` (S1) — Detailed notes + +### CLI shape (from issue + rc-test.sh probe) +``` +oliver meta --from --format json < file.md > meta.json +oliver meta --help # must exit 0, print "Usage: oliver meta --from ... --format json" +``` +* `--from` is required, values exactly `markdown|textile|cooklang` (fail `error.Usage` otherwise). +* `--format` is required, value exactly `json` (only one format today; keeps the wire extensible). +* Reads stdin → writes stdout JSON. No file args; `rc-oliver-adapter.sh:101-102` does `< "$src_path"`. +* Exit 0 on success, 1 on usage error, never on frontmatter parse fallback — out-of-subset stays `""`. +* `oliver render --from ` still works with no flag change; its implementation will call the same `frontmatter.preprocess(..., mode=.yaml, parse=true, ...)` and `result.metadata` rather than the adapter’s `awk`. + +### Input rules (contract + harness) +* **Leading YAML only:** `---` on line 1, column 1, no BOM, no leading blank line. First `---` opens, next `---` closes. `...` does **not** close (contract `oliver-contract.md:110`). +* **Scalar-only:** only the 7 keys matter; value must be a scalar. Lists/maps → treated as missing (`""`). This matches `rc-test.sh:612-621` (`tags: [ignored, list]` + `extra_map: {key: value}` ignored). +* **`null`→"" and empty →"" :** `yq -r '.title // ""'` already does this; Oliver’s JSON must emit `""` for `null`, missing, or out-of-subset. The fake uses `{"title": .title, ...}` → JSON `null` when missing; adapter normalizes `[[ "$doc_title" == "null" ]] && doc_title=""`. Oliver should emit `""` directly so the normalization stays but isn’t required. +* **BOM, `...`, sidecar precedence:** S1 does **not** handle sidecars; `rc-oliver-adapter.sh` stays authoritative for per-field `bones/meta/*.soul.md` (sidecar wins when non-empty and not `null`). S1 only extracts source file; sidecar is a second `oliver meta` call. + +### Output shape +```json +{"title":"","description":"","author":"","date":"","template":"","palette":"","render_profile":""} +``` +* Exactly those 7 keys, always present, always strings. No extra keys (adapter’s `yq eval '.'` validates shape). +* Values are raw lexical scalars (e.g. `" hello "` stays `" hello "`? Contract says scalar strings, HTML-escaped later in S2, not here; keep raw). +* JSON is stdout only; stderr is warnings (if any) but not body HTML. + +### Where to implement +* New file `src/meta.zig` or `src/cli/meta.zig`? Prefer `src/meta.zig` (pure projection) + wired in `src/main.zig` `Command.meta`/`parseArgs` branch. Library export `oliver.meta` can be a thin helper `extractMeta(allocator, input, dialect, &json)`. Keep `src/frontmatter.zig` unchanged for generic parsing; `meta.zig` projects. +* Tests: `src/main.zig` unit tests for arg parsing + `tests/meta_test.zig` or fixture `tests/fixtures/meta-s1-*`. + +### Harness probe expectations +```bash +$fake_bin meta --help | grep -q 'meta' +$fake_bin meta --from markdown --format json < probe.md | grep -q '"title": "Probe"' +# real probe on current pin must skip, not fail +``` +* Our real probe in `rc-test.sh:678-691` must flip to `"Pass: real Oliver meta extraction probe"` after the bump. The hermetic `smoke-fixture-*` tests stay green regardless. + +### Open question for #107 +* Should `oliver meta` honor `frontmatter.Option` TOML (`+++`) or only YAML (`---`)? Issue says `--from ` but output contract lists YAML only. Harness’s `frontmatter-s1.md` uses `---`. Safer to implement YAML only for now and reject `+++` as “no frontmatter → empty strings”, with a `// TODO: TOML` note. + +--- + +## 5. Issue #108 — `oliver wrap` (S2) — Detailed notes + +### CLI shape (from contract + rc-test.sh probe) +``` +oliver wrap --template --meta-json --assets-root --body > page.html +oliver wrap --help # must exit 0, print "Usage: oliver wrap --template ..." +``` +* All four flags required; `--template` is a filesystem path, `--meta-json` is a path to the S1 JSON, `--assets-root` is a literal prefix (e.g. `./assets/` or `../../assets/`), `--body` is a path to the body fragment (output of `oliver render`). Reads those files, writes stdout. +* No stdin use (unlike `oliver meta`/`render`). The adapter’s GAWK currently reads `body_file` and `template_file` via `getline`; Oliver will do `std.fs`. + +### Dialect (exact, from contract + GAWK) +| Token | Source | Escaping | +|-------|--------|----------| +| `$title$` | `meta_json.title` | `html_escape` (`& < > " '` → `& < > " '`) | +| `$description$` | `meta_json.description` | same | +| `$author$` | `meta_json.author` | same | +| `$date$` | `meta_json.date` | same | +| `$palette$` | `meta_json.palette` | same | +| `$assets_root$` | `--assets-root` flag | literal, never escaped | +| `$body$` | `--body` file | literal, never escaped (trusted HTML) | + +* Unknown `$word$` passes verbatim. +* `$if(name)$ … $endif$` only for `title|description|author|date|palette` (not `assets_root`/`body`/`template`/`unknown`). One pass `title→description→author→date→palette`; **first** `$endif$` in the document closes the opener; no nesting; verbatim unknown; empty or `null` → block removed including interior newlines, else interior kept. +* Order: all `$if$` resolved first, then the 7 literal substitutions. + +### Design choice +* Reuse `src/html.zig:writeEscaped` plus `'` for `html_escape`, not a second encoder. Ensure parity with GAWK’s `gsub(/&/, "\\&", s)` chain (order `&` first to avoid double-escaping). +* Implement as `src/wrap.zig` with `pub fn wrap(allocator, template_path, meta_json_path, assets_root, body_path, writer) !void`. Keep I/O in `src/main.zig`. + +### Tests +* Use `rc-test.sh:697-802` verbatim as the oracle: `custom-s2.html` with `$title$` + 3 `$if$` + `assets:$assets_root$` + `body:$body$` + `unknown:$unknown$`. Assert escaped title, gates kept, assets literal, body literal with `$body$`, unknown verbatim, empty-title removal (`TITLE:` absent). + +--- + +## 6. Issue #109 — `oliver render` link rewriting (S3) — Detailed notes + +### What changes +* `oliver render --from [--to xhtml]` already exists (`src/main.zig:parseArgs` with `--to html|xhtml`). S3 adds a post-parse, pre-render AST walk that rewrites `href`/`src` leaves where the URL is an **internal** reference ending in `.md`/`.textile`/`.cook`. + +### Rules (from contract + GAWK + harness) +* Internal `href="foo.md"` → `href="foo.html"` (same for `src`). Preserve fragment `#sec` and query `?v=1` + `#f` tail. So `foo.md?v=1#f2` → `foo.html?v=1#f2`. +* Strip `<>`/`%3C`/`<`/`%3E`/`>` wrapper before testing — the GAWK strips `^(\<|%3C|<).*(>|%3E|>)$`. At AST level the wrapper is already removed by `scanLink` (`""` → `url` without brackets), but keep a defensive strip for `raw_html` `` fallback if any. +* Skip external `://` (`^[a-zA-Z][a-zA-Z0-9+.-]*://`) and `mailto:`. +* Do **not** rewrite `https://example.com/docs.md` or `mailto:a@b` (harness asserts). +* Must be deterministic and respect `html.escape`/`percent_encode` already done in `writeEscapedHref`. + +### Static vs. regex +* The fallback GAWK loops `while (match(line, /(href|src)=("|\x27)([^"\x27]+)("|\x27)/,a))` — which will mangle bytes that happen to look like `href=` inside code spans. AST-level avoids that entirely: walk the document tree (`document.Document.Iterator`) and rewrite node leaves: + * `.link { href, title }` → rewrite `href` + * `.image { src, alt, title }` → rewrite `src` + * `.autolink { href, label }` → rewrite when `href` is `foo.md` text? (low priority — autolink content is already `mailto:`-free per contract) + * `.html_block`/`.raw_html`: leave verbatim (fail-closed XSS risk if rewriting inside raw HTML). Only document-leaves are trusted. +* Helper `rewriteUrl(allocator, url) []const u8`: + ``` + if startsWith "mailto:" or matches scheme:// → return url + strip angle wrappers + find “.md”/“.textile”/“.cook” before (?|#|$) → splice to “.html” + tail + else return url + ``` + Use owned copies in the arena (like `slugify` scratch) so the rewrite is arena-backed. + +### Harness probes (rc-test.sh:804-850) +``` +printf '[x](foo.md)\n' | oliver render --from markdown | grep -q 'foo.html' # internal +printf '[x](foo.textile)\n' | oliver render --from markdown | grep -q 'foo.html' +printf '[x](foo.cook)\n' | oliver render --from markdown | grep -q 'foo.html' +printf '[x]()\n' | oliver render --from markdown | grep -q 'foo.html#sec' +printf '[x](foo.md?v=1#f2)\n' | oliver render --from markdown | grep -q 'foo.html?v=1#f2' +printf '[x](https://example.com/foo.md)\n' | oliver render --from markdown | grep foo.html → must NOT match +``` +Plus contract-corpus: `contract-inline.md` lines like `[Internal](my-first-page.md)` → `my-first-page.html`. + +### Where to implement +* In `src/main.zig:renderWithDiag` after `try oliver.parse(...)` and before `try oliver.html.render(...)`. Check if `cfg` actually needs a pass — always run for `render`, since the cost is one traversal and no flag is needed (probe caches `OLIVER_REWRITES` = true to skip GAWK). + +--- + +## 7. Issue #110 — `oliver plan` + `oliver manifest` (S4+S5) — Detailed notes + +### `oliver plan` — 13-column batch TSV + +**CLI (from `rc-render.sh:299-306`):** +``` +oliver plan \ + --content-dir --output-dir --template-dir --meta-dir \ + --default-template --oliver-bin --root-dir \ + --dry-run --verbose > batch.tsv +# Columns (TSV, tab-separated, no header): +# 1 src 2 dst 3 template 4 assets_root 5 soul 6 oliver_bin +# 7 root 8 content 9 output 10 template_dir 11 meta_dir 12 dry_run 13 verbose +``` + +**Semantics (from `rc-render.sh:211-358`):** + +1. **Discovery:** `find "$CONTENT_DIR" -type f \( -name "*.md" -o -name "*.textile" -o -name "*.cook" \) -print0 | while read -d ''`. Use `std.fs.walk` + filter by extension exactly `.md`/`.textile`/`.cook`. No `*.MD` case sensitivity — keep lowercase only (contract says those three). +2. **`strip_source_ext`:** `foo.md`/`foo.textile`/`foo.cook` → `foo` (remove exactly one suffix, not greedy). Anything else (e.g. `foo.txt`) is impossible because discovery filtered it. +3. **`dst` derivation:** `rel = src relative to content_dir` → `base = strip_source_ext(basename(rel))` → `reldir = dirname(rel)` (`.` for root). If `reldir == "."` → `dst = output_dir/base.html` else `dst = output_dir/reldir/base.html`. +4. **Collision abort:** two distinct sources map to same `dst` (basename collision) → print error, exit 1. The harness’s `declare -A EXPECTED_OUTPUTS` / `OUTPUT_SOURCES` already does this. In Oliver, use `std.StringHashMap([]const u8)` from `dst` → `rel`. +5. **`ASSETS_ROOT` depth:** if `reldir == "."` → `./assets/` else `depth = count('/') in reldir` → `ASSETS_ROOT = rk_up_dirs(depth+1) + "assets/"` where `rk_up_dirs(n) = "../" * n`. Example: `content/docs/x/y/foo.md` (`reldir=docs/x/y`, depth=2) → `../../../assets/`. `src/meta.zig` already not involved; keep helper `upDirs(allocator, depth) []const u8`. +6. **`soul` derivation:** `soul = meta_dir / strip_source_ext(rel) + ".soul.md"` canonicalized; if file exists use its canonical path else `"NONE"` literal (the adapter tests `[[ "$soul_path" == "NONE" ]]`). Keep the `NONE` sentinel. +7. **Passthrough cols 3/6/7/8/9/10/11/12/13:** `template = template_dir/default_template`, `oliver_bin = --oliver-bin`, `root = --root-dir`, etc. These are opaque; the plan just threads them so the adapter can `read -r src dst template assets_root soul oliver_bin …`. + +**Fallback:** probe `oliver plan --help` (must print usage). If probe fails, `rc-render.sh` falls back to the Bash loop. After the bump, plan always succeeds. + +**Where:** `src/plan.zig` with `pub fn plan(allocator, args) !void` reading no stdin, writing TSV to stdout, exiting 1 on collision with a message to stderr. Tests mock `content_dir` with `std.testing.tmpDir`. + +### `oliver manifest` — deduped manifest log + +**CLI (from `rc-render.sh:142-152`):** +``` +oliver manifest --manifest --add # dedup: grep -Fxq || echo >> file +oliver manifest --manifest --verify # (no-op today, but must exist) +oliver manifest --help # prints usage +``` +* `--manifest` is required; `--add ` appends `` (relative path `rel = "$MANIFEST_TSV" minus "$ROOT_DIR"/` or `output/probe.html`) only if not already present (line-exact `grep -Fxq`). Create `manifest` + parent dirs if missing (`touch`). +* `--verify` is a future hook; today it just exits 0 (the fake does `exit 0`). +* No `--format`, no JSON — plain text, one rel per line. + +**Where:** `src/manifest.zig` with a 10-line writer plus `std.fs` existence check. Tests assert dedup and `--verify`. + +--- + +## 8. Implementation checklist (what NOT to do) + +* Do not change `src/frontmatter.zig`’s subset or `src/markdown.zig`’s block precedence — the 652/652 gate is green and the contract-corpus `contract-table.md` pins `|---` parsing. +* Do not move filesystem I/O into `src/oliver.zig`; keep `plan`/`manifest`/`wrap` in `src/main.zig` + companion CLI modules. +* Do not emit `null` in S1 JSON — always `""`. +* Do not HTML-escape `$body$` or `$assets_root$` in S2. +* Do not rewrite inside `.html_block` / `.raw_html` in S3 — only typed leaves. +* Do not add a `--verify` that fails on missing entries yet — the fake returns 0; match it. +* Do not bump `OLIVER_PIN` until all four slices are merged and `zig build test` + `bash rotkeeper.sh test --strict` pass. + +--- + +## 9. Suggested commit stack (single PR) + +``` +feat(meta): oliver meta --from --format json (S1) + render auto-strip + - src/meta.zig (project frontmatter → 7-string JSON) + - src/main.zig (Command.meta, parseArgs, dispatch) + - tests/meta_test.zig (line-1, BOM, ... not honored, scalar-only, null→"", CRLF, empty block) + docs/FRONTMATTER.md: add S1 CLI note + +feat(wrap): oliver wrap dialect (S2) + - src/wrap.zig (7 tokens, 5 html_escape, assets/body literal, $if$ one-pass) + - src/main.zig (Command.wrap) + - tests/wrap_test.zig (custom-s2 + empty-title + unknown-token) + +feat(render): native link rewriting .md/.textile/.cook → .html (S3) + - src/rewrite.zig (isExternal, stripAngle, spliceSuffix) + - src/main.zig:renderWithDiag walk (link/image/src leaves) + - tests/rewrite_test.zig (8 ugly-edge-case + contract-inline + textile/cook) + +feat(plan+manifest): output planning + manifest (S4+S5) + - src/plan.zig (find+strip+soul+assets_root+collision) + - src/manifest.zig (--add dedup, --verify) + - src/main.zig (Command.plan/manifest) + tests/plan_test.zig, tests/manifest_test.zig + +chore(pin): bump rotkeeper pin + re-run harness + rotkeeper/scripts/setup.sh:76 OLIVER_PIN= + home/content/docs/oliver-contract.md: update “Pin moved” table +``` + +Each commit must keep `zig build test` green and the adapter’s hermetic harness green (the fake still passes; the real probe starts passing slice-by-slice). + +--- + +## 10. Verification before marking issues ready + +* `zig build test` — all suites including new `meta`/`wrap`/`rewrite`/`plan`/`manifest`. +* `zig build spec-conformance -- spec.txt` — 652/652 (S3 must not break link dest parsing). +* `zig build cooklang-conformance` — 60/60 (meta with `--from cooklang` must not break ` Recipe.metadata`). +* `./rotkeeper.sh test` in the rotkeeper checkout with the new binary on `PATH` (or `RK_OLIVER_BIN`) — crypt/busy/sterile green. +* `RK_STRICT=1 ./rotkeeper.sh test` — same, but real-OLIVER probes must show “Pass: real Oliver meta/wrap/plan/manifest probe” not “Skipping … lacks …”. +* `xmllint --noout bones/manifest.txt` is not needed, but `xmllint --noout` over the XHTML profile page must stay green. +* Manual spot checks: + ```bash + printf '---\ntitle: "A & "\n---\n# hi\n' | ./zig-out/bin/oliver meta --from markdown --format json + # → {"title":"A & ", ...} (raw, not escaped yet) + printf '[x](foo.md)\n' | ./zig-out/bin/oliver render --from markdown | grep foo.html + printf '---\ntitle: x\n---\nBody' | ./zig-out/bin/oliver render --from markdown # body must not contain title line + ``` + +--- + +## 11. Notes filed on issues (to be posted) + +* #107 — Added analysis comment with scalar-only / line-1 / BOM / `...` notes and the JSON shape. +* #108 — Added note with 7-token table, escape split, `$if$` one-pass/first-`$endif$` rule, unknown verbatim. +* #109 — Added note with AST vs regex, skip `://`/`mailto:`, fragment/query preservation, bare angle strip. +* #110 — Added note with 13-col TSV, collision abort, `ASSETS_ROOT` `rk_up_dirs(depth+1)`, manifest dedup. + +> This file is the planning gate. A human reviewer should ack it before any `src/` edit lands; the per-issue GitHub comments are the terse pointers (this doc is the durable artifact). + diff --git a/src/main.zig b/src/main.zig index 1ddb142..defd015 100644 --- a/src/main.zig +++ b/src/main.zig @@ -18,11 +18,14 @@ const std = @import("std"); const oliver = @import("oliver"); const meta = @import("meta.zig"); +const wrap = @import("wrap.zig"); comptime { - // Force analysis so `zig build test` runs `src/meta.zig` tests via the - // CLI test binary (like `src/oliver.zig` forces cooklang modules). + // Force analysis so `zig build test` runs `src/meta.zig` and + // `src/wrap.zig` tests via the CLI test binary (like + // `src/oliver.zig` forces cooklang modules). _ = meta; + _ = wrap; } // Injected by build.zig: the package version and the source commit SHA @@ -39,6 +42,7 @@ pub const Command = enum { scale, menu, meta, + wrap, }; /// The full command-line configuration, decided by `parseArgs`. `command` @@ -86,6 +90,12 @@ pub const RunConfig = struct { /// (keeps the wire extensible). Meta-only, like `json` is /// serialize-only. meta_format: bool = false, + /// Wrap command paths: template, meta-json, assets-root, body. + /// All four are required for `wrap`; unused by other commands. + wrap_template: ?[]const u8 = null, + wrap_meta_json: ?[]const u8 = null, + wrap_assets_root: ?[]const u8 = null, + wrap_body: ?[]const u8 = null, }; /// Parses the argument vector (excluding the program name) into a @@ -125,6 +135,14 @@ pub fn parseArgs(args: []const []const u8) error{ Usage, Help, Version }!RunConf var saw_factor = false; var saw_servings = false; var saw_format = false; + var wrap_template: ?[]const u8 = null; + var wrap_meta_json: ?[]const u8 = null; + var wrap_assets_root: ?[]const u8 = null; + var wrap_body: ?[]const u8 = null; + var saw_wrap_template = false; + var saw_wrap_meta_json = false; + var saw_wrap_assets_root = false; + var saw_wrap_body = false; var index: usize = 0; while (index < args.len) : (index += 1) { @@ -266,6 +284,30 @@ pub fn parseArgs(args: []const []const u8) error{ Usage, Help, Version }!RunConf saw_format = true; if (!std.mem.eql(u8, args[index], "json")) return error.Usage; meta_format = true; + } else if (std.mem.eql(u8, arg, "--template")) { + if (index + 1 >= args.len) return error.Usage; + index += 1; + if (saw_wrap_template) return error.Usage; + saw_wrap_template = true; + wrap_template = args[index]; + } else if (std.mem.eql(u8, arg, "--meta-json")) { + if (index + 1 >= args.len) return error.Usage; + index += 1; + if (saw_wrap_meta_json) return error.Usage; + saw_wrap_meta_json = true; + wrap_meta_json = args[index]; + } else if (std.mem.eql(u8, arg, "--assets-root")) { + if (index + 1 >= args.len) return error.Usage; + index += 1; + if (saw_wrap_assets_root) return error.Usage; + saw_wrap_assets_root = true; + wrap_assets_root = args[index]; + } else if (std.mem.eql(u8, arg, "--body")) { + if (index + 1 >= args.len) return error.Usage; + index += 1; + if (saw_wrap_body) return error.Usage; + saw_wrap_body = true; + wrap_body = args[index]; } else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { return error.Help; } else if (std.mem.eql(u8, arg, "--version")) { @@ -274,9 +316,11 @@ pub fn parseArgs(args: []const []const u8) error{ Usage, Help, Version }!RunConf } // Exactly one subcommand names the operation, and every command - // needs an input frontend. + // needs an input frontend — except `wrap`, which reads files. const cmd = command orelse return error.Usage; - if (!cooklang and dialect == null) return error.Usage; + if (cmd != .wrap) { + if (!cooklang and dialect == null) return error.Usage; + } // Flags must belong to the command they are given with: `--to` // selects the renderer profile (render only), `--factor` / // `--servings` configure scaling (scale only), serialize/scale/menu @@ -312,10 +356,19 @@ pub fn parseArgs(args: []const []const u8) error{ Usage, Help, Version }!RunConf }, .meta => { if (saw_to or saw_raw_html or factor_num != null or servings_target != null or - ext_flags or frontmatter != null or saw_diagnostics or json) return error.Usage; + ext_flags or frontmatter != null or saw_diagnostics or json or + saw_wrap_template or saw_wrap_meta_json or saw_wrap_assets_root or saw_wrap_body) return error.Usage; // Meta requires --from (any frontend) and --format json. if (!meta_format) return error.Usage; }, + .wrap => { + // Wrap has its own flag set: --template, --meta-json, + // --assets-root, --body (all required). No other flags. + if (saw_from or saw_to or saw_raw_html or saw_frontmatter or + saw_diagnostics or saw_format or json or meta_format or + factor_num != null or servings_target != null or ext_flags) return error.Usage; + if (!saw_wrap_template or !saw_wrap_meta_json or !saw_wrap_assets_root or !saw_wrap_body) return error.Usage; + }, } return .{ .command = cmd, @@ -339,6 +392,10 @@ pub fn parseArgs(args: []const []const u8) error{ Usage, Help, Version }!RunConf .diagnostics = diagnostics, .json = json, .meta_format = meta_format, + .wrap_template = wrap_template, + .wrap_meta_json = wrap_meta_json, + .wrap_assets_root = wrap_assets_root, + .wrap_body = wrap_body, }; } @@ -367,6 +424,21 @@ pub fn main(init: std.process.Init) !u8 { }; const profile = cfg.profile; + // Render directly to stdout through a buffered writer. The + // `--diagnostics json` side channel writes to stderr so stdout stays + // pure HTML (the consumer never parses a mixed stream). + var out_buf: [4096]u8 = undefined; + var out_writer = std.Io.File.stdout().writer(init.io, &out_buf); + var err_buf: [4096]u8 = undefined; + var err_writer = std.Io.File.stderr().writer(init.io, &err_buf); + + // `oliver wrap` reads three files (template, meta-json, body) from + // disk and writes the resolved template to stdout. It does not use + // stdin. + if (cfg.command == .wrap) { + return wrapDispatch(gpa, init.io, cfg, &out_writer, &err_writer); + } + // `oliver meta` is filesystem-free and dialect-agnostic at the wire // level (YAML-only for S1, 7-string JSON). It bypasses the dialect // dispatch so `meta` works uniformly for markdown/textile/cooklang. @@ -385,14 +457,6 @@ pub fn main(init: std.process.Init) !u8 { try input.appendSlice(gpa, buf[0..n]); } - // Render directly to stdout through a buffered writer. The - // `--diagnostics json` side channel writes to stderr so stdout stays - // pure HTML (the consumer never parses a mixed stream). - var out_buf: [4096]u8 = undefined; - var out_writer = std.Io.File.stdout().writer(init.io, &out_buf); - var err_buf: [4096]u8 = undefined; - var err_writer = std.Io.File.stderr().writer(init.io, &err_buf); - // `meta` bypasses the dialect dispatch — it always projects the same // YAML frontmatter shape regardless of --from (markdown/textile/cooklang). if (cfg.command == .meta) { @@ -459,7 +523,7 @@ pub fn main(init: std.process.Init) !u8 { return 1; }; }, - .meta => unreachable, + .meta, .wrap => unreachable, } } else { const outcome = renderWithDiag(gpa, cfg, input.items) catch |err| { @@ -874,6 +938,7 @@ fn printUsage() void { \\ oliver scale --from cooklang (--factor | --servings ) \\ oliver menu --from cooklang \\ oliver meta --from --format json + \\ oliver wrap --template --meta-json --assets-root --body \\ oliver --version \\ \\Reads a document from stdin and writes rendered HTML to stdout @@ -881,8 +946,12 @@ fn printUsage() void { \\Cooklang text (serialize --json dumps the typed Recipe model as \\JSON instead); menu writes the day/meal text dump. meta reads a \\document from stdin and writes the 7-field frontmatter JSON to - \\stdout (--format json only). --version prints the version and the - \\embedded source commit (CI builds). + \\stdout (--format json only). wrap reads --template, --meta-json, + \\--body files and --assets-root prefix, resolves the 7-token + \\template dialect ($title$/$description$/$author$/$date$/$palette$ + \\/$assets_root$/$body$, html-escaped meta + literal assets/body), + \\and writes the result to stdout. --version prints the version and + \\the embedded source commit (CI builds). \\scale --factor accepts the same scalable quantity forms as amounts \\(2, 1/2, 1.5, 1 1/2; quote values containing spaces). \\ @@ -904,6 +973,66 @@ fn usage() u8 { return 1; } +// --------------------------------------------------------------------------- +// The `wrap` command dispatch: reads three files from disk, resolves the +// template dialect, and writes the result to stdout. +// --------------------------------------------------------------------------- + +/// Reads a file into allocated memory. Returns the owned bytes; the caller +/// frees with the same allocator. The file is closed before return. +fn readFileAlloc(gpa: std.mem.Allocator, io: std.Io, path: []const u8) ![]u8 { + const file = try std.Io.Dir.cwd().openFile(io, path, .{}); + defer file.close(io); + // Use a streaming reader to fill a growing buffer. + var buf = std.ArrayList(u8).empty; + errdefer buf.deinit(gpa); + var tmp: [8192]u8 = undefined; + while (true) { + const n = file.readStreaming(io, &.{&tmp}) catch |err| switch (err) { + error.EndOfStream => break, + else => return err, + }; + if (n == 0) break; + try buf.appendSlice(gpa, tmp[0..n]); + } + return try buf.toOwnedSlice(gpa); +} + +/// Dispatches the `oliver wrap` command: reads --template, --meta-json, +/// --body files and --assets-root from disk, resolves the template dialect, +/// and writes the result to stdout. Returns the exit code (0 = success). +fn wrapDispatch( + gpa: std.mem.Allocator, + io: std.Io, + cfg: RunConfig, + out_writer: anytype, + err_writer: anytype, +) !u8 { + _ = err_writer; + const meta_json = readFileAlloc(gpa, io, cfg.wrap_meta_json.?) catch |err| { + std.debug.print("oliver wrap: cannot read --meta-json {s}: {s}\n", .{ cfg.wrap_meta_json.?, @errorName(err) }); + return 1; + }; + defer gpa.free(meta_json); + const template = readFileAlloc(gpa, io, cfg.wrap_template.?) catch |err| { + std.debug.print("oliver wrap: cannot read --template {s}: {s}\n", .{ cfg.wrap_template.?, @errorName(err) }); + return 1; + }; + defer gpa.free(template); + const body = readFileAlloc(gpa, io, cfg.wrap_body.?) catch |err| { + std.debug.print("oliver wrap: cannot read --body {s}: {s}\n", .{ cfg.wrap_body.?, @errorName(err) }); + return 1; + }; + defer gpa.free(body); + + wrap.wrap(gpa, meta_json, template, body, cfg.wrap_assets_root.?, &out_writer.interface) catch |err| { + std.debug.print("oliver wrap: {s}\n", .{@errorName(err)}); + return 1; + }; + out_writer.flush() catch {}; + return 0; +} + /// `--version` is a requested outcome: print the package version and, for /// CI builds that embedded one, the exact source commit, then exit 0. /// Written to stdout (not stderr) so a consumer can parse it: an diff --git a/src/wrap.zig b/src/wrap.zig new file mode 100644 index 0000000..e28b046 --- /dev/null +++ b/src/wrap.zig @@ -0,0 +1,459 @@ +//! Phase 6 S2 — template dialect for `oliver wrap`. +//! +//! Implements the 7-token dialect (`oliver-contract.md:115-145`): +//! +//! $title$ $description$ $author$ $date$ $palette$ → html_escape +//! $assets_root$ $body$ → literal +//! +//! Conditional blocks: `$if(name)$...$endif$` for the five meta fields +//! (title→palette, first `$endif$` closes, no nesting, verbatim unknown). +//! Empty/null → block removed; all `$if$` resolved before literal subs. +//! +//! The library stays filesystem-free; this is pure bytes in → bytes out. + +const std = @import("std"); + +/// The JSON wire type for the 5 meta fields. Unknown fields are +/// silently ignored (the S1 contract also ships `template` and +/// `render_profile` which we don't need here). +const MetaJson = struct { + title: ?[]const u8 = null, + description: ?[]const u8 = null, + author: ?[]const u8 = null, + date: ?[]const u8 = null, + palette: ?[]const u8 = null, +}; + +/// The JSON parse result. The `value` field contains the 5 optional +/// strings; the arena owns the backing memory. The caller must keep +/// this struct alive while accessing `value` fields. +pub const ParsedMeta = std.json.Parsed(MetaJson); + +/// Resolves a field name to its value (for `$if$` lookup and substitution). +/// Returns empty string for missing/null fields (the S1 contract +/// guarantees all 7 keys, but being defensive is free). +fn fieldVal(m: ParsedMeta, name: []const u8) []const u8 { + const v = m.value; + if (std.mem.eql(u8, name, "title")) return v.title orelse ""; + if (std.mem.eql(u8, name, "description")) return v.description orelse ""; + if (std.mem.eql(u8, name, "author")) return v.author orelse ""; + if (std.mem.eql(u8, name, "date")) return v.date orelse ""; + if (std.mem.eql(u8, name, "palette")) return v.palette orelse ""; + return ""; +} + +/// Whether the given name is a recognized meta field with a non-empty value. +fn isNonEmpty(m: ParsedMeta, name: []const u8) bool { + return fieldVal(m, name).len > 0; +} + +/// Resolves the template dialect into the output writer. +/// +/// - `$title$`/`$description$`/`$author$`/`$date$`/`$palette$` → html-escaped meta value +/// - `$assets_root$` → literal (unescaped) +/// - `$body$` → literal (unescaped, trusted HTML) +/// - `$if(name)$...$endif$` → conditional: known meta field + non-empty → keep interior; +/// known meta field + empty → remove block; unknown name → verbatim passthrough +/// - Unknown `$word$` → verbatim +pub fn wrap( + a: std.mem.Allocator, + meta_json: []const u8, + template: []const u8, + body: []const u8, + assets_root: []const u8, + out: anytype, +) !void { + // Parse the meta JSON. The `parsed` struct owns the arena that backs + // the string slices; it must stay alive until we're done reading them. + var parsed = std.json.parseFromSlice(MetaJson, a, meta_json, .{ + .ignore_unknown_fields = true, + }) catch return error.MetaJsonParseError; + defer parsed.deinit(); + + // Phase 1: strip $if(name)$...$endif$ blocks. + const stripped = try stripIfs(a, template, parsed); + defer a.free(stripped); + + // Phase 2: substitute the 7 literal tokens. + try substitute(stripped, parsed, body, assets_root, out); +} + +// --------------------------------------------------------------------------- +// Phase 1: $if$ block stripping. +// --------------------------------------------------------------------------- + +/// Strips all `$if(name)$...$endif$` blocks. Rules (from issue #108): +/// - Known meta field (title/description/author/date/palette) + non-empty → keep interior +/// - Known meta field + empty/null → remove entire block (including interior newlines) +/// - Unknown name → verbatim passthrough (the `$if(unknown)$...$endif$` is literal text) +/// - First `$endif$` closes each opener; no nesting +fn stripIfs(a: std.mem.Allocator, template: []const u8, m: ParsedMeta) ![]u8 { + var out = try std.ArrayList(u8).initCapacity(a, template.len); + errdefer out.deinit(a); + var i: usize = 0; + while (i < template.len) { + // Scan for the literal "$if(" substring. + const if_start = findChar(template, i, '$'); + if (if_start == null) { + try out.appendSlice(a, template[i..]); + break; + } + // Verify the next 3 chars are 'i', 'f', '('. + const fs = if_start.?; + if (fs + 3 >= template.len or + template[fs + 1] != 'i' or + template[fs + 2] != 'f' or + template[fs + 3] != '(') + { + // Not a $if$ — write everything up to and including this + // $, then continue scanning past it. + if (fs + 1 > i) try out.appendSlice(a, template[i .. fs + 1]); + i = fs + 1; + continue; + } + + // Text before the $if$. + if (fs > i) try out.appendSlice(a, template[i..fs]); + + // Find the closing )$ of $if(name)$ — scan after the '('. + const close = std.mem.indexOf(u8, template[fs + 4 ..], ")$"); + if (close == null) { + try out.appendSlice(a, template[fs..]); + break; + } + const name = template[fs + 4 .. fs + 4 + close.?]; + const after_if_close = fs + 4 + close.? + 2; // past )$ + + // Find first $endif$. + const end = findEndif(template, after_if_close); + if (end == null) { + try out.appendSlice(a, template[fs..]); + break; + } + const interior = template[after_if_close..end.?]; + const after_endif = end.? + 7; // len of "$endif$" + + // Unknown name → verbatim passthrough. + if (!isKnownField(name)) { + try out.appendSlice(a, template[fs..after_endif]); + } else if (isNonEmpty(m, name)) { + try out.appendSlice(a, interior); + } + // else: known field but empty → block removed (skip). + + i = after_endif; + } + return out.toOwnedSlice(a); +} + +/// Whether `name` is one of the five recognized meta fields. +fn isKnownField(name: []const u8) bool { + return std.mem.eql(u8, name, "title") or + std.mem.eql(u8, name, "description") or + std.mem.eql(u8, name, "author") or + std.mem.eql(u8, name, "date") or + std.mem.eql(u8, name, "palette"); +} + +/// Finds the next occurrence of byte `ch` in `haystack` starting at `from`. +/// Returns the absolute index or null. +fn findChar(haystack: []const u8, from: usize, ch: u8) ?usize { + const rel = std.mem.indexOfScalar(u8, haystack[from..], ch) orelse return null; + return from + rel; +} + +/// Finds the next `$endif$` starting at `from`. Returns the index of the +/// opening `$` of `$endif$`, or null. +fn findEndif(template: []const u8, from: usize) ?usize { + var i = from; + while (i + 7 <= template.len) : (i += 1) { + if (std.mem.eql(u8, template[i .. i + 7], "$endif$")) return i; + } + return null; +} + +// --------------------------------------------------------------------------- +// Phase 2: literal token substitution. +// --------------------------------------------------------------------------- + +/// Substitutes the 7 recognized tokens: 5 html-escaped meta fields, +/// plus `$assets_root$` and `$body$` as literals. Unknown `$word$` +/// passes verbatim. +fn substitute( + template: []const u8, + m: ParsedMeta, + body: []const u8, + assets_root: []const u8, + out: anytype, +) !void { + var i: usize = 0; + while (i < template.len) { + if (template[i] != '$') { + const end = nextDollar(template, i); + try out.writeAll(template[i..end]); + i = end; + continue; + } + const dollar = i; + i += 1; + // Find closing $. + const close = std.mem.indexOfScalar(u8, template[i..], '$'); + if (close == null) { + try out.writeAll(template[dollar..]); + break; + } + const name = template[i .. i + close.?]; + i = i + close.? + 1; // past closing $ + + if (std.mem.eql(u8, name, "assets_root")) { + try out.writeAll(assets_root); + } else if (std.mem.eql(u8, name, "body")) { + try out.writeAll(body); + } else if (isKnownField(name)) { + try htmlEscape(out, fieldVal(m, name)); + } else { + // Unknown token: verbatim (including $ delimiters). + try out.writeAll(template[dollar..i]); + } + } +} + +/// Returns the index of the next `$` or the end of the string. +fn nextDollar(s: []const u8, from: usize) usize { + var i = from; + while (i < s.len and s[i] != '$') : (i += 1) {} + return i; +} + +/// Writes `text` with HTML escaping (& < > " ' → entities), matching +/// the GAWK `html_escape` used by the rotkeeper adapter. The order +/// (& first) prevents double-escaping. +pub fn htmlEscape(out: anytype, text: []const u8) !void { + var start: usize = 0; + var i: usize = 0; + while (i < text.len) : (i += 1) { + const replacement: ?[]const u8 = switch (text[i]) { + '&' => "&", + '<' => "<", + '>' => ">", + '"' => """, + '\'' => "'", + else => null, + }; + if (replacement) |r| { + if (i > start) try out.writeAll(text[start..i]); + try out.writeAll(r); + start = i + 1; + } + } + if (start < text.len) try out.writeAll(text[start..]); +} + +// --------------------------------------------------------------------------- +// Tests (filesystem-free, pure bytes in → bytes out). +// --------------------------------------------------------------------------- + +const testing = std.testing; + +const full_meta = \\ + \\{"title":"Hi","description":"A page","author":"Bob","date":"2026-01-01","palette":"dark","template":"","render_profile":""} +; +const empty_meta = \\ + \\{"title":"","description":"","author":"","date":"","palette":"","template":"","render_profile":""} +; + +fn wrapT(template: []const u8, meta_json: []const u8, body: []const u8, assets_root: []const u8) ![]u8 { + var aw = std.Io.Writer.Allocating.init(testing.allocator); + errdefer aw.deinit(); + try wrap(testing.allocator, meta_json, template, body, assets_root, &aw.writer); + return try aw.toOwnedSlice(); +} + +// --- basic substitution --- + +test "wrap: basic token substitution" { + const template = "T:$title$ D:$description$"; + const out = try wrapT(template, full_meta, "", "./assets/"); + defer testing.allocator.free(out); + try testing.expectEqualStrings("T:Hi D:A page", out); +} + +test "wrap: all 5 meta tokens" { + const template = "$title$|$description$|$author$|$date$|$palette$"; + const out = try wrapT(template, full_meta, "", ""); + defer testing.allocator.free(out); + try testing.expectEqualStrings("Hi|A page|Bob|2026-01-01|dark", out); +} + +test "wrap: assets_root and body are literal (unescaped)" { + const template = "assets:$assets_root$ body:$body$"; + const out = try wrapT(template, empty_meta, "

Body

", "../../assets/"); + defer testing.allocator.free(out); + try testing.expectEqualStrings("assets:../../assets/ body:

Body

", out); +} + +test "wrap: unknown tokens pass verbatim" { + const template = "$unknown$ and $also_unknown$"; + const out = try wrapT(template, empty_meta, "", "./"); + defer testing.allocator.free(out); + try testing.expectEqualStrings("$unknown$ and $also_unknown$", out); +} + +// --- html escaping --- + +test "wrap: html escaping of meta fields" { + const meta_json = + \\{"title":"Cats & Dogs ","description":"\"quoted\"","author":"O'Brien","date":"","palette":"","template":"","render_profile":""} + ; + const template = "$title$ $description$ $author$"; + const out = try wrapT(template, meta_json, "", ""); + defer testing.allocator.free(out); + try testing.expectEqualStrings("Cats & Dogs <v0.5.1> "quoted" O'Brien", out); +} + +test "wrap: html escaping escapes & first (no double-escape)" { + const meta_json = + \\{"title":"A & B","description":"","author":"","date":"","palette":"","template":"","render_profile":""} + ; + const template = "$title$"; + const out = try wrapT(template, meta_json, "", ""); + defer testing.allocator.free(out); + // JSON decoded: literal "A & B". The & is escaped → "A &amp; B". + try testing.expectEqualStrings("A &amp; B", out); +} + +test "wrap: empty meta fields become empty strings" { + const template = "[$title$][$description$]"; + const out = try wrapT(template, empty_meta, "", ""); + defer testing.allocator.free(out); + try testing.expectEqualStrings("[][]", out); +} + +// --- $if$ blocks --- + +test "wrap: $if(title)$ keeps interior when non-empty" { + const template = "TITLE:$if(title)$[$title$]$endif$"; + const out = try wrapT(template, full_meta, "", ""); + defer testing.allocator.free(out); + // stripIfs: title is "Hi" → keep interior "[$title$]$" → wait, interior + // is between after_if_close and end. The interior includes the trailing + // content up to $endif$. After stripIfs we have "[$title$]", then + // substitute replaces $title$ with "Hi". + try testing.expectEqualStrings("TITLE:[Hi]", out); +} + +test "wrap: $if(title)$ removes block when empty" { + const template = "TITLE:$if(title)$KEEP$endif$"; + const out = try wrapT(template, empty_meta, "", ""); + defer testing.allocator.free(out); + try testing.expectEqualStrings("TITLE:", out); +} + +test "wrap: $if$ with empty and non-empty fields mixed" { + const template = "$if(title)$T:$title$ $endif$$if(author)$A:$author$ $endif$$if(date)$D:$date$$endif$"; + const out = try wrapT(template, full_meta, "", ""); + defer testing.allocator.free(out); + try testing.expectEqualStrings("T:Hi A:Bob D:2026-01-01", out); +} + +test "wrap: $if$ with mixed empty/non-empty" { + const meta_json = + \\{"title":"","description":"has desc","author":"","date":"2026","palette":"","template":"","render_profile":""} + ; + const template = "T:$if(title)$[$title$]$endif$ D:$if(description)$[$description$]$endif$"; + const out = try wrapT(template, meta_json, "", ""); + defer testing.allocator.free(out); + // title empty → block removed; description non-empty → kept. + try testing.expectEqualStrings("T: D:[has desc]", out); +} + +test "wrap: first $endif$ closes (no nesting)" { + const template = "$if(title)$X$endif$Y$endif$"; + const out = try wrapT(template, full_meta, "", ""); + defer testing.allocator.free(out); + // stripIfs: first $endif$ closes → interior is "X". + // The second "$endif$" passes to substitute as unknown token. + try testing.expectEqualStrings("XY$endif$", out); +} + +test "wrap: unknown $if$ name passes verbatim" { + const template = "$if(unknown)$KEEP$endif$ $if(title)$OK$endif$"; + const out = try wrapT(template, full_meta, "", ""); + defer testing.allocator.free(out); + // Unknown name: verbatim passthrough. + // title "Hi" → keep interior "OK". + try testing.expectEqualStrings("$if(unknown)$KEEP$endif$ OK", out); +} + +test "wrap: $if$ with multiline interior" { + const meta_json = + \\{"title":"X","description":"","author":"","date":"","palette":"","template":"","render_profile":""} + ; + const template = "$if(title)$line1\nline2\n$endif$"; + const out = try wrapT(template, meta_json, "", ""); + defer testing.allocator.free(out); + try testing.expectEqualStrings("line1\nline2\n", out); +} + +test "wrap: $if$ removes entire block including interior newlines when empty" { + const template = "BEFORE$if(title)$line1\nline2\n$endif$AFTER"; + const out = try wrapT(template, empty_meta, "", ""); + defer testing.allocator.free(out); + try testing.expectEqualStrings("BEFOREAFTER", out); +} + +// --- combined: $if$ + substitution --- + +test "wrap: custom template with $if$, $title$, $assets_root$, $body$" { + const template = + \\$if(title)$[$title$]$endif$ + \\ + \\
$body$
+ \\ + ; + const out = try wrapT(template, full_meta, "

Hello

", "./assets/"); + defer testing.allocator.free(out); + try testing.expectEqualStrings( + \\[Hi] + \\ + \\

Hello

+ \\ + , out); +} + +test "wrap: empty-title removal with surrounding content" { + const template = "BEFORE:$if(title)$TITLE:$title$:$endif$:AFTER"; + const out = try wrapT(template, empty_meta, "", ""); + defer testing.allocator.free(out); + try testing.expectEqualStrings("BEFORE::AFTER", out); +} + +test "wrap: $body$ empty produces empty slot" { + const template = "
$body$
"; + const out = try wrapT(template, empty_meta, "", "./"); + defer testing.allocator.free(out); + try testing.expectEqualStrings("
", out); +} + +// --- htmlEscape unit tests --- + +test "htmlEscape: no special chars passes through" { + var aw = std.Io.Writer.Allocating.init(testing.allocator); + defer aw.deinit(); + try htmlEscape(&aw.writer, "hello world 123"); + try testing.expectEqualStrings("hello world 123", aw.written()); +} + +test "htmlEscape: all five special chars" { + var aw = std.Io.Writer.Allocating.init(testing.allocator); + defer aw.deinit(); + try htmlEscape(&aw.writer, "a&bd\"e'f"); + try testing.expectEqualStrings("a&b<c>d"e'f", aw.written()); +} + +test "htmlEscape: empty string" { + var aw = std.Io.Writer.Allocating.init(testing.allocator); + defer aw.deinit(); + try htmlEscape(&aw.writer, ""); + try testing.expectEqualStrings("", aw.written()); +} From f8b259a528f8554f910d8d52a39be716390d7b9a Mon Sep 17 00:00:00 2001 From: draw me an elephant <68925779+drawmeanelephant@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:13:41 -0400 Subject: [PATCH 2/2] chore(fmt): run zig fmt on src/wrap.zig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- src/wrap.zig | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/wrap.zig b/src/wrap.zig index e28b046..c1694f8 100644 --- a/src/wrap.zig +++ b/src/wrap.zig @@ -255,10 +255,12 @@ pub fn htmlEscape(out: anytype, text: []const u8) !void { const testing = std.testing; -const full_meta = \\ +const full_meta = + \\ \\{"title":"Hi","description":"A page","author":"Bob","date":"2026-01-01","palette":"dark","template":"","render_profile":""} ; -const empty_meta = \\ +const empty_meta = + \\ \\{"title":"","description":"","author":"","date":"","palette":"","template":"","render_profile":""} ;