feat(core): WordPress-style date tokens in url_pattern ({year}/{month}/{day}) - #1526
Conversation
🦋 Changeset detectedLatest commit: d68e888 The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
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 |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-moderation
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
… 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.
0ff126b to
2fb1b9f
Compare
There was a problem hiding this comment.
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
interpolateUrlPatterndate-token logic and its unit tests. - The sitemap SQL/output changes (
published_atadded to handler and route). - Admin integration in
ContentEditor,ContentList,ContentTypeEditorhelp text, andcontentUrl. - 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, andresolveEmDashPathreverse 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. |
There was a problem hiding this comment.
[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:
- Pass the entry’s
published_atintocreateAutoRedirect(select it increateSlugChangeRedirector flow it from the caller). - Build
oldUrl/newUrlthrough the sharedinterpolateUrlPattern(or the sameapplyDateTokenslogic) so date tokens resolve consistently with canonical URLs. - Add an integration test for
createAutoRedirectwith a date-token pattern.
There was a problem hiding this comment.
Fixed in 721f982 — createSlugChangeRedirect 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, |
There was a problem hiding this comment.
[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.
| date: entry.publishedAt ?? entry.updatedAt, | |
| date: entry.publishedAt, |
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
[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.
| contentUrl(collection, slug || item.id, urlPattern, item.publishedAt ?? item.updatedAt), | |
| contentUrl(collection, slug || item.id, urlPattern, item.publishedAt), |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
[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.
| urlPattern, | |
| item.publishedAt, |
There was a problem hiding this comment.
Fixed in 721f982 — the Live View link passes item.publishedAt only.
| href={contentUrl( | ||
| collection, | ||
| item.slug, | ||
| urlPattern, |
There was a problem hiding this comment.
[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.
| urlPattern, | |
| item.publishedAt, |
There was a problem hiding this comment.
Fixed in 721f982 — the ContentList link passes item.publishedAt only.
| } | ||
|
|
||
| /** | ||
| * Interpolate a collection `url_pattern` with a row's slug, id and publish date. |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
721f982 to
de8d507
Compare
Overlapping PRsThis PR modifies files that are also changed by other open PRs: This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
There was a problem hiding this comment.
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-170getHreflangAlternatesWithDbstill callsinterpolateUrlPatternwithout 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 passesentry.publishedAt).const path = interpolateUrlPattern({ pattern: urlPattern, collection, slug: variant.slug || variant.id, id: variant.id, date: variant.publishedAt, });ContentItemalready exposespublishedAt, sovariant.publishedAtis available here. Please also add a test intests/integration/seo/hreflang.test.tswith a datedurl_patternso hreflang stays in sync with the sitemap going forward.
…}/{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.
39bcf9e to
96624a1
Compare
|
Rebased onto current
Rebase notes: Two hardening fixes from a pre-push review, each with a test:
|
There was a problem hiding this comment.
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:
-
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.
-
Admin
contentUrldoesn't support{id}, though the updated help text now advertises it. TheContentTypeEditorhelp text lists{id}as a supported token, butpackages/admin/src/lib/url.tscontentUrl()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.
| "@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. |
There was a problem hiding this comment.
[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.
| 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. |
| 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}`; |
There was a problem hiding this comment.
[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.
| 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.
|
Both addressed in d68e888:
|
There was a problem hiding this comment.
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.tsnow resolves{id}tokens, matching the updatedContentTypeEditorhelp 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.
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-postor the classic/{year}/{month}/{day}/{slug}.html).url_patternnow 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 (notupdated_at) keeps permalinks stable across later edits.Literal suffixes like
.htmlalready work (they're literal text in the pattern). The tokens are substituted by the single core resolverinterpolateUrlPattern, 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_patternis 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
Checklist
pnpm typecheckpasses —emdash(core) is clean. The admin package shows 8 pre-existingRegistryPluginDetail.tsximplicit-any errors that also occur onmainwithout this change (shallow-clone/unbuilt-dep artifact); none are in the files this PR touches.pnpm lintpasses (oxlint --type-aware --deny-warnings, clean on changed files)pnpm test— added unit tests for the date-token substitution;tests/unit/i18n/resolve.test.tspasses (15/15) locally. (The admin browser-vitest harness doesn't start in my local env — relying on repo CI for it.)pnpm formathas been runmessages.pochanges included.emdash+@emdash-cms/admin, minor)AI-generated code disclosure
Screenshots / test output
Example: pattern
/{year}/{month}/{day}/{slug}.html+ publish date2018-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).