/** * Seed as a directory: one small JSON file per thing instead of one large * seed.json, so a theme repo reads naturally in an editor and diffs stay * reviewable. Every file carries a `$schema` pointing at the schemas shipped * next to it (`.schemas/`), which the exporter writes alongside. * * seed/ * seed.json version, meta, defaultLocale, settings * collections/.json SeedCollection * taxonomies/.json SeedTaxonomy (with terms) * menus/.json SeedMenu * redirects.json SeedRedirect[] * widget-areas/.json SeedWidgetArea * sections/.json SeedSection * bylines/.json SeedByline * content//.json SeedContentEntry * .schemas/*.schema.json * * `splitSeed` and `composeSeed` are exact inverses; a single legacy * `seed.json` (the whole file) is still accepted by `composeSeed`. */ import { SEED_SCHEMAS } from "./schemas.generated.js"; import type { SeedByline, SeedCollection, SeedContentEntry, SeedFile, SeedMenu, SeedRedirect, SeedSection, SeedTaxonomy, SeedWidgetArea, } from "./types.js"; export const SEED_DIR = "seed"; export const SCHEMAS_DIR = ".schemas"; /** Map of repo-relative path → JSON text. */ export type SeedTree = Map; /** Add the JSON Schemas the files' `$schema` pointers refer to (`seed/.schemas/`). */ export function withSchemas(tree: SeedTree): SeedTree { for (const [name, text] of Object.entries(SEED_SCHEMAS)) { tree.set(`${SEED_DIR}/${SCHEMAS_DIR}/${name}`, `${text}\n`); } return tree; } const UNSAFE = /[^a-z0-9._-]+/gi; const EDGES = /^[-.]+|[-.]+$/g; function fileName(name: string, fallback: string): string { const s = String(name || "") .replace(UNSAFE, "-") .replace(EDGES, "") .toLowerCase(); return s || fallback; } function json(value: unknown, schema: string): string { const body = value && typeof value === "object" && !Array.isArray(value) ? { $schema: `../${SCHEMAS_DIR}/${schema}.schema.json`, ...(value as object) } : value; return `${JSON.stringify(body, null, "\t")}\n`; } function stripSchema(value: T): T { if (value && typeof value === "object" && !Array.isArray(value) && "$schema" in value) { const { $schema: _s, ...rest } = value as Record; return rest as T; } return value; } /** Split a seed file into the directory layout (paths are relative to the repo root). */ export function splitSeed(seed: SeedFile): SeedTree { const out: SeedTree = new Map(); const root: Record = { $schema: `./${SCHEMAS_DIR}/seed.schema.json`, version: seed.version, }; if (seed.defaultLocale) root.defaultLocale = seed.defaultLocale; if (seed.meta) root.meta = seed.meta; if (seed.settings) root.settings = seed.settings; // Apply order matters (relations, parents before children); file systems // sort by name, so the root records the order of every group. const order: Record = {}; const names = ( items: Array<{ slug?: string; name?: string }> | undefined, key: "slug" | "name", fb: string, ) => (items ?? []).map((x) => fileName(String(x[key] ?? ""), fb)); if (seed.collections?.length) order.collections = names(seed.collections, "slug", "collection"); if (seed.taxonomies?.length) order.taxonomies = names(seed.taxonomies, "name", "taxonomy"); if (seed.menus?.length) order.menus = names(seed.menus, "name", "menu"); if (seed.widgetAreas?.length) order.widgetAreas = names(seed.widgetAreas, "name", "area"); if (seed.sections?.length) order.sections = names(seed.sections, "slug", "section"); if (seed.bylines?.length) order.bylines = names(seed.bylines, "slug", "byline"); if (seed.content && Object.keys(seed.content).length) { order.content = Object.fromEntries( Object.entries(seed.content).map(([c, entries]) => [ fileName(c, "collection"), entries.map((e, i) => fileName(e.slug || String(i + 1), String(i + 1))), ]), ); } if (Object.keys(order).length) root.order = order; out.set(`${SEED_DIR}/seed.json`, `${JSON.stringify(root, null, "\t")}\n`); for (const c of seed.collections ?? []) out.set( `${SEED_DIR}/collections/${fileName(c.slug, "collection")}.json`, json(c, "collection"), ); for (const t of seed.taxonomies ?? []) out.set(`${SEED_DIR}/taxonomies/${fileName(t.name, "taxonomy")}.json`, json(t, "taxonomy")); for (const m of seed.menus ?? []) out.set(`${SEED_DIR}/menus/${fileName(m.name, "menu")}.json`, json(m, "menu")); if (seed.redirects?.length) out.set(`${SEED_DIR}/redirects.json`, `${JSON.stringify(seed.redirects, null, "\t")}\n`); for (const w of seed.widgetAreas ?? []) out.set(`${SEED_DIR}/widget-areas/${fileName(w.name, "area")}.json`, json(w, "widget-area")); for (const s of seed.sections ?? []) out.set(`${SEED_DIR}/sections/${fileName(s.slug, "section")}.json`, json(s, "section")); for (const b of seed.bylines ?? []) out.set(`${SEED_DIR}/bylines/${fileName(b.slug, "byline")}.json`, json(b, "byline")); for (const [collection, entries] of Object.entries(seed.content ?? {})) { const dir = fileName(collection, "collection"); entries.forEach((e, i) => { const name = fileName(e.slug || String(i + 1), String(i + 1)); out.set( `${SEED_DIR}/content/${dir}/${name}.json`, json(e, "content-entry").replace( `"$schema": "../${SCHEMAS_DIR}/content-entry.schema.json"`, `"$schema": "../../${SCHEMAS_DIR}/content-entry.schema.json"`, ), ); }); } return out; } /** * Compose a seed file from a directory tree (any map of paths → text; paths * may be prefixed by anything up to `seed/`). A lone `seed.json` holding a * whole seed file is returned as-is. */ export function composeSeed(files: SeedTree): SeedFile | null { const entries = Array.from(files.entries(), ([p, text]) => { const i = p.lastIndexOf(`${SEED_DIR}/`); return i >= 0 ? ([p.slice(i + SEED_DIR.length + 1), text] as const) : null; }) .filter((x): x is readonly [string, string] => x !== null && x[0].endsWith(".json")) .toSorted((a, b) => a[0].localeCompare(b[0])); if (entries.length === 0) { // Legacy: a single seed.json anywhere in the tree. const legacy = [...files.entries()].find(([p]) => p.endsWith("seed.json")); return legacy ? (stripSchema(JSON.parse(legacy[1])) as SeedFile) : null; } const parse = (text: string): T => stripSchema(JSON.parse(text)) as T; const rootText = entries.find(([p]) => p === "seed.json")?.[1]; const root = rootText ? parse & { order?: SeedOrder }>(rootText) : {}; // A whole-file seed.json (legacy layout inside seed/). if (root.collections || root.content) return root as SeedFile; const { order, ...rest } = root; const seed: SeedFile = { version: "1", ...rest } as SeedFile; const collections: SeedCollection[] = []; const taxonomies: SeedTaxonomy[] = []; const menus: SeedMenu[] = []; const widgetAreas: SeedWidgetArea[] = []; const sections: SeedSection[] = []; const bylines: SeedByline[] = []; const content: Record = {}; for (const [p, text] of entries) { if (p === "seed.json" || p.startsWith(`${SCHEMAS_DIR}/`)) continue; if (p.startsWith("collections/")) collections.push(parse(text)); else if (p.startsWith("taxonomies/")) taxonomies.push(parse(text)); else if (p.startsWith("menus/")) menus.push(parse(text)); else if (p === "redirects.json") seed.redirects = parse(text); else if (p.startsWith("widget-areas/")) widgetAreas.push(parse(text)); else if (p.startsWith("sections/")) sections.push(parse(text)); else if (p.startsWith("bylines/")) bylines.push(parse(text)); else if (p.startsWith("content/")) { const collection = p.split("/")[1]; if (!collection) continue; (content[collection] ??= []).push(parse(text)); } } const bySlug = (items: T[], want?: string[]) => sortByOrder(items, (x) => fileName(String(x.slug ?? ""), ""), want); const byName = (items: T[], want?: string[]) => sortByOrder(items, (x) => fileName(String(x.name ?? ""), ""), want); if (collections.length) seed.collections = bySlug(collections, order?.collections); if (taxonomies.length) seed.taxonomies = byName(taxonomies, order?.taxonomies); if (menus.length) seed.menus = byName(menus, order?.menus); if (widgetAreas.length) seed.widgetAreas = byName(widgetAreas, order?.widgetAreas); if (sections.length) seed.sections = bySlug(sections, order?.sections); if (bylines.length) seed.bylines = bySlug(bylines, order?.bylines); if (Object.keys(content).length) { seed.content = {}; for (const c of sortByOrder( Object.keys(content), (k) => k, order?.content && Object.keys(order.content), )) { seed.content[c] = sortByOrder( content[c]!, (e) => fileName(e.slug || "", ""), order?.content?.[c], ); } } return seed; } interface SeedOrder { collections?: string[]; taxonomies?: string[]; menus?: string[]; widgetAreas?: string[]; sections?: string[]; bylines?: string[]; content?: Record; } /** Items in `want` order (by their file name), anything unlisted after, in name order. */ function sortByOrder(items: T[], nameOf: (x: T) => string, want?: string[]): T[] { if (!want?.length) return items; const rank = new Map(want.map((n, i) => [n, i])); return items.toSorted((a, b) => { const ra = rank.get(nameOf(a)) ?? Number.MAX_SAFE_INTEGER; const rb = rank.get(nameOf(b)) ?? Number.MAX_SAFE_INTEGER; return ra - rb || nameOf(a).localeCompare(nameOf(b)); }); }