Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/fix-inline-unicode-taxonomies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"emdash": patch
"@emdash-cms/admin": patch
---

Fixes inline taxonomy term creation for Unicode-only labels and adds numeric suffixes when generated term slugs collide.
2 changes: 1 addition & 1 deletion packages/admin/src/components/TaxonomyManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ function TermFormDialog({
const createMutation = useMutation({
mutationFn: () =>
createTerm(taxonomyName, {
slug,
...(autoSlug ? {} : { slug }),
label,
parentId: parentId || undefined,
description: description || undefined,
Expand Down
3 changes: 1 addition & 2 deletions packages/admin/src/components/TaxonomySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import * as React from "react";
import { apiFetch, parseApiResponse, throwResponseError } from "../lib/api/client.js";
import { createTerm, withLocale } from "../lib/api/taxonomies.js";
import { rankTermMatches, termExactMatches } from "../lib/taxonomy-match.js";
import { cn, slugify } from "../lib/utils.js";
import { cn } from "../lib/utils.js";

interface TaxonomyTerm {
id: string;
Expand Down Expand Up @@ -367,7 +367,6 @@ function TaxonomySection({
const createTermMutation = useMutation({
mutationFn: (label: string) =>
createTerm(taxonomy.name, {
slug: slugify(label),
label,
// Create the term in the entry's locale so it resolves on this entry.
...(entryLocale ? { locale: entryLocale } : {}),
Expand Down
2 changes: 1 addition & 1 deletion packages/admin/src/lib/api/taxonomies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export interface CreateTaxonomyInput {
}

export interface CreateTermInput {
slug: string;
slug?: string;
label: string;
parentId?: string;
description?: string;
Expand Down
37 changes: 37 additions & 0 deletions packages/admin/tests/components/TaxonomyManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Toasty } from "@cloudflare/kumo";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { userEvent } from "vitest/browser";

import {
getAvailableParentTerms,
Expand Down Expand Up @@ -344,6 +345,42 @@ describe("TaxonomyManager", () => {
await expect.element(screen.getByText("Description (optional)")).toBeInTheDocument();
});

it("lets the server derive an auto-generated term slug", async () => {
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
wrapper: Wrapper,
});
await screen.getByRole("button", { name: ADD_CATEGORY_BUTTON_REGEX }).click();
await screen.getByLabelText("Name").fill("音楽");
await expect.element(screen.getByLabelText("Slug")).toHaveValue("音楽");

await userEvent.keyboard("{Enter}");

await vi.waitFor(() => {
const call = vi.mocked(apiFetch).mock.calls.find(([, init]) => init?.method === "POST");
expect(call).toBeDefined();
const body = typeof call?.[1]?.body === "string" ? JSON.parse(call[1].body) : undefined;
expect(body).toMatchObject({ label: "音楽" });
expect(body).not.toHaveProperty("slug");
});
});

it("sends a manually edited term slug", async () => {
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
wrapper: Wrapper,
});
await screen.getByRole("button", { name: ADD_CATEGORY_BUTTON_REGEX }).click();
await screen.getByLabelText("Name").fill("Music");
await screen.getByLabelText("Slug").fill("custom-music");

await userEvent.keyboard("{Enter}");

await vi.waitFor(() => {
const call = vi.mocked(apiFetch).mock.calls.find(([, init]) => init?.method === "POST");
const body = typeof call?.[1]?.body === "string" ? JSON.parse(call[1].body) : undefined;
expect(body).toMatchObject({ label: "Music", slug: "custom-music" });
});
});

it("shows parent selector for hierarchical taxonomies", async () => {
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
wrapper: Wrapper,
Expand Down
17 changes: 16 additions & 1 deletion packages/admin/tests/components/TaxonomySidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -357,13 +357,28 @@ describe("TaxonomySidebar", () => {
"/_emdash/api/taxonomies/tags/terms",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ slug: "gamma", label: "Gamma" }),
body: JSON.stringify({ label: "Gamma" }),
}),
);
});
expect(onChange).toHaveBeenCalledWith("tags", ["term_created"]);
});

it("lets the server derive the slug for an inline Unicode term", async () => {
mockApiFetch({ terms: [] });
const screen = await render(<TaxonomySidebar collection="products" />, { wrapper: Wrapper });

await screen.getByLabelText("Add Tags").fill("音楽");
await screen.getByText('Create "音楽"').click();

await vi.waitFor(() => {
const call = vi.mocked(apiFetch).mock.calls.find(([, init]) => init?.method === "POST");
expect(call).toBeDefined();
const body = typeof call?.[1]?.body === "string" ? JSON.parse(call[1].body) : undefined;
expect(body).toEqual({ label: "音楽" });
});
});

it("continues to render hierarchical taxonomies as a checkbox tree", async () => {
mockApiFetch({ taxonomies: [categoriesTaxonomy], terms: [alphaTerm] });

Expand Down
62 changes: 50 additions & 12 deletions packages/core/src/api/handlers/taxonomies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ import { fetchVisibleTermCounts } from "../../taxonomies/term-counts.js";
import type { ApiResult } from "../types.js";

const NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
const MAX_GENERATED_TERM_SLUG_ATTEMPTS = 16;

function isTermSlugUniqueViolation(error: unknown): boolean {
const message = error instanceof Error ? error.message.toLowerCase() : "";
return (
(message.includes("unique constraint failed") || message.includes("duplicate key")) &&
message.includes("slug")
Comment thread
ascorbic marked this conversation as resolved.
);
}

// ---------------------------------------------------------------------------
// Response types
Expand Down Expand Up @@ -812,14 +821,15 @@ export async function handleTermCreate(
db: Kysely<Database>,
taxonomyName: string,
input: {
slug: string;
slug?: string;
label: string;
parentId?: string | null;
description?: string;
locale?: string;
translationOf?: string;
},
): Promise<ApiResult<TermResponse>> {
let attemptedSlug = input.slug;
try {
const locale = resolveConfiguredLocale(input.locale ?? getI18nConfig()?.defaultLocale ?? "en");
// Taxonomy definitions are per-locale, but terms can exist in any locale
Expand All @@ -835,7 +845,8 @@ export async function handleTermCreate(
input.parentId === "" || input.parentId === undefined ? undefined : input.parentId;

// Conflict check is scoped to locale (per-locale slugs are unique).
const existing = await repo.findBySlug(taxonomyName, input.slug, locale);
const existing =
input.slug === undefined ? null : await repo.findBySlug(taxonomyName, input.slug, locale);
if (existing) {
return {
success: false,
Expand Down Expand Up @@ -873,15 +884,33 @@ export async function handleTermCreate(
return { success: false, error: parentError };
}

const term = await repo.create({
name: taxonomyName,
slug: input.slug,
label: input.label,
parentId: parentId ?? undefined,
data: input.description ? { description: input.description } : undefined,
locale,
translationOf: input.translationOf,
});
const create = (slug: string) =>
repo.create({
name: taxonomyName,
slug,
label: input.label,
parentId: parentId ?? undefined,
data: input.description ? { description: input.description } : undefined,
locale,
translationOf: input.translationOf,
});
let term: Awaited<ReturnType<typeof create>> | undefined;
let lastSlugConflict: unknown;
if (input.slug !== undefined) {
term = await create(input.slug);
} else {
for (let attempt = 0; attempt < MAX_GENERATED_TERM_SLUG_ATTEMPTS; attempt++) {
attemptedSlug = await repo.generateUniqueSlug(taxonomyName, input.label, locale);
try {
term = await create(attemptedSlug);
break;
} catch (error) {
if (!isTermSlugUniqueViolation(error)) throw error;
lastSlugConflict = error;
}
}
}
if (!term) throw lastSlugConflict ?? new Error("Failed to create taxonomy term");

invalidateTermCache();

Expand All @@ -901,7 +930,16 @@ export async function handleTermCreate(
},
},
};
} catch {
} catch (error) {
if (isTermSlugUniqueViolation(error)) {
return {
success: false,
error: {
code: "CONFLICT",
message: `Term with slug '${attemptedSlug ?? "(generated)"}' already exists in taxonomy '${taxonomyName}'`,
},
};
}
Comment thread
ascorbic marked this conversation as resolved.
return {
success: false,
error: { code: "TERM_CREATE_ERROR", message: "Failed to create term" },
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/api/schemas/taxonomies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ export const updateTaxonomyDefBody = z

export const createTermBody = z
.object({
slug: z.string().min(1),
slug: z
.string()
.min(1)
.optional()
.meta({ description: "Term slug. Omit to derive a unique slug from the label." }),
label: z.string().min(1),
parentId: z.string().nullish(),
description: z.string().optional(),
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/database/repositories/taxonomy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { sql, type Kysely, type Selectable } from "kysely";
import { ulid } from "ulidx";

import { invalidateTaxonomyObjectCache } from "../../object-cache/index.js";
import { slugify } from "../../utils/slugify.js";
import { withTransaction } from "../transaction.js";
import type { Database, TaxonomyTable } from "../types.js";
import { validateIdentifier } from "../validate.js";
Expand All @@ -18,6 +19,7 @@ export interface SiblingPosition {
* statement inside D1's 100-parameter ceiling.
*/
const GROUPS_PER_UPDATE = 32;
const NUMERIC_SUFFIX_PATTERN = /^\d+$/;

/** Deal the listed groups back out over the slots they hold, in the order given. */
function permuteWithinSlots(
Expand Down Expand Up @@ -216,6 +218,29 @@ export class TaxonomyRepository {
return row ? this.rowToTaxonomy(row) : null;
}

/** Generate a locale-scoped term slug, adding a numeric suffix when needed. */
async generateUniqueSlug(name: string, text: string, locale?: string): Promise<string> {
const baseSlug = slugify(text);
let query = this.db
.selectFrom("taxonomies")
.select("slug")
.where("name", "=", name)
.where((eb) => eb.or([eb("slug", "=", baseSlug), eb("slug", "like", `${baseSlug}-%`)]));
if (locale !== undefined) query = query.where("locale", "=", locale);
Comment thread
ascorbic marked this conversation as resolved.
const candidates = await query.execute();
if (!candidates.some((candidate) => candidate.slug === baseSlug)) return baseSlug;

let maxSuffix = 0;
const prefix = `${baseSlug}-`;
for (const candidate of candidates) {
if (!candidate.slug.startsWith(prefix)) continue;
const suffix = candidate.slug.slice(prefix.length);
if (!NUMERIC_SUFFIX_PATTERN.test(suffix)) continue;
maxSuffix = Math.max(maxSuffix, Number.parseInt(suffix, 10));
}
Comment thread
ascorbic marked this conversation as resolved.
return `${baseSlug}-${maxSuffix + 1}`;
}

/**
* Get all terms for a taxonomy (e.g., all categories).
*
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2528,7 +2528,11 @@ export function createMcpServer(
"new term beneath a chain of 100+ existing ancestors are rejected.",
inputSchema: z.object({
taxonomy: z.string().describe("Taxonomy name (e.g. 'categories', 'tags')"),
slug: z.string().describe("URL-safe identifier for the term"),
slug: z
.string()
.min(1)
.optional()
.describe("URL identifier for the term; omit to derive it from the label"),
label: z.string().describe("Display name"),
parentId: z.string().optional().describe("Parent term ID for hierarchical taxonomies"),
description: z.string().optional().describe("Description of the term"),
Expand Down
12 changes: 12 additions & 0 deletions packages/core/tests/integration/mcp/taxonomy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,18 @@ describe("taxonomy_create_term", () => {
expect(term.label).toBe("Tech");
});

it("derives a Unicode slug when omitted", async () => {
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
const result = await harness.client.callTool({
name: "taxonomy_create_term",
arguments: { taxonomy: "tags", label: "音楽" },
});

expect(result.isError, extractText(result)).toBeFalsy();
const { term } = extractJson<{ term: { slug: string } }>(result);
expect(term.slug).toBe("音楽");
});

it("creates a child term with parentId", async () => {
harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN });
const parent = await harness.client.callTool({
Expand Down
Loading
Loading