--- phase: 01 plan: 04 type: execute wave: 2 depends_on: [01-01] files_modified: - src/content/schemas/fragment.ts - src/content/schemas/season.ts - src/content/schemas/index.ts - src/content/loader.ts - src/content/loader.test.ts - src/content/index.ts - content/seasons/00-demo/fragments.yaml - content/seasons/00-demo/.gitkeep - content/README.md - content/dialogue/.gitkeep autonomous: true requirements: [PIPE-01, STRY-09] must_haves: truths: - "A `/content/seasons//fragments.yaml` file with frontmatter parses, validates against the Zod fragment schema, and is available as a typed `Fragment[]` import (PIPE-01)" - "A deliberately-malformed content file (e.g., numeric ID instead of string) FAILS Vitest's loader.test.ts assertion (proving the build will fail on schema violation per PIPE-01)" - "Fragment IDs match the regex `^season\\d+\\.[a-z0-9._-]+$` per CLAUDE.md stable-string-ID rule" - "The `compile:ink` npm script exists, runs successfully against an empty `/content/dialogue/` directory, and is a no-op (per CONTEXT D-08 deferral and RESEARCH § Pattern 4 — Ink files in Phase 1)" - "The `/content/` repo-root convention is established with one demo fragment, a README explaining the shape, and the dialogue directory ready for Phase 2 (STRY-09 convention prerequisite)" artifacts: - path: src/content/schemas/fragment.ts provides: "FragmentSchema (Zod), Fragment type, ID regex `^season\\d+\\.[a-z0-9._-]+$`" exports: ["FragmentSchema", "Fragment"] - path: src/content/schemas/season.ts provides: "SeasonContentSchema (Zod) — wraps fragments[]" exports: ["SeasonContentSchema", "SeasonContent"] - path: src/content/schemas/index.ts provides: "Re-exports for schemas/" - path: src/content/loader.ts provides: "Vite-native glob loader using import.meta.glob for /content/seasons/*/fragments.yaml + /content/seasons/*/fragments/*.md; Zod-validated at module-eval time" exports: ["fragments", "loadFragmentsFromGlob"] - path: src/content/index.ts provides: "Public surface for Phase 2 consumers" - path: content/seasons/00-demo/fragments.yaml provides: "One demo fragment proving the round-trip; removed in Phase 2 when real Season 1 content lands" contains: "season0.demo" - path: content/README.md provides: "Explains the /content/ convention: directory shape, frontmatter rules, ID convention, where things go in Phase 2" key_links: - from: src/content/loader.ts to: "/content/seasons/*/fragments.yaml" via: "import.meta.glob('/content/seasons/*/fragments.yaml', { eager: true, query: '?raw', import: 'default' })" pattern: "import.meta.glob\\('/content/seasons/\\*/fragments\\.yaml'" - from: src/content/loader.ts to: src/content/schemas/season.ts via: "SeasonContentSchema.safeParse — throws on schema violation, failing the build" pattern: "SeasonContentSchema.safeParse" --- Stand up the Vite-native content pipeline: Zod schemas for `Fragment` (with the `season.` stable-string-ID regex) and `SeasonContent` (wraps `fragments[]`); a `loader.ts` that uses `import.meta.glob('/content/seasons/*/fragments.yaml', { eager: true, query: '?raw', import: 'default' })` to import every YAML file at build time, parses each via the `yaml` package, and validates via Zod at module-evaluation time so any schema violation fails `npm run build`. Ship one demo fragment under `/content/seasons/00-demo/fragments.yaml` to prove the round-trip end-to-end. Verify the `compile:ink` no-op script runs cleanly. Author a `content/README.md` documenting the convention so Phase 2 has a contract to write Season 1 fragments against. Purpose: Phase 2 will pour Season 1 fragments into `/content/seasons/01-soil/`. Without this loader the build doesn't see them; without the schemas the writer can ship typos that compile and explode at runtime. CONTEXT D-11 and CLAUDE.md "Code Style" lock the `/content/` repo-root location and stable-string-ID convention. RESEARCH § Pattern 4 + Pitfall 1 (literal-glob requirement) provide concrete code. Output: A complete content pipeline: schemas, loader, demo fragment, README, all under `src/content/` and `/content/`. One Vitest test that validates against a mocked schema-violating fragment to prove the build will fail (PIPE-01). @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/01-foundations-and-doctrine/01-CONTEXT.md @.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md @.planning/phases/01-foundations-and-doctrine/01-01-SUMMARY.md @CLAUDE.md Task 1: Zod schemas + loader + demo fragment + content README src/content/schemas/fragment.ts, src/content/schemas/season.ts, src/content/schemas/index.ts, src/content/loader.ts, src/content/index.ts, content/seasons/00-demo/fragments.yaml, content/seasons/00-demo/.gitkeep, content/dialogue/.gitkeep, content/README.md - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § "Pattern 4: Vite-Native Content Pipeline" (verbatim code for FragmentSchema, SeasonContentSchema, loader.ts) and § "Common Pitfalls — Pitfall 1: import.meta.glob requires literal patterns" - .planning/phases/01-foundations-and-doctrine/01-CONTEXT.md (D-11 — `/content/` at repo root; D-08 — Ink deferred to Phase 2) - CLAUDE.md "Code Style" — stable string fragment IDs (`season3.canopy.lura_07.vignette`, never numeric) - REQUIREMENTS.md MEMR-03 — fragment ID regex requirement (so the schema regex is correct for Phase 2) - **fragment.ts:** - The `id` field must match `^season\d+\.[a-z0-9._-]+$` (a Phase-1 demo can use `season0.demo`). - The `season` field must be an integer in [0, 7] (allowing 0 for the Phase-1 demo; Phase 2 will narrow to [1, 7] when Season 1 lands). - The `body` field must be a non-empty string. - **loader.ts:** - At module evaluation time, calls `import.meta.glob('/content/seasons/*/fragments.yaml', { eager: true, query: '?raw', import: 'default' })`. - Parses each YAML string and validates against `SeasonContentSchema`. - Throws on any validation failure (the throw fails `npm run build` — that's PIPE-01). - Exports a flat `fragments: Fragment[]` containing all loaded fragments. - **content/seasons/00-demo/fragments.yaml:** one demo fragment with valid shape. **Step 1 — `src/content/schemas/fragment.ts`** (per RESEARCH Pattern 4 verbatim, with the regex from CLAUDE.md and the season range expanded to allow the Phase-1 demo): ```typescript import { z } from 'zod'; /** * Fragment ID convention (CLAUDE.md "Code Style"): stable string, * `season.` where uses lowercase + digits + dot/underscore/hyphen. * Example: `season3.canopy.lura_07.vignette`. * Never numeric. Renames are forbidden. * * Phase 1 allows season 0 for the demo fragment under /content/seasons/00-demo/; * Phase 2 will narrow the range when real Season 1 content arrives. */ export const FragmentSchema = z.object({ id: z.string().regex(/^season\d+\.[a-z0-9._-]+$/), season: z.number().int().min(0).max(7), body: z.string().min(1), }); export type Fragment = z.infer; ``` **Step 2 — `src/content/schemas/season.ts`:** ```typescript import { z } from 'zod'; import { FragmentSchema } from './fragment'; /** Shape of one /content/seasons//fragments.yaml file. */ export const SeasonContentSchema = z.object({ fragments: z.array(FragmentSchema), }); export type SeasonContent = z.infer; ``` **Step 3 — `src/content/schemas/index.ts`:** ```typescript export { FragmentSchema, type Fragment } from './fragment'; export { SeasonContentSchema, type SeasonContent } from './season'; ``` **Step 4 — `src/content/loader.ts`** (per RESEARCH Pattern 4 verbatim, simplified — Phase 1 only loads YAML; Markdown handling is wired here but won't fire until Phase 2 since /content/seasons/*/fragments/*.md doesn't exist yet): ```typescript import grayMatter from 'gray-matter'; import { parse as parseYAML } from 'yaml'; import { SeasonContentSchema, FragmentSchema, type Fragment } from './schemas'; /** * Vite-native content pipeline (PIPE-01). The glob patterns MUST be * string literals at the call site — Vite's plugin walks the AST at build * time and cannot resolve runtime expressions * (.planning/phases/01-foundations-and-doctrine/01-RESEARCH.md Pitfall 1). * * On any schema violation, the throw at module-evaluation time bubbles up * through Vite into the build process — `npm run build` exits non-zero, * which is the PIPE-01 contract. */ const yamlFiles = import.meta.glob('/content/seasons/*/fragments.yaml', { eager: true, query: '?raw', import: 'default', }) as Record; const mdFiles = import.meta.glob('/content/seasons/*/fragments/*.md', { eager: true, query: '?raw', import: 'default', }) as Record; function loadYamlFragments(): Fragment[] { return Object.entries(yamlFiles).flatMap(([path, raw]) => { const data = parseYAML(raw); const parsed = SeasonContentSchema.safeParse(data); if (!parsed.success) { throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`); } return parsed.data.fragments; }); } function loadMdFragments(): Fragment[] { return Object.entries(mdFiles).map(([path, raw]) => { const { data, content } = grayMatter(raw); const merged = { ...data, body: content.trim() }; const parsed = FragmentSchema.safeParse(merged); if (!parsed.success) { throw new Error(`[content] schema violation in ${path}\n${parsed.error.message}`); } return parsed.data; }); } /** * All fragments discovered at build time. Phase 1 ships one demo fragment * under /content/seasons/00-demo/fragments.yaml; Phase 2 fills /content/seasons/01-soil/. */ export const fragments: Fragment[] = [...loadYamlFragments(), ...loadMdFragments()]; /** * Test-only helper that lets loader.test.ts validate a mocked SeasonContent * shape against the schema without touching the filesystem. PIPE-01 is * enforced at build by the throws above; this helper exists so the unit * test can prove the schema rejects bad input. */ export function loadFragmentsFromGlob( yamlGlob: Record, mdGlob: Record = {}, ): Fragment[] { const yaml = Object.entries(yamlGlob).flatMap(([path, raw]) => { const parsed = SeasonContentSchema.safeParse(parseYAML(raw)); if (!parsed.success) throw new Error(`[content] schema violation in ${path}: ${parsed.error.message}`); return parsed.data.fragments; }); const md = Object.entries(mdGlob).map(([path, raw]) => { const { data, content } = grayMatter(raw); const parsed = FragmentSchema.safeParse({ ...data, body: content.trim() }); if (!parsed.success) throw new Error(`[content] schema violation in ${path}: ${parsed.error.message}`); return parsed.data; }); return [...yaml, ...md]; } ``` **Step 5 — `src/content/index.ts`:** ```typescript export { fragments, loadFragmentsFromGlob } from './loader'; export { FragmentSchema, SeasonContentSchema, type Fragment, type SeasonContent } from './schemas'; ``` **Step 6 — `content/seasons/00-demo/fragments.yaml`** (one valid demo fragment): ```yaml # /content/seasons/00-demo/fragments.yaml # Phase 1 demo fragment — proves the loader round-trips end-to-end. # Removed in Phase 2 when real Season 1 content lands under /content/seasons/01-soil/. fragments: - id: season0.demo.first-light season: 0 body: | The garden remembers the first time it was tended, though it cannot say in whose voice. ``` Also create `content/seasons/00-demo/.gitkeep` (in case the YAML is removed in Phase 2 and we want the dir to persist) — actually skip the .gitkeep since Phase 2 will replace the whole directory anyway. Just the YAML file is enough. **Step 7 — `content/dialogue/.gitkeep`** (already created by Plan 01; verify it exists so the no-op `compile:ink` script has a target). **Step 8 — `content/README.md`:** ```markdown # /content/ — authored content tree All player-visible strings, memory fragments, and dialogue live here, never in `src/`. The build pipeline (`src/content/loader.ts`) reads this tree at build time, validates against Zod schemas, and emits typed values into the runtime bundle. ## Directory shape ``` /content/ ├── seasons/ │ ├── 00-demo/ # Phase 1 only; removed in Phase 2 │ │ └── fragments.yaml │ ├── 01-soil/ # Phase 2 fills this │ │ ├── fragments.yaml # bulk-authored fragments │ │ └── fragments/ # one-per-file long-form fragments (.md with frontmatter) │ │ └── lura-first-letter.md │ ├── 02-roots/ # Phase 4 │ └── ... # Seasons 3–7 added in Phase 5+ ├── dialogue/ # Phase 2+ Ink (.ink) files │ └── (empty in Phase 1) └── README.md (this file) ``` ## Fragment ID convention (locked — see CLAUDE.md) Fragment IDs are stable strings of the shape: ``` season. ``` where `` is `0..7` and `` matches `[a-z0-9._-]+`. Examples: - `season1.soil.first-bloom` - `season3.canopy.lura_07.vignette` **Never use numeric IDs.** Renames are forbidden once a fragment ships; re-authoring an existing fragment changes its body, never its ID. ## Adding fragments ### Option A — bulk YAML (preferred for short fragments) Add an entry to `/content/seasons//fragments.yaml`: ```yaml fragments: - id: season1.soil.first-bloom season: 1 body: | Multi-line text here. ``` ### Option B — one-per-file Markdown with frontmatter (for longer pieces) Create `/content/seasons//fragments/.md`: ```markdown --- id: season1.soil.lura-first-letter season: 1 --- The body of the fragment goes here as Markdown. Frontmatter holds the structured fields; the body is everything after the closing `---`. ``` ## Validation (PIPE-01) Every fragment is validated by the Zod schema in `src/content/schemas/fragment.ts`. A schema violation fails `npm run build`. ## Ink dialogue Phase 1 installs `inkjs` + `inklecate` and ships a no-op `npm run compile:ink` script. Phase 2 begins authoring `.ink` files under `/content/dialogue/` and replaces the no-op with `inklecate -o src/content/compiled-ink/ content/dialogue/*.ink`. ## Deferred - Per-Season lazy loading: Phase 2 switches to `{ eager: false }` for Seasons 2–7 so the initial bundle contains only Season 1 (PIPE-02). ``` **Step 9 — Verify `npm run build` succeeds** (the loader runs at build time and the demo fragment passes the schema, so the build is clean). **Step 10 — Verify `npm run compile:ink` succeeds with the no-op stub** (set up in Plan 01; this should print the placeholder message and exit 0). **Step 11 — Commit `feat(01-04): Vite-native content pipeline + Zod schemas + demo fragment + /content/ README`.** npm run build && npm run compile:ink - All 4 schema/loader files exist: `test -f src/content/schemas/fragment.ts && test -f src/content/schemas/season.ts && test -f src/content/schemas/index.ts && test -f src/content/loader.ts && test -f src/content/index.ts`. - `FragmentSchema` enforces the stable-string ID regex `^season\\d+\\.[a-z0-9._-]+$` — verify with `grep -F "^season\\d+\\." src/content/schemas/fragment.ts` (the regex appears in the file). - `loader.ts` calls `import.meta.glob` with literal patterns (per Pitfall 1) — verify with `grep -E "import\\.meta\\.glob\\('/content/seasons/\\*/" src/content/loader.ts | wc -l` returns 2 (one for yaml, one for md). - `loader.ts` throws on schema violation — verify with `grep -q "throw new Error" src/content/loader.ts`. - `content/seasons/00-demo/fragments.yaml` exists with a valid `season0.demo` fragment — verify with `grep -q "season0.demo" content/seasons/00-demo/fragments.yaml`. - `content/README.md` exists and documents the ID convention — verify with `grep -q "season" content/README.md && grep -q "Never use numeric IDs" content/README.md`. - `content/dialogue/.gitkeep` exists (Plan 01 created it; Plan 04 confirms it survives) — verify with `test -f content/dialogue/.gitkeep`. - `npm run build` exits 0 (the loader runs and the demo fragment passes). - `npm run compile:ink` exits 0 (no-op stub from Plan 01) — verify exit code 0. - `src/content/index.ts` exports the public surface — verify with `grep -cE "^export " src/content/index.ts` returns at least 4. Zod schemas for Fragment + SeasonContent with the stable-string-ID regex; Vite-native loader using `import.meta.glob` with literal patterns; one demo fragment proving end-to-end round-trip; `content/README.md` documenting the convention for Phase 2 writers; `npm run build` and `npm run compile:ink` both green; commit landed. Task 2: PIPE-01 enforcement test — schema violation must throw src/content/loader.test.ts - src/content/loader.ts (this plan's Task 1 — the `loadFragmentsFromGlob` helper) - src/content/schemas/fragment.ts (the FragmentSchema regex — pick a violator that's clearly invalid) - .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md § Validation Architecture (PIPE-01 row: "Demo content file with deliberate schema violation fails the build") - Test 1: `loadFragmentsFromGlob({})` returns `[]` (empty glob, no fragments). - Test 2: `loadFragmentsFromGlob` with a valid YAML mock returns the parsed fragments. - Test 3 (the load-bearing PIPE-01 test): `loadFragmentsFromGlob` with a mocked YAML containing a numeric `id` (violates the stable-string-ID regex) THROWS, and the error message contains `[content] schema violation`. - Test 4: `loadFragmentsFromGlob` with a mocked YAML containing a `season` value out of [0,7] range THROWS. - Test 5: `loadFragmentsFromGlob` with a mocked Markdown frontmatter that omits required `id` THROWS. Per RESEARCH § Validation Architecture, PIPE-01 is tested via "Vitest run with mocked import.meta.glob". The exported `loadFragmentsFromGlob(yamlGlob, mdGlob)` helper from Task 1 makes this trivial — no need to mock Vite or run the build; we pass mocked glob outputs directly. Use the Write tool to create `src/content/loader.test.ts`: ```typescript import { describe, it, expect } from 'vitest'; import { loadFragmentsFromGlob } from './loader'; describe('PIPE-01: content schema validation', () => { it('returns [] when both globs are empty', () => { expect(loadFragmentsFromGlob({}, {})).toEqual([]); }); it('parses valid YAML fragments', () => { const yamlGlob = { '/content/seasons/00-demo/fragments.yaml': ` fragments: - id: season0.demo.test season: 0 body: "demo body" `, }; const result = loadFragmentsFromGlob(yamlGlob); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ id: 'season0.demo.test', season: 0, body: 'demo body', }); }); it('THROWS on a numeric-id violation (stable-string-ID rule)', () => { const yamlGlob = { '/content/seasons/01-soil/fragments.yaml': ` fragments: - id: 42 season: 1 body: "this should fail because id must be a string matching the season. regex" `, }; expect(() => loadFragmentsFromGlob(yamlGlob)).toThrow(/\[content\] schema violation/); }); it('THROWS when season is out of [0,7] range', () => { const yamlGlob = { '/content/seasons/99-bogus/fragments.yaml': ` fragments: - id: season99.bogus.test season: 99 body: "season 99 doesn't exist" `, }; expect(() => loadFragmentsFromGlob(yamlGlob)).toThrow(/\[content\] schema violation/); }); it('THROWS when Markdown frontmatter omits required id', () => { const mdGlob = { '/content/seasons/01-soil/fragments/no-id.md': `--- season: 1 --- Body text without an id frontmatter key. `, }; expect(() => loadFragmentsFromGlob({}, mdGlob)).toThrow(/\[content\] schema violation/); }); }); ``` **Step 2 — Run `npx vitest run src/content/loader.test.ts`** and confirm all 5 tests pass (the 3 throw assertions plus the 2 happy-path). **Step 3 — Run `npm test`** and confirm the full Phase-1 suite (sentinel + lint-firewall + save layer + content loader) is green. **Step 4 — Commit `test(01-04): PIPE-01 enforcement — schema violations throw at content load`.** npx vitest run src/content/loader.test.ts && npm test - `src/content/loader.test.ts` exists and imports `loadFragmentsFromGlob` from `./loader` — verify with `grep -q "loadFragmentsFromGlob" src/content/loader.test.ts`. - The test contains at least 3 `expect(() => ...).toThrow` assertions — verify with `grep -c "toThrow" src/content/loader.test.ts` returns at least 3. - One throw assertion matches the numeric-id case — verify with `grep -E "id:\\s*42" src/content/loader.test.ts`. - One throw assertion matches the out-of-range season case — verify with `grep -E "season:\\s*99" src/content/loader.test.ts`. - The throw error messages contain `[content] schema violation` — verify with `grep -q "\\[content\\] schema violation" src/content/loader.test.ts`. - `npx vitest run src/content/loader.test.ts` passes 5 tests — verify exit 0. - `npm test` passes for the entire suite — verify exit 0. Vitest test for PIPE-01 covering 5 cases (2 happy-path + 3 schema violations); the throws prove `npm run build` would fail on equivalent real-file violations; `npm test` passes the entire Phase-1 suite; commit landed. Per RESEARCH § Security Domain, content pipeline threats are minimal. Path traversal via `import.meta.glob` (a malicious content file with `../../` in frontmatter) is not exploitable: Vite glob expansion is at build time; the validator step never resolves paths from frontmatter values. No security-relevant runtime code in this plan. - `npm run build` exits 0 (loader runs at build time, demo fragment validates). - `npm run compile:ink` exits 0 (no-op stub). - `npx vitest run src/content/loader.test.ts` passes 5 tests including 3 schema-violation throws (PIPE-01). - `npm test` runs the entire Phase-1 suite green. - Plan 06 (doctrine docs) and Plan 07 (CI workflow) will both depend on `npm run build` succeeding — this plan unblocks them. - Zod schemas for Fragment (with stable-string-ID regex) and SeasonContent. - Vite-native loader using `import.meta.glob` with literal patterns (Pitfall 1 honored). - Loader throws on schema violation at module-eval time, failing `npm run build`. - One demo fragment under `/content/seasons/00-demo/` proves the round-trip. - `content/README.md` documents the convention for Phase 2 writers (STRY-09 prerequisite). - 5 Vitest assertions covering happy-path + 3 schema-violation cases. - `compile:ink` no-op stub from Plan 01 confirmed runnable. After completion, create `.planning/phases/01-foundations-and-doctrine/01-04-SUMMARY.md` documenting: - The fragment ID regex committed (so Phase 2's writer has the contract). - The path of the demo fragment (`content/seasons/00-demo/fragments.yaml`) and a note that Phase 2 will remove this directory and replace it with `01-soil/`. - Confirmation that `compile:ink` is a no-op in Phase 1 (per CONTEXT D-08) and is replaced in Phase 2. - Note for Phase 2: when authoring real fragments, follow `content/README.md` Section "Adding fragments" — the test in `src/content/loader.test.ts` proves any deviation from the schema fails the build.