Skip to content

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

Open
swissky wants to merge 4 commits into
emdash-cms:mainfrom
swissky:feat/permalink-date-tokens
Open

feat(core): WordPress-style date tokens in url_pattern ({year}/{month}/{day})#1526
swissky wants to merge 4 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 (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.

@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: 39bcf9e

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

This PR includes changesets to release 16 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
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto 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/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: 39bcf9e

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 and others added 3 commits July 11, 2026 17:16
…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.
@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 removed the bot:review Trigger an emdashbot code review on this PR label Jul 12, 2026
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label 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 needs-rebase and removed review/needs-rereview Author pushed changes since the last review labels Jul 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/admin area/core bot:review Trigger an emdashbot code review on this PR cla: signed needs-rebase overlap review/awaiting-author Reviewed; waiting on the author to respond size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants