From 7619326fc56dfe95e9c02db8e85295a6a3c19049 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 16 Aug 2026 15:04:20 +0100 Subject: [PATCH 01/10] fix: keep routable content addressable across languages --- .changeset/unicode-routable-slugs.md | 6 ++ packages/admin/package.json | 8 +- .../src/components/ContentTypeEditor.tsx | 19 ++++ packages/admin/src/lib/api/client.ts | 1 + packages/admin/src/lib/api/schema.ts | 4 + packages/admin/src/lib/utils.ts | 18 +--- packages/admin/src/slugify.ts | 49 ++++++++++ .../components/ContentTypeEditor.test.tsx | 20 +++- packages/admin/tests/lib/utils.test.ts | 35 +++++-- packages/admin/tsdown.config.ts | 2 +- packages/core/src/api/handlers/content.ts | 16 +-- packages/core/src/api/handlers/manifest.ts | 2 + packages/core/src/api/handlers/seo.ts | 7 +- packages/core/src/api/schemas/schema.ts | 3 + packages/core/src/api/types.ts | 1 + packages/core/src/astro/types.ts | 2 + packages/core/src/cli/commands/export-seed.ts | 1 + .../migrations/070_collection_routable.ts | 18 ++++ .../core/src/database/migrations/runner.ts | 2 + .../core/src/database/repositories/content.ts | 10 +- packages/core/src/database/types.ts | 1 + packages/core/src/emdash-runtime.ts | 1 + packages/core/src/index.ts | 2 +- packages/core/src/mcp/server.ts | 7 ++ packages/core/src/schema/registry.ts | 4 + packages/core/src/schema/types.ts | 4 + packages/core/src/seed/apply.ts | 2 + packages/core/src/seed/types.ts | 2 + packages/core/src/seed/validate.ts | 3 + packages/core/src/utils/slugify.ts | 30 +----- .../integration/database/migrations.test.ts | 1 + .../core/tests/integration/seo/seo.test.ts | 9 +- .../integration/seo/sitemap-route.test.ts | 25 +++++ .../unit/api/collection-admin-schema.test.ts | 8 ++ .../tests/unit/api/content-handlers.test.ts | 97 ++++++++++++++++++- .../core/tests/unit/cli/seed-commands.test.ts | 26 +++++ .../core/tests/unit/schema/registry.test.ts | 13 +++ packages/core/tests/unit/seed/apply.test.ts | 20 ++++ .../core/tests/unit/seed/validate.test.ts | 10 ++ 39 files changed, 413 insertions(+), 76 deletions(-) create mode 100644 .changeset/unicode-routable-slugs.md create mode 100644 packages/admin/src/slugify.ts create mode 100644 packages/core/src/database/migrations/070_collection_routable.ts diff --git a/.changeset/unicode-routable-slugs.md b/.changeset/unicode-routable-slugs.md new file mode 100644 index 0000000000..d08a9ab660 --- /dev/null +++ b/.changeset/unicode-routable-slugs.md @@ -0,0 +1,6 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +--- + +Adds native Unicode slugs and requires published entries in routable collections to have a slug. Collections used only for internal or referenced content can set `routable: false` before publishing slugless entries. diff --git a/packages/admin/package.json b/packages/admin/package.json index 136f352681..551fcfdd49 100644 --- a/packages/admin/package.json +++ b/packages/admin/package.json @@ -17,11 +17,15 @@ "types": "./dist/locales/index.d.ts", "default": "./dist/locales/index.js" }, - "./locales/*": "./dist/locales/*" + "./locales/*": "./dist/locales/*", + "./slugify": { + "types": "./dist/slugify.d.ts", + "default": "./dist/slugify.js" + } }, "scripts": { "build": "node --run locale:compile && tsdown && node --run locale:copy && npx @tailwindcss/cli -i src/styles.css -o dist/styles.css --minify", - "dev": "tsdown src/index.ts --format esm --dts --watch", + "dev": "tsdown --watch", "prepublishOnly": "node --run build", "check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm --ignore-rules=no-resolution", "test": "vitest", diff --git a/packages/admin/src/components/ContentTypeEditor.tsx b/packages/admin/src/components/ContentTypeEditor.tsx index a254b7b39a..f2b9347af3 100644 --- a/packages/admin/src/components/ContentTypeEditor.tsx +++ b/packages/admin/src/components/ContentTypeEditor.tsx @@ -160,6 +160,7 @@ export function ContentTypeEditor({ const [labelSingular, setLabelSingular] = React.useState(collection?.labelSingular ?? ""); const [description, setDescription] = React.useState(collection?.description ?? ""); const [urlPattern, setUrlPattern] = React.useState(collection?.urlPattern ?? ""); + const [routable, setRoutable] = React.useState(collection?.routable ?? true); // SEO is managed via the separate `hasSeo` field; strip any legacy "seo" entry // so it isn't sent back on save (the API enum rejects it). const [supports, setSupports] = React.useState( @@ -200,6 +201,7 @@ export function ContentTypeEditor({ labelSingular !== (collection.labelSingular ?? "") || description !== (collection.description ?? "") || urlPattern !== (collection.urlPattern ?? "") || + routable !== (collection.routable ?? true) || JSON.stringify([...supports].toSorted()) !== JSON.stringify(collection.supports.filter((s) => s !== "seo").toSorted()) || hasSeo !== collection.hasSeo || @@ -216,6 +218,7 @@ export function ContentTypeEditor({ labelSingular, description, urlPattern, + routable, supports, hasSeo, commentsEnabled, @@ -261,6 +264,7 @@ export function ContentTypeEditor({ labelSingular: labelSingular || undefined, description: description || undefined, urlPattern: urlPattern || undefined, + routable, supports, hasSeo, }); @@ -270,6 +274,7 @@ export function ContentTypeEditor({ labelSingular: labelSingular || undefined, description: description || undefined, urlPattern: urlPattern || undefined, + routable, supports, hasSeo, commentsEnabled, @@ -418,6 +423,20 @@ export function ContentTypeEditor({ disabled={isFromCode} /> + + {t`Routable`} +

+ {t`Require a slug before content can be published`} +

+ + } + /> +
>> 0).toString(36).padStart(7, "0")}`; +} + +function truncateByGrapheme(value: string, maxLength: number): string { + if (maxLength <= 0) return ""; + if (!Number.isFinite(maxLength)) return value; + + let result = ""; + let length = 0; + for (const { segment } of GRAPHEME_SEGMENTER.segment(value)) { + if (length >= Math.floor(maxLength)) break; + result += segment; + length++; + } + return result.replace(TRAILING_HYPHENS_PATTERN, ""); +} + +/** + * Convert text to a browser-safe Unicode URL slug. + * + * Text is NFKC-normalized and lowercased; whitespace and underscores become + * hyphens while Unicode letters, numbers, and combining marks are preserved. + * The length limit counts grapheme clusters. Inputs without usable characters + * receive a stable `untitled-*` fallback. + */ +export function slugify(text: string, maxLength = 80): string { + const normalized = text.normalize("NFKC").toLowerCase(); + const slug = normalized + .replace(SEPARATOR_PATTERN, "-") + .replace(UNSAFE_CHARACTER_PATTERN, "") + .replace(MULTIPLE_HYPHENS_PATTERN, "-") + .replace(EDGE_HYPHENS_PATTERN, ""); + const value = USABLE_CHARACTER_PATTERN.test(slug) ? slug : fallbackSlug(normalized); + return truncateByGrapheme(value, maxLength); +} diff --git a/packages/admin/tests/components/ContentTypeEditor.test.tsx b/packages/admin/tests/components/ContentTypeEditor.test.tsx index 0129298d4b..16477730d5 100644 --- a/packages/admin/tests/components/ContentTypeEditor.test.tsx +++ b/packages/admin/tests/components/ContentTypeEditor.test.tsx @@ -61,6 +61,7 @@ function makeCollection( supports: ["drafts"], fields: [], hasSeo: false, + routable: true, commentsEnabled: false, commentsModeration: "first_time", commentsClosedAfterDays: 90, @@ -116,7 +117,7 @@ describe("ContentTypeEditor", () => { await labelInput.fill("Blog Posts"); // The slug input should auto-populate from the label - const slugInput = screen.getByLabelText("Slug"); + const slugInput = screen.getByLabelText("Slug", { exact: true }); await expect.element(slugInput).toHaveValue("blog_posts"); }); @@ -127,7 +128,7 @@ describe("ContentTypeEditor", () => { const screen = await render(); // Slug input is only rendered when isNew, so it shouldn't exist - const slugInput = screen.getByLabelText("Slug"); + const slugInput = screen.getByLabelText("Slug", { exact: true }); await expect.element(slugInput).not.toBeInTheDocument(); }); @@ -195,6 +196,7 @@ describe("ContentTypeEditor", () => { labelSingular: "Article", description: undefined, urlPattern: undefined, + routable: true, supports: ["drafts", "revisions"], // default hasSeo: false, }); @@ -217,6 +219,7 @@ describe("ContentTypeEditor", () => { labelSingular: "Post", description: "Blog posts", urlPattern: undefined, + routable: true, supports: ["drafts"], hasSeo: false, commentsEnabled: false, @@ -509,6 +512,19 @@ describe("ContentTypeEditor", () => { expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ urlPattern: "/blog/{slug}" })); }); + it("saves whether the collection is routable", async () => { + const onSave = vi.fn(); + const collection = makeCollection({ routable: true }); + const screen = await render( + , + ); + + await screen.getByLabelText("Routable").click(); + await screen.getByRole("button", { name: "Save", exact: true }).last().click(); + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ routable: false })); + }); + it("shows validation error when pattern lacks {slug}", async () => { const collection = makeCollection(); const screen = await render(); diff --git a/packages/admin/tests/lib/utils.test.ts b/packages/admin/tests/lib/utils.test.ts index b062f4c3a2..b535971583 100644 --- a/packages/admin/tests/lib/utils.test.ts +++ b/packages/admin/tests/lib/utils.test.ts @@ -7,8 +7,22 @@ describe("slugify", () => { expect(slugify("Hello World")).toBe("hello-world"); }); - it("handles unicode and diacritics", () => { - expect(slugify("café résumé")).toBe("cafe-resume"); + it.each([ + ["مرحبا بالعالم", "مرحبا-بالعالم"], + ["你好世界", "你好世界"], + ["日本 語", "日本-語"], + ["한국어 제목", "한국어-제목"], + ["Привет мир", "привет-мир"], + ["שלום עולם", "שלום-עולם"], + ["สวัสดี โลก", "สวัสดี-โลก"], + ["Καλημέρα κόσμε", "καλημέρα-κόσμε"], + ["మేష రాసి", "మేష-రాసి"], + ])("preserves Unicode letters, numbers, and marks in %s", (input, expected) => { + expect(slugify(input)).toBe(expected); + }); + + it("normalizes compatibility characters and canonically equivalent marks", () => { + expect(slugify("Cafe\u0301_2026")).toBe("café-2026"); }); it("strips special characters", () => { @@ -27,12 +41,13 @@ describe("slugify", () => { expect(slugify("hello_world")).toBe("hello-world"); }); - it("returns empty string for empty input", () => { - expect(slugify("")).toBe(""); - }); - - it("handles all special characters", () => { - expect(slugify("!@#$%")).toBe(""); + it("uses a deterministic fallback when no usable characters remain", () => { + const emojiSlug = slugify("😀😀"); + expect(emojiSlug).toMatch(/^untitled-[a-z0-9]+$/); + expect(slugify("😀😀")).toBe(emojiSlug); + expect(slugify("🎉")).not.toBe(emojiSlug); + expect(slugify("")).toMatch(/^untitled-[a-z0-9]+$/); + expect(slugify("!@#$%")).toMatch(/^untitled-[a-z0-9]+$/); }); it("handles mixed case", () => { @@ -42,6 +57,10 @@ describe("slugify", () => { it("handles multiple spaces", () => { expect(slugify("hello world")).toBe("hello-world"); }); + + it("truncates by grapheme without splitting a combined character", () => { + expect(slugify("क्षक्ष", 1)).toBe("क्ष"); + }); }); describe("cn", () => { diff --git a/packages/admin/tsdown.config.ts b/packages/admin/tsdown.config.ts index 510ae580e3..33da1c89af 100644 --- a/packages/admin/tsdown.config.ts +++ b/packages/admin/tsdown.config.ts @@ -24,7 +24,7 @@ function linguiMacroPlugin(): Plugin { } export default defineConfig({ - entry: ["src/index.ts", "src/locales/index.ts"], + entry: ["src/index.ts", "src/locales/index.ts", "src/slugify.ts"], format: ["esm"], dts: true, clean: true, diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index 9ee11a6a4b..297378572e 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -90,17 +90,20 @@ async function collectionHasSeo(db: Kysely, collection: string): Promi return row?.has_seo === 1; } -async function collectionSupportsRevisions( +async function getCollectionPublishConfig( db: Kysely, collection: string, -): Promise { +): Promise<{ supportsRevisions: boolean; routable: boolean }> { const row = await db .selectFrom("_emdash_collections") - .select("supports") + .select(["supports", "routable"]) .where("slug", "=", collection) .executeTakeFirst(); const supports: unknown = row?.supports ? JSON.parse(row.supports) : []; - return Array.isArray(supports) && supports.includes("revisions"); + return { + supportsRevisions: Array.isArray(supports) && supports.includes("revisions"), + routable: row?.routable !== 0, + }; } /** @@ -1558,7 +1561,7 @@ export async function handleContentPublish( const item = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); const resolvedId = (await resolveId(repo, collection, id)) ?? id; - const supportsRevisions = await collectionSupportsRevisions(trx, collection); + const publishConfig = await getCollectionPublishConfig(trx, collection); // Capture the pre-publish state. For revision-supporting collections a // slug edit is staged as `_slug` in the draft revision and only lands @@ -1573,7 +1576,8 @@ export async function handleContentPublish( options.publishedAt, options.requireScheduledDue, options.expectedScheduledAt, - supportsRevisions, + publishConfig.supportsRevisions, + publishConfig.routable, ); // Leave a 301 behind when publishing changed the slug of an entry that diff --git a/packages/core/src/api/handlers/manifest.ts b/packages/core/src/api/handlers/manifest.ts index 1c88ef7609..4495b7394f 100644 --- a/packages/core/src/api/handlers/manifest.ts +++ b/packages/core/src/api/handlers/manifest.ts @@ -19,6 +19,7 @@ interface CollectionDefinition { label: string; labelSingular?: string; supports?: string[]; + routable?: boolean; }; } type CollectionMap = Record; @@ -46,6 +47,7 @@ export async function generateManifest( label: definition.admin.label, labelSingular: definition.admin.labelSingular || definition.admin.label, supports: definition.admin.supports || [], + routable: definition.admin.routable ?? true, fields, }; } diff --git a/packages/core/src/api/handlers/seo.ts b/packages/core/src/api/handlers/seo.ts index d4291b73b6..68eeff8167 100644 --- a/packages/core/src/api/handlers/seo.ts +++ b/packages/core/src/api/handlers/seo.ts @@ -88,7 +88,7 @@ function toW3CDate(value: string): string { * Collect all published, indexable content across SEO-enabled collections * for sitemap generation, grouped by collection. * - * Only includes content from collections with `has_seo = 1`. + * Only includes content from routable collections with `has_seo = 1`. * Excludes content with `seo_no_index = 1` in the `_emdash_seo` table. * * Returns raw data grouped per collection. The caller (route) is @@ -105,7 +105,8 @@ export async function handleSitemapData( let query = db .selectFrom("_emdash_collections") .select(["slug", "url_pattern"]) - .where("has_seo", "=", 1); + .where("has_seo", "=", 1) + .where("routable", "=", 1); if (collectionSlug) { query = query.where("slug", "=", collectionSlug); @@ -149,6 +150,8 @@ export async function handleSitemapData( AND s.content_id = c.id WHERE c.status = 'published' AND c.deleted_at IS NULL + AND c.slug IS NOT NULL + AND c.slug <> '' AND (s.seo_no_index IS NULL OR s.seo_no_index = 0) ORDER BY c.updated_at DESC LIMIT ${SITEMAP_MAX_ENTRIES} diff --git a/packages/core/src/api/schemas/schema.ts b/packages/core/src/api/schemas/schema.ts index b9f4d9ff80..b97b7ff973 100644 --- a/packages/core/src/api/schemas/schema.ts +++ b/packages/core/src/api/schemas/schema.ts @@ -152,6 +152,7 @@ export const createCollectionBody = z supports: z.array(collectionSupportValues).optional(), source: z.string().regex(collectionSourcePattern).optional(), urlPattern: urlPatternValue.optional(), + routable: z.boolean().optional(), hasSeo: z.boolean().optional(), hidden: z.boolean().optional(), sortOrder: z.number().int().nullish(), @@ -167,6 +168,7 @@ export const updateCollectionBody = z admin: collectionAdminInputConfig.optional(), supports: z.array(collectionSupportValues).optional(), urlPattern: urlPatternValue.nullish(), + routable: z.boolean().optional(), hasSeo: z.boolean().optional(), hidden: z.boolean().optional(), sortOrder: z.number().int().nullish(), @@ -260,6 +262,7 @@ export const collectionSchema = z supports: z.array(z.string()), source: z.string().nullable(), urlPattern: z.string().nullable(), + routable: z.boolean(), hasSeo: z.boolean(), hidden: z.boolean(), sortOrder: z.number().int().nullable(), diff --git a/packages/core/src/api/types.ts b/packages/core/src/api/types.ts index be42093657..acfdbf6b32 100644 --- a/packages/core/src/api/types.ts +++ b/packages/core/src/api/types.ts @@ -41,6 +41,7 @@ export interface ManifestResponse { label: string; labelSingular: string; supports: string[]; + routable?: boolean; fields: Record; } >; diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index 69f1e1b9ac..411e614743 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -32,6 +32,8 @@ export interface ManifestCollection { supports: string[]; hasSeo: boolean; urlPattern?: string; + /** Whether published entries require a slug. Defaults to true. */ + routable?: boolean; titleField?: string; dateField?: string; /** diff --git a/packages/core/src/cli/commands/export-seed.ts b/packages/core/src/cli/commands/export-seed.ts index 5f73a3bf3e..86d98ef581 100644 --- a/packages/core/src/cli/commands/export-seed.ts +++ b/packages/core/src/cli/commands/export-seed.ts @@ -317,6 +317,7 @@ async function exportCollections(db: Kysely): Promise 0 ? collection.supports : undefined, urlPattern: collection.urlPattern || undefined, + routable: collection.routable === false ? false : undefined, hidden: collection.hidden || undefined, sortOrder: collection.sortOrder, fields: fields.map( diff --git a/packages/core/src/database/migrations/070_collection_routable.ts b/packages/core/src/database/migrations/070_collection_routable.ts new file mode 100644 index 0000000000..17bdfe2632 --- /dev/null +++ b/packages/core/src/database/migrations/070_collection_routable.ts @@ -0,0 +1,18 @@ +import type { Kysely } from "kysely"; + +import { columnExists } from "../dialect-helpers.js"; + +export async function up(db: Kysely): Promise { + if (!(await columnExists(db, "_emdash_collections", "routable"))) { + await db.schema + .alterTable("_emdash_collections") + .addColumn("routable", "integer", (col) => col.notNull().defaultTo(1)) + .execute(); + } +} + +export async function down(db: Kysely): Promise { + if (await columnExists(db, "_emdash_collections", "routable")) { + await db.schema.alterTable("_emdash_collections").dropColumn("routable").execute(); + } +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index 428672d7fb..8f60cd7e7c 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -72,6 +72,7 @@ import * as m066 from "./066_media_usage_reconciliation.js"; import * as m067 from "./067_indexed_content_fields.js"; import * as m068 from "./068_content_taxonomy_entry_groups.js"; import * as m069 from "./069_collection_title_date_fields.js"; +import * as m070 from "./070_collection_routable.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -142,6 +143,7 @@ const MIGRATIONS: Readonly> = Object.freeze({ "067_indexed_content_fields": m067, "068_content_taxonomy_entry_groups": m068, "069_collection_title_date_fields": m069, + "070_collection_routable": m070, }); /** Total number of registered migrations. Exported for use in tests. */ diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index a13effb937..5d9c1a43c4 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -407,7 +407,8 @@ export class ContentRepository { * (optionally scoped to a locale) and appends a numeric suffix (`-1`, * `-2`, etc.) on collision to guarantee uniqueness. * - * Returns `null` if `baseSlug` is empty after slugification. + * The null return is retained for compatibility as a defensive backstop if + * slug normalization cannot produce a value. */ async generateUniqueSlug(type: string, text: string, locale?: string): Promise { const baseSlug = slugify(text); @@ -1763,6 +1764,7 @@ export class ContentRepository { requireDue = false, expectedScheduledAt?: string, promoteRevision = true, + requireSlug = true, ): Promise { const tableName = getTableName(type); const now = new Date().toISOString(); @@ -1778,6 +1780,9 @@ export class ContentRepository { ) { throw new ScheduledNotDueError(); } + if (!promoteRevision && requireSlug && !existing.slug?.trim()) { + throw new EmDashValidationError("Cannot publish routable content without a slug"); + } if (!promoteRevision) { const revisionRepo = new RevisionRepository(this.db); @@ -1894,6 +1899,9 @@ export class ContentRepository { const stagedSlug = typeof revision.data._slug === "string" ? revision.data._slug : null; const intendedSlug = stagedSlug ?? existing.slug; + if (requireSlug && !intendedSlug?.trim()) { + throw new EmDashValidationError("Cannot publish routable content without a slug"); + } const intendedPublishedAt = publishedAt ?? existing.publishedAt ?? now; if (stagedSlug !== null && stagedSlug !== existing.slug && existing.locale !== null) { const conflict = await this.findBySlugIncludingTrashed(type, stagedSlug, existing.locale); diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts index 2417ea9812..4116485375 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -423,6 +423,7 @@ export interface CollectionTable { title_field: string | null; // field slug for the admin list Title column (NULL = default) date_field: string | null; // field slug (datetime) for the admin list Date column (NULL = default) url_pattern: string | null; // URL pattern with {slug} placeholder (e.g. "/blog/{slug}") + routable: Generated; // 0 or 1 — published entries require a slug when enabled hidden: Generated; // 0 or 1 — omit the auto-generated admin sidebar entry sort_order: number | null; // explicit admin sidebar position; NULL = alphabetical fallback comments_enabled: Generated; // 0 or 1 diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 811a0d8489..7f0c6d9f30 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -2560,6 +2560,7 @@ export class EmDashRuntime { supports: collection.supports || [], hasSeo: collection.hasSeo, urlPattern: collection.urlPattern, + routable: collection.routable !== false, titleField: collection.titleField, dateField: collection.dateField, ...(collection.hidden ? { hidden: true } : {}), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0da3f9a9d2..be00ac0079 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -116,7 +116,7 @@ export type { export { ulid } from "ulidx"; export { computeContentHash, hashString } from "./utils/hash.js"; export { sanitizeHref, isSafeHref } from "./utils/url.js"; -export { decodeSlug } from "./utils/slugify.js"; +export { decodeSlug, slugify } from "./utils/slugify.js"; // Live Collections query functions (loader is in emdash/runtime) export { diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index 847556daaa..097acec26c 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -132,6 +132,9 @@ const schemaUpdateCollectionToolSchema = z.object({ urlPattern: updateCollectionBody.shape.urlPattern.describe( "New public URL pattern; pass null to clear it", ), + routable: updateCollectionBody.shape.routable.describe( + "Whether entries require a slug before they can be published", + ), hasSeo: updateCollectionBody.shape.hasSeo.describe( "Whether the collection supports SEO metadata", ), @@ -1815,6 +1818,9 @@ export function createMcpServer( supports: createCollectionBody.shape.supports.describe( "Features to enable (default: ['drafts', 'revisions'])", ), + routable: createCollectionBody.shape.routable.describe( + "Require a slug before publishing (default: true)", + ), }), }, async (args, extra) => { @@ -1833,6 +1839,7 @@ export function createMcpServer( // SchemaRegistry.createCollection now defaults `supports` to // ['drafts', 'revisions'] when undefined; pass through verbatim. supports: args.supports, + routable: args.routable, }); ec.invalidateUrlPatternCache(); return jsonResult(collection); diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index 3f2a6efa3e..dcf4ce1dc8 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -457,6 +457,7 @@ export class SchemaRegistry { supports: JSON.stringify(supports), source: input.source ?? "manual", has_seo: hasSeo ? 1 : 0, + routable: input.routable === false ? 0 : 1, hidden: input.hidden ? 1 : 0, sort_order: input.sortOrder ?? null, comments_enabled: input.commentsEnabled ? 1 : 0, @@ -599,6 +600,7 @@ export class SchemaRegistry { supports: JSON.stringify(supports), source: "seed", has_seo: hasSeo ? 1 : 0, + routable: input.routable === false ? 0 : 1, hidden: input.hidden ? 1 : 0, sort_order: input.sortOrder ?? null, comments_enabled: input.commentsEnabled ? 1 : 0, @@ -744,6 +746,7 @@ export class SchemaRegistry { if (input.admin !== undefined) updates.admin_config = JSON.stringify(input.admin); if (input.supports !== undefined) updates.supports = JSON.stringify(input.supports); if (input.urlPattern !== undefined) updates.url_pattern = input.urlPattern; + if (input.routable !== undefined) updates.routable = input.routable ? 1 : 0; if (input.hasSeo !== undefined) { updates.has_seo = input.hasSeo ? 1 : 0; } else if (input.supports !== undefined) { @@ -1799,6 +1802,7 @@ export class SchemaRegistry { titleField: row.title_field ?? undefined, dateField: row.date_field ?? undefined, urlPattern: row.url_pattern ?? undefined, + routable: row.routable !== 0, hidden: row.hidden === 1, sortOrder: row.sort_order ?? undefined, commentsEnabled: row.comments_enabled === 1, diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index b5a517e336..cde04e9cc7 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -201,6 +201,8 @@ export interface Collection { dateField?: string; /** URL pattern with {slug} placeholder (e.g. "/{slug}", "/blog/{slug}") */ urlPattern?: string; + /** Whether published entries require a public slug. Defaults to true. */ + routable?: boolean; /** * Omit this collection's auto-generated entry from the admin sidebar. * The collection stays fully functional everywhere else (API, MCP, hooks, @@ -264,6 +266,7 @@ export interface CreateCollectionInput { supports?: CollectionSupport[]; source?: CollectionSource; urlPattern?: string; + routable?: boolean; hasSeo?: boolean; /** Omit the auto-generated admin sidebar entry (defaults to false) */ hidden?: boolean; @@ -283,6 +286,7 @@ export interface UpdateCollectionInput { admin?: CollectionAdminConfig; supports?: CollectionSupport[]; urlPattern?: string | null; + routable?: boolean; hasSeo?: boolean; /** Omit the auto-generated admin sidebar entry */ hidden?: boolean; diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts index 8dd24d3770..36956a1230 100644 --- a/packages/core/src/seed/apply.ts +++ b/packages/core/src/seed/apply.ts @@ -196,6 +196,7 @@ export async function applySeed( admin: collection.admin, supports: collection.supports || [], urlPattern: collection.urlPattern, + routable: collection.routable, hidden: collection.hidden, sortOrder: collection.sortOrder, commentsEnabled: collection.commentsEnabled, @@ -273,6 +274,7 @@ export async function applySeed( admin: collection.admin, supports: collection.supports || [], urlPattern: collection.urlPattern, + routable: collection.routable, hidden: collection.hidden, sortOrder: collection.sortOrder, commentsEnabled: collection.commentsEnabled, diff --git a/packages/core/src/seed/types.ts b/packages/core/src/seed/types.ts index 030a7c6fa1..594e5ad6d9 100644 --- a/packages/core/src/seed/types.ts +++ b/packages/core/src/seed/types.ts @@ -75,6 +75,8 @@ export interface SeedCollection { admin?: CollectionAdminConfig; supports?: ("drafts" | "revisions" | "preview" | "scheduling" | "search" | "seo")[]; urlPattern?: string; + /** Require a slug before an entry can be published. Defaults to true. */ + routable?: boolean; /** * Omit this collection from the admin sidebar. It stays reachable through * the API, MCP, plugin hooks, and direct `/content/:collection` URLs. diff --git a/packages/core/src/seed/validate.ts b/packages/core/src/seed/validate.ts index 7220ca6a50..eff81d6d45 100644 --- a/packages/core/src/seed/validate.ts +++ b/packages/core/src/seed/validate.ts @@ -107,6 +107,9 @@ export function validateSeed(data: unknown): ValidationResult { if (!collection.label) { errors.push(`${prefix}: label is required`); } + if (collection.routable !== undefined && typeof collection.routable !== "boolean") { + errors.push(`${prefix}.routable: must be a boolean`); + } const declaredFieldSlugs = new Set( Array.isArray(collection.fields) diff --git a/packages/core/src/utils/slugify.ts b/packages/core/src/utils/slugify.ts index 2af613d1ec..9872f3b9d2 100644 --- a/packages/core/src/utils/slugify.ts +++ b/packages/core/src/utils/slugify.ts @@ -1,17 +1,5 @@ -// Regex patterns for slug normalization -const DIACRITICS_PATTERN = /[\u0300-\u036f]/g; -const WHITESPACE_UNDERSCORE_PATTERN = /[\s_]+/g; -const NON_ALPHANUMERIC_HYPHEN_PATTERN = /[^a-z0-9-]/g; -const MULTIPLE_HYPHENS_PATTERN = /-+/g; -const LEADING_TRAILING_HYPHEN_PATTERN = /^-|-$/g; -const TRAILING_HYPHEN_PATTERN = /-$/; +export { slugify } from "@emdash-cms/admin/slugify"; -/** - * Convert a string to a URL-friendly slug. - * - * Handles unicode by normalizing to NFD and stripping diacritics, - * so "café" becomes "cafe", "naïve" becomes "naive", etc. - */ /** * Decode a URI-encoded slug parameter. * @@ -22,19 +10,3 @@ const TRAILING_HYPHEN_PATTERN = /-$/; export function decodeSlug(raw: string | undefined): string | undefined { return raw ? decodeURIComponent(raw) : undefined; } - -export function slugify(text: string, maxLength: number = 80): string { - return ( - text - .toLowerCase() - .normalize("NFD") - .replace(DIACRITICS_PATTERN, "") - .replace(WHITESPACE_UNDERSCORE_PATTERN, "-") - .replace(NON_ALPHANUMERIC_HYPHEN_PATTERN, "") - .replace(MULTIPLE_HYPHENS_PATTERN, "-") - .replace(LEADING_TRAILING_HYPHEN_PATTERN, "") - .slice(0, maxLength) - // Clean trailing hyphen from truncation - .replace(TRAILING_HYPHEN_PATTERN, "") - ); -} diff --git a/packages/core/tests/integration/database/migrations.test.ts b/packages/core/tests/integration/database/migrations.test.ts index ad9fe16b22..5a0629f6c0 100644 --- a/packages/core/tests/integration/database/migrations.test.ts +++ b/packages/core/tests/integration/database/migrations.test.ts @@ -160,6 +160,7 @@ describe("Database Migrations (Integration)", () => { "067_indexed_content_fields", "068_content_taxonomy_entry_groups", "069_collection_title_date_fields", + "070_collection_routable", ]; await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute(); diff --git a/packages/core/tests/integration/seo/seo.test.ts b/packages/core/tests/integration/seo/seo.test.ts index e3836ac909..bb33cf03d9 100644 --- a/packages/core/tests/integration/seo/seo.test.ts +++ b/packages/core/tests/integration/seo/seo.test.ts @@ -964,8 +964,8 @@ describe("SEO", () => { expect(result.data!.collections[0]!.collection).toBe("post"); }); - it("should return null slug and valid id when slug is null", async () => { - const created = await repo.create({ + it("should exclude published content without a slug", async () => { + await repo.create({ type: "post", data: { title: "No Slug Post" }, status: "published", @@ -974,10 +974,7 @@ describe("SEO", () => { const result = await handleSitemapData(db); expect(result.success).toBe(true); - const entries = flatEntries(result.data!); - expect(entries[0]!.collection).toBe("post"); - expect(entries[0]!.slug).toBeNull(); - expect(entries[0]!.id).toBe(created.id); + expect(result.data!.collections).toEqual([]); }); it("should include updatedAt and lastmod", async () => { diff --git a/packages/core/tests/integration/seo/sitemap-route.test.ts b/packages/core/tests/integration/seo/sitemap-route.test.ts index bf7a42b265..1faaa3fda4 100644 --- a/packages/core/tests/integration/seo/sitemap-route.test.ts +++ b/packages/core/tests/integration/seo/sitemap-route.test.ts @@ -78,6 +78,31 @@ describe("sitemap-[collection].xml route", () => { expect(res.status).toBe(404); }); + it("does not advertise non-routable collections", async () => { + await registry.updateCollection("post", { routable: false }); + await repo.create({ + type: "post", + slug: "internal", + data: { title: "Internal" }, + status: "published", + }); + + const res = await getSitemap(mockContext({ collectionSlug: "post", db })); + expect(res.status).toBe(404); + }); + + it("does not advertise legacy published entries without a slug", async () => { + await repo.create({ + type: "post", + slug: null, + data: { title: "Legacy" }, + status: "published", + }); + + const res = await getSitemap(mockContext({ collectionSlug: "post", db })); + expect(res.status).toBe(404); + }); + it("renders a non-i18n sitemap with one per row", async () => { setI18nConfig(null); await repo.create({ diff --git a/packages/core/tests/unit/api/collection-admin-schema.test.ts b/packages/core/tests/unit/api/collection-admin-schema.test.ts index 6a5e53c8e2..36c9c7a271 100644 --- a/packages/core/tests/unit/api/collection-admin-schema.test.ts +++ b/packages/core/tests/unit/api/collection-admin-schema.test.ts @@ -10,6 +10,13 @@ const oversizedListColumns = ["title", "priority", "owner", "region", "category" const maximumListColumns = oversizedListColumns.slice(0, 4); describe("collection admin list column schemas", () => { + it("accepts routability on collection writes and responses", () => { + expect( + createCollectionBody.safeParse({ slug: "posts", label: "Posts", routable: false }).success, + ).toBe(true); + expect(updateCollectionBody.safeParse({ routable: true }).success).toBe(true); + }); + it("accepts four list columns in create and update requests", () => { expect( createCollectionBody.safeParse({ @@ -56,6 +63,7 @@ describe("collection admin list column schemas", () => { supports: [], source: "manual", urlPattern: null, + routable: true, hasSeo: false, hidden: false, sortOrder: null, diff --git a/packages/core/tests/unit/api/content-handlers.test.ts b/packages/core/tests/unit/api/content-handlers.test.ts index a6e07823a2..a91d3f4a21 100644 --- a/packages/core/tests/unit/api/content-handlers.test.ts +++ b/packages/core/tests/unit/api/content-handlers.test.ts @@ -88,6 +88,19 @@ describe("Content Handlers — auto-slug generation", () => { expect(result.data?.item.slug).toBe("hello-world-1"); }); + it("should preserve Unicode when resolving slug collisions", async () => { + await handleContentCreate(db, "post", { + data: { title: "你好世界" }, + }); + + const result = await handleContentCreate(db, "post", { + data: { title: "你好世界" }, + }); + + expect(result.success).toBe(true); + expect(result.data?.item.slug).toBe("你好世界-1"); + }); + it("should increment suffix on repeated collisions", async () => { await handleContentCreate(db, "post", { data: { title: "Hello World" }, @@ -130,7 +143,21 @@ describe("Content Handlers — auto-slug generation", () => { }); expect(result.success).toBe(true); - expect(result.data?.item.slug).toBe("cafe-naive"); + expect(result.data?.item.slug).toBe("café-naïve"); + }); + + it("should generate a stable fallback slug for an emoji-only title", async () => { + const first = await handleContentCreate(db, "post", { + data: { title: "😀😀" }, + }); + const second = await handleContentCreate(db, "post", { + data: { title: "😀😀" }, + }); + + expect(first.success).toBe(true); + expect(first.data?.item.slug).toMatch(/^untitled-[a-z0-9]+$/); + expect(second.success).toBe(true); + expect(second.data?.item.slug).toBe(`${first.data?.item.slug}-1`); }); it("should allow same auto-slug in different collections", async () => { @@ -824,6 +851,74 @@ describe("Content Handlers — slug-change auto-redirect on publish", () => { expect(redirects).toHaveLength(0); }); + it("rejects publishing a routable entry without a slug", async () => { + const created = await handleContentCreate(db, "post", { + data: {}, + status: "draft", + }); + expect(created.success).toBe(true); + + const published = await handleContentPublish(db, "post", created.data!.item.id); + + expect(published.success).toBe(false); + if (published.success) return; + expect(published.error.code).toBe("VALIDATION_ERROR"); + expect(published.error.message).toContain("slug"); + }); + + it("allows a non-routable entry to publish without a slug", async () => { + const registry = new SchemaRegistry(db); + await registry.updateCollection("post", { routable: false }); + const created = await handleContentCreate(db, "post", { + data: {}, + status: "draft", + }); + expect(created.success).toBe(true); + + const published = await handleContentPublish(db, "post", created.data!.item.id); + + expect(published.success).toBe(true); + expect(published.data?.item.slug).toBeNull(); + expect(published.data?.item.status).toBe("published"); + }); + + it("rejects a slugless routable publish without revision support", async () => { + const registry = new SchemaRegistry(db); + await registry.updateCollection("post", { supports: [] }); + const created = await handleContentCreate(db, "post", { + data: {}, + status: "draft", + }); + expect(created.success).toBe(true); + + const published = await handleContentPublish(db, "post", created.data!.item.id); + + expect(published.success).toBe(false); + if (published.success) return; + expect(published.error.code).toBe("VALIDATION_ERROR"); + }); + + it("does not replace a live slug with a staged empty slug", async () => { + const created = await handleContentCreate(db, "post", { + data: { title: "Stable" }, + slug: "stable", + status: "published", + }); + expect(created.success).toBe(true); + const id = created.data!.item.id; + + await stageDraftSlugChange("post", id, { title: "Stable" }, ""); + + const published = await handleContentPublish(db, "post", id); + expect(published.success).toBe(false); + if (published.success) return; + expect(published.error.code).toBe("VALIDATION_ERROR"); + + const unchanged = await handleContentGet(db, "post", id); + expect(unchanged.success).toBe(true); + expect(unchanged.data?.item.slug).toBe("stable"); + }); + // #2034: a staged slug colliding with another entry's (slug, locale) used // to surface as an opaque 500 (raw D1/SQLite UNIQUE error). It must be the // same SLUG_CONFLICT (409) as direct slug edits, naming the slug so the diff --git a/packages/core/tests/unit/cli/seed-commands.test.ts b/packages/core/tests/unit/cli/seed-commands.test.ts index 4fca3708eb..30e3a4a0a8 100644 --- a/packages/core/tests/unit/cli/seed-commands.test.ts +++ b/packages/core/tests/unit/cli/seed-commands.test.ts @@ -214,6 +214,32 @@ describe("CLI Seed Commands", () => { }); describe("export-seed output", () => { + it("preserves a non-routable collection", async () => { + const dbPath = join(tempDir, "routable.db"); + const db = createDatabase({ url: `file:${dbPath}` }); + + try { + await runMigrations(db); + await applySeed(db, { + version: "1", + collections: [ + { + slug: "blocks", + label: "Blocks", + routable: false, + fields: [{ slug: "title", label: "Title", type: "string" }], + }, + ], + }); + + const exported = await exportSeed(db); + expect(exported.collections?.[0]?.routable).toBe(false); + expect(validateSeed(exported)).toMatchObject({ valid: true, errors: [] }); + } finally { + await db.destroy(); + } + }); + it("preserves collection list columns through apply and export", async () => { const dbPath = join(tempDir, "admin-config.db"); const db = createDatabase({ url: `file:${dbPath}` }); diff --git a/packages/core/tests/unit/schema/registry.test.ts b/packages/core/tests/unit/schema/registry.test.ts index b3837d2c6a..f3c8244154 100644 --- a/packages/core/tests/unit/schema/registry.test.ts +++ b/packages/core/tests/unit/schema/registry.test.ts @@ -66,6 +66,19 @@ describe("SchemaRegistry", () => { expect(collection.supports).toEqual([]); }); + it("defaults collections to routable and preserves explicit opt-out", async () => { + const routable = await registry.createCollection({ slug: "posts", label: "Posts" }); + const internal = await registry.createCollection({ + slug: "blocks", + label: "Blocks", + routable: false, + }); + + expect(routable.routable).toBe(true); + expect(internal.routable).toBe(false); + expect((await registry.updateCollection("blocks", { routable: true })).routable).toBe(true); + }); + it("should create the content table when creating a collection", async () => { await registry.createCollection({ slug: "articles", diff --git a/packages/core/tests/unit/seed/apply.test.ts b/packages/core/tests/unit/seed/apply.test.ts index 67df1e9e09..8e61477c34 100644 --- a/packages/core/tests/unit/seed/apply.test.ts +++ b/packages/core/tests/unit/seed/apply.test.ts @@ -208,6 +208,26 @@ describe("applySeed", () => { expect((await registry.getCollection("contact_submissions"))?.hidden).toBe(true); }); + it("applies and updates collection routability", async () => { + const collection = { + slug: "contact_submissions", + label: "Contact Submissions", + routable: false, + fields: [{ slug: "title", label: "Title", type: "string" as const }], + }; + await applySeed(db, { version: "1", collections: [collection] }); + + const registry = new SchemaRegistry(db); + expect((await registry.getCollection("contact_submissions"))?.routable).toBe(false); + + await applySeed( + db, + { version: "1", collections: [{ ...collection, routable: true }] }, + { onConflict: "update" }, + ); + expect((await registry.getCollection("contact_submissions"))?.routable).toBe(true); + }); + it("applies sortOrder from the seed and orders the list by it", async () => { const seed: SeedFile = { version: "1", diff --git a/packages/core/tests/unit/seed/validate.test.ts b/packages/core/tests/unit/seed/validate.test.ts index 77479b94a7..03ad96332e 100644 --- a/packages/core/tests/unit/seed/validate.test.ts +++ b/packages/core/tests/unit/seed/validate.test.ts @@ -122,6 +122,16 @@ describe("validateSeed", () => { expect(result.errors).toContain('collections[1].slug: duplicate collection slug "posts"'); }); + it("should reject a non-boolean routable value", () => { + const result = validateSeed({ + version: "1", + collections: [{ slug: "posts", label: "Posts", routable: "false", fields: [] }], + }); + + expect(result.valid).toBe(false); + expect(result.errors).toContain("collections[0].routable: must be a boolean"); + }); + it("should require fields to be an array", () => { const result = validateSeed({ version: "1", From 2d5bb80176fcca7ef19d9309100521a973e31ebd Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 16 Aug 2026 17:37:05 +0100 Subject: [PATCH 02/10] fix: keep invalid legacy slugs out of sitemaps --- e2e/tests/content-types.spec.ts | 2 +- packages/core/src/api/handlers/seo.ts | 2 +- .../integration/runtime/after-hooks-deferred.test.ts | 6 +++++- .../core/tests/integration/seo/sitemap-route.test.ts | 12 ++++++++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/e2e/tests/content-types.spec.ts b/e2e/tests/content-types.spec.ts index 3f6b12c701..836c3569e2 100644 --- a/e2e/tests/content-types.spec.ts +++ b/e2e/tests/content-types.spec.ts @@ -182,7 +182,7 @@ test.describe("Content Types", () => { await expect(pluralInput).toHaveValue(TEST_LABEL_PLURAL); // Override slug with our unique test slug - const slugInput = admin.page.getByLabel("Slug"); + const slugInput = admin.page.getByLabel("Slug", { exact: true }); await slugInput.fill(TEST_SLUG); // Submit diff --git a/packages/core/src/api/handlers/seo.ts b/packages/core/src/api/handlers/seo.ts index 68eeff8167..8e5546690a 100644 --- a/packages/core/src/api/handlers/seo.ts +++ b/packages/core/src/api/handlers/seo.ts @@ -151,7 +151,7 @@ export async function handleSitemapData( WHERE c.status = 'published' AND c.deleted_at IS NULL AND c.slug IS NOT NULL - AND c.slug <> '' + AND TRIM(c.slug) <> '' AND (s.seo_no_index IS NULL OR s.seo_no_index = 0) ORDER BY c.updated_at DESC LIMIT ${SITEMAP_MAX_ENTRIES} diff --git a/packages/core/tests/integration/runtime/after-hooks-deferred.test.ts b/packages/core/tests/integration/runtime/after-hooks-deferred.test.ts index cf8f19394b..8ed297ffe6 100644 --- a/packages/core/tests/integration/runtime/after-hooks-deferred.test.ts +++ b/packages/core/tests/integration/runtime/after-hooks-deferred.test.ts @@ -128,7 +128,11 @@ describe("runtime defers lifecycle hooks through after()", () => { }); it("schedules the afterUnpublish hook via after() on unpublish", async () => { - const item = await repo.create({ type: "post", data: { title: "Live then gone" } }); + const item = await repo.create({ + type: "post", + slug: "live-then-gone", + data: { title: "Live then gone" }, + }); const published = await runtime.handleContentPublish("post", item.id); expect(published.success).toBe(true); diff --git a/packages/core/tests/integration/seo/sitemap-route.test.ts b/packages/core/tests/integration/seo/sitemap-route.test.ts index 1faaa3fda4..e9d60f0b59 100644 --- a/packages/core/tests/integration/seo/sitemap-route.test.ts +++ b/packages/core/tests/integration/seo/sitemap-route.test.ts @@ -103,6 +103,18 @@ describe("sitemap-[collection].xml route", () => { expect(res.status).toBe(404); }); + it("does not advertise legacy published entries with a whitespace-only slug", async () => { + await repo.create({ + type: "post", + slug: " ", + data: { title: "Legacy" }, + status: "published", + }); + + const res = await getSitemap(mockContext({ collectionSlug: "post", db })); + expect(res.status).toBe(404); + }); + it("renders a non-i18n sitemap with one per row", async () => { setI18nConfig(null); await repo.create({ From 46e357f7db1b88c14747fab293e8061d8378bd6f Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 16 Aug 2026 18:22:56 +0100 Subject: [PATCH 03/10] fix: close routable publish bypasses --- packages/core/src/api/handlers/content.ts | 25 +++++++- packages/core/src/schema/registry.ts | 1 + .../database/media-usage-activation.test.ts | 14 ++++ .../tests/unit/api/content-handlers.test.ts | 64 +++++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index 297378572e..d3fb1cd169 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -106,6 +106,12 @@ async function getCollectionPublishConfig( }; } +function requireRoutablePublishSlug(routable: boolean, slug: string | null | undefined): void { + if (routable && !slug?.trim()) { + throw new EmDashValidationError("Cannot publish routable content without a slug"); + } +} + /** * Hydrate SEO data on a single content item if the collection has SEO enabled. */ @@ -837,6 +843,10 @@ export async function handleContentCreate( slug = await repo.generateUniqueSlug(collection, slugSource, effectiveLocale); } } + if (body.status === "published") { + const publishConfig = await getCollectionPublishConfig(trx, collection); + requireRoutablePublishSlug(publishConfig.routable, slug); + } const created = await repo.create({ type: collection, @@ -1015,7 +1025,9 @@ export async function handleContentUpdate( // Read existing item once for both _rev check and old slug capture const existing = - body._rev || body.slug ? await trxRepo.findById(collection, resolvedId) : null; + body._rev || body.slug !== undefined || body.status === "published" + ? await trxRepo.findById(collection, resolvedId) + : null; // Validate _rev if provided (optimistic concurrency) if (body._rev) { @@ -1039,6 +1051,17 @@ export async function handleContentUpdate( oldSlug = existing.slug; } + if (body.status === "published") { + if (!existing) { + throw Object.assign(new Error(`Content item not found: ${id}`), { + apiError: { code: "NOT_FOUND" as const }, + }); + } + const publishConfig = await getCollectionPublishConfig(trx, collection); + const intendedSlug = typeof body.slug === "string" ? body.slug : existing.slug; + requireRoutablePublishSlug(publishConfig.routable, intendedSlug); + } + const updated = await trxRepo.update(collection, resolvedId, { data: body.data, slug: body.slug, diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index dcf4ce1dc8..12010d12b4 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -197,6 +197,7 @@ export async function buildSeedCollectionCaptureFingerprint( sortOrder: input.sortOrder ?? null, commentsEnabled: input.commentsEnabled ?? false, urlPattern: input.urlPattern ?? null, + routable: input.routable ?? true, }, fields: definitions, }), diff --git a/packages/core/tests/integration/database/media-usage-activation.test.ts b/packages/core/tests/integration/database/media-usage-activation.test.ts index b8cbafaa02..dd6d9e6f16 100644 --- a/packages/core/tests/integration/database/media-usage-activation.test.ts +++ b/packages/core/tests/integration/database/media-usage-activation.test.ts @@ -423,6 +423,20 @@ describeEachDialect("media usage production activation", (dialect) => { ); }); + it("includes collection routability in the seed capture fingerprint", async () => { + const fields = [{ slug: "hero", label: "Hero", type: "image" as const }]; + const routable = await buildSeedCollectionCaptureFingerprint( + { slug: "posts", label: "Posts", routable: true }, + fields, + ); + const nonRoutable = await buildSeedCollectionCaptureFingerprint( + { slug: "posts", label: "Posts", routable: false }, + fields, + ); + + expect(nonRoutable).not.toBe(routable); + }); + it("distinguishes an omitted seed default from an explicit null default", async () => { await activateMediaUsageCapture(ctx.db, { writersDrained: true }); const registry = new SchemaRegistry(ctx.db); diff --git a/packages/core/tests/unit/api/content-handlers.test.ts b/packages/core/tests/unit/api/content-handlers.test.ts index a91d3f4a21..c934766c4e 100644 --- a/packages/core/tests/unit/api/content-handlers.test.ts +++ b/packages/core/tests/unit/api/content-handlers.test.ts @@ -218,6 +218,70 @@ describe("Content Handlers — auto-slug generation", () => { }); }); + describe("direct status publishing", () => { + it("rejects creating slugless published content in a routable collection", async () => { + const result = await handleContentCreate(db, "post", { + data: {}, + status: "published", + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("VALIDATION_ERROR"); + }); + + it("allows creating slugless published content in a non-routable collection", async () => { + await new SchemaRegistry(db).updateCollection("post", { routable: false }); + + const result = await handleContentCreate(db, "post", { + data: {}, + status: "published", + }); + + expect(result.success).toBe(true); + expect(result.data?.item.slug).toBeNull(); + }); + + it("rejects updating a slugless routable draft to published", async () => { + const created = await handleContentCreate(db, "post", { data: {} }); + expect(created.success).toBe(true); + + const result = await handleContentUpdate(db, "post", created.data!.item.id, { + status: "published", + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("VALIDATION_ERROR"); + }); + + it("allows publishing a routable draft when the update supplies a slug", async () => { + const created = await handleContentCreate(db, "post", { data: {} }); + expect(created.success).toBe(true); + + const result = await handleContentUpdate(db, "post", created.data!.item.id, { + slug: "published-directly", + status: "published", + }); + + expect(result.success).toBe(true); + expect(result.data?.item.slug).toBe("published-directly"); + }); + + it("allows updating a slugless non-routable draft to published", async () => { + await new SchemaRegistry(db).updateCollection("post", { routable: false }); + const created = await handleContentCreate(db, "post", { data: {} }); + expect(created.success).toBe(true); + + const result = await handleContentUpdate(db, "post", created.data!.item.id, { + status: "published", + }); + + expect(result.success).toBe(true); + expect(result.data?.item.status).toBe("published"); + }); + }); + describe("handleContentDuplicate", () => { it("should generate slug from duplicated title", async () => { const original = await handleContentCreate(db, "post", { From 8232139638906e717c7ced6c0232771a9ea06c22 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 16 Aug 2026 19:45:46 +0100 Subject: [PATCH 04/10] fix: support slugless non-routable seeds --- packages/admin/src/lib/utils.ts | 5 --- packages/core/src/cli/commands/export-seed.ts | 2 +- .../core/src/database/repositories/content.ts | 2 +- .../core/src/database/repositories/types.ts | 2 + packages/core/src/seed/apply.ts | 45 +++++++++++++++---- packages/core/src/seed/types.ts | 4 +- packages/core/src/seed/validate.ts | 9 +++- .../core/tests/unit/cli/seed-commands.test.ts | 27 +++++++++++ packages/core/tests/unit/seed/apply.test.ts | 41 +++++++++++++++++ .../core/tests/unit/seed/validate.test.ts | 39 ++++++++++++++++ 10 files changed, 158 insertions(+), 18 deletions(-) diff --git a/packages/admin/src/lib/utils.ts b/packages/admin/src/lib/utils.ts index d318984abc..eef8d36f9f 100644 --- a/packages/admin/src/lib/utils.ts +++ b/packages/admin/src/lib/utils.ts @@ -28,11 +28,6 @@ export function parseTimestamp(value: string): Date { return new Date(value); } -/** - * Convert a string to a URL-friendly slug. - * - * Handles unicode by normalizing to NFD and stripping diacritics. - */ export function formatRelativeTime(dateString: string): string { const date = parseTimestamp(dateString); const now = new Date(); diff --git a/packages/core/src/cli/commands/export-seed.ts b/packages/core/src/cli/commands/export-seed.ts index 86d98ef581..0dba97f42b 100644 --- a/packages/core/src/cli/commands/export-seed.ts +++ b/packages/core/src/cli/commands/export-seed.ts @@ -749,7 +749,7 @@ async function exportContent( const entry: SeedContentEntry = { id: seedId, - slug: item.slug || item.id, + slug: item.slug ?? (collection.routable === false ? undefined : item.id), status: item.status === "published" || item.status === "draft" ? item.status : undefined, data: processedData, }; diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index 5d9c1a43c4..533f25e1dd 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -309,7 +309,7 @@ export class ContentRepository { * Create a new content item */ async create(input: CreateContentInput): Promise { - const id = ulid(); + const id = input.id ?? ulid(); const now = new Date().toISOString(); const { diff --git a/packages/core/src/database/repositories/types.ts b/packages/core/src/database/repositories/types.ts index 4f3fe1180c..4f4748b847 100644 --- a/packages/core/src/database/repositories/types.ts +++ b/packages/core/src/database/repositories/types.ts @@ -13,6 +13,8 @@ import { encodeBase64, decodeBase64 } from "../../utils/base64.js"; const MAX_CURSOR_LENGTH = 4096; export interface CreateContentInput { + /** Explicit content ID for stable seed imports. Omit to generate a ULID. */ + id?: string; type: string; slug?: string | null; data: Record; diff --git a/packages/core/src/seed/apply.ts b/packages/core/src/seed/apply.ts index 36956a1230..8c3d66c472 100644 --- a/packages/core/src/seed/apply.ts +++ b/packages/core/src/seed/apply.ts @@ -489,23 +489,31 @@ export async function applySeed( // 7. Content (created before menus so refs can resolve) if (includeContent && seed.content) { const contentRepo = new ContentRepository(db); + const schemaRegistry = new SchemaRegistry(db); try { // Create content entries for (const [collectionSlug, entries] of Object.entries(seed.content)) { + const collectionRoutable = + (await schemaRegistry.getCollection(collectionSlug))?.routable !== false; for (const entry of entries) { + const entrySlug = + typeof entry.slug === "string" && entry.slug.trim().length > 0 ? entry.slug : null; // Resolve the entry's locale up front so a non-`en` single-locale // export (which omits `locale`) is filed under the project default // rather than `en` (#1421). const entryLocale = resolveConfiguredLocale(entry.locale ?? defaultLocale); - // Check if entry exists (by slug + locale for locale-aware lookup) - const existing = await contentRepo.findBySlug(collectionSlug, entry.slug, entryLocale); + // Slugful entries use the existing locale-aware key. Slugless seed + // entries persist their seed ID, which keeps re-application idempotent. + const existing = entrySlug + ? await contentRepo.findBySlug(collectionSlug, entrySlug, entryLocale) + : await contentRepo.findById(collectionSlug, entry.id); if (existing) { if (onConflict === "error") { throw new Error( - `Conflict: content "${entry.slug}" in "${collectionSlug}" already exists`, + `Conflict: content "${entrySlug ?? entry.id}" in "${collectionSlug}" already exists`, ); } @@ -558,7 +566,15 @@ export async function applySeed( }); try { await trxContentRepo.setDraftRevision(collectionSlug, existing.id, draft.id); - await trxContentRepo.publish(collectionSlug, existing.id); + await trxContentRepo.publish( + collectionSlug, + existing.id, + undefined, + false, + undefined, + true, + collectionRoutable, + ); } catch (error) { try { await trxRevisionRepo.deleteIfUnreferenced( @@ -619,8 +635,9 @@ export async function applySeed( const trxBylineRepo = new BylineRepository(trx); const item = await trxContentRepo.create({ + ...(entrySlug ? {} : { id: entry.id }), type: collectionSlug, - slug: entry.slug, + slug: entrySlug, status, data: resolvedData, locale: entryLocale, @@ -642,7 +659,15 @@ export async function applySeed( // revision so the admin UI shows "Unpublish" instead of "Save & Publish" // and `live_revision_id` is populated for downstream queries. if (status === "published") { - await trxContentRepo.publish(collectionSlug, item.id); + await trxContentRepo.publish( + collectionSlug, + item.id, + undefined, + false, + undefined, + true, + collectionRoutable, + ); } return item; @@ -990,7 +1015,11 @@ async function applyContentBylines( bylineRepo: BylineRepository, collectionSlug: string, contentId: string, - entry: { slug: string; bylines?: Array<{ byline: string; roleLabel?: string }> }, + entry: { + id: string; + slug?: string | null; + bylines?: Array<{ byline: string; roleLabel?: string }>; + }, seedBylineIdMap: Map, isUpdate = false, ): Promise { @@ -1015,7 +1044,7 @@ async function applyContentBylines( if (credits.length !== entry.bylines.length) { console.warn( - `content.${collectionSlug}.${entry.slug}: one or more byline refs could not be resolved`, + `content.${collectionSlug}.${entry.slug ?? entry.id}: one or more byline refs could not be resolved`, ); } diff --git a/packages/core/src/seed/types.ts b/packages/core/src/seed/types.ts index 594e5ad6d9..459515d263 100644 --- a/packages/core/src/seed/types.ts +++ b/packages/core/src/seed/types.ts @@ -271,8 +271,8 @@ export interface SeedContentEntry { /** Seed-local ID for $ref resolution */ id: string; - /** URL slug */ - slug: string; + /** URL slug. May be omitted for entries in non-routable collections. */ + slug?: string | null; /** Publication status */ status?: "published" | "draft"; diff --git a/packages/core/src/seed/validate.ts b/packages/core/src/seed/validate.ts index eff81d6d45..6ddfcffad0 100644 --- a/packages/core/src/seed/validate.ts +++ b/packages/core/src/seed/validate.ts @@ -593,6 +593,9 @@ export function validateSeed(data: unknown): ValidationResult { errors.push(`content.${collectionSlug}: must be an array`); continue; } + const collectionRoutable = + seed.collections?.find((collection) => collection.slug === collectionSlug)?.routable !== + false; const entryIds = new Set(); @@ -612,7 +615,11 @@ export function validateSeed(data: unknown): ValidationResult { entryIds.add(entry.id); } - if (!entry.slug) { + const hasSlug = typeof entry.slug === "string" && entry.slug.trim().length > 0; + if (entry.slug !== undefined && entry.slug !== null && typeof entry.slug !== "string") { + errors.push(`${prefix}.slug: must be a string`); + } + if (collectionRoutable && !hasSlug) { errors.push(`${prefix}: slug is required`); } diff --git a/packages/core/tests/unit/cli/seed-commands.test.ts b/packages/core/tests/unit/cli/seed-commands.test.ts index 30e3a4a0a8..9e9dcbb6d8 100644 --- a/packages/core/tests/unit/cli/seed-commands.test.ts +++ b/packages/core/tests/unit/cli/seed-commands.test.ts @@ -11,6 +11,8 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { exportSeed } from "../../../src/cli/commands/export-seed.js"; import { createDatabase } from "../../../src/database/connection.js"; import { runMigrations } from "../../../src/database/migrations/runner.js"; +import { ContentRepository } from "../../../src/database/repositories/content.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; import { applySeed } from "../../../src/seed/apply.js"; import type { SeedFile } from "../../../src/seed/types.js"; import { validateSeed } from "../../../src/seed/validate.js"; @@ -214,6 +216,31 @@ describe("CLI Seed Commands", () => { }); describe("export-seed output", () => { + it("preserves slugless content in a non-routable collection", async () => { + const dbPath = join(tempDir, "slugless-content.db"); + const db = createDatabase({ url: `file:${dbPath}` }); + + try { + await runMigrations(db); + const registry = new SchemaRegistry(db); + await registry.createCollection({ slug: "blocks", label: "Blocks", routable: false }); + await registry.createField("blocks", { slug: "title", label: "Title", type: "string" }); + await new ContentRepository(db).create({ + type: "blocks", + slug: null, + status: "published", + data: { title: "Hero" }, + }); + + const exported = await exportSeed(db, "blocks"); + const entry = exported.content?.blocks?.[0]; + expect(entry?.slug).toBeUndefined(); + expect(validateSeed(exported)).toMatchObject({ valid: true, errors: [] }); + } finally { + await db.destroy(); + } + }); + it("preserves a non-routable collection", async () => { const dbPath = join(tempDir, "routable.db"); const db = createDatabase({ url: `file:${dbPath}` }); diff --git a/packages/core/tests/unit/seed/apply.test.ts b/packages/core/tests/unit/seed/apply.test.ts index 8e61477c34..49f05ee784 100644 --- a/packages/core/tests/unit/seed/apply.test.ts +++ b/packages/core/tests/unit/seed/apply.test.ts @@ -1065,6 +1065,47 @@ describe("applySeed", () => { expect(entry?.data.title).toBe("Hello World"); }); + it("idempotently publishes slugless content for a non-routable collection", async () => { + const seed: SeedFile = { + version: "1", + collections: [ + { + slug: "blocks", + label: "Blocks", + routable: false, + fields: [{ slug: "title", label: "Title", type: "string" }], + }, + ], + content: { + blocks: [{ id: "hero", status: "published", data: { title: "Hero" } }], + }, + }; + + const first = await applySeed(db, seed, { includeContent: true }); + expect(first.content.created).toBe(1); + const contentRepo = new ContentRepository(db); + const created = await contentRepo.findById("blocks", "hero"); + expect(created).toMatchObject({ id: "hero", slug: null, status: "published" }); + expect(created?.liveRevisionId).not.toBeNull(); + + const second = await applySeed(db, seed, { includeContent: true }); + expect(second.content).toEqual({ created: 0, skipped: 1, updated: 0 }); + expect((await contentRepo.findMany("blocks", {})).items).toHaveLength(1); + + const updatedSeed: SeedFile = { + ...seed, + content: { + blocks: [{ id: "hero", status: "published", data: { title: "Updated Hero" } }], + }, + }; + const updated = await applySeed(db, updatedSeed, { + includeContent: true, + onConflict: "update", + }); + expect(updated.content.updated).toBe(1); + expect((await contentRepo.findById("blocks", "hero"))?.data.title).toBe("Updated Hero"); + }); + it("should skip existing content entries", async () => { const registry = new SchemaRegistry(db); await registry.createCollection({ slug: "posts", label: "Posts" }); diff --git a/packages/core/tests/unit/seed/validate.test.ts b/packages/core/tests/unit/seed/validate.test.ts index 03ad96332e..0a2dda388a 100644 --- a/packages/core/tests/unit/seed/validate.test.ts +++ b/packages/core/tests/unit/seed/validate.test.ts @@ -777,6 +777,45 @@ describe("validateSeed", () => { expect(result.errors).toContain("content.posts[0]: slug is required"); }); + it("allows a slugless entry in a non-routable collection", () => { + const result = validateSeed({ + version: "1", + collections: [ + { + slug: "blocks", + label: "Blocks", + routable: false, + fields: [{ slug: "title", label: "Title", type: "string" }], + }, + ], + content: { + blocks: [{ id: "hero", data: { title: "Hero" }, status: "published" }], + }, + }); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("rejects a whitespace-only slug in a routable collection", () => { + const result = validateSeed({ + version: "1", + collections: [ + { + slug: "posts", + label: "Posts", + fields: [{ slug: "title", label: "Title", type: "string" }], + }, + ], + content: { + posts: [{ id: "empty", slug: " ", data: { title: "Empty" } }], + }, + }); + + expect(result.valid).toBe(false); + expect(result.errors).toContain("content.posts[0]: slug is required"); + }); + it("should require entry data to be an object", () => { const result = validateSeed({ version: "1", From be17c3fff3f8038af22ed50e6f513ae82a423029 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 16 Aug 2026 20:09:15 +0100 Subject: [PATCH 05/10] fix: protect published routable slugs on update --- packages/admin/src/lib/api/content.ts | 4 +- packages/core/src/api/handlers/content.ts | 9 ++-- packages/core/src/astro/types.ts | 4 +- packages/core/src/emdash-runtime.ts | 4 +- .../tests/unit/api/content-handlers.test.ts | 43 +++++++++++++++++++ 5 files changed, 54 insertions(+), 10 deletions(-) diff --git a/packages/admin/src/lib/api/content.ts b/packages/admin/src/lib/api/content.ts index 9641e7c8b7..3238ddea96 100644 --- a/packages/admin/src/lib/api/content.ts +++ b/packages/admin/src/lib/api/content.ts @@ -62,7 +62,7 @@ export interface ContentItem { export interface CreateContentInput { type: string; - slug?: string; + slug?: string | null; data: Record; status?: string; bylines?: BylineCreditInput[]; @@ -105,7 +105,7 @@ export interface ContentSeoInput { export interface UpdateContentInput { data?: Record; - slug?: string; + slug?: string | null; status?: string; publishedAt?: string | null; authorId?: string | null; diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index d3fb1cd169..237ff4a31e 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -795,7 +795,7 @@ export async function handleContentCreate( collection: string, body: { data: Record; - slug?: string; + slug?: string | null; status?: string; authorId?: string; bylines?: ContentBylineInput[]; @@ -981,7 +981,7 @@ export async function handleContentUpdate( id: string, body: { data?: Record; - slug?: string; + slug?: string | null; status?: string; authorId?: string | null; bylines?: ContentBylineInput[]; @@ -1051,14 +1051,15 @@ export async function handleContentUpdate( oldSlug = existing.slug; } - if (body.status === "published") { + const resultingStatus = body.status ?? existing?.status; + if (resultingStatus === "published") { if (!existing) { throw Object.assign(new Error(`Content item not found: ${id}`), { apiError: { code: "NOT_FOUND" as const }, }); } const publishConfig = await getCollectionPublishConfig(trx, collection); - const intendedSlug = typeof body.slug === "string" ? body.slug : existing.slug; + const intendedSlug = body.slug !== undefined ? body.slug : existing.slug; requireRoutablePublishSlug(publishConfig.routable, intendedSlug); } diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index 411e614743..274e24bff4 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -282,7 +282,7 @@ export interface EmDashHandlers { collection: string, body: { data: Record; - slug?: string; + slug?: string | null; status?: string; authorId?: string; bylines?: Array<{ bylineId: string; roleLabel?: string | null }>; @@ -299,7 +299,7 @@ export interface EmDashHandlers { id: string, body: { data?: Record; - slug?: string; + slug?: string | null; status?: string; authorId?: string | null; bylines?: Array<{ bylineId: string; roleLabel?: string | null }>; diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 7f0c6d9f30..f65fd9f7d9 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -2915,7 +2915,7 @@ export class EmDashRuntime { collection: string, body: { data: Record; - slug?: string; + slug?: string | null; status?: string; authorId?: string; bylines?: Array<{ bylineId: string; roleLabel?: string | null }>; @@ -2975,7 +2975,7 @@ export class EmDashRuntime { id: string, body: { data?: Record; - slug?: string; + slug?: string | null; status?: string; authorId?: string | null; bylines?: Array<{ bylineId: string; roleLabel?: string | null }>; diff --git a/packages/core/tests/unit/api/content-handlers.test.ts b/packages/core/tests/unit/api/content-handlers.test.ts index c934766c4e..a611f0382f 100644 --- a/packages/core/tests/unit/api/content-handlers.test.ts +++ b/packages/core/tests/unit/api/content-handlers.test.ts @@ -280,6 +280,49 @@ describe("Content Handlers — auto-slug generation", () => { expect(result.success).toBe(true); expect(result.data?.item.status).toBe("published"); }); + + it("rejects clearing the slug of published routable content", async () => { + const created = await handleContentCreate(db, "post", { + data: { title: "Published" }, + slug: "published", + status: "published", + }); + expect(created.success).toBe(true); + + const result = await handleContentUpdate(db, "post", created.data!.item.id, { + slug: null, + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe("VALIDATION_ERROR"); + expect((await handleContentGet(db, "post", created.data!.item.id)).data?.item.slug).toBe( + "published", + ); + }); + + it("allows clearing a slug when the resulting content is not routable and published", async () => { + const created = await handleContentCreate(db, "post", { + data: { title: "Published" }, + slug: "published", + status: "published", + }); + expect(created.success).toBe(true); + + const draft = await handleContentUpdate(db, "post", created.data!.item.id, { + slug: null, + status: "draft", + }); + expect(draft.success).toBe(true); + expect(draft.data?.item).toMatchObject({ slug: null, status: "draft" }); + + await new SchemaRegistry(db).updateCollection("post", { routable: false }); + const republished = await handleContentUpdate(db, "post", created.data!.item.id, { + status: "published", + }); + expect(republished.success).toBe(true); + expect(republished.data?.item).toMatchObject({ slug: null, status: "published" }); + }); }); describe("handleContentDuplicate", () => { From 009ca116be7a51dcf1e1a4776cdd5a6b57779007 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 16 Aug 2026 20:29:23 +0100 Subject: [PATCH 06/10] fix: normalize unusable slugs in seed exports --- packages/core/src/cli/commands/export-seed.ts | 2 +- .../core/tests/unit/cli/seed-commands.test.ts | 33 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/core/src/cli/commands/export-seed.ts b/packages/core/src/cli/commands/export-seed.ts index 0dba97f42b..b4803b836e 100644 --- a/packages/core/src/cli/commands/export-seed.ts +++ b/packages/core/src/cli/commands/export-seed.ts @@ -749,7 +749,7 @@ async function exportContent( const entry: SeedContentEntry = { id: seedId, - slug: item.slug ?? (collection.routable === false ? undefined : item.id), + slug: item.slug?.trim() ? item.slug : collection.routable === false ? undefined : item.id, status: item.status === "published" || item.status === "draft" ? item.status : undefined, data: processedData, }; diff --git a/packages/core/tests/unit/cli/seed-commands.test.ts b/packages/core/tests/unit/cli/seed-commands.test.ts index 9e9dcbb6d8..a432814b21 100644 --- a/packages/core/tests/unit/cli/seed-commands.test.ts +++ b/packages/core/tests/unit/cli/seed-commands.test.ts @@ -241,6 +241,39 @@ describe("CLI Seed Commands", () => { } }); + it("falls back for unusable routable slugs while preserving a zero slug", async () => { + const dbPath = join(tempDir, "legacy-routable-content.db"); + const db = createDatabase({ url: `file:${dbPath}` }); + + try { + await runMigrations(db); + const registry = new SchemaRegistry(db); + await registry.createCollection({ slug: "posts", label: "Posts" }); + await registry.createField("posts", { slug: "title", label: "Title", type: "string" }); + const contentRepo = new ContentRepository(db); + const whitespace = await contentRepo.create({ + type: "posts", + slug: " ", + status: "published", + data: { title: "Legacy" }, + }); + await contentRepo.create({ + type: "posts", + slug: "0", + status: "published", + data: { title: "Zero" }, + }); + + const exported = await exportSeed(db, "posts"); + const slugs = exported.content?.posts?.map((entry) => entry.slug); + expect(slugs).toContain(whitespace.id); + expect(slugs).toContain("0"); + expect(validateSeed(exported)).toMatchObject({ valid: true, errors: [] }); + } finally { + await db.destroy(); + } + }); + it("preserves a non-routable collection", async () => { const dbPath = join(tempDir, "routable.db"); const db = createDatabase({ url: `file:${dbPath}` }); From 3ae8359d203fb0de28ce18096fda36b0e410b4db Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 16 Aug 2026 20:56:52 +0100 Subject: [PATCH 07/10] fix: reject slugless routable schedules --- packages/core/src/api/handlers/content.ts | 7 ++- .../tests/unit/api/content-handlers.test.ts | 43 +++++++++++++++++-- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index 237ff4a31e..0b54b6fdb0 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -1490,7 +1490,12 @@ export async function handleContentSchedule( try { const item = await withTransaction(db, async (trx) => { const repo = new ContentRepository(trx); - const resolvedId = (await resolveId(repo, collection, id)) ?? id; + const existing = await repo.findByIdOrSlug(collection, id); + const resolvedId = existing?.id ?? id; + if (existing) { + const publishConfig = await getCollectionPublishConfig(trx, collection); + requireRoutablePublishSlug(publishConfig.routable, existing.slug); + } return repo.schedule(collection, resolvedId, scheduledAt); }); diff --git a/packages/core/tests/unit/api/content-handlers.test.ts b/packages/core/tests/unit/api/content-handlers.test.ts index a611f0382f..964bbfbe08 100644 --- a/packages/core/tests/unit/api/content-handlers.test.ts +++ b/packages/core/tests/unit/api/content-handlers.test.ts @@ -7,6 +7,7 @@ import { handleContentGet, handleContentList, handleContentPublish, + handleContentSchedule, handleContentUpdate, } from "../../../src/api/index.js"; import { BylineRepository } from "../../../src/database/repositories/byline.js"; @@ -325,6 +326,44 @@ describe("Content Handlers — auto-slug generation", () => { }); }); + describe("scheduling", () => { + it("rejects scheduling slugless content in a routable collection", async () => { + const created = await handleContentCreate(db, "post", { data: {} }); + expect(created.success).toBe(true); + + const scheduled = await handleContentSchedule( + db, + "post", + created.data!.item.id, + new Date(Date.now() + 60_000).toISOString(), + ); + + expect(scheduled.success).toBe(false); + if (scheduled.success) return; + expect(scheduled.error.code).toBe("VALIDATION_ERROR"); + expect(scheduled.error.message).toContain("slug"); + + const unchanged = await handleContentGet(db, "post", created.data!.item.id); + expect(unchanged.data?.item).toMatchObject({ status: "draft", scheduledAt: null }); + }); + + it("allows scheduling slugless content in a non-routable collection", async () => { + await new SchemaRegistry(db).updateCollection("post", { routable: false }); + const created = await handleContentCreate(db, "post", { data: {} }); + expect(created.success).toBe(true); + + const scheduled = await handleContentSchedule( + db, + "post", + created.data!.item.id, + new Date(Date.now() + 60_000).toISOString(), + ); + + expect(scheduled.success).toBe(true); + expect(scheduled.data?.item).toMatchObject({ slug: null, status: "scheduled" }); + }); + }); + describe("handleContentDuplicate", () => { it("should generate slug from duplicated title", async () => { const original = await handleContentCreate(db, "post", { @@ -1026,10 +1065,6 @@ describe("Content Handlers — slug-change auto-redirect on publish", () => { expect(unchanged.data?.item.slug).toBe("stable"); }); - // #2034: a staged slug colliding with another entry's (slug, locale) used - // to surface as an opaque 500 (raw D1/SQLite UNIQUE error). It must be the - // same SLUG_CONFLICT (409) as direct slug edits, naming the slug so the - // admin can show it inline. it("returns SLUG_CONFLICT naming the slug when the staged slug is taken", async () => { const taken = await handleContentCreate(db, "post", { data: { title: "Owner" }, From b292c3c395df149a076fbfae03cf4171e23920b1 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 16 Aug 2026 21:32:54 +0100 Subject: [PATCH 08/10] chore: clarify slug generation contract --- packages/core/src/database/repositories/content.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/database/repositories/content.ts b/packages/core/src/database/repositories/content.ts index 533f25e1dd..80e62b3295 100644 --- a/packages/core/src/database/repositories/content.ts +++ b/packages/core/src/database/repositories/content.ts @@ -407,8 +407,7 @@ export class ContentRepository { * (optionally scoped to a locale) and appends a numeric suffix (`-1`, * `-2`, etc.) on collision to guarantee uniqueness. * - * The null return is retained for compatibility as a defensive backstop if - * slug normalization cannot produce a value. + * Returns null when slug normalization cannot produce a value. */ async generateUniqueSlug(type: string, text: string, locale?: string): Promise { const baseSlug = slugify(text); From 041ca5cd2a76c2dc9f6bdd51b29ba29362be04c1 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 16 Aug 2026 21:41:05 +0100 Subject: [PATCH 09/10] chore: remove editor implementation narrative --- packages/admin/src/components/ContentTypeEditor.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/admin/src/components/ContentTypeEditor.tsx b/packages/admin/src/components/ContentTypeEditor.tsx index f2b9347af3..070a9135b2 100644 --- a/packages/admin/src/components/ContentTypeEditor.tsx +++ b/packages/admin/src/components/ContentTypeEditor.tsx @@ -330,10 +330,6 @@ export function ContentTypeEditor({ return (
- {/* Sticky header keeps the primary save action in view while users - scroll through the settings + fields panels. The bottom-of-form - save button is preserved below for keyboard / screen-reader users - so DOM order still ends with a submit control. */} Date: Sun, 16 Aug 2026 22:04:45 +0100 Subject: [PATCH 10/10] test: keep revisionless fixture routable --- .../core/tests/integration/plugins/content-update-drafts.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/tests/integration/plugins/content-update-drafts.test.ts b/packages/core/tests/integration/plugins/content-update-drafts.test.ts index aec6bc27b6..9de36ad08c 100644 --- a/packages/core/tests/integration/plugins/content-update-drafts.test.ts +++ b/packages/core/tests/integration/plugins/content-update-drafts.test.ts @@ -111,6 +111,7 @@ describeEachDialect("plugin content updates with revisions", (dialect) => { await registry.createField("plain_post", { slug: "content", label: "Content", type: "string" }); const created = await contentRepo.create({ type: "plain_post", + slug: "plain-post", status: "published", data: { title: "Original title", content: "Original body" }, });