TryGhost / TryGhost/Ghost

`.md` URLs 404 or redirect infinitely for pages remapped by `routes.yaml`

Open
#30,375 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

needs:triage
Dominant language
TypeScript
Stars
55.4k
Forks
12k
Avg merge
1d 41m
Merged PRs (30d)
534

Description

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.jsgetMarkdownPath() 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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with core/frontend/services/routing/controllers/entry/markdown.js and compare its redirect handling with core/frontend/services/llms/markdown.js, especially getMarkdownPath and getMarkdownUrl. Reproduce the routes.yaml collection and root-page cases, then verify that remapped pages resolve to the canonical .md path without a 404 or redirect loop.

Written by the indexing model from the issue text.

Assessment

Tech stack
nodejs, typescript
Domain
backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.