From d52e35f3ad2122c1294fabf5809d8fa55256f388 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 8 May 2026 23:28:59 -0400 Subject: [PATCH] feat(01-04): Vite-native content pipeline + Zod schemas + demo fragment + /content/ README - FragmentSchema with stable-string-ID regex /^season\d+\.[a-z0-9._-]+$/ - SeasonContentSchema wraps fragments[] - loader.ts uses import.meta.glob with literal patterns (Pitfall 1) - Throws on schema violation at module-eval time, failing npm run build (PIPE-01) - Test-only loadFragmentsFromGlob helper for unit-test injection - Demo fragment season0.demo.first-light proves end-to-end round-trip - content/README.md documents the convention for Phase 2 writers (STRY-09) - Removes now-redundant src/content/.gitkeep firewall marker --- content/README.md | 110 +++++++++++++++++++++++++ content/seasons/00-demo/fragments.yaml | 13 +++ src/content/.gitkeep | 0 src/content/index.ts | 7 ++ src/content/loader.ts | 88 ++++++++++++++++++++ src/content/schemas/fragment.ts | 20 +++++ src/content/schemas/index.ts | 2 + src/content/schemas/season.ts | 12 +++ 8 files changed, 252 insertions(+) create mode 100644 content/README.md create mode 100644 content/seasons/00-demo/fragments.yaml delete mode 100644 src/content/.gitkeep create mode 100644 src/content/index.ts create mode 100644 src/content/loader.ts create mode 100644 src/content/schemas/fragment.ts create mode 100644 src/content/schemas/index.ts create mode 100644 src/content/schemas/season.ts diff --git a/content/README.md b/content/README.md new file mode 100644 index 0000000..db4436b --- /dev/null +++ b/content/README.md @@ -0,0 +1,110 @@ +# /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. + +This is the contract. Phase 2's writer can author against it without reading +any TypeScript. + +## 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. + +The exact regex enforced by `src/content/schemas/fragment.ts` is: + +``` +^season\d+\.[a-z0-9._-]+$ +``` + +## 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 `---`. +``` + +The loader (`src/content/loader.ts`) merges frontmatter + body into the +same `Fragment` shape as the YAML form. + +## Validation (PIPE-01) + +Every fragment is validated by the Zod schema in +`src/content/schemas/fragment.ts`. A schema violation throws at module-eval +time, which fails `npm run build`. + +Test coverage in `src/content/loader.test.ts` proves the schema rejects: + +- numeric IDs (violates the stable-string rule) +- season values outside `[0, 7]` +- Markdown frontmatter missing required fields + +If your edit causes the build or tests to fail with a `[content] schema +violation` error, the message includes the offending file path. + +## 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 (Phase 2+) + +- **Per-Season lazy loading:** Phase 2 switches to `{ eager: false }` for + Seasons 2–7 so the initial bundle contains only Season 1 (PIPE-02). +- **Tag/keyword indices:** Phase 5+ may add fragment tagging if the + Memory Storm UI needs filtered queries. +- **Season-range narrowing:** Phase 2 narrows the `season` field to `[1, 7]` + when the demo fragment is removed. diff --git a/content/seasons/00-demo/fragments.yaml b/content/seasons/00-demo/fragments.yaml new file mode 100644 index 0000000..c4432ed --- /dev/null +++ b/content/seasons/00-demo/fragments.yaml @@ -0,0 +1,13 @@ +# /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/. +# +# Fragment ID convention is `season.` per CLAUDE.md "Code Style" +# and content/README.md. Never numeric. Renames forbidden once shipped. +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. diff --git a/src/content/.gitkeep b/src/content/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/content/index.ts b/src/content/index.ts new file mode 100644 index 0000000..dbbf959 --- /dev/null +++ b/src/content/index.ts @@ -0,0 +1,7 @@ +export { fragments, loadFragmentsFromGlob } from './loader.ts'; +export { + FragmentSchema, + SeasonContentSchema, + type Fragment, + type SeasonContent, +} from './schemas/index.ts'; diff --git a/src/content/loader.ts b/src/content/loader.ts new file mode 100644 index 0000000..86f7601 --- /dev/null +++ b/src/content/loader.ts @@ -0,0 +1,88 @@ +import grayMatter from 'gray-matter'; +import { parse as parseYAML } from 'yaml'; +import { SeasonContentSchema, FragmentSchema, type Fragment } from './schemas/index.ts'; + +/** + * 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. + * + * Phase 1 ships one demo fragment under /content/seasons/00-demo/fragments.yaml; + * Phase 2 fills /content/seasons/01-soil/ and may also begin authoring + * one-per-file Markdown fragments under /content/seasons//fragments/*.md. + */ +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 mocked SeasonContent + * shapes 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 the same way a real + * malformed file would at build time. + */ +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]; +} diff --git a/src/content/schemas/fragment.ts b/src/content/schemas/fragment.ts new file mode 100644 index 0000000..cf3752b --- /dev/null +++ b/src/content/schemas/fragment.ts @@ -0,0 +1,20 @@ +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 once a fragment ships; re-authoring + * an existing fragment changes its body, never its ID. + * + * 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 (MEMR-03). + */ +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; diff --git a/src/content/schemas/index.ts b/src/content/schemas/index.ts new file mode 100644 index 0000000..1ef69e6 --- /dev/null +++ b/src/content/schemas/index.ts @@ -0,0 +1,2 @@ +export { FragmentSchema, type Fragment } from './fragment.ts'; +export { SeasonContentSchema, type SeasonContent } from './season.ts'; diff --git a/src/content/schemas/season.ts b/src/content/schemas/season.ts new file mode 100644 index 0000000..ba1a7d7 --- /dev/null +++ b/src/content/schemas/season.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; +import { FragmentSchema } from './fragment.ts'; + +/** + * Shape of one /content/seasons//fragments.yaml file. + * Wraps a `fragments[]` array of validated fragments. + */ +export const SeasonContentSchema = z.object({ + fragments: z.array(FragmentSchema), +}); + +export type SeasonContent = z.infer;