Skip to content

Commit 388ce2f

Browse files
committed
fix: keep inline taxonomy terms Unicode-safe
1 parent 329c503 commit 388ce2f

12 files changed

Lines changed: 207 additions & 11 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"emdash": patch
3+
"@emdash-cms/admin": patch
4+
---
5+
6+
Fixes inline taxonomy term creation for Unicode-only labels and adds numeric suffixes when generated term slugs collide.

packages/admin/src/components/TaxonomyManager.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,7 @@ function TermFormDialog({
434434
const createMutation = useMutation({
435435
mutationFn: () =>
436436
createTerm(taxonomyName, {
437-
slug,
437+
...(autoSlug ? {} : { slug }),
438438
label,
439439
parentId: parentId || undefined,
440440
description: description || undefined,

packages/admin/src/components/TaxonomySidebar.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import * as React from "react";
1717
import { apiFetch, parseApiResponse, throwResponseError } from "../lib/api/client.js";
1818
import { createTerm, withLocale } from "../lib/api/taxonomies.js";
1919
import { rankTermMatches, termExactMatches } from "../lib/taxonomy-match.js";
20-
import { cn, slugify } from "../lib/utils.js";
20+
import { cn } from "../lib/utils.js";
2121

2222
interface TaxonomyTerm {
2323
id: string;
@@ -367,7 +367,6 @@ function TaxonomySection({
367367
const createTermMutation = useMutation({
368368
mutationFn: (label: string) =>
369369
createTerm(taxonomy.name, {
370-
slug: slugify(label),
371370
label,
372371
// Create the term in the entry's locale so it resolves on this entry.
373372
...(entryLocale ? { locale: entryLocale } : {}),

packages/admin/src/lib/api/taxonomies.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export interface CreateTaxonomyInput {
7171
}
7272

7373
export interface CreateTermInput {
74-
slug: string;
74+
slug?: string;
7575
label: string;
7676
parentId?: string;
7777
description?: string;

packages/admin/tests/components/TaxonomyManager.test.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Toasty } from "@cloudflare/kumo";
22
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
33
import * as React from "react";
44
import { describe, it, expect, vi, beforeEach } from "vitest";
5+
import { userEvent } from "vitest/browser";
56

67
import {
78
getAvailableParentTerms,
@@ -344,6 +345,42 @@ describe("TaxonomyManager", () => {
344345
await expect.element(screen.getByText("Description (optional)")).toBeInTheDocument();
345346
});
346347

348+
it("lets the server derive an auto-generated term slug", async () => {
349+
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
350+
wrapper: Wrapper,
351+
});
352+
await screen.getByRole("button", { name: ADD_CATEGORY_BUTTON_REGEX }).click();
353+
await screen.getByLabelText("Name").fill("音楽");
354+
await expect.element(screen.getByLabelText("Slug")).toHaveValue("音楽");
355+
356+
await userEvent.keyboard("{Enter}");
357+
358+
await vi.waitFor(() => {
359+
const call = vi.mocked(apiFetch).mock.calls.find(([, init]) => init?.method === "POST");
360+
expect(call).toBeDefined();
361+
const body = typeof call?.[1]?.body === "string" ? JSON.parse(call[1].body) : undefined;
362+
expect(body).toMatchObject({ label: "音楽" });
363+
expect(body).not.toHaveProperty("slug");
364+
});
365+
});
366+
367+
it("sends a manually edited term slug", async () => {
368+
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
369+
wrapper: Wrapper,
370+
});
371+
await screen.getByRole("button", { name: ADD_CATEGORY_BUTTON_REGEX }).click();
372+
await screen.getByLabelText("Name").fill("Music");
373+
await screen.getByLabelText("Slug").fill("custom-music");
374+
375+
await userEvent.keyboard("{Enter}");
376+
377+
await vi.waitFor(() => {
378+
const call = vi.mocked(apiFetch).mock.calls.find(([, init]) => init?.method === "POST");
379+
const body = typeof call?.[1]?.body === "string" ? JSON.parse(call[1].body) : undefined;
380+
expect(body).toMatchObject({ label: "Music", slug: "custom-music" });
381+
});
382+
});
383+
347384
it("shows parent selector for hierarchical taxonomies", async () => {
348385
const screen = await render(<TaxonomyManager taxonomyName="categories" />, {
349386
wrapper: Wrapper,

packages/admin/tests/components/TaxonomySidebar.test.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,13 +357,28 @@ describe("TaxonomySidebar", () => {
357357
"/_emdash/api/taxonomies/tags/terms",
358358
expect.objectContaining({
359359
method: "POST",
360-
body: JSON.stringify({ slug: "gamma", label: "Gamma" }),
360+
body: JSON.stringify({ label: "Gamma" }),
361361
}),
362362
);
363363
});
364364
expect(onChange).toHaveBeenCalledWith("tags", ["term_created"]);
365365
});
366366

367+
it("lets the server derive the slug for an inline Unicode term", async () => {
368+
mockApiFetch({ terms: [] });
369+
const screen = await render(<TaxonomySidebar collection="products" />, { wrapper: Wrapper });
370+
371+
await screen.getByLabelText("Add Tags").fill("音楽");
372+
await screen.getByText('Create "音楽"').click();
373+
374+
await vi.waitFor(() => {
375+
const call = vi.mocked(apiFetch).mock.calls.find(([, init]) => init?.method === "POST");
376+
expect(call).toBeDefined();
377+
const body = typeof call?.[1]?.body === "string" ? JSON.parse(call[1].body) : undefined;
378+
expect(body).toEqual({ label: "音楽" });
379+
});
380+
});
381+
367382
it("continues to render hierarchical taxonomies as a checkbox tree", async () => {
368383
mockApiFetch({ taxonomies: [categoriesTaxonomy], terms: [alphaTerm] });
369384

packages/core/src/api/handlers/taxonomies.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -812,7 +812,7 @@ export async function handleTermCreate(
812812
db: Kysely<Database>,
813813
taxonomyName: string,
814814
input: {
815-
slug: string;
815+
slug?: string;
816816
label: string;
817817
parentId?: string | null;
818818
description?: string;
@@ -829,19 +829,21 @@ export async function handleTermCreate(
829829
if (!lookup.success) return lookup;
830830

831831
const repo = new TaxonomyRepository(db);
832+
const generatedSlug = input.slug === undefined;
833+
const slug = input.slug ?? (await repo.generateUniqueSlug(taxonomyName, input.label, locale));
832834

833835
// Coerce empty-string parentId to undefined (treat as "no parent").
834836
const parentId =
835837
input.parentId === "" || input.parentId === undefined ? undefined : input.parentId;
836838

837839
// Conflict check is scoped to locale (per-locale slugs are unique).
838-
const existing = await repo.findBySlug(taxonomyName, input.slug, locale);
840+
const existing = generatedSlug ? null : await repo.findBySlug(taxonomyName, slug, locale);
839841
if (existing) {
840842
return {
841843
success: false,
842844
error: {
843845
code: "CONFLICT",
844-
message: `Term '${input.slug}' already exists in '${taxonomyName}' (${locale})`,
846+
message: `Term '${slug}' already exists in '${taxonomyName}' (${locale})`,
845847
},
846848
};
847849
}
@@ -875,7 +877,7 @@ export async function handleTermCreate(
875877

876878
const term = await repo.create({
877879
name: taxonomyName,
878-
slug: input.slug,
880+
slug,
879881
label: input.label,
880882
parentId: parentId ?? undefined,
881883
data: input.description ? { description: input.description } : undefined,

packages/core/src/api/schemas/taxonomies.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,11 @@ export const updateTaxonomyDefBody = z
5252

5353
export const createTermBody = z
5454
.object({
55-
slug: z.string().min(1),
55+
slug: z
56+
.string()
57+
.min(1)
58+
.optional()
59+
.meta({ description: "Term slug. Omit to derive a unique slug from the label." }),
5660
label: z.string().min(1),
5761
parentId: z.string().nullish(),
5862
description: z.string().optional(),

packages/core/src/database/repositories/taxonomy.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { sql, type Kysely, type Selectable } from "kysely";
22
import { ulid } from "ulidx";
33

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

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

221+
/** Generate a locale-scoped term slug, adding a numeric suffix when needed. */
222+
async generateUniqueSlug(name: string, text: string, locale?: string): Promise<string> {
223+
const baseSlug = slugify(text);
224+
let query = this.db
225+
.selectFrom("taxonomies")
226+
.select("slug")
227+
.where("name", "=", name)
228+
.where((eb) => eb.or([eb("slug", "=", baseSlug), eb("slug", "like", `${baseSlug}-%`)]));
229+
if (locale !== undefined) query = query.where("locale", "=", locale);
230+
const candidates = await query.execute();
231+
if (!candidates.some((candidate) => candidate.slug === baseSlug)) return baseSlug;
232+
233+
let maxSuffix = 0;
234+
const prefix = `${baseSlug}-`;
235+
for (const candidate of candidates) {
236+
if (!candidate.slug.startsWith(prefix)) continue;
237+
const suffix = candidate.slug.slice(prefix.length);
238+
if (!NUMERIC_SUFFIX_PATTERN.test(suffix)) continue;
239+
maxSuffix = Math.max(maxSuffix, Number.parseInt(suffix, 10));
240+
}
241+
return `${baseSlug}-${maxSuffix + 1}`;
242+
}
243+
219244
/**
220245
* Get all terms for a taxonomy (e.g., all categories).
221246
*

packages/core/src/mcp/server.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2528,7 +2528,11 @@ export function createMcpServer(
25282528
"new term beneath a chain of 100+ existing ancestors are rejected.",
25292529
inputSchema: z.object({
25302530
taxonomy: z.string().describe("Taxonomy name (e.g. 'categories', 'tags')"),
2531-
slug: z.string().describe("URL-safe identifier for the term"),
2531+
slug: z
2532+
.string()
2533+
.min(1)
2534+
.optional()
2535+
.describe("URL identifier for the term; omit to derive it from the label"),
25322536
label: z.string().describe("Display name"),
25332537
parentId: z.string().optional().describe("Parent term ID for hierarchical taxonomies"),
25342538
description: z.string().optional().describe("Description of the term"),

0 commit comments

Comments
 (0)