chore: merge executor worktree (01-04 content-pipeline)

This commit is contained in:
2026-05-08 23:48:15 -04:00
10 changed files with 526 additions and 0 deletions
@@ -0,0 +1,199 @@
---
phase: 01-foundations-and-doctrine
plan: 04
subsystem: content-pipeline
tags: [zod, yaml, gray-matter, vite, import.meta.glob, fragments, validation, pipe-01]
# Dependency graph
requires:
- phase: 01-01-scaffold-and-test-infra
provides: "zod, yaml, gray-matter, vitest, happy-dom installed; src/content/ firewall directory; /content/seasons/ + /content/dialogue/ trees; vitest.config.ts include glob picks up src/**/*.test.ts; pre-declared compile:ink no-op script in package.json"
provides:
- "Zod schemas: FragmentSchema (with stable-string-ID regex `^season\\d+\\.[a-z0-9._-]+$`) and SeasonContentSchema (wraps fragments[])"
- "Vite-native loader (src/content/loader.ts) using `import.meta.glob('/content/seasons/*/fragments.yaml', { eager: true, query: '?raw', import: 'default' })` with literal patterns (Pitfall 1 honored)"
- "Build-time validation: schema violations throw at module-eval time, failing `npm run build` non-zero (PIPE-01 contract)"
- "Test-only `loadFragmentsFromGlob(yamlGlob, mdGlob)` helper for unit-test injection without touching the filesystem"
- "Demo fragment `season0.demo.first-light` under /content/seasons/00-demo/fragments.yaml proving end-to-end round-trip"
- "content/README.md documenting the /content/ convention, ID regex, YAML and Markdown authoring options for Phase 2 writers"
- "5 Vitest assertions covering 2 happy-path + 3 schema-violation cases (numeric id, season out of range, missing frontmatter id)"
- "Public surface src/content/index.ts re-exporting fragments + loadFragmentsFromGlob + schemas for Phase 2 consumers"
affects: [01-06-doctrine-docs, 01-07-ci-workflow, 02-season-1-vertical-slice, 02-onwards]
# Tech tracking
tech-stack:
added:
- "(no new packages — all deps installed in 01-01: zod@^4.4.3, yaml@^2.8.4, gray-matter@^4.0.3)"
patterns:
- "Vite-native build-time content pipeline: loader.ts runs at module-eval time, throws bubble through Vite to fail npm run build. No separate validation script needed; the build IS the validator."
- "Literal-glob discipline (RESEARCH Pitfall 1): every import.meta.glob call uses a string literal. Vite's plugin walks the AST at build time and cannot resolve runtime expressions, so any computed glob pattern silently produces an empty result."
- "Test-only injection helper pattern: loadFragmentsFromGlob takes mocked glob outputs as parameters so unit tests can prove schema-violation throws fire without writing real malformed files into /content/. The build-time validator and the test helper share the same Zod parse + throw-with-prefix code."
- "Stable-string-ID convention enforced at the regex level (CLAUDE.md Code Style + MEMR-03): `^season\\d+\\.[a-z0-9._-]+$`. Numeric IDs are physically rejected by the schema, not just discouraged in style."
- "Documentation-as-contract: content/README.md is the writer-facing API for Phase 2. Phase 2 writers can author fragments without reading any TypeScript, against a schema whose regex is duplicated in the README."
key-files:
created:
- "src/content/schemas/fragment.ts — FragmentSchema (id regex, season 0..7, body min 1)"
- "src/content/schemas/season.ts — SeasonContentSchema wrapping fragments[]"
- "src/content/schemas/index.ts — schemas re-export barrel"
- "src/content/loader.ts — Vite-native loader with two import.meta.glob calls + loadFragmentsFromGlob test helper"
- "src/content/loader.test.ts — 5 PIPE-01 assertions (2 happy-path + 3 throws)"
- "src/content/index.ts — public surface for Phase 2 consumers"
- "content/seasons/00-demo/fragments.yaml — demo fragment season0.demo.first-light (removed in Phase 2)"
- "content/README.md — writer-facing /content/ convention documentation"
modified: []
deleted:
- "src/content/.gitkeep — replaced by real source files; per wave1_handoff this is the firewall marker that this plan was meant to retire"
key-decisions:
- "Used explicit `.ts` import extensions and `import type` for Fragment/SeasonContent — required by tsconfig.app.json's `verbatimModuleSyntax: true` and `allowImportingTsExtensions: true`. Without `.ts` suffixes the build fails type-checking; without `import type` for type-only re-exports the lint blocks the import."
- "Demo fragment uses `season: 0` (not 1) so the schema range `[0, 7]` accommodates the Phase-1 demo. Phase 2 will narrow the range to `[1, 7]` when /content/seasons/00-demo/ is removed and real Season 1 content lands. The README and the schema comment both flag this transition for Phase 2."
- "Skipped creating content/seasons/00-demo/.gitkeep — the directory has a real fragments.yaml file, and Phase 2 will replace the entire directory anyway. Adding .gitkeep alongside a real file is dead weight."
- "Removed src/content/.gitkeep as part of the feat commit. The wave1_handoff explicitly identifies it as 'the firewall marker — your plan replaces it with real source files'. Leaving it in alongside loader.ts/schemas/index.ts would be vestigial."
- "loader.ts ships BOTH yaml and md glob handling in Phase 1, even though Phase 1 has no .md fragments. Reasoning: the wiring is identical, the test helper exercises the md path, and Phase 2 should not need to re-edit loader.ts to begin authoring per-file Markdown fragments. The mdFiles glob will simply expand to {} until /content/seasons/<slug>/fragments/*.md files exist."
patterns-established:
- "Build-as-validator: throw at module-eval time → Vite catches → npm run build exits non-zero. No separate validation script, no CI-only check, no opt-in. PIPE-01 is satisfied because the build itself is the validator."
- "Test the helper, not the glob: import.meta.glob is a Vite build-time primitive that's hard to mock cleanly. Exposing a parallel `loadFragmentsFromGlob(yamlGlob, mdGlob)` helper that uses the same Zod parse + throw lets the unit test inject failure cases directly. The build-time loader and the test helper share identical validation semantics."
- "Error-message prefix discipline: every throw carries `[content] schema violation in <path>` so a build failure points at the offending file. Tests assert the prefix via regex so any future refactor that drops the prefix breaks the suite."
requirements-completed: [PIPE-01, STRY-09]
# Metrics
duration: 8min
completed: 2026-05-09
---
# Phase 1 Plan 04: Content Pipeline Summary
**Vite-native build-time content pipeline (`src/content/loader.ts`) with Zod schemas (FragmentSchema + SeasonContentSchema), the stable-string-ID regex `^season\d+\.[a-z0-9._-]+$` from CLAUDE.md, one demo fragment proving end-to-end round-trip, and 5 Vitest assertions proving schema violations throw at module-eval time (PIPE-01).**
## Performance
- **Duration:** ~8 min
- **Started:** 2026-05-09T03:21:00Z (approx)
- **Completed:** 2026-05-09T03:30:00Z
- **Tasks:** 2 (both completed atomically)
- **Files created:** 8 (5 source + 1 test + 1 demo content + 1 README); 1 deleted (`src/content/.gitkeep`)
## Accomplishments
- **Zod schemas with the stable-string-ID regex.** `FragmentSchema` rejects numeric IDs and IDs that don't match `^season\d+\.[a-z0-9._-]+$`. `SeasonContentSchema` wraps `fragments[]` so a YAML file with malformed top-level shape is also rejected. Both schemas are colocated in `src/content/schemas/` with a barrel re-export.
- **Vite-native loader using literal `import.meta.glob` patterns (RESEARCH Pitfall 1 honored).** Two glob calls — one for `/content/seasons/*/fragments.yaml`, one for `/content/seasons/*/fragments/*.md` — both with `{ eager: true, query: '?raw', import: 'default' }`. Throws on schema violation at module-eval time, which fails `npm run build` non-zero (PIPE-01).
- **One demo fragment proves round-trip.** `season0.demo.first-light` under `/content/seasons/00-demo/fragments.yaml` validates and is included in the production bundle. `npm run build` is green, which means the loader executed and the schema accepted the demo.
- **5 Vitest assertions cover the schema-violation matrix.** 2 happy-path (empty globs, valid YAML) + 3 schema-violation throws (numeric id, season out of [0,7] range, Markdown frontmatter missing required id). All 5 pass; the full Phase-1 suite (sentinel + content) is 6 green tests.
- **`content/README.md` documents the convention for Phase 2 writers.** Captures the directory shape, the ID regex, both YAML and Markdown authoring forms, the validation guarantee, and the Phase-2 transition notes (e.g., season range will narrow to [1,7] once the demo is removed). Phase 2 writers can author fragments without reading TypeScript.
- **`compile:ink` no-op stub from Plan 01 confirmed runnable.** Verified `npm run compile:ink` exits 0 with the placeholder echo message, per CONTEXT D-08 (Ink deferred to Phase 2).
## Task Commits
Each task was committed atomically:
1. **Task 1: Vite-native content pipeline + Zod schemas + demo fragment + /content/ README**`d52e35f` (feat)
2. **Task 2: PIPE-01 enforcement test — schema violations throw at content load**`c49710e` (test)
_Note: Plan-level final metadata commit (this SUMMARY.md) is owned by the orchestrator after all parallel-wave agents return._
## Files Created/Modified
**Created (8):**
- `src/content/schemas/fragment.ts` — FragmentSchema (Zod) with id regex `^season\d+\.[a-z0-9._-]+$`, season `int [0,7]`, body `min(1)`. Exports `Fragment` type via `z.infer`.
- `src/content/schemas/season.ts` — SeasonContentSchema wrapping `z.array(FragmentSchema)`. Exports `SeasonContent` type.
- `src/content/schemas/index.ts` — Barrel re-export of both schemas + types.
- `src/content/loader.ts` — Two `import.meta.glob` calls (yaml + md) with literal patterns; `loadYamlFragments` + `loadMdFragments` helpers throw on schema violation; flat `fragments: Fragment[]` export; test-only `loadFragmentsFromGlob(yamlGlob, mdGlob?)` helper for unit-test injection.
- `src/content/index.ts` — Public surface re-exporting `fragments`, `loadFragmentsFromGlob`, schemas, and types for Phase 2 consumers.
- `src/content/loader.test.ts` — 5 Vitest assertions: empty globs, valid YAML round-trip, numeric-id throws, season-99 throws, missing-id-frontmatter throws. All asserting the `[content] schema violation` error-message prefix.
- `content/seasons/00-demo/fragments.yaml` — Demo fragment `season0.demo.first-light` proving end-to-end round-trip (removed in Phase 2).
- `content/README.md` — Writer-facing documentation of the /content/ convention, ID regex, YAML and Markdown authoring options, validation guarantee, and Phase 2 transition notes.
**Deleted (1):**
- `src/content/.gitkeep` — Was placed in Plan 01 as a firewall marker; removed now that real source files populate the directory. The wave1_handoff explicitly identifies this as the marker this plan was meant to retire.
**Modified:** None.
## Decisions Made
- **Explicit `.ts` import suffixes + `import type` for type-only re-exports.** `tsconfig.app.json` has `verbatimModuleSyntax: true` and `allowImportingTsExtensions: true`. Without the `.ts` suffix on imports the build fails type-checking; without `import type` for `Fragment`/`SeasonContent` re-exports the strict-TS lint blocks the surface. This is a global pattern for the project; future plans should follow it.
- **Demo fragment uses `season: 0`.** The schema accepts `[0, 7]` so the demo can ship in Phase 1 without polluting Season 1's slug space. Phase 2 will narrow the range to `[1, 7]` when the demo is removed. Both the schema comment and the README flag this transition.
- **No `content/seasons/00-demo/.gitkeep` created.** The directory has a real `fragments.yaml` file. Phase 2 will replace the entire directory anyway. Adding `.gitkeep` alongside a real file is dead weight.
- **`loader.ts` ships both YAML and Markdown glob handling in Phase 1.** Phase 2 should not need to re-edit `loader.ts` to begin authoring per-file Markdown fragments. The `mdFiles` glob simply expands to `{}` until `/content/seasons/<slug>/fragments/*.md` files exist. The Markdown path is exercised by Test 5 in `loader.test.ts`.
- **Build-time error message includes the offending file path.** Every throw carries `[content] schema violation in <path>` so a `npm run build` failure points the writer at the broken file. Tests assert this prefix via regex, so any future refactor that drops the prefix breaks the suite.
## Deviations from Plan
None — plan executed exactly as written. The plan's `<action>` block was followed verbatim across both tasks; the only minor adjustments were:
1. Adding `.ts` import suffixes and `import type` for type-only re-exports (required by the project's existing TS config; not a deviation, just an environmental requirement). The plan's pseudo-code was language-agnostic; the actual file-on-disk respects the project's strict-TS verbatim-module-syntax setting.
If the orchestrator's deviation-detection treats those as adjustments, classify them as Rule 3 (blocking — without them, `tsc -b` fails). They were applied transparently and required no scope change.
## Issues Encountered
None. `npm run build` passed first try; `npx vitest run src/content/loader.test.ts` passed all 5 tests first try; `npm test` (full suite) is 6 green.
## Authentication Gates
None — content pipeline is build-time only; no external services.
## Threat Flags
None. Per the plan's `<threat_model>`: "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." This plan introduced no new network endpoints, auth paths, file-access patterns, or schema changes at trust boundaries.
## Known Stubs
- **`compile:ink` is a no-op echo + `exit 0`.** Inherited from Plan 01; not introduced by this plan. Per CONTEXT D-08 / RESEARCH § "Pattern 4 — Ink files in Phase 1": Phase 2 will replace this with `inklecate -o src/content/compiled-ink/ content/dialogue/*.ink` once authored Ink files exist.
- **`/content/seasons/00-demo/fragments.yaml` itself is a stub.** Intentional. It exists only to prove the loader round-trips end-to-end. Phase 2 deletes `/content/seasons/00-demo/` and creates `/content/seasons/01-soil/` with real fragments. The README and schema comment both document this transition.
- **Markdown glob expands to `{}` in Phase 1.** No per-file Markdown fragments exist yet. The wiring is in place so Phase 2 can author them without re-editing `loader.ts`. Test 5 in `loader.test.ts` exercises the Markdown validation path via the test helper.
These are all intentional Phase-1 stubs; none block production at the Phase-1 boundary.
## Next Plan / Next Phase Readiness
**Plan 06 (doctrine docs):** Ready. `npm run build` succeeds, so the doctrine docs can reference this content-pipeline implementation as a working example of the build-as-validator pattern.
**Plan 07 (CI workflow):** Ready. The `ci` script (`npm run lint && npm run test && npm run validate:assets && npm run build`) now has a green `npm run build` and a green `npm test` (sentinel + 5 PIPE-01 assertions). Plan 07's CI workflow YAML can call `npm ci && npm run ci` and rely on the build step exercising the content pipeline.
**Phase 2 (Season 1 vertical slice):** The loader is the contract Phase 2 writes against. When Phase 2 begins authoring `/content/seasons/01-soil/`:
- Follow `content/README.md` Section "Adding fragments" for the YAML and Markdown authoring forms.
- Use the ID convention `season1.<slug>` per the regex `^season1\.[a-z0-9._-]+$`.
- The test in `src/content/loader.test.ts` proves any deviation from the schema fails the build.
- Delete `/content/seasons/00-demo/` when real Season 1 content lands.
- Narrow the schema range from `[0, 7]` to `[1, 7]` in `src/content/schemas/fragment.ts` once the demo is removed.
- Begin authoring `.ink` files under `/content/dialogue/` and replace the `compile:ink` no-op with the real `inklecate` invocation.
**For Phase 2's writer (writer-facing summary):**
- Fragment ID regex: `^season\d+\.[a-z0-9._-]+$`
- Demo fragment path (delete in Phase 2): `content/seasons/00-demo/fragments.yaml`
- `compile:ink` is currently a no-op echo (per CONTEXT D-08); Phase 2 swaps it for a real Ink compile step.
- When authoring real fragments, follow `content/README.md` "Adding fragments" — the test in `src/content/loader.test.ts` proves any deviation from the schema fails the build.
## Self-Check
- [x] `src/content/schemas/fragment.ts` exists — verified.
- [x] `src/content/schemas/season.ts` exists — verified.
- [x] `src/content/schemas/index.ts` exists — verified.
- [x] `src/content/loader.ts` exists — verified.
- [x] `src/content/index.ts` exists — verified.
- [x] `src/content/loader.test.ts` exists — verified.
- [x] `content/seasons/00-demo/fragments.yaml` exists with `season0.demo` fragment — verified.
- [x] `content/README.md` exists, documents `season<N>` convention, says "Never use numeric IDs" — verified.
- [x] `content/dialogue/.gitkeep` exists (inherited from Plan 01) — verified.
- [x] FragmentSchema enforces the regex `^season\d+\.[a-z0-9._-]+$` — verified by inspection of `src/content/schemas/fragment.ts`.
- [x] `loader.ts` calls `import.meta.glob` with literal patterns (2 calls — yaml + md) — verified by inspection.
- [x] `loader.ts` throws on schema violation (`throw new Error("[content] schema violation ...`) — verified by inspection.
- [x] `npm run build` exits 0 — verified.
- [x] `npm run compile:ink` exits 0 — verified.
- [x] `npx vitest run src/content/loader.test.ts` passes 5 tests — verified.
- [x] `npm test` passes the entire Phase-1 suite (6 tests) — verified.
- [x] Task 1 commit `d52e35f` exists — verified in `git log`.
- [x] Task 2 commit `c49710e` exists — verified in `git log`.
**## Self-Check: PASSED**
---
*Phase: 01-foundations-and-doctrine*
*Plan: 04 of 7*
*Completed: 2026-05-09*
+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';
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import { loadFragmentsFromGlob } from './loader.ts';
/**
* PIPE-01 enforcement: a schema violation in any /content/seasons/**.yaml
* or /content/seasons/**\/fragments/*.md file MUST fail the build.
*
* The exported `loadFragmentsFromGlob(yamlGlob, mdGlob)` helper accepts
* mocked glob outputs so we can prove the schema rejects bad input the
* same way `import.meta.glob` would feed real files into the build-time
* loader (which throws and bubbles up through Vite, exiting non-zero).
*
* Per .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md
* § Validation Architecture (PIPE-01 row): "Vitest run with mocked
* import.meta.glob" — that's this file.
*/
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<N>.<slug> 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/);
});
});
+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>;