Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/permalink-date-tokens.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 4 additions & 3 deletions packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/admin/src/components/ContentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1002,7 +1002,7 @@ function ContentListItem({
<div className="flex items-center justify-end space-x-1">
{item.status === "published" && item.slug && (
<LinkButton
href={contentUrl(collection, item.slug, urlPattern)}
href={contentUrl(collection, item.slug, urlPattern, item.publishedAt)}
external
variant="ghost"
shape="square"
Expand Down
2 changes: 1 addition & 1 deletion packages/admin/src/components/ContentTypeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ export function ContentTypeEditor({
</p>
)}
<p className="text-xs text-kumo-subtle mt-1">
{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.`}
</p>
</div>

Expand Down
36 changes: 33 additions & 3 deletions packages/admin/src/lib/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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 */
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/api/handlers/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,13 +374,23 @@ 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,
oldSlug,
newSlug,
contentId,
collectionRow?.url_pattern ?? null,
publishedAt,
);
invalidateRedirectCache();
}
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/api/handlers/seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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}
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/astro/routes/sitemap-[collection].xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down
22 changes: 16 additions & 6 deletions packages/core/src/database/repositories/redirect.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { sql, type Kysely } from "kysely";
import { ulid } from "ulidx";

import { interpolateUrlPattern } from "../../i18n/resolve.js";
import {
compilePattern,
matchPattern,
Expand Down Expand Up @@ -355,13 +356,22 @@ export class RedirectRepository {
newSlug: string,
contentId: string,
urlPattern: string | null,
publishedAt?: string | null,
): Promise<Redirect> {
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);
Expand Down
38 changes: 34 additions & 4 deletions packages/core/src/i18n/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] This PR introduces date tokens to url_pattern, but RedirectRepository.createAutoRedirect (packages/core/src/database/repositories/redirect.ts:352) still interpolates patterns with only string .replace("{slug}", oldSlug).replace("{id}", contentId).

For a pattern like /{year}/{month}/{day}/{slug}.html, a slug change creates an auto-redirect whose source and destination contain literal braces:

/{year}/{month}/{day}/{old-slug}.html → /{year}/{month}/{day}/{new-slug}.html

A real request to /2023/05/08/old-slug.html will never match that source, so the old URL 404s instead of redirecting. This is a direct regression risk for the new date-token feature.

Fix direction:

  1. Pass the entry’s published_at into createAutoRedirect (select it in createSlugChangeRedirect or flow it from the caller).
  2. Build oldUrl/newUrl through the shared interpolateUrlPattern (or the same applyDateTokens logic) so date tokens resolve consistently with canonical URLs.
  3. Add an integration test for createAutoRedirect with a date-token pattern.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 721f982createSlugChangeRedirect now selects the entry's published_at and createAutoRedirect builds both URLs through the shared interpolateUrlPattern (which also normalizes slashes/encoding). Two new repository tests: date tokens resolve from the publish date, and stay literal when there is none.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The PR description already flags this as a follow-up, but worth confirming here: packages/core/src/menus/index.ts defines its own interpolateUrlPattern that only replaces {slug} and {id}. Menu links for dated patterns will render literal {year}/{month}/{day} placeholders until that resolver is routed through the shared one. Consider handling it before users start relying on the new tokens, since menus are a primary navigation surface.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 721f982 rather than deferring — the menus resolver now selects published_at alongside id/slug and routes through the shared interpolateUrlPattern (its private {slug}/{id}-only copy is deleted). New test: a /{year}/{month}/{slug} pattern renders /2023/05/widget-co for a menu entry reference.

*
* 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
Expand All @@ -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}`;
Expand Down
36 changes: 14 additions & 22 deletions packages/core/src/menus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading
Loading