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
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>;