diff --git a/.changeset/permalink-date-tokens.md b/.changeset/permalink-date-tokens.md new file mode 100644 index 0000000000..bee659c8f8 --- /dev/null +++ b/.changeset/permalink-date-tokens.md @@ -0,0 +1,6 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +--- + +Adds WordPress-style date tokens to collection URL patterns. `url_pattern` now supports `{year}`, `{month}`, `{day}`, `{hour}`, `{minute}`, `{second}` (resolved from the entry's publish date, zero-padded) alongside `{slug}` and `{id}` — so you can reproduce permalinks like `/{year}/{month}/{day}/{slug}.html`. The tokens resolve in sitemap canonical URLs and the admin "View published" links. diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 806aca20a6..0f2a327416 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -522,14 +522,14 @@ export function ContentEditor({ window.open(result.url, "_blank", "noopener,noreferrer"); } else { window.open( - contentUrl(collection, slug || item.id, urlPattern), + contentUrl(collection, slug || item.id, urlPattern, item.publishedAt), "_blank", "noopener,noreferrer", ); } } catch { window.open( - contentUrl(collection, slug || item?.id || "", urlPattern), + contentUrl(collection, slug || item?.id || "", urlPattern, item?.publishedAt), "_blank", "noopener,noreferrer", ); @@ -559,7 +559,8 @@ export function ContentEditor({ const draftStatus = item ? getDraftStatus(item) : "unpublished"; const hasPendingChanges = draftStatus === "published_with_changes"; const isLive = draftStatus === "published" || draftStatus === "published_with_changes"; - const liveViewUrl = isLive && item?.slug ? contentUrl(collection, item.slug, urlPattern) : null; + const liveViewUrl = + isLive && item?.slug ? contentUrl(collection, item.slug, urlPattern, item.publishedAt) : null; // Scheduling — keyed off scheduledAt rather than status, since published // posts can now have a pending schedule without changing status. diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx index 165df63d72..8aa292d388 100644 --- a/packages/admin/src/components/ContentList.tsx +++ b/packages/admin/src/components/ContentList.tsx @@ -1002,7 +1002,7 @@ function ContentListItem({
{item.status === "published" && item.slug && ( )}

- {t`Pattern for generating URLs, e.g. /blog/${"{slug}"}`} + {t`Pattern for generating URLs, e.g. /blog/${"{slug}"}. Tokens: ${"{slug}"}, ${"{id}"}, and date tokens ${"{year}"}/${"{month}"}/${"{day}"} (also ${"{hour}"}/${"{minute}"}/${"{second}"}) from the publish date — e.g. ${"/{year}/{month}/{day}/{slug}.html"} for WordPress-style permalinks.`}

diff --git a/packages/admin/src/lib/url.ts b/packages/admin/src/lib/url.ts index c21a753d5b..e5ec582e07 100644 --- a/packages/admin/src/lib/url.ts +++ b/packages/admin/src/lib/url.ts @@ -21,16 +21,46 @@ export function sanitizeRedirectUrl(raw: string): string { return DEFAULT_REDIRECT; } +const DATE_TOKEN = /\{(year|month|day|hour|minute|second)\}/g; +const pad2 = (n: number) => String(n).padStart(2, "0"); + +/** + * Substitute WordPress-style date tokens from a publish date (zero-padded). + * Tokens are left untouched when no valid date is available. Kept in sync with + * the core `interpolateUrlPattern` resolver used for sitemap/canonical URLs. + */ +function applyDateTokens(path: string, date?: string | null): string { + const d = date == null ? null : new Date(date); + if (!d || Number.isNaN(d.getTime())) return path; + const parts: Record = { + year: String(d.getUTCFullYear()), + month: pad2(d.getUTCMonth() + 1), + day: pad2(d.getUTCDate()), + hour: pad2(d.getUTCHours()), + minute: pad2(d.getUTCMinutes()), + second: pad2(d.getUTCSeconds()), + }; + return path.replace(DATE_TOKEN, (match, key: string) => parts[key] ?? match); +} + /** * Build a public content URL from collection metadata and slug. * * Uses the collection's `urlPattern` when available (e.g. `/blog/{slug}`), - * otherwise falls back to `/{collection}/{slug}`. Leading slashes are + * otherwise falls back to `/{collection}/{slug}`. Also resolves the date + * tokens `{year}`/`{month}`/`{day}`/`{hour}`/`{minute}`/`{second}` from the + * entry's publish `date` (for WordPress-style permalinks). Leading slashes are * stripped from the slug to prevent protocol-relative URLs. */ -export function contentUrl(collection: string, slug: string, urlPattern?: string): string { +export function contentUrl( + collection: string, + slug: string, + urlPattern?: string, + date?: string | null, +): string { const safe = slug.replace(LEADING_SLASHES, ""); - return urlPattern ? urlPattern.replace("{slug}", safe) : `/${collection}/${safe}`; + const path = urlPattern ? urlPattern.replaceAll("{slug}", safe) : `/${collection}/${safe}`; + return applyDateTokens(path, date); } /** Matches http:// or https:// URLs */ diff --git a/packages/core/src/api/handlers/content.ts b/packages/core/src/api/handlers/content.ts index e4eccc4389..779a6db837 100644 --- a/packages/core/src/api/handlers/content.ts +++ b/packages/core/src/api/handlers/content.ts @@ -374,6 +374,15 @@ async function createSlugChangeRedirect( .where("slug", "=", collection) .executeTakeFirst(); + // Date tokens in the URL pattern resolve from the publish date, so the + // redirect URLs must be built with it — otherwise they'd keep literal + // `{year}`-style braces and never match a real request. + validateIdentifier(collection, "collection slug"); + const entryRow = await sql<{ published_at: string | null }>` + SELECT published_at FROM ${sql.ref(`ec_${collection}`)} WHERE id = ${contentId} + `.execute(db); + const publishedAt = entryRow.rows[0]?.published_at ?? null; + const redirectRepo = new RedirectRepository(db); await redirectRepo.createAutoRedirect( collection, @@ -381,6 +390,7 @@ async function createSlugChangeRedirect( newSlug, contentId, collectionRow?.url_pattern ?? null, + publishedAt, ); invalidateRedirectCache(); } diff --git a/packages/core/src/api/handlers/seo.ts b/packages/core/src/api/handlers/seo.ts index d4291b73b6..b769fe05a0 100644 --- a/packages/core/src/api/handlers/seo.ts +++ b/packages/core/src/api/handlers/seo.ts @@ -18,6 +18,13 @@ export interface SitemapContentEntry { slug: string | null; /** ISO date of last modification */ updatedAt: string; + /** + * ISO publish date, or null when never published. Used to resolve + * date tokens (`{year}`/`{month}`/`{day}`) in the collection's + * `url_pattern` — the published date keeps permalinks stable across + * later edits (unlike `updatedAt`). + */ + publishedAt: string | null; /** * Locale of this row (e.g. `"en"`, `"fr"`). Always present — rows in * pre-i18n databases are backfilled to the configured `defaultLocale`. @@ -138,11 +145,12 @@ export async function handleSitemapData( slug: string | null; id: string; updated_at: string; + published_at: string | null; locale: string; translation_group: string | null; seo_image: string | null; }>` - SELECT c.slug, c.id, c.updated_at, c.locale, c.translation_group, s.seo_image + SELECT c.slug, c.id, c.updated_at, c.published_at, c.locale, c.translation_group, s.seo_image FROM ${sql.ref(tableName)} c LEFT JOIN _emdash_seo s ON s.collection = ${col.slug} @@ -162,6 +170,7 @@ export async function handleSitemapData( id: row.id, slug: row.slug, updatedAt: toW3CDate(row.updated_at), + publishedAt: row.published_at ?? null, locale: row.locale, translationGroup: row.translation_group, image: row.seo_image ?? null, diff --git a/packages/core/src/astro/routes/sitemap-[collection].xml.ts b/packages/core/src/astro/routes/sitemap-[collection].xml.ts index c30f5309cb..d056b36261 100644 --- a/packages/core/src/astro/routes/sitemap-[collection].xml.ts +++ b/packages/core/src/astro/routes/sitemap-[collection].xml.ts @@ -102,6 +102,10 @@ export const GET: APIRoute = async ({ params, locals, url }) => { collection: col.collection, slug: entry.slug || entry.id, id: entry.id, + // Published date keeps date-token permalinks stable across edits; + // no updatedAt fallback — tokens stay literal without a publish + // date, consistent with the resolver's documented behavior. + date: entry.publishedAt, }); const localized = await localizePath(path, entry.locale); const absolute = localized === null ? null : `${siteUrl}${localized}`; diff --git a/packages/core/src/database/repositories/redirect.ts b/packages/core/src/database/repositories/redirect.ts index 30c4561d18..cd95c82f70 100644 --- a/packages/core/src/database/repositories/redirect.ts +++ b/packages/core/src/database/repositories/redirect.ts @@ -1,6 +1,7 @@ import { sql, type Kysely } from "kysely"; import { ulid } from "ulidx"; +import { interpolateUrlPattern } from "../../i18n/resolve.js"; import { compilePattern, matchPattern, @@ -355,13 +356,22 @@ export class RedirectRepository { newSlug: string, contentId: string, urlPattern: string | null, + publishedAt?: string | null, ): Promise { - const oldUrl = urlPattern - ? urlPattern.replace("{slug}", oldSlug).replace("{id}", contentId) - : `/${collection}/${oldSlug}`; - const newUrl = urlPattern - ? urlPattern.replace("{slug}", newSlug).replace("{id}", contentId) - : `/${collection}/${newSlug}`; + const oldUrl = interpolateUrlPattern({ + pattern: urlPattern, + collection, + slug: oldSlug, + id: contentId, + date: publishedAt, + }); + const newUrl = interpolateUrlPattern({ + pattern: urlPattern, + collection, + slug: newSlug, + id: contentId, + date: publishedAt, + }); // Collapse chains: update any existing redirects pointing to the old URL await this.collapseChains(oldUrl, newUrl); diff --git a/packages/core/src/i18n/resolve.ts b/packages/core/src/i18n/resolve.ts index 3e0cad1190..ca290a35a3 100644 --- a/packages/core/src/i18n/resolve.ts +++ b/packages/core/src/i18n/resolve.ts @@ -37,9 +37,36 @@ export function resolveLocaleChain(explicit?: string): string[] { } const REPEATED_SLASHES = /\/{2,}/g; +const DATE_TOKEN = /\{(year|month|day|hour|minute|second)\}/g; +const pad2 = (n: number) => String(n).padStart(2, "0"); /** - * Interpolate a collection `url_pattern` with a row's slug and id. + * Substitute WordPress-style date tokens (`{year}`, `{month}`, `{day}`, + * `{hour}`, `{minute}`, `{second}`) from a publish date. Month/day/time parts + * are zero-padded, mirroring WordPress (`%monthnum%`, `%day%`, ...). Tokens are + * left untouched when no valid date is available, so callers without a date + * (or unpublished entries) never produce a half-resolved URL. + */ +function applyDateTokens(path: string, date: string | Date | null | undefined): string { + const d = date == null ? null : new Date(date); + if (!d || Number.isNaN(d.getTime())) return path; + const parts: Record = { + year: String(d.getUTCFullYear()), + month: pad2(d.getUTCMonth() + 1), + day: pad2(d.getUTCDate()), + hour: pad2(d.getUTCHours()), + minute: pad2(d.getUTCMinutes()), + second: pad2(d.getUTCSeconds()), + }; + return path.replace(DATE_TOKEN, (match, key: string) => parts[key] ?? match); +} + +/** + * Interpolate a collection `url_pattern` with a row's slug, id and publish date. + * + * Supported tokens: `{slug}`, `{id}`, and the date tokens `{year}`, + * `{month}`, `{day}`, `{hour}`, `{minute}`, `{second}` (resolved from `date`, + * for WordPress-style permalinks like `/{year}/{month}/{day}/{slug}.html`). * * Falls back to `/{collection}/{slug}` when no pattern is configured. * Does NOT apply any locale prefix — pass the result through @@ -51,12 +78,15 @@ export function interpolateUrlPattern(options: { collection: string; slug: string; id: string; + /** Publish date used for date tokens; tokens stay literal when absent. */ + date?: string | Date | null; }): string { - const { pattern, collection, slug, id } = options; + const { pattern, collection, slug, id, date } = options; const basePattern = pattern ?? `/${encodeURIComponent(collection)}/{slug}`; let path = basePattern - .replace("{slug}", encodeURIComponent(slug)) - .replace("{id}", encodeURIComponent(id)); + .replaceAll("{slug}", encodeURIComponent(slug)) + .replaceAll("{id}", encodeURIComponent(id)); + path = applyDateTokens(path, date); path = path.replace(REPEATED_SLASHES, "/"); if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1); if (!path.startsWith("/")) path = `/${path}`; diff --git a/packages/core/src/menus/index.ts b/packages/core/src/menus/index.ts index dbd2238284..7e7180077e 100644 --- a/packages/core/src/menus/index.ts +++ b/packages/core/src/menus/index.ts @@ -13,7 +13,7 @@ import { sql } from "kysely"; import type { Database } from "../database/types.js"; import { validateIdentifier } from "../database/validate.js"; -import { resolveLocale, resolveLocaleChain } from "../i18n/resolve.js"; +import { interpolateUrlPattern, resolveLocale, resolveLocaleChain } from "../i18n/resolve.js"; import { getDb } from "../loader.js"; import { cachedQuery, CacheNamespace } from "../object-cache/index.js"; import { requestCached } from "../request-cache.js"; @@ -284,18 +284,6 @@ async function resolveMenuItem( }; } -const SLUG_PLACEHOLDER = /\{slug\}/g; -const ID_PLACEHOLDER = /\{id\}/g; - -/** - * Interpolate a URL pattern with entry data - * - * Replaces `{slug}` and `{id}` placeholders. - */ -function interpolateUrlPattern(pattern: string, slug: string, id: string): string { - return pattern.replace(SLUG_PLACEHOLDER, slug).replace(ID_PLACEHOLDER, id); -} - /** * Resolve the URL for a content reference. `referenceGroup` is the content * row's translation_group; we look up the row in the requested locale @@ -315,15 +303,15 @@ async function resolveContentUrl( validateIdentifier(collection, "menu item collection"); // Try the requested locale first, then any locale (deterministic). - let result = await sql<{ id: string; slug: string }>` - SELECT id, slug FROM ${sql.ref(`ec_${collection}`)} + let result = await sql<{ id: string; slug: string; published_at: string | null }>` + SELECT id, slug, published_at FROM ${sql.ref(`ec_${collection}`)} WHERE translation_group = ${referenceGroup} AND locale = ${locale} LIMIT 1 `.execute(db); let row = result.rows[0]; if (!row) { - result = await sql<{ id: string; slug: string }>` - SELECT id, slug FROM ${sql.ref(`ec_${collection}`)} + result = await sql<{ id: string; slug: string; published_at: string | null }>` + SELECT id, slug, published_at FROM ${sql.ref(`ec_${collection}`)} WHERE translation_group = ${referenceGroup} ORDER BY locale ASC LIMIT 1 `.execute(db); @@ -333,17 +321,21 @@ async function resolveContentUrl( // Legacy rows whose reference_id still points at an id directly // (defensive — migration 036 normalised these, but a row inserted // between migrations could predate the remap). - const legacy = await sql<{ id: string; slug: string }>` - SELECT id, slug FROM ${sql.ref(`ec_${collection}`)} + const legacy = await sql<{ id: string; slug: string; published_at: string | null }>` + SELECT id, slug, published_at FROM ${sql.ref(`ec_${collection}`)} WHERE id = ${referenceGroup} LIMIT 1 `.execute(db); row = legacy.rows[0]; } if (!row) return null; - const pattern = urlPatterns.get(collection); - if (pattern) return interpolateUrlPattern(pattern, row.slug, row.id); - return `/${collection}/${row.slug}`; + return interpolateUrlPattern({ + pattern: urlPatterns.get(collection) ?? null, + collection, + slug: row.slug, + id: row.id, + date: row.published_at, + }); } catch (error) { console.error(`Failed to resolve content URL for ${collection}/${referenceGroup}:`, error); return null; diff --git a/packages/core/tests/integration/redirects/redirect-repository.test.ts b/packages/core/tests/integration/redirects/redirect-repository.test.ts index 81da38b5c5..415d8c2e7b 100644 --- a/packages/core/tests/integration/redirects/redirect-repository.test.ts +++ b/packages/core/tests/integration/redirects/redirect-repository.test.ts @@ -386,6 +386,34 @@ describe("RedirectRepository", () => { expect(redirect.type).toBe(301); }); + it("resolves date tokens from the publish date (#1526)", async () => { + const redirect = await repo.createAutoRedirect( + "posts", + "old-title", + "new-title", + "id1", + "/{year}/{month}/{day}/{slug}.html", + "2023-05-08T12:00:00.000Z", + ); + + expect(redirect.source).toBe("/2023/05/08/old-title.html"); + expect(redirect.destination).toBe("/2023/05/08/new-title.html"); + }); + + it("keeps date tokens literal without a publish date", async () => { + const redirect = await repo.createAutoRedirect( + "posts", + "old-title", + "new-title", + "id1", + "/{year}/{slug}", + null, + ); + + expect(redirect.source).toBe("/{year}/old-title"); + expect(redirect.destination).toBe("/{year}/new-title"); + }); + it("uses fallback URL when no url pattern", async () => { const redirect = await repo.createAutoRedirect("posts", "old-slug", "new-slug", "id1", null); diff --git a/packages/core/tests/unit/i18n/resolve.test.ts b/packages/core/tests/unit/i18n/resolve.test.ts index dd4176dc16..ab51f1b688 100644 --- a/packages/core/tests/unit/i18n/resolve.test.ts +++ b/packages/core/tests/unit/i18n/resolve.test.ts @@ -83,6 +83,41 @@ describe("interpolateUrlPattern", () => { }), ).toBe("/blog/hello"); }); + + it("substitutes WordPress-style date tokens from the publish date (zero-padded)", () => { + expect( + interpolateUrlPattern({ + pattern: "/{year}/{month}/{day}/{slug}.html", + collection: "post", + slug: "hello", + id: "abc", + date: "2018-05-08T09:03:07Z", + }), + ).toBe("/2018/05/08/hello.html"); + }); + + it("supports hour/minute/second date tokens", () => { + expect( + interpolateUrlPattern({ + pattern: "/{year}/{hour}{minute}{second}/{slug}", + collection: "post", + slug: "hello", + id: "abc", + date: "2018-05-08T09:03:07Z", + }), + ).toBe("/2018/090307/hello"); + }); + + it("leaves date tokens untouched when no valid date is provided", () => { + expect( + interpolateUrlPattern({ + pattern: "/{year}/{month}/{slug}", + collection: "post", + slug: "hello", + id: "abc", + }), + ).toBe("/{year}/{month}/hello"); + }); }); describe("localizePath", () => { diff --git a/packages/core/tests/unit/menus/menus.test.ts b/packages/core/tests/unit/menus/menus.test.ts index a11759ed41..554171af53 100644 --- a/packages/core/tests/unit/menus/menus.test.ts +++ b/packages/core/tests/unit/menus/menus.test.ts @@ -473,6 +473,23 @@ describe("Navigation Menus", () => { expect(menu!.items[0].url).toBe("/work/widget-co"); }); + it("resolves date tokens in the url_pattern from the publish date (#1526)", async () => { + await setupProjectsCollection("/{year}/{month}/{slug}"); + await sql` + INSERT INTO ec_projects (id, slug, locale, translation_group, published_at) + VALUES ('proj-1', 'widget-co', 'en', 'group-proj-1', '2023-05-08T12:00:00.000Z') + `.execute(db); + await insertMenuWithCollectionItem({ + referenceCollection: "projects", + referenceId: "group-proj-1", + }); + + const menu = await getMenuWithDb("primary", db); + expect(menu).not.toBeNull(); + expect(menu!.items).toHaveLength(1); + expect(menu!.items[0].url).toBe("/2023/05/widget-co"); + }); + it("resolves items without a reference_id to the collection archive URL", async () => { // Archive links (no entry reference) keep their root-relative // /{collection}/ shape — the same URL shape as every other