feat(core): WordPress-style date tokens in url_pattern ({year}/{month}/{day})#1526
feat(core): WordPress-style date tokens in url_pattern ({year}/{month}/{day})#1526swissky wants to merge 4 commits into
Conversation
🦋 Changeset detectedLatest commit: 39bcf9e The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 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/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.
…edAt fallbacks
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.
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.
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 (
packages/core/src/menus/index.ts) keeps its own{slug}/{id}interpolation and does not yet resolve date tokens — a small follow-up could route it through the shared resolver. Raised in the Discussion.