Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/manifest-field-mapping.md
Original file line number Diff line number Diff line change
@@ -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.
135 changes: 123 additions & 12 deletions packages/core/src/api/handlers/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FieldType, string> = {
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: {
Expand All @@ -23,6 +55,10 @@
}
type CollectionMap = Record<string, CollectionDefinition>;

interface GenerateManifestOptions {
db?: Kysely<Database> | null;
}

/**
* Generate admin manifest from collections
*/
Expand All @@ -35,8 +71,33 @@
widgets?: string[];
}
> = {},
options: GenerateManifestOptions = {},
): Promise<ManifestResponse> {
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<Database> | null,
): Promise<ManifestCollectionMap> {
const manifestCollections: ManifestCollectionMap = {};

for (const [name, definition] of Object.entries(collections)) {
// Extract field descriptors from Zod schema
Expand All @@ -46,19 +107,38 @@
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<string, ManifestFieldDescriptor> = {};
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;
}

/**
Expand All @@ -68,8 +148,8 @@
function extractFieldDescriptors(schema: {
_def?: { shape?: () => Record<string, unknown> };
shape?: Record<string, unknown>;
}): Record<string, FieldDescriptor> {
const fields: Record<string, FieldDescriptor> = {};
}): Record<string, ManifestFieldDescriptor> {
const fields: Record<string, ManifestFieldDescriptor> = {};

// Handle Zod object schema
const shape = typeof schema._def?.shape === "function" ? schema._def.shape() : schema.shape || {};
Expand Down Expand Up @@ -147,6 +227,37 @@
}
}

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<string, unknown>;

Check warning on line 255 in packages/core/src/api/handlers/manifest.ts

View workflow job for this annotation

GitHub Actions / Lint

typescript(no-unnecessary-type-assertion)

This assertion is unnecessary since the receiver accepts the original type of the expression.
}

return entry;
}

/**
* Format field name as label
*/
Expand Down
27 changes: 18 additions & 9 deletions packages/core/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, FieldDescriptor>;
}
>;
collections: ManifestCollectionMap;
plugins: Record<
string,
{
Expand All @@ -53,6 +45,23 @@ export interface ManifestResponse {
>;
}

export type ManifestCollectionMap = Record<string, ManifestCollectionDescriptor>;

export interface ManifestCollectionDescriptor {
label: string;
labelSingular: string;
supports: string[];
hasSeo: boolean;
urlPattern?: string;
fields: Record<string, ManifestFieldDescriptor>;
}

export interface ManifestFieldDescriptor extends FieldDescriptor {
id?: string;
widget?: string;
validation?: Record<string, unknown>;
}

export interface FieldDescriptor {
kind: string;
label?: string;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/astro/routes/api/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
97 changes: 4 additions & 93 deletions packages/core/src/emdash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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<FieldType, string> = {
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;

Expand Down Expand Up @@ -2301,77 +2279,10 @@ export class EmDashRuntime {
* is two queries in practice; never N+1.
*/
private async _buildManifest(): Promise<EmDashManifest> {
// 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<string, ManifestCollection> = {};
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<string, unknown>` 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<string, unknown>;
id?: string;
validation?: Record<string, unknown>;
}
> = {};

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<string, unknown>`. 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<
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ export type {
RevisionListResponse,
RevisionResponse,
ManifestResponse,
ManifestCollectionMap,
ManifestCollectionDescriptor,
ManifestFieldDescriptor,
FieldDescriptor,
ApiContext,
} from "./api/index.js";
Expand Down
Loading
Loading