Skip to content
Merged
6 changes: 6 additions & 0 deletions .changeset/unicode-routable-slugs.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion e2e/tests/content-types.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions packages/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 19 additions & 4 deletions packages/admin/src/components/ContentTypeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>(
Expand Down Expand Up @@ -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 ||
Expand All @@ -216,6 +218,7 @@ export function ContentTypeEditor({
labelSingular,
description,
urlPattern,
routable,
supports,
hasSeo,
commentsEnabled,
Expand Down Expand Up @@ -261,6 +264,7 @@ export function ContentTypeEditor({
labelSingular: labelSingular || undefined,
description: description || undefined,
urlPattern: urlPattern || undefined,
routable,
supports,
hasSeo,
});
Expand All @@ -270,6 +274,7 @@ export function ContentTypeEditor({
labelSingular: labelSingular || undefined,
description: description || undefined,
urlPattern: urlPattern || undefined,
routable,
supports,
hasSeo,
commentsEnabled,
Expand Down Expand Up @@ -325,10 +330,6 @@ export function ContentTypeEditor({

return (
<div className="space-y-6">
{/* 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. */}
<EditorHeader
leading={
<RouterLinkButton
Expand Down Expand Up @@ -418,6 +419,20 @@ export function ContentTypeEditor({
disabled={isFromCode}
/>

<Switch
checked={routable}
onCheckedChange={setRoutable}
disabled={isFromCode}
label={
<div>
<span className="text-sm font-medium">{t`Routable`}</span>
<p className="text-xs text-kumo-subtle">
{t`Require a slug before content can be published`}
</p>
</div>
}
/>

<div>
<Input
label={t`URL Pattern`}
Expand Down
1 change: 1 addition & 0 deletions packages/admin/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export interface AdminManifest {
supports: string[];
hasSeo: boolean;
urlPattern?: string;
routable?: boolean;
titleField?: string;
dateField?: string;
hidden?: boolean;
Expand Down
4 changes: 2 additions & 2 deletions packages/admin/src/lib/api/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export interface ContentItem {

export interface CreateContentInput {
type: string;
slug?: string;
slug?: string | null;
data: Record<string, unknown>;
status?: string;
bylines?: BylineCreditInput[];
Expand Down Expand Up @@ -105,7 +105,7 @@ export interface ContentSeoInput {

export interface UpdateContentInput {
data?: Record<string, unknown>;
slug?: string;
slug?: string | null;
status?: string;
publishedAt?: string | null;
authorId?: string | null;
Expand Down
4 changes: 4 additions & 0 deletions packages/admin/src/lib/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export interface SchemaCollection {
supports: string[];
source?: string;
urlPattern?: string;
/** Published entries require a slug unless this is false. */
routable?: boolean;
hasSeo: boolean;
/** Sidebar entry omitted in the admin; the collection stays reachable by URL */
hidden: boolean;
Expand Down Expand Up @@ -93,6 +95,7 @@ export interface CreateCollectionInput {
admin?: CollectionAdminConfig;
supports?: string[];
urlPattern?: string;
routable?: boolean;
hasSeo?: boolean;
hidden?: boolean;
sortOrder?: number | null;
Expand All @@ -106,6 +109,7 @@ export interface UpdateCollectionInput {
admin?: CollectionAdminConfig;
supports?: string[];
urlPattern?: string;
routable?: boolean;
hasSeo?: boolean;
hidden?: boolean;
sortOrder?: number | null;
Expand Down
23 changes: 1 addition & 22 deletions packages/admin/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

// Regex patterns for slugify
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;
export { slugify } from "../slugify.js";

// Regex patterns for parseTimestamp
const NAIVE_DATETIME_PATTERN = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/;
Expand All @@ -33,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();
Expand All @@ -58,14 +48,3 @@ export function formatRelativeTime(dateString: string): string {
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}

export function slugify(text: string): 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, "");
}
49 changes: 49 additions & 0 deletions packages/admin/src/slugify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
const SEPARATOR_PATTERN = /[\s_]+/gu;
const UNSAFE_CHARACTER_PATTERN = /[^\p{Letter}\p{Number}\p{Mark}-]+/gu;
const MULTIPLE_HYPHENS_PATTERN = /-+/g;
const EDGE_HYPHENS_PATTERN = /^-+|-+$/g;
const TRAILING_HYPHENS_PATTERN = /-+$/g;
const USABLE_CHARACTER_PATTERN = /[\p{Letter}\p{Number}]/u;
const GRAPHEME_SEGMENTER = new Intl.Segmenter("en", { granularity: "grapheme" });
Comment thread
ascorbic marked this conversation as resolved.

function fallbackSlug(value: string): string {
let hash = 2_166_136_261;
for (let index = 0; index < value.length; index++) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16_777_619);
}
return `untitled-${(hash >>> 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);
}
20 changes: 18 additions & 2 deletions packages/admin/tests/components/ContentTypeEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ function makeCollection(
supports: ["drafts"],
fields: [],
hasSeo: false,
routable: true,
commentsEnabled: false,
commentsModeration: "first_time",
commentsClosedAfterDays: 90,
Expand Down Expand Up @@ -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");
});

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

// 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();
});

Expand Down Expand Up @@ -195,6 +196,7 @@ describe("ContentTypeEditor", () => {
labelSingular: "Article",
description: undefined,
urlPattern: undefined,
routable: true,
supports: ["drafts", "revisions"], // default
hasSeo: false,
});
Expand All @@ -217,6 +219,7 @@ describe("ContentTypeEditor", () => {
labelSingular: "Post",
description: "Blog posts",
urlPattern: undefined,
routable: true,
supports: ["drafts"],
hasSeo: false,
commentsEnabled: false,
Expand Down Expand Up @@ -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(
<ContentTypeEditor {...defaultProps({ onSave })} collection={collection} />,
);

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(<ContentTypeEditor {...defaultProps()} collection={collection} />);
Expand Down
35 changes: 27 additions & 8 deletions packages/admin/tests/lib/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/admin/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading