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
This commit is contained in:
2026-05-08 23:28:59 -04:00
parent 1e99356b27
commit d52e35f3ad
8 changed files with 252 additions and 0 deletions
+110
View File
@@ -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 37 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<N>.<id>
```
where `<N>` is `0..7` and `<id>` 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/<slug>/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/<slug>/fragments/<slug>.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 27 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.
+13
View File
@@ -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<N>.<id>` 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.
View File
+7
View File
@@ -0,0 +1,7 @@
export { fragments, loadFragmentsFromGlob } from './loader.ts';
export {
FragmentSchema,
SeasonContentSchema,
type Fragment,
type SeasonContent,
} from './schemas/index.ts';
+88
View File
@@ -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/<slug>/fragments/*.md.
*/
const yamlFiles = import.meta.glob('/content/seasons/*/fragments.yaml', {
eager: true,
query: '?raw',
import: 'default',
}) as Record<string, string>;
const mdFiles = import.meta.glob('/content/seasons/*/fragments/*.md', {
eager: true,
query: '?raw',
import: 'default',
}) as Record<string, string>;
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<string, string>,
mdGlob: Record<string, string> = {},
): 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];
}
+20
View File
@@ -0,0 +1,20 @@
import { z } from 'zod';
/**
* Fragment ID convention (CLAUDE.md "Code Style"): stable string,
* `season<N>.<id>` where <id> 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<typeof FragmentSchema>;
+2
View File
@@ -0,0 +1,2 @@
export { FragmentSchema, type Fragment } from './fragment.ts';
export { SeasonContentSchema, type SeasonContent } from './season.ts';
+12
View File
@@ -0,0 +1,12 @@
import { z } from 'zod';
import { FragmentSchema } from './fragment.ts';
/**
* Shape of one /content/seasons/<slug>/fragments.yaml file.
* Wraps a `fragments[]` array of validated fragments.
*/
export const SeasonContentSchema = z.object({
fragments: z.array(FragmentSchema),
});
export type SeasonContent = z.infer<typeof SeasonContentSchema>;