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/forms-content-mapping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/plugin-forms": minor
---

Adds an optional `contentMapping` form setting that creates a draft content entry in a target collection from each successful submission, with per-field transforms (`portableText`, `string`, `number`, `date`), an optional `slugFrom` field, and constant `metadata` fields. Mappings are validated against the target collection when the form is saved — including that every required collection field is covered — and the submission is always stored in the forms inbox even if content creation fails.
5 changes: 5 additions & 0 deletions .changeset/plugin-content-create-slugs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": minor
---

Adds collection schema lookup and slug generation to the plugin content API. `ctx.content.getCollection(slug)` returns a collection definition with its fields, and `ctx.content.create` now derives a unique slug the same way the admin and REST create paths do — from a reserved `slug` key when provided, falling back to the entry's `title` or `name` field. Entries are also created in the site's configured default locale instead of always `en`. Previously, plugin-created entries always had a null slug.
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,8 @@ export type {
PluginStorageConfig,
StorageCollection,
KVAccess,
CollectionFieldInfo,
CollectionInfo,
ContentAccess,
MediaAccess,
HttpAccess,
Expand Down
66 changes: 65 additions & 1 deletion packages/core/src/plugins/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ import { TaxonomyRepository, type Taxonomy } from "../database/repositories/taxo
import { UserRepository } from "../database/repositories/user.js";
import { withTransaction } from "../database/transaction.js";
import type { Database } from "../database/types.js";
import { getI18nConfig } from "../i18n/config.js";
import {
resolveAndValidateExternalUrl,
SsrfError,
stripCredentialHeaders,
} from "../import/ssrf.js";
import { enrichImageMetadata } from "../media/enrich.js";
import { markContentMediaUsageCollectionStaleSafely } from "../media/usage/content-refresh.js";
import { SchemaRegistry } from "../schema/registry.js";
import { invalidateSiteSettingsCache } from "../settings/index.js";
import type { Storage } from "../storage/types.js";
import { CronAccessImpl } from "./cron.js";
Expand All @@ -36,6 +38,7 @@ import type {
KVAccess,
CronAccess,
EmailAccess,
CollectionInfo,
ContentAccess,
ContentAccessWithWrite,
MediaAccess,
Expand Down Expand Up @@ -180,6 +183,34 @@ function splitSeoFromInput(input: ContentWriteInput): {
return { fields, seo };
}

/**
* Extract the reserved `seo` and `slug` keys from a plugin-supplied create
* input. `slug` is only honored on create — `update` ignores it.
*/
function splitCreateInput(input: ContentWriteInput): {
fields: Record<string, unknown>;
seo: ContentItemSeoInput | undefined;
slug: string | undefined;
} {
const { slug, ...rest } = input;
// Reject non-string slug values rather than silently dropping them.
if (slug !== undefined && (typeof slug !== "string" || slug.length === 0)) {
throw new Error("content.slug must be a non-empty string");
}
const { fields, seo } = splitSeoFromInput(rest);
return { fields, seo, slug };
}

/**
* Derive slug source text from content fields. Matches `getSlugSource` in
* the REST content-create handler (api/handlers/content.ts).
*/
function getSlugSource(data: Record<string, unknown>): string | null {
if (typeof data.title === "string" && data.title.length > 0) return data.title;
if (typeof data.name === "string" && data.name.length > 0) return data.name;
return null;
}

/**
* Reject writing SEO to a collection that does not have it enabled.
* Matches the REST API behavior (VALIDATION_ERROR).
Expand Down Expand Up @@ -236,8 +267,26 @@ function taxonomyToTermInfo(term: Taxonomy): TaxonomyTermInfo {
export function createContentAccess(db: Kysely<Database>): ContentAccess {
const contentRepo = new ContentRepository(db);
const seoRepo = new SeoRepository(db);
const schemaRegistry = new SchemaRegistry(db);

return {
async getCollection(collection: string): Promise<CollectionInfo | null> {
const result = await schemaRegistry.getCollectionWithFields(collection);
if (!result) return null;

return {
slug: result.slug,
label: result.label,
labelSingular: result.labelSingular ?? null,
fields: result.fields.map((field) => ({
slug: field.slug,
label: field.label,
type: field.type,
required: field.required,
})),
};
},

async get(collection: string, id: string): Promise<ContentItem | null> {
const item = await contentRepo.findById(collection, id);
if (!item) return null;
Expand Down Expand Up @@ -374,7 +423,7 @@ export function createContentAccessWithWrite(db: Kysely<Database>): ContentAcces
...readAccess,

async create(collection: string, data: ContentWriteInput): Promise<ContentItem> {
const { fields, seo } = splitSeoFromInput(data);
const { fields, seo, slug: slugInput } = splitCreateInput(data);
let contentMutated = false;

try {
Expand All @@ -384,9 +433,24 @@ export function createContentAccessWithWrite(db: Kysely<Database>): ContentAcces

const hasSeo = await assertSeoEnabled(trxSeoRepo, collection, seo);

// Same slug generation as the REST create path: the slug
// source — the reserved `slug` key, falling back to the
// entry's title/name — is slugified and de-duplicated by
// ContentRepository.generateUniqueSlug. Entries default to
// the site's configured default locale, and slug
// uniqueness is scoped to it, again matching the REST
// path.
const effectiveLocale = getI18nConfig()?.defaultLocale;
const slugSource = slugInput ?? getSlugSource(fields);
const slug = slugSource
? await trxContentRepo.generateUniqueSlug(collection, slugSource, effectiveLocale)
: null;

const item = await trxContentRepo.create({
type: collection,
slug,
data: fields,
locale: effectiveLocale,
});
contentMutated = true;

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ export type {
SiteInfo,
UserInfo,
UserAccess,
CollectionFieldInfo,
CollectionInfo,
ContentItem,
MediaItem,
ContentListOptions,
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/plugins/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,11 +241,41 @@ export interface ContentListOptions {
* key is extracted and routed to the core SEO panel (the `_emdash_seo`
* table), matching the shape accepted by the REST API. Passing `seo` for a
* collection that does not have SEO enabled throws a validation error.
*
* The reserved `slug` key is honored by `create` only: it is treated as
* slug source text and run through the same slug generation the admin/REST
* create path uses (slugified, de-duplicated with a numeric suffix). When
* omitted, the slug is derived from the entry's `title` or `name` field,
* again matching the REST create path. `update` ignores the key.
*/
export type ContentWriteInput = Record<string, unknown> & {
seo?: ContentItemSeoInput;
slug?: string;
};

/**
* A content collection field definition, as exposed to plugins via
* `content.getCollection`.
*/
export interface CollectionFieldInfo {
slug: string;
label: string;
/** Field type slug (e.g. "text", "number", "portableText"). */
type: string;
required: boolean;
}

/**
* A content collection definition, as exposed to plugins via
* `content.getCollection`.
*/
export interface CollectionInfo {
slug: string;
label: string;
labelSingular: string | null;
fields: CollectionFieldInfo[];
}

/**
* Taxonomy definition returned from the taxonomy API (e.g. "category", "tag").
*/
Expand Down Expand Up @@ -292,6 +322,8 @@ export interface ContentAccess {
// Read operations (requires read:content)
get(collection: string, id: string): Promise<ContentItem | null>;
list(collection: string, options?: ContentListOptions): Promise<PaginatedResult<ContentItem>>;
/** Look up a collection definition (with its fields) by slug. */
getCollection(collection: string): Promise<CollectionInfo | null>;

// Write operations (requires write:content) - optional on interface
create?(collection: string, data: ContentWriteInput): Promise<ContentItem>;
Expand Down
116 changes: 116 additions & 0 deletions packages/core/tests/integration/plugins/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { runMigrations } from "../../../src/database/migrations/runner.js";
import { OptionsRepository } from "../../../src/database/repositories/options.js";
import { UserRepository } from "../../../src/database/repositories/user.js";
import type { Database as DbSchema } from "../../../src/database/types.js";
import { setI18nConfig } from "../../../src/i18n/config.js";
import {
PluginContextFactory,
createContentAccess,
Expand Down Expand Up @@ -265,6 +266,46 @@ describe("Capability Enforcement Integration (v2)", () => {
});
});

describe("getCollection", () => {
beforeEach(async () => {
await sql`
INSERT INTO _emdash_collections (id, slug, label, label_singular)
VALUES ('col-events', 'events', 'Events', 'Event')
`.execute(db);
await sql`
INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order)
VALUES
('field-title', 'col-events', 'title', 'Title', 'text', 'TEXT', 1, 0),
('field-body', 'col-events', 'body', 'Body', 'portableText', 'JSON', 0, 1)
`.execute(db);
});

it("returns the collection definition with its fields", async () => {
const access = createContentAccess(db);
const collection = await access.getCollection("events");

expect(collection).toEqual({
slug: "events",
label: "Events",
labelSingular: "Event",
fields: [
{ slug: "title", label: "Title", type: "text", required: true },
{ slug: "body", label: "Body", type: "portableText", required: false },
],
});
});

it("returns null for an unknown collection", async () => {
const access = createContentAccess(db);
expect(await access.getCollection("nonexistent")).toBeNull();
});

it("is available on write access too", async () => {
const access = createContentAccessWithWrite(db);
expect((await access.getCollection("events"))?.slug).toBe("events");
});
});

describe("taxonomy read access", () => {
beforeEach(async () => {
// Migrations seed the default `category`/`tag` defs — clear them
Expand Down Expand Up @@ -439,6 +480,81 @@ describe("Capability Enforcement Integration (v2)", () => {
const found = await access.get("posts", created.id);
expect(found).not.toBeNull();
});

it("creates drafts by default", async () => {
const access = createContentAccessWithWrite(db);

const created = await access.create("posts", { title: "Draft Check" });

expect(created.status).toBe("draft");
});

it("generates a unique slug from the title, matching the REST create path", async () => {
const access = createContentAccessWithWrite(db);

// "Hello World" slugifies to "hello-world", which the fixture
// post-1 already uses — the generator appends a suffix.
const created = await access.create("posts", {
title: "Hello World",
content: "Body",
});

expect(created.slug).toBe("hello-world-1");
});

it("derives the slug from the reserved slug key when provided", async () => {
const access = createContentAccessWithWrite(db);

const created = await access.create("posts", {
title: "Ignored For Slug",
slug: "My Custom Slug!",
});

expect(created.slug).toBe("my-custom-slug");
});

it("de-duplicates slugs provided via the reserved key", async () => {
const access = createContentAccessWithWrite(db);

const created = await access.create("posts", {
title: "Whatever",
slug: "hello-world",
});

expect(created.slug).toBe("hello-world-1");
});

it("creates content without a slug when no source is available", async () => {
const access = createContentAccessWithWrite(db);

const created = await access.create("posts", { content: "no title or name" });

expect(created.slug).toBeNull();
});

it("rejects non-string slug values", async () => {
const access = createContentAccessWithWrite(db);

await expect(access.create("posts", { title: "X", slug: 42 as never })).rejects.toThrow(
/content\.slug must be a non-empty string/,
);
});

it("creates entries in the configured default locale with locale-scoped slugs", async () => {
setI18nConfig({ defaultLocale: "fr", locales: ["fr", "en"] });
try {
const access = createContentAccessWithWrite(db);

// "hello-world" exists only in locale "en" (fixture post-1),
// so the French entry can take the plain slug.
const created = await access.create("posts", { title: "Hello World" });

expect(created.locale).toBe("fr");
expect(created.slug).toBe("hello-world");
} finally {
setI18nConfig(null);
}
});
});

describe("SEO panel integration", () => {
Expand Down
Loading
Loading