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