v4: what the server and the client both state is declared once — several rules were written out in two or more files, some already drifting apart. A link's site name (its host without www.) was computed in five places, now shared/url.ts's siteHost; dumpFileVersion, which the frontend and the server had to keep equal by hand, is one function in shared/dump.ts; the Markdown flattening of the chat's reply previews and the digest's quotes is shared/markdown.ts's markdownToPlainText, so the digest also keeps an image's alt text; an attachment's URL and how to find one in a text are shared/attachment.ts, which the editor, Markdown and attachment-service read, its id pattern the new UUID_RE that the file and thumbnail routes check ids with. On the server, the Bandcamp, YouTube playlist and link preview caches use one TtlCache (api/lib/ttl-cache.ts) instead of three copies of the same expiry and eviction; the link previews' theme-color and the emails' theme tokens read colours with one parser (api/lib/css-color.ts), and a transparent theme-color now gives no tint; the WebSocket route sends through ws-service's send, the welcome included, so it is type-checked; fetch.ts strips an IPv6 literal's brackets with public-address's bareHostname; and a category's slug is checked by the same validateSlug as a dump's, with its own length. The migrations keep their frozen copies, as they document. 272 backend tests and 677 frontend tests pass, and deno check, tsc, eslint and doc:lint are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,27 +22,17 @@ import {
|
||||
} from "../model/dump.ts";
|
||||
import { type Page, pageOf } from "../lib/pagination.ts";
|
||||
import { isReservedSlug } from "../lib/reserved-slugs.ts";
|
||||
import { SLUG_RE } from "../../shared/slug.ts";
|
||||
import { validateSlug } from "../lib/slugify.ts";
|
||||
import { VALIDATION } from "../../shared/validation.ts";
|
||||
import { parseRows } from "../model/rows.ts";
|
||||
|
||||
/**
|
||||
* Throws a 400 unless `slug` is well-formed, short enough and not reserved,
|
||||
* with a message for each case.
|
||||
* Throws a 400 unless `slug` is a valid category slug: a slug within a
|
||||
* category's length, and none of the top-level paths other pages hold
|
||||
* (reserved-slugs.ts), since a category's feed lives at the URL root.
|
||||
*/
|
||||
function validateSlug(slug: string): void {
|
||||
if (!SLUG_RE.test(slug)) {
|
||||
throw new APIException(
|
||||
400,
|
||||
"Slug must be lowercase letters, numbers and hyphens",
|
||||
);
|
||||
}
|
||||
if (slug.length > VALIDATION.CATEGORY_SLUG_MAX) {
|
||||
throw new APIException(
|
||||
400,
|
||||
`Slug must be at most ${VALIDATION.CATEGORY_SLUG_MAX} characters`,
|
||||
);
|
||||
}
|
||||
function validateCategorySlug(slug: string): void {
|
||||
validateSlug(slug, VALIDATION.CATEGORY_SLUG_MAX);
|
||||
if (isReservedSlug(slug)) {
|
||||
throw new APIException(400, `Slug "${slug}" is reserved`);
|
||||
}
|
||||
@@ -133,7 +123,7 @@ export function listCategories(): Category[] {
|
||||
* either is taken.
|
||||
*/
|
||||
export function createCategory(req: CreateCategoryRequest): Category {
|
||||
validateSlug(req.slug);
|
||||
validateCategorySlug(req.slug);
|
||||
const name = requireName(req.name);
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
@@ -177,7 +167,7 @@ export function updateCategory(
|
||||
|
||||
const newSlug = req.slug ?? existing.slug;
|
||||
if (req.slug !== undefined && req.slug !== existing.slug) {
|
||||
validateSlug(req.slug);
|
||||
validateCategorySlug(req.slug);
|
||||
}
|
||||
const newName = req.name !== undefined
|
||||
? requireName(req.name)
|
||||
|
||||
Reference in New Issue
Block a user