first commit
This commit is contained in:
1141
packages/core/src/seed/apply.ts
Normal file
1141
packages/core/src/seed/apply.ts
Normal file
File diff suppressed because it is too large
Load Diff
86
packages/core/src/seed/default.ts
Normal file
86
packages/core/src/seed/default.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Default seed applied when no user seed file exists.
|
||||
*
|
||||
* Provides the baseline schema every EmDash site needs:
|
||||
* posts, pages, categories, and tags.
|
||||
*/
|
||||
|
||||
import type { SeedFile } from "./types.js";
|
||||
|
||||
export const defaultSeed: SeedFile = {
|
||||
version: "1",
|
||||
meta: {
|
||||
name: "Default",
|
||||
description: "Posts and pages with categories and tags",
|
||||
},
|
||||
collections: [
|
||||
{
|
||||
slug: "posts",
|
||||
label: "Posts",
|
||||
labelSingular: "Post",
|
||||
supports: ["drafts", "revisions", "search"],
|
||||
fields: [
|
||||
{
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
required: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
slug: "featured_image",
|
||||
label: "Featured Image",
|
||||
type: "image",
|
||||
},
|
||||
{
|
||||
slug: "content",
|
||||
label: "Content",
|
||||
type: "portableText",
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
slug: "excerpt",
|
||||
label: "Excerpt",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "pages",
|
||||
label: "Pages",
|
||||
labelSingular: "Page",
|
||||
supports: ["drafts", "revisions", "search"],
|
||||
fields: [
|
||||
{
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
required: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
slug: "content",
|
||||
label: "Content",
|
||||
type: "portableText",
|
||||
searchable: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
taxonomies: [
|
||||
{
|
||||
name: "category",
|
||||
label: "Categories",
|
||||
labelSingular: "Category",
|
||||
hierarchical: true,
|
||||
collections: ["posts"],
|
||||
},
|
||||
{
|
||||
name: "tag",
|
||||
label: "Tags",
|
||||
labelSingular: "Tag",
|
||||
hierarchical: false,
|
||||
collections: ["posts"],
|
||||
},
|
||||
],
|
||||
};
|
||||
28
packages/core/src/seed/index.ts
Normal file
28
packages/core/src/seed/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Seed API - public exports
|
||||
*
|
||||
* Provides the seeding API for bootstrapping EmDash sites.
|
||||
*/
|
||||
|
||||
export { applySeed } from "./apply.js";
|
||||
export { defaultSeed } from "./default.js";
|
||||
export { loadSeed, loadUserSeed } from "./load.js";
|
||||
export { validateSeed } from "./validate.js";
|
||||
|
||||
export type {
|
||||
SeedFile,
|
||||
SeedCollection,
|
||||
SeedField,
|
||||
SeedTaxonomy,
|
||||
SeedTaxonomyTerm,
|
||||
SeedMenu,
|
||||
SeedMenuItem,
|
||||
SeedRedirect,
|
||||
SeedWidgetArea,
|
||||
SeedWidget,
|
||||
SeedSection,
|
||||
SeedContentEntry,
|
||||
SeedApplyOptions,
|
||||
SeedApplyResult,
|
||||
ValidationResult,
|
||||
} from "./types.js";
|
||||
35
packages/core/src/seed/load.ts
Normal file
35
packages/core/src/seed/load.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Seed file loading
|
||||
*
|
||||
* Imports seed data from the virtual module, which embeds the user's seed file
|
||||
* (or the default seed) at Vite build time. This avoids runtime filesystem access,
|
||||
* which doesn't work in workerd/miniflare where process.cwd() returns "/".
|
||||
*/
|
||||
|
||||
import type { SeedFile } from "./types.js";
|
||||
|
||||
interface SeedModule {
|
||||
seed: SeedFile;
|
||||
userSeed: SeedFile | null;
|
||||
}
|
||||
|
||||
async function getSeedModule(): Promise<SeedModule> {
|
||||
// @ts-ignore - virtual module, only available within Vite runtime
|
||||
return import("virtual:emdash/seed") as Promise<SeedModule>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the seed file (user seed or default).
|
||||
*/
|
||||
export async function loadSeed(): Promise<SeedFile> {
|
||||
const { seed } = await getSeedModule();
|
||||
return seed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the user's seed file, or null if none exists.
|
||||
*/
|
||||
export async function loadUserSeed(): Promise<SeedFile | null> {
|
||||
const { userSeed } = await getSeedModule();
|
||||
return userSeed ?? null;
|
||||
}
|
||||
341
packages/core/src/seed/types.ts
Normal file
341
packages/core/src/seed/types.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Seed file types for bootstrapping EmDash sites
|
||||
*
|
||||
* Seed files are DB-agnostic JSON documents that declare everything needed to set up a site:
|
||||
* collections, fields, menus, settings, taxonomies, redirects, widget areas, and optional sample content.
|
||||
*/
|
||||
|
||||
import type { FieldType } from "../schema/types.js";
|
||||
import type { SiteSettings } from "../settings/types.js";
|
||||
import type { Storage } from "../storage/types.js";
|
||||
|
||||
/**
|
||||
* Root seed file structure
|
||||
*/
|
||||
export interface SeedFile {
|
||||
/** JSON schema reference (optional) */
|
||||
$schema?: string;
|
||||
|
||||
/** Seed format version */
|
||||
version: "1";
|
||||
|
||||
/** Metadata about the seed */
|
||||
meta?: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
};
|
||||
|
||||
/** Site settings */
|
||||
settings?: Partial<SiteSettings>;
|
||||
|
||||
/** Collection definitions */
|
||||
collections?: SeedCollection[];
|
||||
|
||||
/** Taxonomy definitions */
|
||||
taxonomies?: SeedTaxonomy[];
|
||||
|
||||
/** Navigation menus */
|
||||
menus?: SeedMenu[];
|
||||
|
||||
/** Redirect rules */
|
||||
redirects?: SeedRedirect[];
|
||||
|
||||
/** Widget areas */
|
||||
widgetAreas?: SeedWidgetArea[];
|
||||
|
||||
/** Sections (reusable content blocks, like WP patterns/reusable blocks) */
|
||||
sections?: SeedSection[];
|
||||
|
||||
/** Bylines used for presentation credits */
|
||||
bylines?: SeedByline[];
|
||||
|
||||
/** Sample content (organized by collection) */
|
||||
content?: Record<string, SeedContentEntry[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection definition in seed
|
||||
*/
|
||||
export interface SeedCollection {
|
||||
slug: string;
|
||||
label: string;
|
||||
labelSingular?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
supports?: ("drafts" | "revisions" | "preview" | "scheduling" | "search" | "seo")[];
|
||||
urlPattern?: string;
|
||||
/** Enable comments on this collection */
|
||||
commentsEnabled?: boolean;
|
||||
fields: SeedField[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Field definition in seed
|
||||
*/
|
||||
export interface SeedField {
|
||||
slug: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
required?: boolean;
|
||||
unique?: boolean;
|
||||
searchable?: boolean;
|
||||
defaultValue?: unknown;
|
||||
validation?: Record<string, unknown>;
|
||||
widget?: string;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Taxonomy definition in seed
|
||||
*/
|
||||
export interface SeedTaxonomy {
|
||||
name: string;
|
||||
label: string;
|
||||
labelSingular?: string;
|
||||
hierarchical: boolean;
|
||||
collections: string[];
|
||||
terms?: SeedTaxonomyTerm[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Taxonomy term in seed
|
||||
*/
|
||||
export interface SeedTaxonomyTerm {
|
||||
slug: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
parent?: string; // Slug of parent term (for hierarchical taxonomies)
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu definition in seed
|
||||
*/
|
||||
export interface SeedMenu {
|
||||
name: string;
|
||||
label: string;
|
||||
items: SeedMenuItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu item in seed
|
||||
*/
|
||||
export interface SeedMenuItem {
|
||||
type: string;
|
||||
label?: string;
|
||||
url?: string; // For custom type
|
||||
ref?: string; // For page/post: content id in seed; for taxonomy: term slug
|
||||
collection?: string; // Collection name for page/post/taxonomy types
|
||||
target?: "_blank" | "_self";
|
||||
titleAttr?: string;
|
||||
cssClasses?: string;
|
||||
children?: SeedMenuItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect definition in seed
|
||||
*/
|
||||
export interface SeedRedirect {
|
||||
source: string;
|
||||
destination: string;
|
||||
type?: 301 | 302 | 307 | 308;
|
||||
enabled?: boolean;
|
||||
groupName?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget area definition in seed
|
||||
*/
|
||||
export interface SeedWidgetArea {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
widgets: SeedWidget[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget in seed
|
||||
*/
|
||||
export interface SeedWidget {
|
||||
type: "content" | "menu" | "component";
|
||||
title?: string;
|
||||
|
||||
// For content widgets - using loose type since @portabletext/types is optional
|
||||
content?: Array<{ _type: string; _key?: string; [key: string]: unknown }>;
|
||||
|
||||
// For menu widgets
|
||||
menuName?: string;
|
||||
|
||||
// For component widgets
|
||||
componentId?: string;
|
||||
props?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Section (reusable content block) in seed
|
||||
*/
|
||||
export interface SeedSection {
|
||||
slug: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Search keywords */
|
||||
keywords?: string[];
|
||||
/** Portable Text content */
|
||||
content: Array<{ _type: string; _key?: string; [key: string]: unknown }>;
|
||||
/** Source: "theme" for seed-provided, "import" for WP imports */
|
||||
source?: "theme" | "import";
|
||||
}
|
||||
|
||||
/**
|
||||
* Byline profile in seed
|
||||
*/
|
||||
export interface SeedByline {
|
||||
/** Seed-local ID for byline references in content entries */
|
||||
id: string;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
bio?: string;
|
||||
websiteUrl?: string;
|
||||
isGuest?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content entry in seed
|
||||
*/
|
||||
export interface SeedContentEntry {
|
||||
/** Seed-local ID for $ref resolution */
|
||||
id: string;
|
||||
|
||||
/** URL slug */
|
||||
slug: string;
|
||||
|
||||
/** Publication status */
|
||||
status?: "published" | "draft";
|
||||
|
||||
/** Content data (field slug -> value) */
|
||||
data: Record<string, unknown>;
|
||||
|
||||
/** Taxonomy term assignments (taxonomy name -> term slugs) */
|
||||
taxonomies?: Record<string, string[]>;
|
||||
|
||||
/** Ordered byline credits for this entry */
|
||||
bylines?: SeedBylineCredit[];
|
||||
|
||||
/** BCP 47 locale code. When omitted, defaults to defaultLocale. */
|
||||
locale?: string;
|
||||
|
||||
/**
|
||||
* Seed-local ID of the source content entry this translates.
|
||||
* Must reference another entry's `id` in the same collection.
|
||||
*/
|
||||
translationOf?: string;
|
||||
}
|
||||
|
||||
export interface SeedBylineCredit {
|
||||
/** Seed byline ID from root `bylines[]` */
|
||||
byline: string;
|
||||
roleLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for applying a seed
|
||||
*/
|
||||
export interface SeedApplyOptions {
|
||||
/** Include sample content (default: false) */
|
||||
includeContent?: boolean;
|
||||
|
||||
/** How to handle conflicts (default: "skip") */
|
||||
onConflict?: "skip" | "update" | "error";
|
||||
|
||||
/** Base path for local media files (for $media.file resolution) */
|
||||
mediaBasePath?: string;
|
||||
|
||||
/**
|
||||
* Storage adapter for media uploads.
|
||||
* Required if seed contains $media references with URLs.
|
||||
*/
|
||||
storage?: Storage;
|
||||
|
||||
/**
|
||||
* Skip downloading and storing media for $media references.
|
||||
*
|
||||
* When true, $media references are resolved to a MediaValue
|
||||
* that uses the original external URL directly as the `src`,
|
||||
* with provider set to "external". No storage adapter is needed.
|
||||
*
|
||||
* Useful for playground/demo environments where media storage
|
||||
* is unavailable or undesirable.
|
||||
*/
|
||||
skipMediaDownload?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of applying a seed
|
||||
*/
|
||||
export interface SeedApplyResult {
|
||||
collections: { created: number; skipped: number; updated: number };
|
||||
fields: { created: number; skipped: number; updated: number };
|
||||
taxonomies: { created: number; terms: number };
|
||||
bylines: { created: number; skipped: number; updated: number };
|
||||
menus: { created: number; items: number };
|
||||
redirects: { created: number; skipped: number; updated: number };
|
||||
widgetAreas: { created: number; widgets: number };
|
||||
sections: { created: number; skipped: number; updated: number };
|
||||
settings: { applied: number };
|
||||
content: { created: number; skipped: number; updated: number };
|
||||
media: { created: number; skipped: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation result
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Media queue item for background download
|
||||
*/
|
||||
export interface MediaQueueItem {
|
||||
url: string;
|
||||
targetMediaId: string;
|
||||
alt?: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* $media reference in seed content
|
||||
*
|
||||
* Use this syntax in seed files to import media from URLs:
|
||||
* ```json
|
||||
* {
|
||||
* "featured_image": {
|
||||
* "$media": {
|
||||
* "url": "https://images.unsplash.com/photo-xxx",
|
||||
* "alt": "Description of the image",
|
||||
* "filename": "my-image.jpg"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* The seed engine will:
|
||||
* 1. Download the image from the URL
|
||||
* 2. Upload it to the configured storage
|
||||
* 3. Create a media record in the database
|
||||
* 4. Replace the $media object with the proper field value
|
||||
*/
|
||||
export interface SeedMediaReference {
|
||||
$media: {
|
||||
/** URL to download the media from */
|
||||
url: string;
|
||||
/** Alt text for the image */
|
||||
alt?: string;
|
||||
/** Custom filename (defaults to URL basename or generated) */
|
||||
filename?: string;
|
||||
/** Caption for the media */
|
||||
caption?: string;
|
||||
};
|
||||
}
|
||||
642
packages/core/src/seed/validate.ts
Normal file
642
packages/core/src/seed/validate.ts
Normal file
@@ -0,0 +1,642 @@
|
||||
/**
|
||||
* Seed file validation
|
||||
*
|
||||
* Validates a seed file structure before applying it.
|
||||
*/
|
||||
|
||||
import { FIELD_TYPES } from "../schema/types.js";
|
||||
import type { SeedFile, SeedMenuItem, ValidationResult } from "./types.js";
|
||||
|
||||
const COLLECTION_FIELD_SLUG_PATTERN = /^[a-z][a-z0-9_]*$/;
|
||||
const SLUG_PATTERN = /^[a-z0-9-]+$/;
|
||||
const REDIRECT_TYPES = new Set([301, 302, 307, 308]);
|
||||
const CRLF_PATTERN = /[\r\n]/;
|
||||
|
||||
/** Type guard for Record<string, unknown> */
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isValidRedirectPath(path: string): boolean {
|
||||
if (!path.startsWith("/") || path.startsWith("//") || CRLF_PATTERN.test(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return !decodeURIComponent(path).split("/").includes("..");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a seed file
|
||||
*
|
||||
* @param data - Unknown data to validate as a seed file
|
||||
* @returns Validation result with errors and warnings
|
||||
*/
|
||||
export function validateSeed(data: unknown): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Basic type check
|
||||
if (!data || typeof data !== "object") {
|
||||
return {
|
||||
valid: false,
|
||||
errors: ["Seed must be an object"],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const seed = data as Partial<SeedFile>;
|
||||
|
||||
// Required fields
|
||||
if (!seed.version) {
|
||||
errors.push("Seed must have a version field");
|
||||
} else if (seed.version !== "1") {
|
||||
errors.push(`Unsupported seed version: ${String(seed.version)}`);
|
||||
}
|
||||
|
||||
// Validate collections
|
||||
if (seed.collections) {
|
||||
if (!Array.isArray(seed.collections)) {
|
||||
errors.push("collections must be an array");
|
||||
} else {
|
||||
const collectionSlugs = new Set<string>();
|
||||
|
||||
for (let i = 0; i < seed.collections.length; i++) {
|
||||
const collection = seed.collections[i];
|
||||
const prefix = `collections[${i}]`;
|
||||
|
||||
if (!collection.slug) {
|
||||
errors.push(`${prefix}: slug is required`);
|
||||
} else {
|
||||
// Check for valid slug format
|
||||
if (!COLLECTION_FIELD_SLUG_PATTERN.test(collection.slug)) {
|
||||
errors.push(
|
||||
`${prefix}.slug: must start with a letter and contain only lowercase letters, numbers, and underscores`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check for duplicate slugs
|
||||
if (collectionSlugs.has(collection.slug)) {
|
||||
errors.push(`${prefix}.slug: duplicate collection slug "${collection.slug}"`);
|
||||
}
|
||||
collectionSlugs.add(collection.slug);
|
||||
}
|
||||
|
||||
if (!collection.label) {
|
||||
errors.push(`${prefix}: label is required`);
|
||||
}
|
||||
|
||||
// Validate fields
|
||||
if (!Array.isArray(collection.fields)) {
|
||||
errors.push(`${prefix}.fields: must be an array`);
|
||||
} else {
|
||||
const fieldSlugs = new Set<string>();
|
||||
|
||||
for (let j = 0; j < collection.fields.length; j++) {
|
||||
const field = collection.fields[j];
|
||||
const fieldPrefix = `${prefix}.fields[${j}]`;
|
||||
|
||||
if (!field.slug) {
|
||||
errors.push(`${fieldPrefix}: slug is required`);
|
||||
} else {
|
||||
// Check for valid slug format
|
||||
if (!COLLECTION_FIELD_SLUG_PATTERN.test(field.slug)) {
|
||||
errors.push(
|
||||
`${fieldPrefix}.slug: must start with a letter and contain only lowercase letters, numbers, and underscores`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check for duplicate field slugs
|
||||
if (fieldSlugs.has(field.slug)) {
|
||||
errors.push(
|
||||
`${fieldPrefix}.slug: duplicate field slug "${field.slug}" in collection "${collection.slug}"`,
|
||||
);
|
||||
}
|
||||
fieldSlugs.add(field.slug);
|
||||
}
|
||||
|
||||
if (!field.label) {
|
||||
errors.push(`${fieldPrefix}: label is required`);
|
||||
}
|
||||
|
||||
if (!field.type) {
|
||||
errors.push(`${fieldPrefix}: type is required`);
|
||||
} else if (!(FIELD_TYPES as readonly string[]).includes(field.type)) {
|
||||
errors.push(`${fieldPrefix}.type: unsupported field type "${field.type}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate taxonomies
|
||||
if (seed.taxonomies) {
|
||||
if (!Array.isArray(seed.taxonomies)) {
|
||||
errors.push("taxonomies must be an array");
|
||||
} else {
|
||||
const taxonomyNames = new Set<string>();
|
||||
|
||||
for (let i = 0; i < seed.taxonomies.length; i++) {
|
||||
const taxonomy = seed.taxonomies[i];
|
||||
const prefix = `taxonomies[${i}]`;
|
||||
|
||||
if (!taxonomy.name) {
|
||||
errors.push(`${prefix}: name is required`);
|
||||
} else {
|
||||
// Check for duplicate taxonomy names
|
||||
if (taxonomyNames.has(taxonomy.name)) {
|
||||
errors.push(`${prefix}.name: duplicate taxonomy name "${taxonomy.name}"`);
|
||||
}
|
||||
taxonomyNames.add(taxonomy.name);
|
||||
}
|
||||
|
||||
if (!taxonomy.label) {
|
||||
errors.push(`${prefix}: label is required`);
|
||||
}
|
||||
|
||||
if (taxonomy.hierarchical === undefined) {
|
||||
errors.push(`${prefix}: hierarchical is required`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(taxonomy.collections)) {
|
||||
errors.push(`${prefix}.collections: must be an array`);
|
||||
} else if (taxonomy.collections.length === 0) {
|
||||
warnings.push(
|
||||
`${prefix}.collections: taxonomy "${taxonomy.name}" is not assigned to any collections`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate terms if present
|
||||
if (taxonomy.terms) {
|
||||
if (!Array.isArray(taxonomy.terms)) {
|
||||
errors.push(`${prefix}.terms: must be an array`);
|
||||
} else {
|
||||
const termSlugs = new Set<string>();
|
||||
|
||||
for (let j = 0; j < taxonomy.terms.length; j++) {
|
||||
const term = taxonomy.terms[j];
|
||||
const termPrefix = `${prefix}.terms[${j}]`;
|
||||
|
||||
if (!term.slug) {
|
||||
errors.push(`${termPrefix}: slug is required`);
|
||||
} else {
|
||||
// Check for duplicate term slugs
|
||||
if (termSlugs.has(term.slug)) {
|
||||
errors.push(
|
||||
`${termPrefix}.slug: duplicate term slug "${term.slug}" in taxonomy "${taxonomy.name}"`,
|
||||
);
|
||||
}
|
||||
termSlugs.add(term.slug);
|
||||
}
|
||||
|
||||
if (!term.label) {
|
||||
errors.push(`${termPrefix}: label is required`);
|
||||
}
|
||||
|
||||
// Check parent reference validity (for hierarchical taxonomies)
|
||||
if (term.parent && taxonomy.hierarchical) {
|
||||
// Parent will be validated in a second pass
|
||||
} else if (term.parent && !taxonomy.hierarchical) {
|
||||
warnings.push(
|
||||
`${termPrefix}.parent: taxonomy "${taxonomy.name}" is not hierarchical, parent will be ignored`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: validate parent references
|
||||
if (taxonomy.hierarchical && taxonomy.terms) {
|
||||
for (let j = 0; j < taxonomy.terms.length; j++) {
|
||||
const term = taxonomy.terms[j];
|
||||
if (term.parent && !termSlugs.has(term.parent)) {
|
||||
errors.push(
|
||||
`${prefix}.terms[${j}].parent: parent term "${term.parent}" not found in taxonomy`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check for circular references
|
||||
if (term.parent === term.slug) {
|
||||
errors.push(`${prefix}.terms[${j}].parent: term cannot be its own parent`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate menus
|
||||
if (seed.menus) {
|
||||
if (!Array.isArray(seed.menus)) {
|
||||
errors.push("menus must be an array");
|
||||
} else {
|
||||
const menuNames = new Set<string>();
|
||||
|
||||
for (let i = 0; i < seed.menus.length; i++) {
|
||||
const menu = seed.menus[i];
|
||||
const prefix = `menus[${i}]`;
|
||||
|
||||
if (!menu.name) {
|
||||
errors.push(`${prefix}: name is required`);
|
||||
} else {
|
||||
// Check for duplicate menu names
|
||||
if (menuNames.has(menu.name)) {
|
||||
errors.push(`${prefix}.name: duplicate menu name "${menu.name}"`);
|
||||
}
|
||||
menuNames.add(menu.name);
|
||||
}
|
||||
|
||||
if (!menu.label) {
|
||||
errors.push(`${prefix}: label is required`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(menu.items)) {
|
||||
errors.push(`${prefix}.items: must be an array`);
|
||||
} else {
|
||||
validateMenuItems(menu.items, prefix, errors, warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate redirects
|
||||
if (seed.redirects) {
|
||||
if (!Array.isArray(seed.redirects)) {
|
||||
errors.push("redirects must be an array");
|
||||
} else {
|
||||
const redirectSources = new Set<string>();
|
||||
|
||||
for (let i = 0; i < seed.redirects.length; i++) {
|
||||
const redirect = seed.redirects[i];
|
||||
const prefix = `redirects[${i}]`;
|
||||
|
||||
if (!isRecord(redirect)) {
|
||||
errors.push(`${prefix}: must be an object`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const source = typeof redirect.source === "string" ? redirect.source : undefined;
|
||||
const destination =
|
||||
typeof redirect.destination === "string" ? redirect.destination : undefined;
|
||||
|
||||
if (!source) {
|
||||
errors.push(`${prefix}: source is required`);
|
||||
} else {
|
||||
if (!isValidRedirectPath(source)) {
|
||||
errors.push(
|
||||
`${prefix}.source: must be a path starting with / (no protocol-relative URLs, path traversal, or newlines)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (redirectSources.has(source)) {
|
||||
errors.push(`${prefix}.source: duplicate redirect source "${source}"`);
|
||||
}
|
||||
redirectSources.add(source);
|
||||
}
|
||||
|
||||
if (!destination) {
|
||||
errors.push(`${prefix}: destination is required`);
|
||||
} else if (!isValidRedirectPath(destination)) {
|
||||
errors.push(
|
||||
`${prefix}.destination: must be a path starting with / (no protocol-relative URLs, path traversal, or newlines)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (redirect.type !== undefined) {
|
||||
if (typeof redirect.type !== "number" || !REDIRECT_TYPES.has(redirect.type)) {
|
||||
errors.push(`${prefix}.type: must be 301, 302, 307, or 308`);
|
||||
}
|
||||
}
|
||||
|
||||
if (redirect.enabled !== undefined && typeof redirect.enabled !== "boolean") {
|
||||
errors.push(`${prefix}.enabled: must be a boolean`);
|
||||
}
|
||||
|
||||
if (
|
||||
redirect.groupName !== undefined &&
|
||||
typeof redirect.groupName !== "string" &&
|
||||
redirect.groupName !== null
|
||||
) {
|
||||
errors.push(`${prefix}.groupName: must be a string or null`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate widget areas
|
||||
if (seed.widgetAreas) {
|
||||
if (!Array.isArray(seed.widgetAreas)) {
|
||||
errors.push("widgetAreas must be an array");
|
||||
} else {
|
||||
const areaNames = new Set<string>();
|
||||
|
||||
for (let i = 0; i < seed.widgetAreas.length; i++) {
|
||||
const area = seed.widgetAreas[i];
|
||||
const prefix = `widgetAreas[${i}]`;
|
||||
|
||||
if (!area.name) {
|
||||
errors.push(`${prefix}: name is required`);
|
||||
} else {
|
||||
// Check for duplicate area names
|
||||
if (areaNames.has(area.name)) {
|
||||
errors.push(`${prefix}.name: duplicate widget area name "${area.name}"`);
|
||||
}
|
||||
areaNames.add(area.name);
|
||||
}
|
||||
|
||||
if (!area.label) {
|
||||
errors.push(`${prefix}: label is required`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(area.widgets)) {
|
||||
errors.push(`${prefix}.widgets: must be an array`);
|
||||
} else {
|
||||
for (let j = 0; j < area.widgets.length; j++) {
|
||||
const widget = area.widgets[j];
|
||||
const widgetPrefix = `${prefix}.widgets[${j}]`;
|
||||
|
||||
if (!widget.type) {
|
||||
errors.push(`${widgetPrefix}: type is required`);
|
||||
} else if (!["content", "menu", "component"].includes(widget.type)) {
|
||||
errors.push(`${widgetPrefix}.type: must be "content", "menu", or "component"`);
|
||||
}
|
||||
|
||||
// Type-specific validation
|
||||
if (widget.type === "menu" && !widget.menuName) {
|
||||
errors.push(`${widgetPrefix}: menuName is required for menu widgets`);
|
||||
}
|
||||
|
||||
if (widget.type === "component" && !widget.componentId) {
|
||||
errors.push(`${widgetPrefix}: componentId is required for component widgets`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate sections
|
||||
if (seed.sections) {
|
||||
if (!Array.isArray(seed.sections)) {
|
||||
errors.push("sections must be an array");
|
||||
} else {
|
||||
const sectionSlugs = new Set<string>();
|
||||
|
||||
for (let i = 0; i < seed.sections.length; i++) {
|
||||
const section = seed.sections[i];
|
||||
const prefix = `sections[${i}]`;
|
||||
|
||||
if (!section.slug) {
|
||||
errors.push(`${prefix}: slug is required`);
|
||||
} else {
|
||||
if (!SLUG_PATTERN.test(section.slug)) {
|
||||
errors.push(
|
||||
`${prefix}.slug: must contain only lowercase letters, numbers, and hyphens`,
|
||||
);
|
||||
}
|
||||
if (sectionSlugs.has(section.slug)) {
|
||||
errors.push(`${prefix}.slug: duplicate section slug "${section.slug}"`);
|
||||
}
|
||||
sectionSlugs.add(section.slug);
|
||||
}
|
||||
|
||||
if (!section.title) {
|
||||
errors.push(`${prefix}: title is required`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(section.content)) {
|
||||
errors.push(`${prefix}.content: must be an array`);
|
||||
}
|
||||
|
||||
// Validate source
|
||||
if (section.source && !["theme", "import"].includes(section.source)) {
|
||||
errors.push(`${prefix}.source: must be "theme" or "import"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate bylines
|
||||
if (seed.bylines) {
|
||||
if (!Array.isArray(seed.bylines)) {
|
||||
errors.push("bylines must be an array");
|
||||
} else {
|
||||
const bylineIds = new Set<string>();
|
||||
const bylineSlugs = new Set<string>();
|
||||
for (let i = 0; i < seed.bylines.length; i++) {
|
||||
const byline = seed.bylines[i];
|
||||
const prefix = `bylines[${i}]`;
|
||||
|
||||
if (!byline.id) {
|
||||
errors.push(`${prefix}: id is required`);
|
||||
} else {
|
||||
if (bylineIds.has(byline.id)) {
|
||||
errors.push(`${prefix}.id: duplicate byline id "${byline.id}"`);
|
||||
}
|
||||
bylineIds.add(byline.id);
|
||||
}
|
||||
|
||||
if (!byline.slug) {
|
||||
errors.push(`${prefix}: slug is required`);
|
||||
} else {
|
||||
if (!SLUG_PATTERN.test(byline.slug)) {
|
||||
errors.push(
|
||||
`${prefix}.slug: must contain only lowercase letters, numbers, and hyphens`,
|
||||
);
|
||||
}
|
||||
if (bylineSlugs.has(byline.slug)) {
|
||||
errors.push(`${prefix}.slug: duplicate byline slug "${byline.slug}"`);
|
||||
}
|
||||
bylineSlugs.add(byline.slug);
|
||||
}
|
||||
|
||||
if (!byline.displayName) {
|
||||
errors.push(`${prefix}: displayName is required`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate content
|
||||
if (seed.content) {
|
||||
if (typeof seed.content !== "object" || Array.isArray(seed.content)) {
|
||||
errors.push("content must be an object (collection -> entries)");
|
||||
} else {
|
||||
for (const [collectionSlug, entries] of Object.entries(seed.content)) {
|
||||
if (!Array.isArray(entries)) {
|
||||
errors.push(`content.${collectionSlug}: must be an array`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryIds = new Set<string>();
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
const prefix = `content.${collectionSlug}[${i}]`;
|
||||
|
||||
if (!entry.id) {
|
||||
errors.push(`${prefix}: id is required`);
|
||||
} else {
|
||||
// Check for duplicate entry IDs
|
||||
if (entryIds.has(entry.id)) {
|
||||
errors.push(
|
||||
`${prefix}.id: duplicate entry id "${entry.id}" in collection "${collectionSlug}"`,
|
||||
);
|
||||
}
|
||||
entryIds.add(entry.id);
|
||||
}
|
||||
|
||||
if (!entry.slug) {
|
||||
errors.push(`${prefix}: slug is required`);
|
||||
}
|
||||
|
||||
if (!entry.data || typeof entry.data !== "object") {
|
||||
errors.push(`${prefix}: data must be an object`);
|
||||
}
|
||||
|
||||
// Validate i18n fields
|
||||
if (entry.translationOf) {
|
||||
if (!entry.locale) {
|
||||
errors.push(`${prefix}: locale is required when translationOf is set`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: validate translationOf references within this collection
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (entry.translationOf && !entryIds.has(entry.translationOf)) {
|
||||
errors.push(
|
||||
`content.${collectionSlug}[${i}].translationOf: references "${entry.translationOf}" which is not in this collection`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate cross-references (content refs in menus)
|
||||
if (seed.menus && seed.content) {
|
||||
const allContentIds = new Set<string>();
|
||||
for (const entries of Object.values(seed.content)) {
|
||||
if (Array.isArray(entries)) {
|
||||
for (const entry of entries) {
|
||||
if (entry.id) {
|
||||
allContentIds.add(entry.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check menu item refs
|
||||
for (const menu of seed.menus) {
|
||||
if (Array.isArray(menu.items)) {
|
||||
validateMenuItemRefs(menu.items, allContentIds, warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate byline refs in content
|
||||
if (seed.content) {
|
||||
const seedBylineIds = new Set<string>((seed.bylines ?? []).map((byline) => byline.id));
|
||||
for (const [collectionSlug, entries] of Object.entries(seed.content)) {
|
||||
if (!Array.isArray(entries)) continue;
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (!entry.bylines) continue;
|
||||
if (!Array.isArray(entry.bylines)) {
|
||||
errors.push(`content.${collectionSlug}[${i}].bylines: must be an array`);
|
||||
continue;
|
||||
}
|
||||
for (let j = 0; j < entry.bylines.length; j++) {
|
||||
const credit = entry.bylines[j];
|
||||
const prefix = `content.${collectionSlug}[${i}].bylines[${j}]`;
|
||||
if (!credit.byline) {
|
||||
errors.push(`${prefix}.byline: is required`);
|
||||
continue;
|
||||
}
|
||||
if (!seedBylineIds.has(credit.byline)) {
|
||||
errors.push(`${prefix}.byline: references unknown byline "${credit.byline}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate menu items recursively
|
||||
*/
|
||||
function validateMenuItems(
|
||||
items: unknown[],
|
||||
prefix: string,
|
||||
errors: string[],
|
||||
warnings: string[],
|
||||
): void {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const raw = items[i];
|
||||
const itemPrefix = `${prefix}.items[${i}]`;
|
||||
|
||||
if (!isRecord(raw)) {
|
||||
errors.push(`${itemPrefix}: must be an object`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = raw;
|
||||
|
||||
const itemType = typeof item.type === "string" ? item.type : undefined;
|
||||
|
||||
if (!itemType) {
|
||||
errors.push(`${itemPrefix}: type is required`);
|
||||
} else if (!["custom", "page", "post", "taxonomy", "collection"].includes(itemType)) {
|
||||
errors.push(
|
||||
`${itemPrefix}.type: must be "custom", "page", "post", "taxonomy", or "collection"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Type-specific validation
|
||||
if (itemType === "custom" && !item.url) {
|
||||
errors.push(`${itemPrefix}: url is required for custom menu items`);
|
||||
}
|
||||
|
||||
if ((itemType === "page" || itemType === "post") && !item.ref) {
|
||||
errors.push(`${itemPrefix}: ref is required for page/post menu items`);
|
||||
}
|
||||
|
||||
// Validate children recursively
|
||||
if (Array.isArray(item.children)) {
|
||||
validateMenuItems(item.children, itemPrefix, errors, warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate menu item references exist in content
|
||||
*/
|
||||
function validateMenuItemRefs(
|
||||
items: SeedMenuItem[],
|
||||
contentIds: Set<string>,
|
||||
warnings: string[],
|
||||
): void {
|
||||
for (const item of items) {
|
||||
if ((item.type === "page" || item.type === "post") && item.ref) {
|
||||
if (!contentIds.has(item.ref)) {
|
||||
warnings.push(`Menu item references content "${item.ref}" which is not in the seed file`);
|
||||
}
|
||||
}
|
||||
|
||||
if (item.children) {
|
||||
validateMenuItemRefs(item.children, contentIds, warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user