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:
@@ -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<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 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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export { fragments, loadFragmentsFromGlob } from './loader.ts';
|
||||||
|
export {
|
||||||
|
FragmentSchema,
|
||||||
|
SeasonContentSchema,
|
||||||
|
type Fragment,
|
||||||
|
type SeasonContent,
|
||||||
|
} from './schemas/index.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/<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];
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { FragmentSchema, type Fragment } from './fragment.ts';
|
||||||
|
export { SeasonContentSchema, type SeasonContent } from './season.ts';
|
||||||
@@ -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>;
|
||||||
Reference in New Issue
Block a user