Summary
When a page is given a URL by routes.yaml — as a collection's data: source, or as the site index — its .md variant stops resolving. Ghost issues a 301 to a path composed of the page's routed directory plus the page's slug, which does not exist:
/essais.md → 301 → /essais/essais.md → 404
When the page is served at /, the 301 points at the request URL itself, producing an infinite redirect loop.
llms.txt, which Ghost generates, lists these URLs, so the advertised agent-discovery surface contains dead links and at least one redirect loop.
Environment
- Reproduced on a clean
ghost:6-alpine container, Ghost 6.60.0, SQLite, default Casper theme, no theme changes.
- Same symptoms observed on Ghost 6.61 (Ghost Pro) on a live site.
Steps to reproduce
Fresh install, then create one published page with slug essais.
Control — default routes
GET /essais/ → 200 text/html
GET /essais.md → 200 text/markdown ✅
Case A — page used as a collection's data: source
routes:
collections:
/essais/:
permalink: /essais/{slug}/
template: index
data: page.essais
taxonomies:
tag: /tag/{slug}/
author: /author/{slug}/
GET /essais/ → 200 text/html
GET /essais.md → 301 → /essais/essais.md
GET /essais/essais.md → 404 ❌
Case B — same, with no name collision
Collection mounted at /rubrique/, page slug still essais:
collections:
/rubrique/:
permalink: /rubrique/{slug}/
template: index
data: page.essais
GET /essais/ → 301 → /rubrique/ (correct HTML redirect)
GET /rubrique/ → 200 text/html
GET /essais.md → 301 → /rubrique/essais.md
GET /rubrique/essais.md → 404 ❌
GET /rubrique.md → 404 ❌ (no markdown URL exists at all)
The collection path and the page slug being the same word is not required — any page whose URL comes from routes.yaml is affected.
Case C — page served at the site root
routes:
/:
data: page.accueil
template: index
GET / → 200 text/html (serves the page body)
GET /accueil/ → 301 → / → 200 text/html ✅ HTML handled correctly
GET /accueil → 301 → /accueil/ → 301 → / → 200 ✅
GET /accueil.md → 301 → /accueil.md → 301 → … ❌ infinite loop
(still redirecting after 10 hops)
GET /index.md → 404 ❌
/index.md is the path Ghost's own getMarkdownPath() returns for /, so on this configuration there is no working markdown URL for the home page at all. llms.txt on this same instance advertises /accueil.md — the looping URL.
This is not the .html static-asset behaviour from #29991
#29991 was closed with "Extension-bearing URLs are treated as theme static files by design." That reasoning does not apply here, and the response codes prove it:
GET /does-not-exist.md → 404 static fallthrough, as described in #29991
GET /a-propos.md → 200 markdown route, page at its natural URL
GET /tech.md → 301 markdown route engaged, wrong target computed
A URL falling through to the static handler returns 404. /tech.md returns a 301, which means Ghost's markdown router resolved the page and then built a redirect target that does not exist. .md is a first-class Ghost route, not a theme asset: it is served by core/frontend/services/routing/controllers/entry/markdown.js, it works for every post and for pages at their natural URL, and Ghost itself generates these URLs in llms.txt.
This is not a configuration mistake either
data: on a collection is a supported routes.yaml feature, and Ghost handles the resulting URL change correctly for HTML. Only the markdown path fails. Same page, same routes.yaml, same Ghost-issued redirect:
HTML /essais/ → 301 → /rubrique/ → 200 text/html ✅
MARKDOWN /essais.md → 301 → /rubrique/essais.md → 404 ❌
Ghost knows the page now lives at /rubrique/ — its HTML redirect proves it. Case C shows the identical split on a root-mounted page.
Expected
For a page served at /rubrique/, the markdown variant should be /rubrique.md (and /index.md for a page served at /), matching what core/frontend/services/llms/markdown.js → getMarkdownPath() already computes from a pathname. Alternatively /{slug}.md should keep serving the page directly.
At minimum, the root case should not redirect to itself.
Actual
The redirect target appears to be composed as <routed directory> + <slug> + .md rather than by passing the page's canonical pathname through getMarkdownPath().
Pointer rather than a diagnosis — I did not trace the full path: core/frontend/services/routing/controllers/entry/markdown.js and core/frontend/services/llms/markdown.js (getMarkdownPath, getMarkdownUrl).
Impact on a live site
On a production site (Ghost 6.61) using routes.yaml, checking every URL its own llms.txt advertises:
|
Resolve |
Broken |
| Posts |
205 / 205 |
0 |
| Pages |
6 / 14 |
8 |
The eight failures are the site's main entry points, and each is a page remapped by routes.yaml:
/tech.md → 301 → /tech/tech.md → 404
/fiction.md → 301 → /fiction/fiction.md → 404
/archives.md → 301 → /archives/archives.md → 404
/le-flow.md → 301 → /le-flow/le-flow.md → 404
/podcast.md → 301 → /double-vie/podcast.md → 404
/en-blog.md → 301 → /en/blog/en-blog.md → 404
/home-en.md → 301 → /en/home-en.md → 404
/home.md → 301 → /home.md → infinite loop
Neither redirects.yaml nor the theme can work around this: no URL variant serves the content. /tech/.md returns 403, and /tech/index.md, /tech.md/ and /tech/tech.md all return 404.
Summary
When a page is given a URL by
routes.yaml— as a collection'sdata:source, or as the site index — its.mdvariant stops resolving. Ghost issues a 301 to a path composed of the page's routed directory plus the page's slug, which does not exist:When the page is served at
/, the 301 points at the request URL itself, producing an infinite redirect loop.llms.txt, which Ghost generates, lists these URLs, so the advertised agent-discovery surface contains dead links and at least one redirect loop.Environment
ghost:6-alpinecontainer, Ghost 6.60.0, SQLite, default Casper theme, no theme changes.Steps to reproduce
Fresh install, then create one published page with slug
essais.Control — default routes
Case A — page used as a collection's
data:sourceCase B — same, with no name collision
Collection mounted at
/rubrique/, page slug stillessais:The collection path and the page slug being the same word is not required — any page whose URL comes from
routes.yamlis affected.Case C — page served at the site root
/index.mdis the path Ghost's owngetMarkdownPath()returns for/, so on this configuration there is no working markdown URL for the home page at all.llms.txton this same instance advertises/accueil.md— the looping URL.This is not the
.htmlstatic-asset behaviour from #29991#29991 was closed with "Extension-bearing URLs are treated as theme static files by design." That reasoning does not apply here, and the response codes prove it:
A URL falling through to the static handler returns 404.
/tech.mdreturns a 301, which means Ghost's markdown router resolved the page and then built a redirect target that does not exist..mdis a first-class Ghost route, not a theme asset: it is served bycore/frontend/services/routing/controllers/entry/markdown.js, it works for every post and for pages at their natural URL, and Ghost itself generates these URLs inllms.txt.This is not a configuration mistake either
data:on a collection is a supportedroutes.yamlfeature, and Ghost handles the resulting URL change correctly for HTML. Only the markdown path fails. Same page, sameroutes.yaml, same Ghost-issued redirect:Ghost knows the page now lives at
/rubrique/— its HTML redirect proves it. Case C shows the identical split on a root-mounted page.Expected
For a page served at
/rubrique/, the markdown variant should be/rubrique.md(and/index.mdfor a page served at/), matching whatcore/frontend/services/llms/markdown.js→getMarkdownPath()already computes from a pathname. Alternatively/{slug}.mdshould keep serving the page directly.At minimum, the root case should not redirect to itself.
Actual
The redirect target appears to be composed as
<routed directory> + <slug> + .mdrather than by passing the page's canonical pathname throughgetMarkdownPath().Pointer rather than a diagnosis — I did not trace the full path:
core/frontend/services/routing/controllers/entry/markdown.jsandcore/frontend/services/llms/markdown.js(getMarkdownPath,getMarkdownUrl).Impact on a live site
On a production site (Ghost 6.61) using
routes.yaml, checking every URL its ownllms.txtadvertises:The eight failures are the site's main entry points, and each is a page remapped by
routes.yaml:Neither
redirects.yamlnor the theme can work around this: no URL variant serves the content./tech/.mdreturns 403, and/tech/index.md,/tech.md/and/tech/tech.mdall return 404.