/** * emdash export-seed * * Export current database schema (and optionally content) as a seed file */ import { resolve } from "node:path"; import { defineCommand } from "citty"; import consola from "consola"; import type { Kysely } from "kysely"; import { sql } from "kysely"; import { createDatabase } from "../../database/connection.js"; import { runMigrations } from "../../database/migrations/runner.js"; import { BylineRepository } from "../../database/repositories/byline.js"; import { ContentRepository } from "../../database/repositories/content.js"; import { MediaRepository } from "../../database/repositories/media.js"; import { OptionsRepository } from "../../database/repositories/options.js"; import { TaxonomyRepository } from "../../database/repositories/taxonomy.js"; import type { Database } from "../../database/types.js"; import { validateIdentifier } from "../../database/validate.js"; import { getI18nConfig, isI18nEnabled } from "../../i18n/config.js"; import { SchemaRegistry } from "../../schema/registry.js"; import type { FieldType } from "../../schema/types.js"; import type { SeedFile, SeedCollection, SeedField, SeedTaxonomy, SeedTaxonomyTerm, SeedMenu, SeedMenuItem, SeedWidgetArea, SeedWidget, SeedContentEntry, SeedByline, SeedBylineCredit, } from "../../seed/types.js"; import { isMissingTableError } from "../../utils/db-errors.js"; import { slugify } from "../../utils/slugify.js"; const SETTINGS_PREFIX = "site:"; export const exportSeedCommand = defineCommand({ meta: { name: "export-seed", description: "Export database schema and content as a seed file", }, args: { database: { type: "string", alias: "d", description: "Database path", default: "./data.db", }, cwd: { type: "string", description: "Working directory", default: process.cwd(), }, "with-content": { type: "string", description: "Include content (all or comma-separated collection names)", required: false, }, pretty: { type: "boolean", description: "Pretty print JSON output", default: true, }, }, async run({ args }) { const cwd = resolve(args.cwd); // Connect to database const dbPath = resolve(cwd, args.database); consola.info(`Database: ${dbPath}`); const db = createDatabase({ url: `file:${dbPath}` }); // Run migrations to ensure tables exist try { await runMigrations(db); } catch (error) { consola.error("Migration failed:", error); await db.destroy(); process.exit(1); } try { const seed = await exportSeed(db, args["with-content"]); // Output to stdout const output = args.pretty ? JSON.stringify(seed, null, "\t") : JSON.stringify(seed); console.log(output); } catch (error) { consola.error("Export failed:", error); await db.destroy(); process.exit(1); } await db.destroy(); }, }); /** * Export database to seed file format */ export async function exportSeed(db: Kysely, withContent?: string): Promise { const seed: SeedFile = { $schema: "https://emdashcms.com/seed.schema.json", version: "1", meta: { name: "Exported Seed", description: "Exported from existing EmDash database", }, }; // 1. Export settings seed.settings = await exportSettings(db); // 2. Export collections and fields seed.collections = await exportCollections(db); // Decide locale-awareness from the data. The runtime sets the i18n config via // middleware, but the CLI never does, so `isI18nEnabled()` is always false // under `emdash export-seed` (#1330). Detecting multiple locales in the data // keeps the export locale-aware without the runtime flag. const { i18nEnabled, defaultLocale } = await detectLocaleInfo(db, seed.collections); // Self-describe the default locale so a non-`en` single-locale project // survives the round-trip: `emdash seed` runs outside the runtime and would // otherwise backfill omitted locales as `en` (#1421). if (defaultLocale) seed.defaultLocale = defaultLocale; // 3. Export taxonomy definitions and terms seed.taxonomies = await exportTaxonomies(db, i18nEnabled); // 4. Export menus seed.menus = await exportMenus(db, i18nEnabled); // 5. Export widget areas seed.widgetAreas = await exportWidgetAreas(db); // 6. Export byline profiles. The returned map (translation_group -> seed-local // id) lets content credits below reference the same ids the root list emits. const { bylines, groupToSeedId } = await exportBylines(db); if (bylines.length > 0) { seed.bylines = bylines; } // 7. Export content (if requested) if (withContent !== undefined) { // Treat "all" as a synonym for the bare flag and "true". The args help // text documents `all` as a valid value, but without this the literal // string is read as a collection name and matches no collection (#1329). const includeAll = withContent === "" || withContent === "true" || withContent === "all"; const collections = includeAll ? null // all collections : withContent .split(",") .map((s) => s.trim()) .filter(Boolean); seed.content = await exportContent( db, seed.collections || [], collections, groupToSeedId, i18nEnabled, ); } return seed; } /** * Export byline profiles as root-level `bylines[]`. * * `SeedByline` has no locale axis, so locale siblings of the same byline * (sharing a `translation_group`) collapse to a single profile. The returned * `groupToSeedId` map keys on `translation_group` — the value stored in * `_emdash_content_bylines.byline_id` — so content credits can resolve to the * emitted seed id. */ async function exportBylines( db: Kysely, ): Promise<{ bylines: SeedByline[]; groupToSeedId: Map }> { const bylineRepo = new BylineRepository(db); const bylines: SeedByline[] = []; const groupToSeedId = new Map(); const usedSeedIds = new Set(); let cursor: string | undefined; do { const result = await bylineRepo.findMany({ limit: 100, cursor }); for (const byline of result.items) { const group = byline.translationGroup ?? byline.id; // One seed entry per translation group; first row seen wins. if (groupToSeedId.has(group)) continue; let seedId = `byline:${byline.slug}`; // Disambiguate the rare case of two distinct groups sharing a slug // (slug is unique per-locale, not globally) so seed ids stay unique. if (usedSeedIds.has(seedId)) seedId = `byline:${byline.slug}:${group}`; usedSeedIds.add(seedId); groupToSeedId.set(group, seedId); bylines.push({ id: seedId, slug: byline.slug, displayName: byline.displayName, bio: byline.bio || undefined, websiteUrl: byline.websiteUrl || undefined, isGuest: byline.isGuest || undefined, }); } cursor = result.nextCursor; } while (cursor); return { bylines, groupToSeedId }; } /** * Determine locale-awareness and the data's default locale for the export. * * The runtime initializes the i18n config in middleware, but the CLI never does, * so `isI18nEnabled()` is always false under `emdash export-seed` (#1330). When * the flag is unset, fall back to the data: a project is multi-locale when its * i18n-aware tables hold rows in more than one distinct locale. `locale` is * NOT NULL (defaulting to the site's default locale), so a per-row presence * check is not enough — only the *count* of distinct locales distinguishes a * genuinely single-locale project from a multi-locale one. This keeps * single-locale exports on bare ids and gives multi-locale exports the * per-locale suffix they need to avoid duplicate seed ids. * * `defaultLocale` self-describes the single-locale case so a non-`en` default * survives the round-trip (#1421). When more than one locale is present every * row already carries its own `locale`, so no fallback is needed and we leave it * undefined rather than guess which locale is the "default" without the runtime * config. */ async function detectLocaleInfo( db: Kysely, collections: SeedCollection[], ): Promise<{ i18nEnabled: boolean; defaultLocale: string | undefined }> { const config = getI18nConfig(); if (isI18nEnabled() && config) { return { i18nEnabled: true, defaultLocale: config.defaultLocale }; } const locales = new Set(); const collectDistinctLocales = async (tableRef: ReturnType): Promise => { const result = await sql<{ locale: string | null }>` SELECT DISTINCT locale FROM ${tableRef} `.execute(db); for (const row of result.rows) { if (row.locale) locales.add(row.locale); } }; await collectDistinctLocales(sql.ref("_emdash_taxonomy_defs")); await collectDistinctLocales(sql.ref("_emdash_menus")); for (const collection of collections) { validateIdentifier(collection.slug, "collection slug"); // On D1, deleteCollection is non-atomic, so a collection row can outlive // its ec_* table. Skip missing tables rather than crashing the export. try { await collectDistinctLocales(sql.ref(`ec_${collection.slug}`)); } catch (error) { if (!isMissingTableError(error)) throw error; } } return { i18nEnabled: locales.size > 1, defaultLocale: locales.size === 1 ? [...locales][0] : undefined, }; } /** * Export site settings */ async function exportSettings(db: Kysely): Promise { const options = new OptionsRepository(db); const allOptions = await options.getByPrefix(SETTINGS_PREFIX); const settings: Record = {}; for (const [key, value] of allOptions) { const settingKey = key.replace(SETTINGS_PREFIX, ""); settings[settingKey] = value; } return Object.keys(settings).length > 0 ? settings : undefined; } /** * Export collections and their fields */ async function exportCollections(db: Kysely): Promise { const registry = new SchemaRegistry(db); const collections = await registry.listCollections(); const result: SeedCollection[] = []; for (const collection of collections) { const fields = await registry.listFields(collection.id); const seedCollection: SeedCollection = { slug: collection.slug, label: collection.label, labelSingular: collection.labelSingular || undefined, description: collection.description || undefined, icon: collection.icon || undefined, supports: collection.supports.length > 0 ? (collection.supports as ( | "drafts" | "revisions" | "preview" | "scheduling" | "search" )[]) : undefined, urlPattern: collection.urlPattern || undefined, fields: fields.map( (field): SeedField => ({ slug: field.slug, label: field.label, type: field.type, required: field.required || undefined, unique: field.unique || undefined, searchable: field.searchable || undefined, defaultValue: field.defaultValue, validation: field.validation ? { ...field.validation } : undefined, widget: field.widget || undefined, options: field.options || undefined, }), ), }; result.push(seedCollection); } return result; } /** * Export taxonomy definitions and terms */ async function exportTaxonomies( db: Kysely, i18nEnabled: boolean, ): Promise { // Mirrors the content export pattern: one entry per (name, locale), stable // seed-local id, translations linked via `translationOf` to the anchor's id. const defs = await db .selectFrom("_emdash_taxonomy_defs") .selectAll() .orderBy(["name", "locale"]) .execute(); const result: SeedTaxonomy[] = []; const termRepo = new TaxonomyRepository(db); // translation_group -> seed-local id of first def we emitted in that group. const defGroupToSeedId = new Map(); for (const def of defs) { const defSeedId = i18nEnabled && def.locale ? `tax:${def.name}:${def.locale}` : `tax:${def.name}`; // Terms in this def's locale. const terms = await termRepo.findByName(def.name, { locale: def.locale }); // translation_group -> slug for parent resolution within this locale. // `parentId` stores the parent's translation_group, not a row id. const groupToSlug = new Map(); for (const term of terms) groupToSlug.set(term.translationGroup ?? term.id, term.slug); // translation_group -> seed id of the anchor term. const termGroupToSeedId = new Map(); const seedTerms: SeedTaxonomyTerm[] = []; for (const term of terms) { const termSeedId = i18nEnabled && term.locale ? `term:${def.name}:${term.slug}:${term.locale}` : `term:${def.name}:${term.slug}`; const seedTerm: SeedTaxonomyTerm = { id: termSeedId, slug: term.slug, label: term.label, description: typeof term.data?.description === "string" ? term.data.description : undefined, }; if (term.parentId) seedTerm.parent = groupToSlug.get(term.parentId); if (i18nEnabled && term.locale) { seedTerm.locale = term.locale; if (term.translationGroup) { const anchor = termGroupToSeedId.get(term.translationGroup); if (anchor) seedTerm.translationOf = anchor; else termGroupToSeedId.set(term.translationGroup, termSeedId); } } seedTerms.push(seedTerm); } // Anchors first so import can resolve `translationOf`. seedTerms.sort((a, b) => Number(!!a.translationOf) - Number(!!b.translationOf)); const taxonomy: SeedTaxonomy = { id: defSeedId, name: def.name, label: def.label, labelSingular: def.label_singular || undefined, hierarchical: def.hierarchical === 1, collections: def.collections ? JSON.parse(def.collections) : [], }; if (i18nEnabled && def.locale) { taxonomy.locale = def.locale; if (def.translation_group) { const anchor = defGroupToSeedId.get(def.translation_group); if (anchor) taxonomy.translationOf = anchor; else defGroupToSeedId.set(def.translation_group, defSeedId); } } if (seedTerms.length > 0) taxonomy.terms = seedTerms; result.push(taxonomy); } // Anchors first at def level too. result.sort((a, b) => Number(!!a.translationOf) - Number(!!b.translationOf)); return result; } /** * Export menus with their items */ async function exportMenus(db: Kysely, i18nEnabled: boolean): Promise { const menus = await db .selectFrom("_emdash_menus") .selectAll() .orderBy(["name", "locale"]) .execute(); const result: SeedMenu[] = []; // translation_group -> seed-local id of the anchor menu in that group. const groupToSeedId = new Map(); // Shared across menus: translated items reference anchor items in sibling menus. const itemGroupToSeedId = new Map(); const usedItemSeedIds = new Set(); for (const menu of menus) { const seedId = i18nEnabled && menu.locale ? `menu:${menu.name}:${menu.locale}` : `menu:${menu.name}`; const items = await db .selectFrom("_emdash_menu_items") .selectAll() .where("menu_id", "=", menu.id) .orderBy("sort_order", "asc") .execute(); const seedItems = buildMenuItemTree(items, { i18nEnabled, menuName: menu.name, menuLocale: menu.locale ?? null, itemGroupToSeedId, usedItemSeedIds, }); const seedMenu: SeedMenu = { id: seedId, name: menu.name, label: menu.label, items: seedItems, }; if (i18nEnabled && menu.locale) { seedMenu.locale = menu.locale; if (menu.translation_group) { const anchor = groupToSeedId.get(menu.translation_group); if (anchor) seedMenu.translationOf = anchor; else groupToSeedId.set(menu.translation_group, seedId); } } result.push(seedMenu); } // Anchors first so import can resolve `translationOf`. result.sort((a, b) => Number(!!a.translationOf) - Number(!!b.translationOf)); return result; } /** Type guard for valid widget types */ function isWidgetType(t: string): t is SeedWidget["type"] { return t === "content" || t === "menu" || t === "component"; } /** * Build hierarchical menu item tree from flat array */ function buildMenuItemTree( items: Array<{ id: string; parent_id: string | null; type: string; label: string; custom_url: string | null; reference_collection: string | null; reference_id: string | null; target: string | null; title_attr: string | null; css_classes: string | null; locale?: string | null; translation_group?: string | null; }>, i18nCtx: { i18nEnabled: boolean; menuName: string; menuLocale: string | null; // translation_group -> seed-local id of the anchor item in that group. itemGroupToSeedId: Map; usedItemSeedIds: Set; }, ): SeedMenuItem[] { // Build parent -> children map const childMap = new Map(); for (const item of items) { const parentId = item.parent_id; if (!childMap.has(parentId)) { childMap.set(parentId, []); } childMap.get(parentId)!.push(item); } function makeSeedId(item: (typeof items)[number]): string { const base = slugify(item.label || "") || item.id; const locale = i18nCtx.i18nEnabled ? (item.locale ?? i18nCtx.menuLocale) : null; const candidate = locale ? `item:${i18nCtx.menuName}:${base}:${locale}` : `item:${i18nCtx.menuName}:${base}`; if (!i18nCtx.usedItemSeedIds.has(candidate)) { i18nCtx.usedItemSeedIds.add(candidate); return candidate; } // Collision fallback: append DB id to disambiguate duplicate labels. const fallback = locale ? `item:${i18nCtx.menuName}:${base}:${item.id}:${locale}` : `item:${i18nCtx.menuName}:${base}:${item.id}`; i18nCtx.usedItemSeedIds.add(fallback); return fallback; } // Recursively build tree function buildLevel(parentId: string | null): SeedMenuItem[] { const children = childMap.get(parentId) || []; const result = children.map((item) => { const seedItem: SeedMenuItem = { type: item.type, label: item.label || undefined, }; if (item.type === "custom") { seedItem.url = item.custom_url || undefined; } else { seedItem.ref = item.reference_id || undefined; seedItem.collection = item.reference_collection || undefined; } if (item.target === "_blank") { seedItem.target = "_blank"; } if (item.title_attr) { seedItem.titleAttr = item.title_attr; } if (item.css_classes) { seedItem.cssClasses = item.css_classes; } if (i18nCtx.i18nEnabled) { const itemLocale = item.locale ?? i18nCtx.menuLocale; const seedId = makeSeedId(item); seedItem.id = seedId; if (itemLocale) seedItem.locale = itemLocale; if (item.translation_group) { const anchor = i18nCtx.itemGroupToSeedId.get(item.translation_group); if (anchor && anchor !== seedId) seedItem.translationOf = anchor; else if (!anchor) i18nCtx.itemGroupToSeedId.set(item.translation_group, seedId); } } // Add children const itemChildren = buildLevel(item.id); if (itemChildren.length > 0) { seedItem.children = itemChildren; } return seedItem; }); // Sibling order is preserved (maps to sort_order on import). Cross-menu // `translationOf` already resolves because exportMenus sorts anchors first. return result; } return buildLevel(null); } /** * Export widget areas with their widgets */ async function exportWidgetAreas(db: Kysely): Promise { // Get all widget areas const areas = await db.selectFrom("_emdash_widget_areas").selectAll().execute(); const result: SeedWidgetArea[] = []; for (const area of areas) { // Get widgets for this area const widgets = await db .selectFrom("_emdash_widgets") .selectAll() .where("area_id", "=", area.id) .orderBy("sort_order", "asc") .execute(); const seedWidgets: SeedWidget[] = widgets .filter((w) => isWidgetType(w.type)) .map((widget) => { const wType: SeedWidget["type"] = isWidgetType(widget.type) ? widget.type : "content"; const seedWidget: SeedWidget = { type: wType, }; if (widget.title) { seedWidget.title = widget.title; } if (widget.type === "content" && widget.content) { seedWidget.content = JSON.parse(widget.content); } else if (widget.type === "menu" && widget.menu_name) { seedWidget.menuName = widget.menu_name; } else if (widget.type === "component") { if (widget.component_id) { seedWidget.componentId = widget.component_id; } if (widget.component_props) { seedWidget.props = JSON.parse(widget.component_props); } } return seedWidget; }); result.push({ name: area.name, label: area.label, description: area.description || undefined, widgets: seedWidgets, }); } return result; } /** * Export content from collections */ async function exportContent( db: Kysely, collections: SeedCollection[], includeCollections: string[] | null, bylineGroupToSeedId: Map, i18nEnabled: boolean, ): Promise> { const content: Record = {}; const contentRepo = new ContentRepository(db); const taxonomyRepo = new TaxonomyRepository(db); const mediaRepo = new MediaRepository(db); // Build media id -> info map for $media conversion const mediaMap = new Map< string, { url: string; filename: string; alt?: string; caption?: string } >(); try { let cursor: string | undefined; do { const result = await mediaRepo.findMany({ limit: 100, cursor, status: "all", }); for (const media of result.items) { mediaMap.set(media.id, { url: `/_emdash/api/media/file/${media.storageKey}`, filename: media.filename, alt: media.alt || undefined, caption: media.caption || undefined, }); } cursor = result.nextCursor; } while (cursor); } catch { // Media table might not exist or be empty } for (const collection of collections) { // Skip if not in include list if (includeCollections && !includeCollections.includes(collection.slug)) { continue; } const entries: SeedContentEntry[] = []; let cursor: string | undefined; // When i18n is enabled, track translation_group -> seed ID so that // translations can reference the source entry's seed-local ID. // Key: EmDash translation_group ULID, Value: seed-local ID of the first entry in that group const translationGroupToSeedId = new Map(); // Paginate through all entries do { const result = await contentRepo.findMany(collection.slug, { limit: 100, cursor, }); for (const item of result.items) { // Generate seed ID from collection:slug:locale for stable references const seedId = item.slug ? i18nEnabled && item.locale ? `${collection.slug}:${item.slug}:${item.locale}` : `${collection.slug}:${item.slug}` : item.id; // Process data fields for $media conversion const processedData = processDataForExport(item.data, collection.fields, mediaMap); const entry: SeedContentEntry = { id: seedId, slug: item.slug || item.id, status: item.status === "published" || item.status === "draft" ? item.status : undefined, data: processedData, }; // Add i18n fields when enabled if (i18nEnabled && item.locale) { entry.locale = item.locale; if (item.translationGroup) { const sourceSeedId = translationGroupToSeedId.get(item.translationGroup); if (sourceSeedId) { // This is a translation — reference the source entry entry.translationOf = sourceSeedId; } else { // First entry in this translation group — track it translationGroupToSeedId.set(item.translationGroup, seedId); } } } // Get taxonomy assignments const taxonomies = await getTaxonomyAssignments(taxonomyRepo, collection.slug, item.id); if (Object.keys(taxonomies).length > 0) { entry.taxonomies = taxonomies; } // Get byline credits. Read the junction directly: its `byline_id` // stores the translation_group, which is exactly the key in // `bylineGroupToSeedId`. This is locale-agnostic (one row per // credit) and avoids the locale-sibling fan-out a hydrated read // would produce. const bylines = await getBylineCredits(db, collection.slug, item.id, bylineGroupToSeedId); if (bylines.length > 0) { entry.bylines = bylines; } entries.push(entry); } cursor = result.nextCursor; } while (cursor); if (i18nEnabled && entries.length > 0) { // Sort entries so source locale entries appear before their translations. // Entries without translationOf come first; entries with translationOf come after. entries.sort((a, b) => { if (a.translationOf && !b.translationOf) return 1; if (!a.translationOf && b.translationOf) return -1; return 0; }); } if (entries.length > 0) { content[collection.slug] = entries; } } return content; } /** * Process content data for export, converting image fields to $media syntax */ function processDataForExport( data: Record, fields: SeedField[], mediaMap: Map, ): Record { const result: Record = {}; // Create field type lookup const fieldTypes = new Map(); for (const field of fields) { fieldTypes.set(field.slug, field.type); } for (const [key, value] of Object.entries(data)) { const fieldType = fieldTypes.get(key); if (fieldType === "image" && value && typeof value === "object") { // Convert image field to $media syntax const imageValue = value as { id?: string; src?: string; alt?: string }; if (imageValue.id) { const mediaInfo = mediaMap.get(imageValue.id); if (mediaInfo) { result[key] = { $media: { url: mediaInfo.url, filename: mediaInfo.filename, alt: imageValue.alt || mediaInfo.alt, caption: mediaInfo.caption, }, }; continue; } } // Fallback: keep as-is if no media info found result[key] = value; } else if (fieldType === "reference" && typeof value === "string") { // Convert reference to $ref syntax (assumes same collection for now) result[key] = `$ref:${value}`; } else if (Array.isArray(value)) { // Process arrays (could contain references or images) result[key] = value.map((item) => { if (typeof item === "string" && fieldType === "reference") { return `$ref:${item}`; } return item; }); } else { result[key] = value; } } return result; } /** * Get ordered byline credits for a content entry as `SeedBylineCredit[]`. * * The `_emdash_content_bylines.byline_id` column stores the credited byline's * `translation_group`, so it maps straight through `groupToSeedId`. Credits * whose group wasn't emitted in the root `bylines[]` are skipped (defensive; * shouldn't happen for a consistent DB). */ async function getBylineCredits( db: Kysely, collection: string, entryId: string, groupToSeedId: Map, ): Promise { const rows = await db .selectFrom("_emdash_content_bylines") .select(["byline_id", "role_label"]) .where("collection_slug", "=", collection) .where("content_id", "=", entryId) .orderBy("sort_order", "asc") .execute(); const credits: SeedBylineCredit[] = []; for (const row of rows) { const seedId = groupToSeedId.get(row.byline_id); if (!seedId) continue; const credit: SeedBylineCredit = { byline: seedId }; if (row.role_label) credit.roleLabel = row.role_label; credits.push(credit); } return credits; } /** * Get taxonomy term assignments for a content entry */ async function getTaxonomyAssignments( taxonomyRepo: TaxonomyRepository, collection: string, entryId: string, ): Promise> { const terms = await taxonomyRepo.getTermsForEntry(collection, entryId); const result: Record = {}; for (const term of terms) { if (!result[term.name]) { result[term.name] = []; } result[term.name].push(term.slug); } return result; }