Skip to content

Commit 5828233

Browse files
authored
fix: preserve Unicode in routable content slugs (#2505)
* fix: keep routable content addressable across languages * fix: keep invalid legacy slugs out of sitemaps * fix: close routable publish bypasses * fix: support slugless non-routable seeds * fix: protect published routable slugs on update * fix: normalize unusable slugs in seed exports * fix: reject slugless routable schedules * chore: clarify slug generation contract * chore: remove editor implementation narrative * test: keep revisionless fixture routable
1 parent 1401799 commit 5828233

45 files changed

Lines changed: 822 additions & 114 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"emdash": minor
3+
"@emdash-cms/admin": minor
4+
---
5+
6+
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.

e2e/tests/content-types.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ test.describe("Content Types", () => {
182182
await expect(pluralInput).toHaveValue(TEST_LABEL_PLURAL);
183183

184184
// Override slug with our unique test slug
185-
const slugInput = admin.page.getByLabel("Slug");
185+
const slugInput = admin.page.getByLabel("Slug", { exact: true });
186186
await slugInput.fill(TEST_SLUG);
187187

188188
// Submit

packages/admin/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,15 @@
1717
"types": "./dist/locales/index.d.ts",
1818
"default": "./dist/locales/index.js"
1919
},
20-
"./locales/*": "./dist/locales/*"
20+
"./locales/*": "./dist/locales/*",
21+
"./slugify": {
22+
"types": "./dist/slugify.d.ts",
23+
"default": "./dist/slugify.js"
24+
}
2125
},
2226
"scripts": {
2327
"build": "node --run locale:compile && tsdown && node --run locale:copy && npx @tailwindcss/cli -i src/styles.css -o dist/styles.css --minify",
24-
"dev": "tsdown src/index.ts --format esm --dts --watch",
28+
"dev": "tsdown --watch",
2529
"prepublishOnly": "node --run build",
2630
"check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm --ignore-rules=no-resolution",
2731
"test": "vitest",

packages/admin/src/components/ContentTypeEditor.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ export function ContentTypeEditor({
160160
const [labelSingular, setLabelSingular] = React.useState(collection?.labelSingular ?? "");
161161
const [description, setDescription] = React.useState(collection?.description ?? "");
162162
const [urlPattern, setUrlPattern] = React.useState(collection?.urlPattern ?? "");
163+
const [routable, setRoutable] = React.useState(collection?.routable ?? true);
163164
// SEO is managed via the separate `hasSeo` field; strip any legacy "seo" entry
164165
// so it isn't sent back on save (the API enum rejects it).
165166
const [supports, setSupports] = React.useState<string[]>(
@@ -200,6 +201,7 @@ export function ContentTypeEditor({
200201
labelSingular !== (collection.labelSingular ?? "") ||
201202
description !== (collection.description ?? "") ||
202203
urlPattern !== (collection.urlPattern ?? "") ||
204+
routable !== (collection.routable ?? true) ||
203205
JSON.stringify([...supports].toSorted()) !==
204206
JSON.stringify(collection.supports.filter((s) => s !== "seo").toSorted()) ||
205207
hasSeo !== collection.hasSeo ||
@@ -216,6 +218,7 @@ export function ContentTypeEditor({
216218
labelSingular,
217219
description,
218220
urlPattern,
221+
routable,
219222
supports,
220223
hasSeo,
221224
commentsEnabled,
@@ -261,6 +264,7 @@ export function ContentTypeEditor({
261264
labelSingular: labelSingular || undefined,
262265
description: description || undefined,
263266
urlPattern: urlPattern || undefined,
267+
routable,
264268
supports,
265269
hasSeo,
266270
});
@@ -270,6 +274,7 @@ export function ContentTypeEditor({
270274
labelSingular: labelSingular || undefined,
271275
description: description || undefined,
272276
urlPattern: urlPattern || undefined,
277+
routable,
273278
supports,
274279
hasSeo,
275280
commentsEnabled,
@@ -325,10 +330,6 @@ export function ContentTypeEditor({
325330

326331
return (
327332
<div className="space-y-6">
328-
{/* Sticky header keeps the primary save action in view while users
329-
scroll through the settings + fields panels. The bottom-of-form
330-
save button is preserved below for keyboard / screen-reader users
331-
so DOM order still ends with a submit control. */}
332333
<EditorHeader
333334
leading={
334335
<RouterLinkButton
@@ -418,6 +419,20 @@ export function ContentTypeEditor({
418419
disabled={isFromCode}
419420
/>
420421

422+
<Switch
423+
checked={routable}
424+
onCheckedChange={setRoutable}
425+
disabled={isFromCode}
426+
label={
427+
<div>
428+
<span className="text-sm font-medium">{t`Routable`}</span>
429+
<p className="text-xs text-kumo-subtle">
430+
{t`Require a slug before content can be published`}
431+
</p>
432+
</div>
433+
}
434+
/>
435+
421436
<div>
422437
<Input
423438
label={t`URL Pattern`}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export interface AdminManifest {
9393
supports: string[];
9494
hasSeo: boolean;
9595
urlPattern?: string;
96+
routable?: boolean;
9697
titleField?: string;
9798
dateField?: string;
9899
hidden?: boolean;

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export interface ContentItem {
6262

6363
export interface CreateContentInput {
6464
type: string;
65-
slug?: string;
65+
slug?: string | null;
6666
data: Record<string, unknown>;
6767
status?: string;
6868
bylines?: BylineCreditInput[];
@@ -105,7 +105,7 @@ export interface ContentSeoInput {
105105

106106
export interface UpdateContentInput {
107107
data?: Record<string, unknown>;
108-
slug?: string;
108+
slug?: string | null;
109109
status?: string;
110110
publishedAt?: string | null;
111111
authorId?: string | null;

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export interface SchemaCollection {
3636
supports: string[];
3737
source?: string;
3838
urlPattern?: string;
39+
/** Published entries require a slug unless this is false. */
40+
routable?: boolean;
3941
hasSeo: boolean;
4042
/** Sidebar entry omitted in the admin; the collection stays reachable by URL */
4143
hidden: boolean;
@@ -93,6 +95,7 @@ export interface CreateCollectionInput {
9395
admin?: CollectionAdminConfig;
9496
supports?: string[];
9597
urlPattern?: string;
98+
routable?: boolean;
9699
hasSeo?: boolean;
97100
hidden?: boolean;
98101
sortOrder?: number | null;
@@ -106,6 +109,7 @@ export interface UpdateCollectionInput {
106109
admin?: CollectionAdminConfig;
107110
supports?: string[];
108111
urlPattern?: string;
112+
routable?: boolean;
109113
hasSeo?: boolean;
110114
hidden?: boolean;
111115
sortOrder?: number | null;

packages/admin/src/lib/utils.ts

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
import { type ClassValue, clsx } from "clsx";
22
import { twMerge } from "tailwind-merge";
33

4-
// Regex patterns for slugify
5-
const DIACRITICS_PATTERN = /[\u0300-\u036f]/g;
6-
const WHITESPACE_UNDERSCORE_PATTERN = /[\s_]+/g;
7-
const NON_ALPHANUMERIC_HYPHEN_PATTERN = /[^a-z0-9-]/g;
8-
const MULTIPLE_HYPHENS_PATTERN = /-+/g;
9-
const LEADING_TRAILING_HYPHEN_PATTERN = /^-|-$/g;
4+
export { slugify } from "../slugify.js";
105

116
// Regex patterns for parseTimestamp
127
const NAIVE_DATETIME_PATTERN = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/;
@@ -33,11 +28,6 @@ export function parseTimestamp(value: string): Date {
3328
return new Date(value);
3429
}
3530

36-
/**
37-
* Convert a string to a URL-friendly slug.
38-
*
39-
* Handles unicode by normalizing to NFD and stripping diacritics.
40-
*/
4131
export function formatRelativeTime(dateString: string): string {
4232
const date = parseTimestamp(dateString);
4333
const now = new Date();
@@ -58,14 +48,3 @@ export function formatRelativeTime(dateString: string): string {
5848
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
5949
});
6050
}
61-
62-
export function slugify(text: string): string {
63-
return text
64-
.toLowerCase()
65-
.normalize("NFD")
66-
.replace(DIACRITICS_PATTERN, "")
67-
.replace(WHITESPACE_UNDERSCORE_PATTERN, "-")
68-
.replace(NON_ALPHANUMERIC_HYPHEN_PATTERN, "")
69-
.replace(MULTIPLE_HYPHENS_PATTERN, "-")
70-
.replace(LEADING_TRAILING_HYPHEN_PATTERN, "");
71-
}

packages/admin/src/slugify.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
const SEPARATOR_PATTERN = /[\s_]+/gu;
2+
const UNSAFE_CHARACTER_PATTERN = /[^\p{Letter}\p{Number}\p{Mark}-]+/gu;
3+
const MULTIPLE_HYPHENS_PATTERN = /-+/g;
4+
const EDGE_HYPHENS_PATTERN = /^-+|-+$/g;
5+
const TRAILING_HYPHENS_PATTERN = /-+$/g;
6+
const USABLE_CHARACTER_PATTERN = /[\p{Letter}\p{Number}]/u;
7+
const GRAPHEME_SEGMENTER = new Intl.Segmenter("en", { granularity: "grapheme" });
8+
9+
function fallbackSlug(value: string): string {
10+
let hash = 2_166_136_261;
11+
for (let index = 0; index < value.length; index++) {
12+
hash ^= value.charCodeAt(index);
13+
hash = Math.imul(hash, 16_777_619);
14+
}
15+
return `untitled-${(hash >>> 0).toString(36).padStart(7, "0")}`;
16+
}
17+
18+
function truncateByGrapheme(value: string, maxLength: number): string {
19+
if (maxLength <= 0) return "";
20+
if (!Number.isFinite(maxLength)) return value;
21+
22+
let result = "";
23+
let length = 0;
24+
for (const { segment } of GRAPHEME_SEGMENTER.segment(value)) {
25+
if (length >= Math.floor(maxLength)) break;
26+
result += segment;
27+
length++;
28+
}
29+
return result.replace(TRAILING_HYPHENS_PATTERN, "");
30+
}
31+
32+
/**
33+
* Convert text to a browser-safe Unicode URL slug.
34+
*
35+
* Text is NFKC-normalized and lowercased; whitespace and underscores become
36+
* hyphens while Unicode letters, numbers, and combining marks are preserved.
37+
* The length limit counts grapheme clusters. Inputs without usable characters
38+
* receive a stable `untitled-*` fallback.
39+
*/
40+
export function slugify(text: string, maxLength = 80): string {
41+
const normalized = text.normalize("NFKC").toLowerCase();
42+
const slug = normalized
43+
.replace(SEPARATOR_PATTERN, "-")
44+
.replace(UNSAFE_CHARACTER_PATTERN, "")
45+
.replace(MULTIPLE_HYPHENS_PATTERN, "-")
46+
.replace(EDGE_HYPHENS_PATTERN, "");
47+
const value = USABLE_CHARACTER_PATTERN.test(slug) ? slug : fallbackSlug(normalized);
48+
return truncateByGrapheme(value, maxLength);
49+
}

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ function makeCollection(
6161
supports: ["drafts"],
6262
fields: [],
6363
hasSeo: false,
64+
routable: true,
6465
commentsEnabled: false,
6566
commentsModeration: "first_time",
6667
commentsClosedAfterDays: 90,
@@ -116,7 +117,7 @@ describe("ContentTypeEditor", () => {
116117
await labelInput.fill("Blog Posts");
117118

118119
// The slug input should auto-populate from the label
119-
const slugInput = screen.getByLabelText("Slug");
120+
const slugInput = screen.getByLabelText("Slug", { exact: true });
120121
await expect.element(slugInput).toHaveValue("blog_posts");
121122
});
122123

@@ -127,7 +128,7 @@ describe("ContentTypeEditor", () => {
127128
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);
128129

129130
// Slug input is only rendered when isNew, so it shouldn't exist
130-
const slugInput = screen.getByLabelText("Slug");
131+
const slugInput = screen.getByLabelText("Slug", { exact: true });
131132
await expect.element(slugInput).not.toBeInTheDocument();
132133
});
133134

@@ -195,6 +196,7 @@ describe("ContentTypeEditor", () => {
195196
labelSingular: "Article",
196197
description: undefined,
197198
urlPattern: undefined,
199+
routable: true,
198200
supports: ["drafts", "revisions"], // default
199201
hasSeo: false,
200202
});
@@ -217,6 +219,7 @@ describe("ContentTypeEditor", () => {
217219
labelSingular: "Post",
218220
description: "Blog posts",
219221
urlPattern: undefined,
222+
routable: true,
220223
supports: ["drafts"],
221224
hasSeo: false,
222225
commentsEnabled: false,
@@ -509,6 +512,19 @@ describe("ContentTypeEditor", () => {
509512
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ urlPattern: "/blog/{slug}" }));
510513
});
511514

515+
it("saves whether the collection is routable", async () => {
516+
const onSave = vi.fn();
517+
const collection = makeCollection({ routable: true });
518+
const screen = await render(
519+
<ContentTypeEditor {...defaultProps({ onSave })} collection={collection} />,
520+
);
521+
522+
await screen.getByLabelText("Routable").click();
523+
await screen.getByRole("button", { name: "Save", exact: true }).last().click();
524+
525+
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ routable: false }));
526+
});
527+
512528
it("shows validation error when pattern lacks {slug}", async () => {
513529
const collection = makeCollection();
514530
const screen = await render(<ContentTypeEditor {...defaultProps()} collection={collection} />);

0 commit comments

Comments
 (0)