chore: merge executor worktree (01-04 content-pipeline)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
export { fragments, loadFragmentsFromGlob } from './loader.ts';
|
||||
export {
|
||||
FragmentSchema,
|
||||
SeasonContentSchema,
|
||||
type Fragment,
|
||||
type SeasonContent,
|
||||
} from './schemas/index.ts';
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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