diff --git a/.changeset/manifest-field-mapping.md b/.changeset/manifest-field-mapping.md new file mode 100644 index 0000000000..bd4bfb79a1 --- /dev/null +++ b/.changeset/manifest-field-mapping.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes admin manifest field mapping so database-backed collections expose field IDs, widget hints, selected validation, SEO flags, and URL patterns with matching public types. diff --git a/packages/core/src/api/handlers/manifest.ts b/packages/core/src/api/handlers/manifest.ts index 1c88ef7609..60e71ea981 100644 --- a/packages/core/src/api/handlers/manifest.ts +++ b/packages/core/src/api/handlers/manifest.ts @@ -2,13 +2,45 @@ * Manifest generation handlers */ +import type { Kysely } from "kysely"; + +import type { Database } from "../../database/types.js"; +import { SchemaRegistry } from "../../schema/registry.js"; +import type { Field, FieldType } from "../../schema/types.js"; import { hashString } from "../../utils/hash.js"; -import type { ManifestResponse, FieldDescriptor } from "../types.js"; +import type { + FieldDescriptor, + ManifestCollectionMap, + ManifestFieldDescriptor, + ManifestResponse, +} from "../types.js"; /** Pattern to add spaces before capital letters */ const CAMEL_CASE_PATTERN = /([A-Z])/g; const FIRST_CHAR_PATTERN = /^./; +/** + * Map schema field types to editor field kinds. + */ +const FIELD_TYPE_TO_KIND: Record = { + string: "string", + slug: "string", + url: "url", + text: "richText", + number: "number", + integer: "number", + boolean: "boolean", + datetime: "datetime", + select: "select", + multiSelect: "multiSelect", + portableText: "portableText", + image: "image", + file: "file", + reference: "reference", + json: "json", + repeater: "repeater", +}; + // Collection definition shape for manifest generation interface CollectionDefinition { schema: { @@ -23,6 +55,10 @@ interface CollectionDefinition { } type CollectionMap = Record; +interface GenerateManifestOptions { + db?: Kysely | null; +} + /** * Generate admin manifest from collections */ @@ -35,8 +71,33 @@ export async function generateManifest( widgets?: string[]; } > = {}, + options: GenerateManifestOptions = {}, ): Promise { - const manifestCollections: ManifestResponse["collections"] = {}; + const manifestCollections = await buildManifestCollections(collections, options.db); + + // Generate hash from collections (for cache invalidation) + const hash = await hashString(JSON.stringify(manifestCollections)); + + return { + version: "0.1.0", + hash, + collections: manifestCollections, + plugins, + }; +} + +/** + * Build collection descriptors from build-time config plus live database rows. + * + * Config collections are added first and win on slug conflicts. Runtime/manual + * collections have no Zod schema to inspect, so their field descriptors are + * synthesized from `_emdash_fields`. + */ +export async function buildManifestCollections( + collections: CollectionMap, + db?: Kysely | null, +): Promise { + const manifestCollections: ManifestCollectionMap = {}; for (const [name, definition] of Object.entries(collections)) { // Extract field descriptors from Zod schema @@ -46,19 +107,38 @@ export async function generateManifest( label: definition.admin.label, labelSingular: definition.admin.labelSingular || definition.admin.label, supports: definition.admin.supports || [], + hasSeo: (definition.admin.supports || []).includes("seo"), fields, }; } - // Generate hash from collections (for cache invalidation) - const hash = await hashString(JSON.stringify(manifestCollections)); + if (!db) return manifestCollections; - return { - version: "0.1.0", - hash, - collections: manifestCollections, - plugins, - }; + try { + const registry = new SchemaRegistry(db); + const dbCollections = await registry.listCollectionsWithFields(); + for (const collection of dbCollections) { + if (manifestCollections[collection.slug]) continue; + + const fields: Record = {}; + for (const field of collection.fields) { + fields[field.slug] = dbFieldDescriptor(field); + } + + manifestCollections[collection.slug] = { + label: collection.label, + labelSingular: collection.labelSingular || collection.label, + supports: collection.supports || [], + hasSeo: collection.hasSeo, + urlPattern: collection.urlPattern, + fields, + }; + } + } catch (error) { + console.debug("EmDash: Could not load database collections for manifest:", error); + } + + return manifestCollections; } /** @@ -68,8 +148,8 @@ export async function generateManifest( function extractFieldDescriptors(schema: { _def?: { shape?: () => Record }; shape?: Record; -}): Record { - const fields: Record = {}; +}): Record { + const fields: Record = {}; // Handle Zod object schema const shape = typeof schema._def?.shape === "function" ? schema._def.shape() : schema.shape || {}; @@ -147,6 +227,37 @@ function extractFieldType(name: string, schema: unknown): FieldDescriptor { } } +function dbFieldDescriptor(field: Field): ManifestFieldDescriptor { + const entry: ManifestFieldDescriptor = { + kind: FIELD_TYPE_TO_KIND[field.type] ?? "string", + label: field.label, + required: field.required, + id: field.id, + }; + + if (field.widget) entry.widget = field.widget; + if (field.options) entry.options = field.options; + + // Legacy: select/multiSelect enum options live on `field.validation.options`. + // They win over widget options to preserve existing select behavior. + if (field.validation?.options) { + entry.options = field.validation.options.map((value) => ({ + value, + label: value.charAt(0).toUpperCase() + value.slice(1), + })); + } + + // Include validation only for field widgets that need it client-side. + if ( + (field.type === "repeater" || field.type === "file" || field.type === "image") && + field.validation + ) { + entry.validation = { ...field.validation } as Record; + } + + return entry; +} + /** * Format field name as label */ diff --git a/packages/core/src/api/types.ts b/packages/core/src/api/types.ts index be42093657..959e14e3da 100644 --- a/packages/core/src/api/types.ts +++ b/packages/core/src/api/types.ts @@ -35,15 +35,7 @@ export interface ContentResponse { export interface ManifestResponse { version: string; hash: string; - collections: Record< - string, - { - label: string; - labelSingular: string; - supports: string[]; - fields: Record; - } - >; + collections: ManifestCollectionMap; plugins: Record< string, { @@ -53,6 +45,23 @@ export interface ManifestResponse { >; } +export type ManifestCollectionMap = Record; + +export interface ManifestCollectionDescriptor { + label: string; + labelSingular: string; + supports: string[]; + hasSeo: boolean; + urlPattern?: string; + fields: Record; +} + +export interface ManifestFieldDescriptor extends FieldDescriptor { + id?: string; + widget?: string; + validation?: Record; +} + export interface FieldDescriptor { kind: string; label?: string; diff --git a/packages/core/src/astro/routes/api/manifest.ts b/packages/core/src/astro/routes/api/manifest.ts index 11e7303fdb..1cd55312b5 100644 --- a/packages/core/src/astro/routes/api/manifest.ts +++ b/packages/core/src/astro/routes/api/manifest.ts @@ -4,7 +4,7 @@ * GET /_emdash/api/manifest * * Returns the admin manifest with collection definitions and plugin info. - * The manifest is generated from the user's live.config.ts at runtime. + * The manifest is generated from live database schema plus runtime plugin state. */ import type { APIRoute } from "astro"; diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index c50f363211..04ce0c8734 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -13,13 +13,14 @@ import { Kysely, type Dialect } from "kysely"; import virtualConfig from "virtual:emdash/config"; import { z } from "zod"; +import { buildManifestCollections } from "./api/handlers/manifest.js"; import { validateRev } from "./api/rev.js"; import type { EmDashConfig, PluginAdminPage, PluginDashboardWidget, } from "./astro/integration/runtime.js"; -import type { EmDashManifest, ManifestCollection } from "./astro/types.js"; +import type { EmDashManifest } from "./astro/types.js"; import { getAuthMode } from "./auth/mode.js"; import { getTrustedProxyHeaders } from "./auth/trusted-proxy.js"; import { isSqlite } from "./database/dialect-helpers.js"; @@ -67,7 +68,6 @@ import type { FieldWidgetConfig, SettingField, } from "./plugins/types.js"; -import type { FieldType } from "./schema/types.js"; import { hashString } from "./utils/hash.js"; import { createInitLock, type InitLock, initWithLock } from "./utils/init-lock.js"; import { createSingleFlightCache, singleFlightCached } from "./utils/single-flight-cache.js"; @@ -204,28 +204,6 @@ import { publishDueContent, type PublishedRef } from "./scheduled-publish.js"; import { FTSManager } from "./search/fts-manager.js"; import { invalidateSiteSettingsCache } from "./settings/index.js"; -/** - * Map schema field types to editor field kinds - */ -const FIELD_TYPE_TO_KIND: Record = { - string: "string", - slug: "string", - url: "url", - text: "richText", - number: "number", - integer: "number", - boolean: "boolean", - datetime: "datetime", - select: "select", - multiSelect: "multiSelect", - portableText: "portableText", - image: "image", - file: "file", - reference: "reference", - json: "json", - repeater: "repeater", -}; - const DRAFT_ONLY_UPDATE_KEYS = new Set(["data", "slug", "locale", "skipRevision"]); const MAX_DRAFT_STAGE_ATTEMPTS = 32; @@ -2301,77 +2279,10 @@ export class EmDashRuntime { * is two queries in practice; never N+1. */ private async _buildManifest(): Promise { - // Build collections from database. + // Build collections from the live database. // Use this.db (ALS-aware getter) so playground mode picks up the // per-session DO database instead of the hardcoded singleton. - const manifestCollections: Record = {}; - try { - const registry = new SchemaRegistry(this.db); - const dbCollections = await registry.listCollectionsWithFields(); - for (const collection of dbCollections) { - const fields: Record< - string, - { - kind: string; - label?: string; - required?: boolean; - widget?: string; - // Two shapes: legacy enum-style `[{ value, label }]` for select widgets, - // or arbitrary `Record` for plugin field widgets that - // need per-field config (e.g. a checkbox grid receiving its column defs). - options?: Array<{ value: string; label: string }> | Record; - id?: string; - validation?: Record; - } - > = {}; - - for (const field of collection.fields) { - const entry: (typeof fields)[string] = { - kind: FIELD_TYPE_TO_KIND[field.type] ?? "string", - label: field.label, - required: field.required, - }; - // Always include the field's database ID so the admin can forward it - // to upload/media-list API calls for MIME allowlist widening. - entry.id = field.id; - if (field.widget) entry.widget = field.widget; - // Plugin field widgets read their per-field config from `field.options`, - // which the seed schema types as `Record`. Pass it - // through to the manifest so plugin widgets in the admin SPA receive it. - if (field.options) { - entry.options = field.options; - } - // Legacy: select/multiSelect enum options live on `field.validation.options`. - // Wins over `field.options` to preserve existing behavior for enum widgets. - if (field.validation?.options) { - entry.options = field.validation.options.map((v) => ({ - value: v, - label: v.charAt(0).toUpperCase() + v.slice(1), - })); - } - // Include full validation for repeater fields (subFields, minItems, maxItems) - // and for file/image fields (allowedMimeTypes). - if ( - (field.type === "repeater" || field.type === "file" || field.type === "image") && - field.validation - ) { - entry.validation = { ...field.validation }; - } - fields[field.slug] = entry; - } - - manifestCollections[collection.slug] = { - label: collection.label, - labelSingular: collection.labelSingular || collection.label, - supports: collection.supports || [], - hasSeo: collection.hasSeo, - urlPattern: collection.urlPattern, - fields, - }; - } - } catch (error) { - console.debug("EmDash: Could not load database collections:", error); - } + const manifestCollections = await buildManifestCollections({}, this.db); // Build plugins manifest const manifestPlugins: Record< diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4392244aaa..08ac9931d3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -85,6 +85,9 @@ export type { RevisionListResponse, RevisionResponse, ManifestResponse, + ManifestCollectionMap, + ManifestCollectionDescriptor, + ManifestFieldDescriptor, FieldDescriptor, ApiContext, } from "./api/index.js"; diff --git a/packages/core/tests/unit/runtime/manifest-build.test.ts b/packages/core/tests/unit/runtime/manifest-build.test.ts index ee9e379005..73d566cfd0 100644 --- a/packages/core/tests/unit/runtime/manifest-build.test.ts +++ b/packages/core/tests/unit/runtime/manifest-build.test.ts @@ -17,6 +17,7 @@ import type { Kysely } from "kysely"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { generateManifest } from "../../../src/api/handlers/manifest.js"; import type { EmDashConfig } from "../../../src/astro/integration/runtime.js"; import type { Database } from "../../../src/database/types.js"; import { EmDashRuntime } from "../../../src/emdash-runtime.js"; @@ -24,6 +25,37 @@ import { createHookPipeline } from "../../../src/plugins/hooks.js"; import { SchemaRegistry } from "../../../src/schema/registry.js"; import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; +const zodString = { _def: { typeName: "ZodString" } }; +const zodNumber = { _def: { typeName: "ZodNumber" } }; + +const configCollections = { + posts: { + schema: { + shape: { + title: zodString, + views: zodNumber, + }, + }, + admin: { + label: "Posts", + labelSingular: "Post", + supports: ["preview"], + }, + }, + pages: { + schema: { + shape: { + heading: zodString, + }, + }, + admin: { + label: "Pages", + labelSingular: "Page", + supports: [], + }, + }, +}; + function buildRuntime(db: Kysely): EmDashRuntime { const config: EmDashConfig = {}; const pipelineFactoryOptions = { db } as const; @@ -64,6 +96,157 @@ function buildRuntime(db: Kysely): EmDashRuntime { }); } +describe("generateManifest()", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("merges runtime manual collections from the database with config collections", async () => { + const registry = new SchemaRegistry(db); + await registry.createCollection({ + slug: "currents", + label: "Currents", + labelSingular: "Current", + source: "manual", + supports: ["drafts", "preview"], + }); + await registry.createField("currents", { + slug: "title", + label: "Title", + type: "string", + required: true, + }); + await registry.createField("currents", { + slug: "priority", + label: "Priority", + type: "integer", + }); + + const manifest = await generateManifest(configCollections, {}, { db }); + + expect(Object.keys(manifest.collections).toSorted()).toEqual(["currents", "pages", "posts"]); + expect(manifest.collections.currents).toMatchObject({ + label: "Currents", + labelSingular: "Current", + supports: ["drafts", "preview"], + }); + expect(manifest.collections.currents?.fields.title).toMatchObject({ + kind: "string", + label: "Title", + required: true, + }); + expect(manifest.collections.currents?.fields.priority).toMatchObject({ + kind: "number", + label: "Priority", + }); + }); + + it("keeps config collection fields when the database has the same slug", async () => { + const registry = new SchemaRegistry(db); + await registry.createCollection({ + slug: "posts", + label: "DB Posts", + labelSingular: "DB Post", + source: "manual", + }); + await registry.createField("posts", { slug: "body", label: "Body", type: "text" }); + + const manifest = await generateManifest({ posts: configCollections.posts }, {}, { db }); + + expect(manifest.collections.posts?.label).toBe("Posts"); + expect(Object.keys(manifest.collections.posts?.fields ?? {}).toSorted()).toEqual([ + "title", + "views", + ]); + expect(manifest.collections.posts?.fields.body).toBeUndefined(); + }); + + it("includes manual collections that have no fields", async () => { + const registry = new SchemaRegistry(db); + await registry.createCollection({ + slug: "links", + label: "Links", + labelSingular: "Link", + source: "manual", + }); + + const manifest = await generateManifest({}, {}, { db }); + + expect(manifest.collections.links).toBeDefined(); + expect(manifest.collections.links?.fields).toEqual({}); + }); + + it("changes the hash when a manual collection is added", async () => { + const registry = new SchemaRegistry(db); + const before = await generateManifest(configCollections, {}, { db }); + + await registry.createCollection({ + slug: "currents", + label: "Currents", + labelSingular: "Current", + source: "manual", + }); + + const after = await generateManifest(configCollections, {}, { db }); + + expect(after.hash).not.toBe(before.hash); + }); + + it("falls back to config collections when database collection loading fails", async () => { + const failingDb = { + selectFrom() { + throw new Error("missing registry tables"); + }, + } as unknown as Kysely; + + const manifest = await generateManifest(configCollections, {}, { db: failingDb }); + + expect(Object.keys(manifest.collections).toSorted()).toEqual(["pages", "posts"]); + expect(manifest.collections.posts?.fields.title?.kind).toBe("string"); + }); + + it("falls back to a text descriptor for unknown database field types", async () => { + const registry = new SchemaRegistry(db); + const collection = await registry.createCollection({ + slug: "imports", + label: "Imports", + labelSingular: "Import", + source: "manual", + }); + await db + .insertInto("_emdash_fields") + .values({ + id: "field_unknown_type", + collection_id: collection.id, + slug: "payload", + label: "Payload", + type: "unknown_plugin_type", + column_type: "TEXT", + required: 0, + unique: 0, + default_value: null, + validation: null, + widget: null, + options: null, + sort_order: 0, + }) + .execute(); + + const manifest = await generateManifest({}, {}, { db }); + + expect(manifest.collections.imports?.fields.payload).toMatchObject({ + kind: "string", + label: "Payload", + }); + }); +}); + describe("EmDashRuntime.getManifest()", () => { let db: Kysely;