Skip to content

feat(core): WordPress-style date tokens in url_pattern ({year}/{month}/{day}) - #1526

Merged
ascorbic merged 2 commits into
emdash-cms:mainfrom
swissky:feat/permalink-date-tokens
Sep 10, 2026
Merged

feat(core): WordPress-style date tokens in url_pattern ({year}/{month}/{day})#1526
ascorbic merged 2 commits into
emdash-cms:mainfrom
swissky:feat/permalink-date-tokens

Conversation

@swissky

@swissky swissky commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds WordPress-style date tokens to collection url_pattern, so teams migrating from WordPress can reproduce their permalink structure (e.g. /2018/05/28/my-post or the classic /{year}/{month}/{day}/{slug}.html).

url_pattern now resolves these tokens in addition to the existing {slug} / {id}:

  • {year} {month} {day} {hour} {minute} {second} — from the entry's publish date (zero-padded). Using the publish date (not updated_at) keeps permalinks stable across later edits.

Literal suffixes like .html already work (they're literal text in the pattern). The tokens are substituted by the single core resolver interpolateUrlPattern, so they apply consistently to sitemap canonical URLs (+ hreflang) and the admin "View published" links. Tokens are left untouched when no valid publish date is available, so an unpublished/dateless entry never produces a half-resolved URL.

url_pattern is presentational in EmDash core (core serves no content routes — templates own routing), so this is the forward / canonical-URL side. Reverse-matching an incoming dated URL back to an entry remains the template's job, unchanged here.

Discussion: #1525

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes — emdash (core) is clean. The admin package shows 8 pre-existing RegistryPluginDetail.tsx implicit-any errors that also occur on main without this change (shallow-clone/unbuilt-dep artifact); none are in the files this PR touches.
  • pnpm lint passes (oxlint --type-aware --deny-warnings, clean on changed files)
  • pnpm test — added unit tests for the date-token substitution; tests/unit/i18n/resolve.test.ts passes (15/15) locally. (The admin browser-vitest harness doesn't start in my local env — relying on repo CI for it.)
  • pnpm format has been run
  • I have added/updated tests for my changes
  • User-visible strings wrapped for translation (URL-pattern help text). No messages.po changes included.
  • I have added a changeset (emdash + @emdash-cms/admin, minor)
  • New features link to a Discussion: WordPress-style permalink structure tags for url_pattern ({year}/{month}/{day}/{slug}) #1525

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Cursor + Claude Opus 4.8

Screenshots / test output

✓ tests/unit/i18n/resolve.test.ts (15 tests)
  ✓ interpolateUrlPattern > substitutes WordPress-style date tokens from the publish date (zero-padded)
  ✓ interpolateUrlPattern > supports hour/minute/second date tokens
  ✓ interpolateUrlPattern > leaves date tokens untouched when no valid date is provided

Example: pattern /{year}/{month}/{day}/{slug}.html + publish date 2018-05-08/2018/05/08/hello.html.

Notes / follow-ups

The menu resolver now routes through the shared interpolateUrlPattern (its private {slug}/{id}-only copy is deleted), so menu links resolve date tokens too.

Known limitation, inherent to date-based permalinks (WordPress has the same): editing the publish date of an already-published entry moves its URL without leaving a redirect. Slug changes do leave one (built from the old publish date for the source and the new one for the destination).

@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Jun 17, 2026
@changeset-bot

changeset-bot Bot commented Jun 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d68e888

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
create-emdash Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/release-service Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jun 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1526

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1526

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1526

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1526

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1526

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1526

emdash

npm i https://pkg.pr.new/emdash@1526

create-emdash

npm i https://pkg.pr.new/create-emdash@1526

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1526

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1526

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1526

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1526

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1526

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/@emdash-cms/registry-moderation@1526

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@1526

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1526

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1526

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1526

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1526

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1526

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1526

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1526

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1526

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1526

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1526

commit: d68e888

swissky added a commit to swissky/emdash that referenced this pull request Jul 6, 2026
… and i18n prefix

The code comment claimed date tokens resolve through url_pattern, but
interpolateUrlPattern only substitutes {slug}/{id} (date tokens are
PR emdash-cms#1526, not yet merged) — comment now sticks to what exists. Two new
route tests close the review's coverage gaps: EMDASH_PREVIEW_PATH_PATTERN
still wins over a configured url_pattern, and a non-default-locale entry
gets its locale prefix via localizePath.
@ascorbic ascorbic added this to the 1.0 milestone Jul 8, 2026
@swissky
swissky force-pushed the feat/permalink-date-tokens branch from 0ff126b to 2fb1b9f Compare July 9, 2026 18:51
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 10, 2026

@emdashbot emdashbot Bot left a comment

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.

Approach judgment: this is the right change. WordPress-style date tokens are a natural, additive extension of url_pattern and fit EmDash’s model where core only owns the forward/canonical URL side while templates own reverse routing. It links to the approved Discussion, includes a changeset, and the implementation is narrowly scoped around a single core resolver.

What I checked

  • The new interpolateUrlPattern date-token logic and its unit tests.
  • The sitemap SQL/output changes (published_at added to handler and route).
  • Admin integration in ContentEditor, ContentList, ContentTypeEditor help text, and contentUrl.
  • AGENTS.md conventions: Lingui wrapping, RTL-safe Tailwind (no new physical classes), SQL parameterization, changeset coverage.
  • Cross-cutting consumers of url_pattern: menu resolver, redirect auto-generation, and resolveEmDashPath reverse matching.

Headline conclusion: the feature is clean and tests pass according to the PR, but there is one concrete data-integrity gap and a small inconsistency that should be fixed before merge. Auto-redirects on slug changes are not updated for date tokens, so dated URL patterns will emit broken redirects with literal {year}/{month}/{day} braces. The admin/sitemap updatedAt fallback for date resolution also conflicts with the stated “stay literal without a publish date” behavior.

}

/**
* 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.

slug: entry.slug || entry.id,
id: entry.id,
// Published date keeps date-token permalinks stable across edits.
date: entry.publishedAt ?? entry.updatedAt,

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 sitemap passes entry.publishedAt ?? entry.updatedAt to interpolateUrlPattern. The PR description says tokens are resolved from the publish date and stay literal when no publish date is available; falling back to updatedAt means the canonical URL for an entry without publishedAt can shift on every edit, undermining the stability goal. Since the SQL already filters to status = 'published', publishedAt should be non-null for every sitemap row.

Suggested change
date: entry.publishedAt ?? entry.updatedAt,
date: entry.publishedAt,

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 721f982 — the sitemap passes entry.publishedAt only; since the query filters status = 'published' the value is present for every row, and tokens stay literal in the (theoretical) absence, consistent with the resolver's documented behavior.

} else {
window.open(
contentUrl(collection, slug || item.id, urlPattern),
contentUrl(collection, slug || item.id, urlPattern, item.publishedAt ?? item.updatedAt),

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 preview fallback uses updatedAt when publishedAt is absent. That resolves date tokens from the modification date, which can make the preview URL differ from the eventual published URL and conflicts with the stated behavior that tokens stay literal without a publish date.

Suggested change
contentUrl(collection, slug || item.id, urlPattern, item.publishedAt ?? item.updatedAt),
contentUrl(collection, slug || item.id, urlPattern, item.publishedAt),

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 721f982 — the preview fallback (both call sites in handlePreview) now passes item.publishedAt without the updatedAt fallback.

href={contentUrl(
collection,
item.slug,
urlPattern,

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 Live View link is gated on isLive, but it still falls back to updatedAt when publishedAt is missing. To keep the behavior consistent with the new resolver, pass only publishedAt so tokens remain literal when no publish date exists.

Suggested change
urlPattern,
item.publishedAt,

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 721f982 — the Live View link passes item.publishedAt only.

href={contentUrl(
collection,
item.slug,
urlPattern,

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] Only published entries render this link, so publishedAt is the right date source. Dropping the updatedAt fallback keeps the admin link consistent with the canonical/sitemap resolver.

Suggested change
urlPattern,
item.publishedAt,

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 721f982 — the ContentList link passes item.publishedAt only.

}

/**
* 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.

[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.

@github-actions github-actions Bot added size/L review/needs-rereview Author pushed changes since the last review needs-rebase and removed size/M review/needs-review No maintainer or bot review yet labels Jul 10, 2026
@swissky
swissky force-pushed the feat/permalink-date-tokens branch from 721f982 to de8d507 Compare July 11, 2026 15:18
@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@swissky swissky added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jul 12, 2026

@emdashbot emdashbot Bot left a comment

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.

Re-review of the date-token permalink feature. The two gaps from the previous review are fixed: slug-change auto-redirects now resolve date tokens from published_at, and the sitemap no longer falls back to updatedAt for token resolution.

The approach still looks right — WordPress-style date tokens are a narrow, additive extension of url_pattern and stay on the forward/canonical-URL side, which matches EmDash's model.

Fresh scan found one concrete inconsistency: getHreflangAlternatesWithDb calls interpolateUrlPattern without passing a publish date, so hreflang alternate URLs will contain literal {year}/{month}/{day} braces when a collection uses a dated pattern. The module itself says it "mirrors the sitemap route's semantics," and the sitemap route does pass publishedAt, so this is a correctness gap in the same canonical-URL surface. Fix is a one-line addition plus a test.

Everything else I checked looks clean: Lingui-wrapped help text, no new physical Tailwind classes, published_at query is parameterized/identifier-validated, tests are added for resolve/redirect/menu cases, and the changeset covers both packages. The PR description's note that the menu resolver doesn't yet resolve date tokens is now out of date — the implementation does route it through the shared resolver.

The single finding below is blocking-ish; once hreflang is aligned this is an easy approve.


Findings

  • [needs fixing] packages/core/src/seo/hreflang.ts:165-170

    getHreflangAlternatesWithDb still calls interpolateUrlPattern without a publish date, so date-token patterns emit literal {year}/{month}/{day} braces in <link rel="alternate"> URLs. This contradicts the module's stated goal of mirroring the sitemap route's semantics (and the sitemap route now passes entry.publishedAt).

    		const path = interpolateUrlPattern({
    			pattern: urlPattern,
    			collection,
    			slug: variant.slug || variant.id,
    			id: variant.id,
    			date: variant.publishedAt,
    		});
    

    ContentItem already exposes publishedAt, so variant.publishedAt is available here. Please also add a test in tests/integration/seo/hreflang.test.ts with a dated url_pattern so hreflang stays in sync with the sitemap going forward.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Jul 12, 2026
…}/{day})

Rebased onto current main (contentUrl now takes ContentUrlOptions with
locale/i18n; date joins as an option). Addresses the outstanding review
finding: hreflang alternates now resolve date tokens from each variant's
publish date, matching the sitemap, with an integration test.
@swissky
swissky force-pushed the feat/permalink-date-tokens branch from 39bcf9e to 96624a1 Compare September 10, 2026 07:51
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond needs-rebase labels Sep 10, 2026
@swissky

swissky commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main and addressed the outstanding finding:

  • hreflang: getHreflangAlternatesWithDb now passes date: variant.publishedAt to interpolateUrlPattern, so alternate URLs resolve date tokens per variant, matching the sitemap. New integration test in tests/integration/seo/hreflang.test.ts with a dated pattern.

Rebase notes: contentUrl gained locale prefixing on main (#2895), so the publish date now travels as a date field on the existing ContentUrlOptions instead of a positional parameter; the menus resolver change was re-applied onto the batched content-reference lookup, carrying published_at through both query paths.

Two hardening fixes from a pre-push review, each with a test:

  • createAutoRedirect now takes the old and new publish date separately — a save that changes slug and publish date previously built the redirect source from the post-update date, producing a source URL that was never live. The handlers pass both from state they already had, which also drops the extra SELECT published_at round-trip.
  • Offset-less datetimes (2023-05-08 23:30:00, the SQLite storage format) are now parsed as UTC in both resolvers. new Date() treats them as server-local time, so the same row could yield /05/08/ on workerd and /05/09/ on a Node host in an eastern timezone.

@swissky swissky added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Sep 10, 2026

@emdashbot emdashbot Bot left a comment

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.

The prior review's blocking hreflang gap is fixed: getHreflangAlternatesWithDb now passes variant.publishedAt to interpolateUrlPattern, and a solid integration test covers per-locale date-token alternates. The approach — adding WordPress-style date tokens as a narrow, additive extension of url_pattern on the forward/canonical-URL side — still fits EmDash's model well.

Fresh pass found two remaining items:

  1. Changeset is incomplete. It says tokens resolve in sitemap canonical URLs and admin "View published" links, but the same shared resolver also powers hreflang alternates, navigation menu links, and slug-change auto-redirects. Those are user-visible surfaces that admins will see in the release changelog, so the entry should name them.

  2. Admin contentUrl doesn't support {id}, though the updated help text now advertises it. The ContentTypeEditor help text lists {id} as a supported token, but packages/admin/src/lib/url.ts contentUrl() only substitutes {slug} and date tokens. For collections whose pattern uses {id}, the admin preview link will contain literal {id} braces.

I also checked for lingering un-date-aware interpolateUrlPattern callers: sitemap, hreflang, menus, and redirects all pass a publish date now; only the Cloudflare AI-search plugin has its own urlTemplates path, which is separate from url_pattern and out of scope. No new logged-out queries are introduced (just extra columns on existing selects), and the SQL changes use existing safe patterns (sql.ref, parameterized sql).

Once the changeset and the admin {id} handling are aligned, this is an easy approve.

Comment thread .changeset/permalink-date-tokens.md Outdated
"@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.

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] The changeset only mentions sitemap canonical URLs and admin "View published" links, but the shared interpolateUrlPattern resolver also powers hreflang <link rel="alternate"> URLs, navigation menu links, and slug-change auto-redirects. Omitting those surfaces makes it harder for readers to tell whether the release affects their site, which is exactly what the changeset README flags as a documentation problem.

Suggested change
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.
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, hreflang alternate links, navigation menu links, and admin "View published" links; slug-change auto-redirects also resolve date tokens from the old and new publish dates.

Comment on lines 74 to +80
options?: ContentUrlOptions,
): string {
const safe = slug.replace(LEADING_SLASHES, "");
const path = urlPattern ? urlPattern.replace("{slug}", safe) : `/${collection}/${safe}`;
// Date tokens resolve against the pattern before the slug is inserted, so
// a slug that happens to contain `{year}`-style text stays untouched.
const pattern = urlPattern && applyDateTokens(urlPattern, options?.date);
const path = pattern ? pattern.replaceAll("{slug}", safe) : `/${collection}/${safe}`;

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] contentUrl() only substitutes {slug} and date tokens, yet the updated ContentTypeEditor help text now tells editors that {id} is a supported url_pattern token. For a collection whose pattern contains {id}, the admin "View published" link will render literal {id} braces instead of the entry id. Thread the entry id through the options and replace the token, or remove {id} from the help text until the admin helper supports it.

Suggested change
options?: ContentUrlOptions,
): string {
const safe = slug.replace(LEADING_SLASHES, "");
const path = urlPattern ? urlPattern.replace("{slug}", safe) : `/${collection}/${safe}`;
// Date tokens resolve against the pattern before the slug is inserted, so
// a slug that happens to contain `{year}`-style text stays untouched.
const pattern = urlPattern && applyDateTokens(urlPattern, options?.date);
const path = pattern ? pattern.replaceAll("{slug}", safe) : `/${collection}/${safe}`;
export interface ContentUrlOptions {
locale?: string | null;
i18n?: {
defaultLocale: string;
locales: string[];
prefixDefaultLocale?: boolean;
};
/** Publish date used to resolve `{year}`/`{month}`/... tokens in the pattern. */
date?: string | null;
/** Entry id used to resolve `{id}` tokens in the pattern. */
id?: string;
}
...
export function contentUrl(
collection: string,
slug: string,
urlPattern?: string,
options?: ContentUrlOptions,
): string {
const safe = slug.replace(LEADING_SLASHES, "");
// Date tokens resolve against the pattern before the slug/id are inserted, so
// a slug or id that happens to contain `{year}`-style text stays untouched.
const pattern = urlPattern && applyDateTokens(urlPattern, options?.date);
const path = pattern
? pattern.replaceAll("{slug}", safe).replaceAll("{id}", options?.id ?? safe)
: `/${collection}/${safe}`;

Callers in ContentEditor.tsx and ContentList.tsx should also pass id: item.id so {id} patterns resolve to the real entry id rather than falling back to the slug.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 10, 2026
@swissky

swissky commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Both addressed in d68e888:

  • The changeset now names every surface the shared resolver powers: sitemap canonical URLs, hreflang alternates, navigation menu links, slug-change auto-redirects, and the admin preview/"View published" links.
  • Admin contentUrl now substitutes {id} (entry id passed via ContentUrlOptions from all four call sites), matching the help text and the core resolver. Test added.

@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Sep 10, 2026

@emdashbot emdashbot Bot left a comment

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.

This is a clean re-review of #1526. The approach is still sound: WordPress-style date tokens are a narrow, additive extension of the existing url_pattern resolver, which keeps core’s routing-agnostic model intact while giving templates stable canonical URLs.

The two remaining items from my previous review are both resolved:

  • The changeset now names every user-visible surface that resolves date tokens: sitemap canonicals, hreflang alternates, navigation menus, slug-change auto-redirects, and the admin preview / “View published” links.
  • packages/admin/src/lib/url.ts now resolves {id} tokens, matching the updated ContentTypeEditor help text.

I traced all interpolateUrlPattern and contentUrl call sites; sitemap, hreflang, menus, redirects, and admin links all pass a publish date. The core resolver treats stored datetimes as UTC (including offset-less SQLite forms), tokens fall back to literals when no date exists, and existing {slug}/{id} behavior is preserved. Tests cover date-token substitution, per-locale hreflang alternates, and redirects whose source/destination use the old vs. new publish date. No new logged-out queries are introduced, and the SQL changes are additive columns on existing selects using existing safe patterns.

Good to merge.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 10, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Sep 10, 2026

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks!

@ascorbic
ascorbic merged commit 0bcb1d9 into emdash-cms:main Sep 10, 2026
55 checks passed
@emdashbot emdashbot Bot mentioned this pull request Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants