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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { TextField } from "@dariah-eric/ui/text-field";
import { useExtracted } from "next-intl";
import type { ReactNode } from "react";

import { maxSlugLength } from "@/lib/slug";

interface EntitySlugFieldProps {
/** The document's current slug. Omit when creating — there is none yet. */
slug?: string;
Expand Down Expand Up @@ -47,7 +49,12 @@ export function EntitySlugField(props: Readonly<EntitySlugFieldProps>): ReactNod
}

return (
<TextField defaultValue={slug} name="slug">
// A coarse cap, not the rule: `maxLength` counts UTF-16 code units of what is typed, while the
// limit applies to the bytes of the slug that gets stored — and slugifying moves in both
// directions, dropping punctuation but expanding transliterations ("ä" → "ae"). It keeps a
// pasted article title from silently overrunning the field; `EntitySlugInputSchema` is what
// actually decides, by measuring the slugified value.
<TextField defaultValue={slug} maxLength={maxSlugLength} name="slug">
<Label>{t("Slug")}</Label>
<Input placeholder={slug == null ? t("Generated from the title") : undefined} />
<Description>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { renderEntityOption } from "@/app/(app)/[locale]/(dashboard)/dashboard/administrator/maintenance/_components/entity-option-item";
import { updateEntitySlugAction } from "@/app/(app)/[locale]/(dashboard)/dashboard/administrator/maintenance/_lib/update-entity-slug.action";
import { useRouter } from "@/lib/navigation/navigation";
import { maxSlugLength } from "@/lib/slug";

export function SlugEditor(): ReactNode {
const t = useExtracted();
Expand Down Expand Up @@ -74,6 +75,7 @@ export function SlugEditor(): ReactNode {

<TextField
isDisabled={selected == null || isPending}
maxLength={maxSlugLength}
onChange={(value) => {
setSlug(value);
setError(null);
Expand Down
21 changes: 20 additions & 1 deletion apps/knowledge-base/lib/data/entity-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import slugify from "@sindresorhus/slugify";
import type { Transaction } from "@/lib/db";
import { isUniqueViolation } from "@/lib/db/errors";
import { asc, eq, inArray, or } from "@/lib/db/sql";
import { assertSlugWithinMaxLength, maxSlugLength, truncateSlug } from "@/lib/slug";
import { UserFacingError } from "@/lib/user-facing-error";

export interface DocumentVersion {
Expand Down Expand Up @@ -373,6 +374,8 @@ export async function createPublishedDocument(
typeId: string,
slug: string,
): Promise<CreatedDocument> {
assertSlugWithinMaxLength(slug);

const [document] = await tx
.insert(schema.entities)
.values({ slug, typeId })
Expand All @@ -386,6 +389,13 @@ export async function createPublishedDocument(

const maxSlugAttempts = 50;

/**
* The longest suffix `insertDocumentWithFreeSlug` can append (`-50`, for `maxSlugAttempts`).
* Derived base slugs hold this many bytes back, so deduplicating one that was truncated to the
* limit cannot push it past the limit again.
*/
const maxSlugSuffixLength = `-${String(maxSlugAttempts)}`.length;

/**
* Insert an `entities` row under `baseSlug`, falling back to `<baseSlug>-2`, `-3`, … while the type
* already uses the candidate.
Expand Down Expand Up @@ -449,13 +459,16 @@ async function createFallbackSlug(tx: Transaction, typeId: string): Promise<stri
* clash must not fail the create, because the user cannot see the slug field and so has no way to
* act on the error beyond rewording an otherwise valid title. A slug the user _did_ choose keeps
* the opposite handling — see `createDraftDocument`.
*
* A very long title is treated the same way: the derived slug is cut to `maxSlugLength` rather than
* refused, since the length of a URL segment is not something the title's author is asked about.
*/
export async function createDraftDocumentFromTitle(
tx: Transaction,
typeId: string,
title: string,
): Promise<CreatedDocument> {
const derivedSlug = slugify(title);
const derivedSlug = truncateSlug(slugify(title), maxSlugLength - maxSlugSuffixLength);
const baseSlug = derivedSlug === "" ? await createFallbackSlug(tx, typeId) : derivedSlug;

const document = await insertDocumentWithFreeSlug(tx, typeId, baseSlug);
Expand All @@ -478,6 +491,8 @@ export async function createDraftDocument(
typeId: string,
slug: string,
): Promise<CreatedDocument> {
assertSlugWithinMaxLength(slug);

const [document] = await tx
.insert(schema.entities)
.values({ slug, typeId })
Expand Down Expand Up @@ -557,6 +572,10 @@ export async function updateDraftDocumentSlug(
return;
}

// Checked only for an actual rename, so re-saving a document that already holds an over-long slug
// is not blocked by a value it is not changing.
assertSlugWithinMaxLength(slug);

const { publishedId } = await getDocumentVersions(tx, documentId);
if (publishedId != null) {
// Reached only by a forged submission or a publish that raced this save — the form hides the
Expand Down
4 changes: 4 additions & 0 deletions apps/knowledge-base/lib/data/entity-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "@/lib/data/lifecycle-adapters";
import type { Transaction } from "@/lib/db";
import { eq, inArray, sql } from "@/lib/db/sql";
import { assertSlugWithinMaxLength } from "@/lib/slug";

export interface EntityIdentity {
id: string;
Expand Down Expand Up @@ -58,6 +59,9 @@ export async function updateEntitySlug(

const slug = slugify(rawSlug);
assert(slug.length > 0, "Slug must not be empty.");
// This editor has no form schema in front of it, so the length limit is applied here — as a typed
// error, since pasting a whole title in is an ordinary mistake and deserves a real message.
assertSlugWithinMaxLength(slug);

if (slug !== entity.slug) {
await tx.update(schema.entities).set({ slug }).where(eq(schema.entities.id, documentId));
Expand Down
13 changes: 11 additions & 2 deletions apps/knowledge-base/lib/entity-slug-input.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import slugify from "@sindresorhus/slugify";
import * as v from "valibot";

import { getSlugLength, maxSlugLength } from "@/lib/slug";

/**
* A slug as typed into an entity form. Always optional: leaving it empty on create means "derive
* one from the title", and the field is not offered at all once a document is published.
*
* Normalised with the same slugifier that derives slugs from titles, so the field accepts what a
* user naturally types ("My Page") and stores what a URL needs ("my-page") rather than rejecting
* it. Only input that survives slugification as nothing at all is refused, since that cannot
* address a page.
* it. Input that survives slugification as nothing at all is refused, since that cannot address a
* page, and so is input too long to survive as a URL segment.
*
* Both checks run on the slugified value rather than what was typed, because that is what gets
* stored: "My Page" and "my-page" must be judged identically.
*/
export const EntitySlugInputSchema = v.optional(
v.pipe(
Expand All @@ -18,6 +23,10 @@ export const EntitySlugInputSchema = v.optional(
(value) => value === "" || slugify(value) !== "",
"The slug must contain letters or numbers that can be used in a URL.",
),
v.check(
(value) => getSlugLength(slugify(value)) <= maxSlugLength,
`The slug must be no longer than ${String(maxSlugLength)} characters.`,
),
),
);

Expand Down
1 change: 1 addition & 0 deletions apps/knowledge-base/lib/server/create-command-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ export function createCommandAction<
serviceKpiConflict: t(
"Both services have a value for the same KPI in the same country report. Remove the duplicate KPIs from that report, then merge.",
),
slugTooLong: t("This slug is too long to be used as a web address. Please shorten it."),
socialMediaKpiConflict: t(
"Both accounts have a value for the same KPI in the same country report. Remove the duplicate KPIs from that report, then merge.",
),
Expand Down
1 change: 1 addition & 0 deletions apps/knowledge-base/lib/server/create-server-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export function createServerAction<
serviceKpiConflict: t(
"Both services have a value for the same KPI in the same country report. Remove the duplicate KPIs from that report, then merge.",
),
slugTooLong: t("This slug is too long to be used as a web address. Please shorten it."),
socialMediaKpiConflict: t(
"Both accounts have a value for the same KPI in the same country report. Remove the duplicate KPIs from that report, then merge.",
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface ErrorMessages {
relationNotEndable: string;
relationPeriodOverlap: string;
serviceKpiConflict: string;
slugTooLong: string;
socialMediaKpiConflict: string;
uniqueConflict: string;
}
Expand Down Expand Up @@ -46,6 +47,9 @@ export function getUserFacingErrorMessage(error: unknown, messages: ErrorMessage
case "service-kpi-conflict": {
return messages.serviceKpiConflict;
}
case "slug-too-long": {
return messages.slugTooLong;
}
case "social-media-kpi-conflict": {
return messages.socialMediaKpiConflict;
}
Expand Down
71 changes: 71 additions & 0 deletions apps/knowledge-base/lib/slug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { UserFacingError } from "@/lib/user-facing-error";

/**
* The longest slug we allow, in bytes.
*
* A slug is the last segment of an entity's public URL, and the website prerenders one file per
* URL: the segment `generateStaticParams` returns ends up in a filename on disk, together with the
* suffixes Next.js appends to it, and the whole name has to stay inside the filesystem's 255-byte
* limit. 246 bytes is what Next.js handles there, so anything longer breaks a website build rather
* than the form that created it — long after whoever chose the slug could act on it.
*
* Counted in bytes rather than characters, because that is what the filename limit counts. Slugs
* are ASCII in practice, since `slugify` transliterates, but nothing guarantees every input reduces
* to one byte per character.
*/
export const maxSlugLength = 246;

const encoder = new TextEncoder();

/** A slug's length in bytes — the unit `maxSlugLength` is expressed in. */
export function getSlugLength(slug: string): number {
return encoder.encode(slug).length;
}

/** Whether `slug` is longer than a URL segment may be. */
export function isSlugTooLong(slug: string): boolean {
return getSlugLength(slug) > maxSlugLength;
}

/**
* Cut `slug` down to at most `maxLength` bytes.
*
* For slugs we derive ourselves, where the alternative — refusing the write — would fail a create
* over a title the user cannot see the consequences of. A slug the user typed is rejected instead,
* so they can choose how to shorten it.
*
* Cuts on a character boundary, never mid-sequence, and drops any hyphen the cut exposes at the
* end, so the result stays a well-formed slug.
*/
export function truncateSlug(slug: string, maxLength = maxSlugLength): string {
if (getSlugLength(slug) <= maxLength) {
return slug;
}

let truncated = "";
let length = 0;

for (const character of slug) {
const characterLength = getSlugLength(character);
if (length + characterLength > maxLength) {
break;
}
truncated += character;
length += characterLength;
}

return truncated.replace(/-+$/, "");
}

/**
* Refuse a slug that would not fit in a URL segment.
*
* The data layer's own guard: slugs coming from an entity form are already checked by
* `EntitySlugInputSchema`, but the maintenance slug editor and any non-form caller are not, and a
* slug that reaches the database over-length is only discovered when the website next builds.
*/
export function assertSlugWithinMaxLength(slug: string): void {
if (isSlugTooLong(slug)) {
throw new UserFacingError("slug-too-long");
}
}
1 change: 1 addition & 0 deletions apps/knowledge-base/lib/user-facing-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type UserFacingErrorKind =
| "relation-not-endable"
| "relation-period-overlap"
| "service-kpi-conflict"
| "slug-too-long"
| "social-media-kpi-conflict";

/**
Expand Down
3 changes: 2 additions & 1 deletion apps/knowledge-base/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,8 @@
"f+sh2j": "Administration",
"9uOFF3": "Overview",
"2atspc": "Drafts",
"dudqv/": "Maintenance",
"UUfKZg": "Guided forms",
"dudqv/": "Maintenance",
"4BU/eo": "Newsletters",
"yhU1et": "Tasks",
"YDMrKK": "Users",
Expand Down Expand Up @@ -1513,6 +1513,7 @@
"lNKpUQ": "This relation is not one this form can end, or it has already been ended. Refresh the page and try again.",
"6L0TeR": "This relation already exists during an overlapping period. Adjust the dates and try again.",
"Hr85CO": "Both services have a value for the same KPI in the same country report. Remove the duplicate KPIs from that report, then merge.",
"paaApZ": "This slug is too long to be used as a web address. Please shorten it.",
"Jl3xT+": "Both accounts have a value for the same KPI in the same country report. Remove the duplicate KPIs from that report, then merge.",
"qDy0wr": "The submitted data violates a data rule.",
"Bosjdo": "The submitted data is incomplete.",
Expand Down
29 changes: 17 additions & 12 deletions apps/knowledge-base/messages/en.po
Original file line number Diff line number Diff line change
Expand Up @@ -1840,17 +1840,17 @@ msgstr "Drafts"

#: app/(app)/[locale]/(dashboard)/dashboard/_components/dashboard-sidebar.tsx:66
#: app/(app)/[locale]/(dashboard)/dashboard/_components/dashboard-sidebar.tsx:67
#: app/(app)/[locale]/(dashboard)/dashboard/administrator/guided-forms/page.tsx:40
msgid "UUfKZg"
msgstr "Guided forms"

#: app/(app)/[locale]/(dashboard)/dashboard/_components/dashboard-sidebar.tsx:72
#: app/(app)/[locale]/(dashboard)/dashboard/_components/dashboard-sidebar.tsx:73
#: app/(app)/[locale]/(dashboard)/dashboard/administrator/maintenance/_components/maintenance-dashboard.tsx:50
#: app/(app)/[locale]/(dashboard)/dashboard/administrator/maintenance/_components/maintenance-dashboard.tsx:55
msgid "dudqv/"
msgstr "Maintenance"

#: app/(app)/[locale]/(dashboard)/dashboard/_components/dashboard-sidebar.tsx:72
#: app/(app)/[locale]/(dashboard)/dashboard/_components/dashboard-sidebar.tsx:73
#: app/(app)/[locale]/(dashboard)/dashboard/administrator/guided-forms/page.tsx:40
msgid "UUfKZg"
msgstr "Guided forms"

#: app/(app)/[locale]/(dashboard)/dashboard/_components/dashboard-sidebar.tsx:78
#: app/(app)/[locale]/(dashboard)/dashboard/_components/dashboard-sidebar.tsx:79
#: app/(app)/[locale]/(dashboard)/dashboard/administrator/newsletters/_components/newsletters-page.tsx:42
Expand Down Expand Up @@ -8153,20 +8153,25 @@ msgstr "Both services have a value for the same KPI in the same country report.

#: lib/server/create-command-action.ts:167
#: lib/server/create-server-action.ts:136
msgid "paaApZ"
msgstr "This slug is too long to be used as a web address. Please shorten it."

#: lib/server/create-command-action.ts:168
#: lib/server/create-server-action.ts:137
msgid "Jl3xT+"
msgstr "Both accounts have a value for the same KPI in the same country report. Remove the duplicate KPIs from that report, then merge."

#: lib/server/create-command-action.ts:170
#: lib/server/create-server-action.ts:139
#: lib/server/create-command-action.ts:171
#: lib/server/create-server-action.ts:140
msgid "qDy0wr"
msgstr "The submitted data violates a data rule."

#: lib/server/create-command-action.ts:171
#: lib/server/create-server-action.ts:140
#: lib/server/create-command-action.ts:172
#: lib/server/create-server-action.ts:141
msgid "Bosjdo"
msgstr "The submitted data is incomplete."

#: lib/server/create-command-action.ts:174
#: lib/server/create-server-action.ts:145
#: lib/server/create-command-action.ts:175
#: lib/server/create-server-action.ts:146
msgid "lkI8/e"
msgstr "Internal server error."
Loading
Loading