Docs Writing
Publishing
How a post gets from a Ghost draft to a deployed page, and what breaks when it doesn't.
The publication 無人之境 lives at /blog. Posts are authored in Ghost and
rendered by this site at build time — Ghost is the editor and the database, but
it never serves a visitor.
The path a post takes
- You publish in Ghost.
- Ghost fires its
Post publishedwebhook at a Cloudflare Workers Builds deploy hook. - Cloudflare rebuilds the
siteWorker. During the build, the site fetches posts from the Ghost Content API and renders them into static HTML. - The new Worker goes live. The post is now a prerendered page, an entry in
/blog/rss.xml, and a row on the home page.
The consequence worth internalising: a post is not live until a build finishes.
Editing in Ghost and refreshing /blog does nothing. If a change has not appeared,
the question is always “did the deploy run”, not “did the cache expire”.
Configuration
| Variable | Where it must exist | Why |
|---|---|---|
PUBLIC_GHOST_URL |
Cloudflare build environment | The Content API origin. |
GHOST_CONTENT_API_KEY |
Cloudflare build environment | Read access to published posts. |
Both are read at build time, not at request time. Setting them as Worker runtime secrets alone is not enough — the pages are prerendered, so the fetch happens during the build or not at all.
Wiring the hook
- In Cloudflare, create a Workers Builds deploy hook for the production branch.
- In Ghost, point the
Post publishedwebhook at that URL. - Keep the event as
Post published. A broader event fires builds for drafts.
Rendering
Ghost returns its own content contract (.kg-* cards: images, galleries,
embeds, callouts, code). Those are restyled here rather than themed in Ghost, so
the published post reads in this site’s typography and palette, in both light and
dark. Code fences are rehighlighted, images get a blur-up placeholder, and
YouTube and Apple Music embeds are replaced with local, privacy-preserving
components.
The practical rule: write plain Ghost content and let this site style it. Custom HTML in a post will render, but it will not inherit the type scale and it will not adapt to the theme.
Unlisted posts
Use Ghost’s internal #unlisted tag when a post should work as a direct link
without entering the publication’s discovery surfaces. Ghost exposes this tag
with the slug hash-unlisted; the site treats that exact internal tag as the
marker. A public post with any other tag remains listed.
The build keeps two post collections separate:
| Collection | Source | Includes #unlisted posts |
Used by |
|---|---|---|---|
| Accessible | getAccessiblePosts() |
Yes | /blog/<slug> static paths and direct slug lookup |
| Listed | getListedPosts() |
No | Home and blog indexes, tag directories and archives, adjacent links, RSS, sitemap, Pagefind, palette data, llms.txt, and generated agent Markdown indexes |
An unlisted post therefore has a stable URL, but readers must already have the
URL. The article response emits noindex, nofollow, noarchive, nosnippet in
the robots meta tag. The layout also marks the whole document with
data-pagefind-ignore="all" and suppresses its text/markdown alternate link.
The generated static Markdown asset is omitted; a direct request with
Accept: text/markdown, or through <post URL>/index.md, renders at runtime
and returns the same directives in X-Robots-Tag.
Do not remove the tag from a post and assume the page is immediately discoverable.
The Ghost publish webhook starts a new site build, and the post enters listed
surfaces only after that build deploys. The source of truth for this rule is
src/features/posts/unlisted.ts;
the collection split lives in
src/features/posts/adapter/provider.ts.