---
phase: 27-page-templates
plan: 01
type: implement
wave: 1
depends_on: []
files_modified: [internal/pages/templates.go, internal/pages/templates_test.go, internal/pages/service.go, internal/server/handlers_pages.go, internal/server/handlers_templates_test.go, internal/server/router.go, web/src/api/client.ts, web/src/lib/templates.ts, web/src/lib/templates.test.ts, web/src/components/CreatePageModal.tsx, web/src/components/CreatePageModal.test.tsx]
autonomous: true
requirements: [ED-06a]

must_haves:
  truths:
    - "Templates are plain .md files in a top-level Templates/ folder; a list endpoint (GET /api/v1/templates) enumerates them; the create-page picker offers them with a Blank default — choosing Blank uses the existing title-only pages.Create path UNCHANGED"
    - "Choosing a template seeds the new page's frontmatter + body; {{title}}/{{date}}/{{time}} are substituted CLIENT-side via String.replaceAll + Intl.DateTimeFormat (user-local date/time); no templating engine, only these three placeholders, and any other {{…}} text is left literal (no code execution)"
    - "Robust frontmatter merge is SERVER-side via okf (yaml.Node): a template carrying its OWN --- frontmatter yields ONE valid frontmatter block — no double ---, no duplicate keys, no malformed YAML — with the canonical title set to the user's title (wins over any template title:), then okf.Repair adds the required fields. An adversarial merge test is included"
    - "Template reads (the list + the chosen template's content) resolve through the existing fuzz-tested safe resolver (repo.Resolve), constrained to the Templates/ subtree; a path escaping Templates/ or the repo root is rejected (ErrUnsafePath); a non-template path cannot be seeded as a template"
    - "The template-seeded create reuses the EXISTING single-writer create pipeline (uniquePath slug, enqueueWrite \"create\", index+graph upsert, audit, hidden git commit) — it does not fork a second write path; the page path still derives from the user's title"
    - "Backend + frontend; no executable/JS templates (static text + the 3 safe placeholders only); template bytes are untrusted page content and flow through the existing sanitized render path"
    - "No regression: the blank/title-only create still works; existing pages/okf/repo tests stay green"
---

<objective>
Let new pages seed from Obsidian-style templates (.md files in Templates/): a list endpoint + a
create-page picker (blank default); client-side {{title}}/{{date}}/{{time}} substitution; and a
robust SERVER-side frontmatter merge (single valid block + repair) via okf, reusing the existing
create pipeline and the safe path resolver. Backend + frontend. ED-06a. Fully independent phase.
</objective>

<execution_context>
@$HOME/.claude/anvil/workflows/execute-plan.md
</execution_context>

<context>
@.planning/phases/27-page-templates/27-CONTEXT.md
@.planning/REQUIREMENTS.md §ED-06a
@.planning/ROADMAP.md §"Phase 27"
</context>

<grounding_facts>
Verified by reading the code (TRUST these):
- `pages.Create(ctx, folder, title, user) (string, error)` (service.go:135): `uniquePath(folder,title)` →
  `doc := &okf.Doc{}; okf.Repair(doc, s.now()); okf.SetField(doc, okf.FieldTitle, title)` → `doc.Emit()` →
  `enqueueWrite(ctx, path, bytesOut, "create", user)` + `enqueueIndexUpsert` + `enqueueGraphUpsert`. A
  template-seeded create is THIS pipeline, seeded from `okf.Parse(template)` instead of an empty doc.
- `okf.Repair(d *Doc, now time.Time)` (repair.go:22) adds ONLY missing required fields (title/type/timestamp/
  description/tags), never re-orders/rewrites existing keys; `okf.SetField(d, field, value)` byte-stable set;
  `okf.FieldTitle`; `doc.Emit() ([]byte, error)`; `okf.Parse(src []byte) (*Doc, error)` parses a full
  `--- fm --- body`. yaml.Node ⇒ parsing a single frontmatter block + SetField(title) + Repair = ONE valid
  block, deduped, no malformed YAML (this is why the merge is server-side, not client string-merge).
- `repo.Resolve(rel) (string, error)` (path.go:83) → abs path inside root or `ErrUnsafePath` (NUL/escape/…).
  The repo also has the directory-listing used by the tree — enumerate `Templates/*.md` through it and
  resolve each via Resolve (constrain to the Templates/ subtree).
- `handleCreatePage` (handlers_pages.go:162): decodes `createPageRequest{Folder,Title}` → `pages.Create` →
  audit `ActionPageCreate` → 201 `{path}`. Editor-gated + CSRF via the router group (router.go:251
  `editor.Post("/pages", h.handleCreatePage)`).
- Templates are normal pages → the client can read a chosen template's content via the existing
  `getPage(path)` (returns `{frontmatter, body, revision}`); only a LIST endpoint is genuinely new.
- Client `createPage(folder, title)` (client.ts:327) posts `/pages` via the `mutate` (CSRF) helper;
  `CreatePageModal.tsx` is title-only (createPage → invalidate tree → navigate).
</grounding_facts>

<tasks>

<task type="implement" tdd="true">
  <name>Task 1: server — list templates (Templates/*.md), path-safe</name>
  <files>internal/pages/templates.go, internal/pages/templates_test.go, internal/server/handlers_pages.go, internal/server/handlers_templates_test.go, internal/server/router.go</files>
  <read_first>
    - internal/pages/service.go — the repo/tree directory-listing mechanism + s.repo usage; `uniquePath`
    - internal/repo/path.go — Resolve / ErrUnsafePath (the safety boundary)
    - internal/server/handlers_pages.go — handler + JSON-write helpers (writeJSON/writeError); router.go agent/editor groups
  </read_first>
  <behavior>
    `templates.go` (NEW): `func (s *Service) ListTemplates(ctx) ([]TemplateInfo, error)` where
    `TemplateInfo{ Name, Path string }`. Enumerate `*.md` directly under `Templates/` (flat; non-recursive
    for v1.4) via the repo's existing directory listing; each path resolved through `repo.Resolve` and
    confirmed to be within the `Templates/` subtree. `Name` = filename without `.md` (display). No
    `Templates/` folder → empty list (not an error). Skip non-`.md` and nested entries.
    `handlers_pages.go`: `handleListTemplates` → `{ templates: [{name, path}] }` (200). Register
    `GET /templates` in router.go (same authed group as page reads — non-secret; the picker is editor-only).
  </behavior>
  <verify>
    `templates_test.go`: ListTemplates returns the .md files under Templates/ (name without extension),
    skips non-.md + nested files, empty when no folder; a crafted escaping path never resolves outside
    Templates/ (Resolve rejects). `handlers_templates_test.go`: GET /templates returns 200 + the list;
    authz matches the page-read group.
  </verify>
</task>

<task type="implement" tdd="true">
  <name>Task 2: server — CreateFromTemplate (okf merge + repair) + extend POST /pages; adversarial merge</name>
  <files>internal/pages/service.go, internal/pages/templates.go, internal/pages/templates_test.go, internal/server/handlers_pages.go, internal/server/handlers_templates_test.go</files>
  <read_first>
    - internal/pages/service.go — Create (135): the exact pipeline to mirror (uniquePath → okf → Emit → enqueueWrite "create" → index/graph upsert)
    - internal/okf/repair.go — Repair (22) + SetField + Parse + Emit (the merge engine)
    - internal/server/handlers_pages.go — handleCreatePage (162) + createPageRequest (24)
  </read_first>
  <behavior>
    `CreateFromTemplate(ctx, folder, title, seedFrontmatter, seedBody, user) (string, error)` in
    pages/templates.go (or service.go): MIRROR Create exactly, but seed the doc from the submitted content
    instead of empty — `doc, err := okf.Parse(assemble(seedFrontmatter, seedBody))` (reuse the canonical
    assembler used elsewhere, e.g. AssembleSource), then `okf.SetField(doc, okf.FieldTitle, title)` (USER
    title wins over any template `title:`), `okf.Repair(doc, s.now())`, `doc.Emit()`, then the SAME
    `uniquePath` + `enqueueWrite(..., "create", user)` + `enqueueIndexUpsert` + `enqueueGraphUpsert`. Empty
    title → ErrTitleRequired (as Create). This yields ONE valid frontmatter block (yaml.Node dedup) + all
    required fields — no double `---`, no duplicate keys.
    `handlers_pages.go`: extend `createPageRequest` with optional `Frontmatter`, `Body` (omitempty). In
    `handleCreatePage`: if either seed field is non-empty → `CreateFromTemplate(...)`, else the existing
    `Create(...)` (blank default UNCHANGED). Same audit `ActionPageCreate`, same 201 `{path}`. (Do NOT trust
    a client-sent path for the template — only the substituted CONTENT is sent; the server re-parses+repairs.)
  </behavior>
  <verify>
    `templates_test.go` (ADVERSARIAL merge — mandatory): a template WITH its own `---` frontmatter →
    exactly ONE frontmatter block in the output, no duplicate keys; a template whose frontmatter has its own
    `title:` → the USER title wins; a template with NO frontmatter → required fields added (Repair); malformed/
    odd YAML in the seed → either a clean valid result or a clean error (never a corrupt two-block page);
    body-only template → promoted to a valid page; the path still derives from the user title; the result
    Emits valid (re-Parse round-trips). `handlers_templates_test.go`: POST /pages with Frontmatter+Body →
    201, the created page has the merged single-block frontmatter + seeded body; POST without seed → the
    existing blank create; reader → 403; missing CSRF → rejected.
  </verify>
</task>

<task type="implement" tdd="true">
  <name>Task 3: client — substitution lib + listTemplates + CreatePageModal picker</name>
  <files>web/src/lib/templates.ts, web/src/lib/templates.test.ts, web/src/api/client.ts, web/src/components/CreatePageModal.tsx, web/src/components/CreatePageModal.test.tsx</files>
  <read_first>
    - web/src/api/client.ts — createPage (327) + the mutate (CSRF) helper + getPage (to read a chosen template)
    - web/src/components/CreatePageModal.tsx — the title-only modal to add the picker to
    - web/src/lib/frontmatter.ts — only if needed for display; the authoritative merge is server-side
  </read_first>
  <behavior>
    `templates.ts` (NEW): `substitutePlaceholders(text, { title, now }): string` — replace `{{title}}`,
    `{{date}}`, `{{time}}` ONLY, via `String.replaceAll`, with `title`, and `Intl.DateTimeFormat`-formatted
    user-local date (`YYYY-MM-DD`) + time (`HH:mm`, 24h). Any other `{{…}}` left literal. Pure, no eval.
    `client.ts`: `listTemplates(): Promise<{ name: string; path: string }[]>` (GET /templates); extend the
    create client — `createPage(folder, title, seed?: { frontmatter: string; body: string })` posting the
    optional seed (CSRF via mutate). `CreatePageModal.tsx`: add a template `<select>` (default "Blank") fed
    by `listTemplates`; on submit, if a template is chosen: `getPage(templatePath)` → `substitutePlaceholders`
    on BOTH frontmatter and body (title = the entered title, now = new Date()) → `createPage(folder, title,
    { frontmatter, body })`; Blank → `createPage(folder, title)` (unchanged). Keep the existing
    invalidate-tree + navigate-on-success. Graceful: no templates → only "Blank" shown.
  </behavior>
  <verify>
    `templates.test.ts`: substitutePlaceholders replaces the 3 placeholders (date/time formatted, user-local),
    leaves other `{{x}}` literal, handles repeats (replaceAll), no-placeholder passthrough. `CreatePageModal.test.tsx`:
    mounting lists templates (mocked); Blank → createPage(folder,title) with NO seed; choosing a template →
    getPage then createPage WITH the substituted seed (assert {{title}} replaced by the entered title);
    no-templates → only Blank; existing title-required validation intact.
  </verify>
</task>

</tasks>

<success_criteria>
1. Templates under Templates/ appear in the create picker (Blank default); choosing one seeds the new
   page's frontmatter + body with {{title}}/{{date}}/{{time}} resolved (user-local); no engine, 3
   placeholders only, no code execution.
2. A template with its own --- frontmatter yields a single valid frontmatter block (no dup keys / malformed
   YAML) with the user's title, then required-field repaired — proven by the adversarial server test.
3. Template reads go through repo.Resolve constrained to Templates/ (escape rejected); the seeded create
   reuses the existing single-writer pipeline; blank create unchanged.
4. Backend: `go build ./... && go test ./...` green. Frontend (from web/): `npx vitest run` green; `npm run
   build` clean. No regression in pages/okf/repo or the existing create flow.
</success_criteria>
