Date: Fri, 10 Jul 2026 12:21:07 +0200
Subject: [PATCH 3/4] fix: resolve date tokens in auto-redirects and menu
links, drop updatedAt fallbacks
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
createAutoRedirect and the menu content resolver now build URLs through
the shared interpolateUrlPattern with the entry's publish date — string
.replace left literal {year}-style braces in redirect sources, so old
URLs 404ed instead of redirecting. Admin preview/live links and the
sitemap no longer fall back to updatedAt: tokens stay literal without a
publish date, keeping canonical URLs stable across edits.
---
.../admin/src/components/ContentEditor.tsx | 6 ++--
packages/admin/src/components/ContentList.tsx | 7 +---
packages/core/src/api/handlers/content.ts | 10 ++++++
.../astro/routes/sitemap-[collection].xml.ts | 6 ++--
.../src/database/repositories/redirect.ts | 22 ++++++++----
packages/core/src/menus/index.ts | 36 ++++++++-----------
.../redirects/redirect-repository.test.ts | 28 +++++++++++++++
packages/core/tests/unit/menus/menus.test.ts | 17 +++++++++
8 files changed, 93 insertions(+), 39 deletions(-)
diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx
index 485c042a15..3d10562223 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, item.publishedAt ?? item.updatedAt),
+ contentUrl(collection, slug || item.id, urlPattern, item.publishedAt),
"_blank",
"noopener,noreferrer",
);
}
} catch {
window.open(
- contentUrl(collection, slug || item?.id || "", urlPattern, item?.publishedAt ?? item?.updatedAt),
+ contentUrl(collection, slug || item?.id || "", urlPattern, item?.publishedAt),
"_blank",
"noopener,noreferrer",
);
@@ -561,7 +561,7 @@ export function ContentEditor({
const isLive = draftStatus === "published" || draftStatus === "published_with_changes";
const liveViewUrl =
isLive && item?.slug
- ? contentUrl(collection, item.slug, urlPattern, item.publishedAt ?? item.updatedAt)
+ ? contentUrl(collection, item.slug, urlPattern, item.publishedAt)
: null;
// Scheduling — keyed off scheduledAt rather than status, since published
diff --git a/packages/admin/src/components/ContentList.tsx b/packages/admin/src/components/ContentList.tsx
index e4893c6db9..8aa292d388 100644
--- a/packages/admin/src/components/ContentList.tsx
+++ b/packages/admin/src/components/ContentList.tsx
@@ -1002,12 +1002,7 @@ function ContentListItem({
{item.status === "published" && item.slug && (
`
+ 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/astro/routes/sitemap-[collection].xml.ts b/packages/core/src/astro/routes/sitemap-[collection].xml.ts
index 35e561c7e1..d056b36261 100644
--- a/packages/core/src/astro/routes/sitemap-[collection].xml.ts
+++ b/packages/core/src/astro/routes/sitemap-[collection].xml.ts
@@ -102,8 +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.
- date: entry.publishedAt ?? entry.updatedAt,
+ // 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/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/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
From 39bcf9e6e63c7c437d20496c82fd678140e27c9a Mon Sep 17 00:00:00 2001
From: swissky <30409887+swissky@users.noreply.github.com>
Date: Sat, 11 Jul 2026 17:20:42 +0200
Subject: [PATCH 4/4] chore: format
---
packages/admin/src/components/ContentEditor.tsx | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx
index 3d10562223..0f2a327416 100644
--- a/packages/admin/src/components/ContentEditor.tsx
+++ b/packages/admin/src/components/ContentEditor.tsx
@@ -560,9 +560,7 @@ export function ContentEditor({
const hasPendingChanges = draftStatus === "published_with_changes";
const isLive = draftStatus === "published" || draftStatus === "published_with_changes";
const liveViewUrl =
- isLive && item?.slug
- ? contentUrl(collection, item.slug, urlPattern, item.publishedAt)
- : null;
+ 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.