Changed routing events to carry domain data instead of Express routers - #29801
Conversation
ref https://linear.app/ghost/issue/HKG-1899 - `router.created` handed the sitemap a live Express-backed router and the sitemap reached into it for `.name`, `.identifier` and `.getRoute()`, so any change to a router's method surface could silently stop static and collection routes appearing in the sitemap - replaced it with the domain events the design doc calls for: `RouteRegistered {path, type, id}` and `RoutesReset` - `path` is the route in domain notation, matching the issue spec, rather than a pre-rendered absolute URL: the sitemap already imported urlUtils for its /404/ sentinel, so `urlUtils.createUrl(path, true)` reproduces exactly what `getRoute({absolute: true})` returned — verified identical for both emitting router types, including subdirectory installs — and the event stays domain-level rather than carrying rendered output - kept the frontend-internal EventEmitter rather than moving to DomainEvents: 729891b deliberately took these events off the server's shared bus, and DomainEvents swallows handler errors, which here would mean a silently stale sitemap - not every router owns a route — StaticPagesRouter has none and taxonomies have no index route — so `path` is null for those; previously the sitemap's name filter ran before it ever called `getRoute()`, and it still filters on `type` before the path is used - dropped the unreachable `!router` half of the guard below the emit, which every call site (all `routerCreated(this)`) already made dead and which the new payload reads through - no consumer of `router.created` remained outside the sitemap, so nothing is kept for backwards compatibility
ref https://linear.app/ghost/issue/HKG-1899 - last loose end of the DDD architecture cleanup milestone, folded in here rather than filed as a ticket because this is the milestone's final PR - the export never had a production caller; its docstring promised one on the download path "in HKG-1897", which never happened and provably cannot now: `DynamicRoutingService.download()` returns `settings.yamlSource` verbatim and never re-serialises the domain model back to YAML - the round-trip tests went with it — they can no longer be expressed, and they were the only thing keeping the export referenced
|
| Command | Status | Duration | Result |
|---|---|---|---|
nx run ghost:test:ci:integration |
✅ Succeeded | 3m 1s | View ↗ |
nx run ghost:test:integration |
✅ Succeeded | 2m 33s | View ↗ |
nx run ghost:test:e2e |
✅ Succeeded | 2m 12s | View ↗ |
nx run ghost:test:legacy |
✅ Succeeded | 2m 13s | View ↗ |
nx run ghost-monorepo:lint:boundaries |
✅ Succeeded | 21s | View ↗ |
nx run-many -t test:unit -p ghost |
✅ Succeeded | 31s | View ↗ |
nx run-many -t lint -p ghost,ghost-monorepo |
✅ Succeeded | 20s | View ↗ |
nx run-many --target=build --projects=tag:publi... |
✅ Succeeded | <1s | View ↗ |
nx run @tryghost/admin:build |
✅ Succeeded | 8s | View ↗ |
💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗
☁️ Nx Cloud last updated this comment at 2026-08-06 12:12:00 UTC
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
WalkthroughFrontend routing now emits Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
Refactors Ghost’s frontend-internal routing events so subscribers (notably the sitemap) receive domain data ({path, type, id}) rather than a live Express-backed router instance, reducing infrastructure coupling as part of the DDD architecture cleanup.
Changes:
RouterManagernow emitsRouteRegistered {path, type, id}andRoutesResetinstead ofrouter.created/routers.reset.SiteMapManagernow derives absolute URLs from the event’s domainpathusingurlUtils.createUrl.- Removes unused
toDomainNotation()from the permalink adapter and updates affected unit tests.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| ghost/core/core/frontend/services/routing/router-manager.js | Emit domain payloads (RouteRegistered, RoutesReset) instead of Express router instances. |
| ghost/core/core/frontend/services/sitemap/site-map-manager.js | Consume new routing domain events and build sitemap entries from {path, type, id}. |
| ghost/core/core/frontend/services/routing/events.js | Documents the new routing domain event payloads and intent. |
| ghost/core/core/frontend/services/routing/permalink-adapter.ts | Removes unused toDomainNotation() helper. |
| ghost/core/test/unit/frontend/services/routing/permalink-adapter.test.js | Updates tests to reflect removal of toDomainNotation(). |
| ghost/core/test/unit/frontend/services/routing/router-manager.test.js | Adds/updates unit tests pinning the new event payload shape and ordering. |
| ghost/core/test/unit/frontend/services/sitemap/manager.test.js | Updates sitemap unit tests to use RouteRegistered / RoutesReset payloads. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
ref https://linear.app/ghost/issue/HKG-1899 - "ot" -> "to" and "tasting" -> "testing", both in comment blocks this branch already touches; raised by Copilot on the PR
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #29801 +/- ##
==========================================
- Coverage 75.45% 75.41% -0.04%
==========================================
Files 1606 1606
Lines 140364 140371 +7
Branches 17408 17394 -14
==========================================
- Hits 105905 105858 -47
- Misses 33410 33436 +26
- Partials 1049 1077 +28
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|

ref https://linear.app/ghost/issue/HKG-1899
The last piece of code in the DDD Architecture Cleanup milestone (design step 3.8).
The problem
RouterManager.routerCreated()broadcast a live Express-backed router instance:The sitemap — the only listener — then reached into that object for three things:
Two frontend subsystems coupled through an infrastructure handle rather than a message. Change a router's method surface and static/collection routes silently stop appearing in the sitemap, with nothing to catch it.
The change
RouterManageremits data; the sitemap filters ontypeand absolutisespathitself.Three decisions worth reviewing
1.
path(domain notation), not a pre-rendered absolute URL.This matches the issue spec and the design doc's Layer-2 table (
RouteRegistered { path, type, id }). Our internal plan doc had argued for an emitter-computed absoluteurlon the grounds that the sitemap would otherwise "have to reach back forurlUtils" — that premise was wrong:site-map-manager.js:2already importedurlUtilsfor its/404/sentinel. Sopathcosts nothing and keeps the event domain-level instead of carrying rendered output.The conversion is provably lossless.
ParentRouter.getRoute()andCollectionRouter.getRoute()(the only two emitting types) are both exactlyurlUtils.createUrl(this.route.value, options.absolute), and the subscriber now callsurlUtils.createUrl(path, true)on that same value. Verified identical by instantiating the real router classes under three configs — root,http://localhost/blog/,https://example.com/blog/— across/,/about/,/feed/,/podcast/,/hello/world/. Subdirectory installs included.2. Kept the frontend-internal
EventEmitterrather than@tryghost/domain-events.The issue allows either. Two reasons for local:
729891bc00deliberately createdfrontend/services/routing/events.jsto move these events off the server's shared bus, as part of taking these modules off the dependency-cruiser allowlist. AdoptingDomainEvents— a process-wide static singleton — reverses that.DomainEvents.subscribewraps every handler intry/catchand only logs (DomainEvents.js:33-39). The sitemap's handler mutates_routerEntriesand invalidates the index; a swallowed throw there is a silently stale sitemap.3.
pathisnullfor routers that own no route.StaticPagesRouterhas nothis.routeandTaxonomyRouterhas no index route (/tag/doesn't exist). Previously invisible because the subscriber's name filter ran before it ever calledgetRoute(); thetypefilter still runs beforepathis used, socreateUrl(null, …)is unreachable for the two live types.typedeliberately carries the router'sname— a hand-passedsuper()string literal, notconstructor.name, so it's already decoupled from the class name. Re-vocabularising it tostatic-route/collectionwould add exactly the mapping layer the design doc's "Keeping it lean" section warns against, for no behavioural gain.No backwards-compat shim
The issue offers keeping
router.created"if other consumers exist". None do — repo-wide search across.js/.ts/.tsx/.json/.hbs(includingapps/,packages/,e2e/, and computed event names) found only the two emitters, the one consumer, and their tests.grep -rn "router.created\|routers.reset" ghost/core/core ghost/core/testnow returns nothing.Second commit —
toDomainNotation()A pure deletion, split into its own commit for blame. It's the milestone's documented last loose end. It never had a production caller, and its docstring's promise of one on the download path is provably obsolete:
DynamicRoutingService.download()returnssettings.yamlSourceverbatim and never re-serialises.Testing
Behaviour is intended to be byte-identical; the tests were written first.
router-manager.test.js— newdomain eventsblock (5 → 7 tests): payload shape pinned byObject.keys(event).sort(), the null-path case, and the ordering the sitemap depends on (RoutesResetalways precedes every registration that refills the entries it emptied).manager.test.js— the hand-built fake router becomes a plain payload.pnpm test:unit: matches pristinemainexactly (same 2 pre-existing failures inautomations-repositoryandemail-renderer, both unrelated and reproducing onmain).test/e2e-frontend/— 18 files / 259 tests green. This is the real safety net:default-routes.test.jsandcustom-routes.test.jsrender actual sitemap XML through the real emit→subscribe path, andadvanced-url-config.test.jscovers the subdirectory install.--project legacy— 35 files / 451 tests green.pnpm lint:boundaries— 0 violations across 5,137 modules; no new cross-context coupling.The one
tscerror locally (values-service.ts/subFieldsOf) reproduces on pristinemain— a stale local workspace-package build, not this branch.Reviewed before opening by three independent agents (clean code, production safety, scope/design alignment); their actionable findings are already folded in.