Moving the Dev Blog to Storyblok and Astro
Twenty-eight articles and 155 images left their Markdown files for a headless CMS, so posts can be written in a visual editor and one source can feed both the website and the game. The new site is built with Astro and looks pixel for pixel like the old one.
If I want to put Storyblok to use for our game here, what could I use this headless CMS for if there were no restrictions? A changelog in the game? Makes sense. The website moved into Storyblok? Sure thing, not a problem. But the game itself: anything that makes sense?
Why move a blog that worked #
The site you are reading started as Markdown drafts in the game's repository. A Python script, about 830 lines, turned them into static pages framed with the game's 9-slice textures.1 Post 27 describes that first site: the game's UI textures as CSS border-image, subset pixel fonts, WebP images and the Tufte-style layout. It worked well, and every article was a text file next to the code it describes.
The developer had two reasons to move it anyway.
- Writing in a real editor. A Markdown file in a code repository suits an agent. It suits a person less, if they want to see the page while they write it, fix a caption on their phone, or let someone else edit without git. Storyblok's visual editor shows the real page in a frame next to the fields and updates it as you type.
- One source for the website and the game. The more important reason. The site had just gained a Changelog page, and the game was about to get a notice board on its town squares (the next post). Both show the same entries. If the changelog lives in a CMS, a patch note is written once and shows up on the website, on the board in the Vale and on the start screen.
The conversation began with the question at the top of this post and ended as a backlog file, docs/BACKLOG_STORYBLOK.md, with eight items. Nothing started until the developer had created two spaces in Storyblok, one for the website and one for the game, and said go. When they did, the coordinator split the first five items over four agents. The CMS spaces, the migration, the Astro site, the in-game board and its web preview were all reported done about eighty minutes later.
The contract: words in the CMS, numbers in git #
Four agents writing to the same two spaces need one shared description of the content, or each invents its own. So the first commit of the day was not code. It was docs/STORYBLOK.md, the "content contract", which every agent and tool reads and writes by. Its first line sets the rule for everything after it:
Storyblok owns words, news and schedules; git owns mechanics and numbers.
Article text, changelog entries, notices and the dates of scheduled events live in the CMS. How much experience a Harvest Moon gives, which events exist and how a skill scales stay in the repository's JSON files, where more than 18,000 test checks cover them. The CMS can schedule an event; it can't invent one. An event slot names an id from data/events.json, and the game drops any slot whose id it doesn't know.
The contract lists both spaces with their content types and fields:
| Space | Content types | Holds |
|---|---|---|
| website | article, page, site_config |
the 28 articles, the About page, the home page's settings |
| game | news_entry, event_slot |
changelog entries, announcements, event posters, the event calendar |
An article is a title, subtitle, part number, date, teaser and a hero image, plus a body made of blocks. The body blocks are the old site's layout features turned into components: section (a heading with its anchor), rich_text, figure (with the layout default, margin or full), epigraph, code_block, table and sidenote. Article URLs keep the old file names as slugs, so every existing link still works, and each section keeps the anchor the old site gave it.
Sidenotes as components #
The blog's most distinctive layout feature is the sidenote. It uses them heavily: a small number in the text, and the note beside that line in the margin. In Markdown that is an ordinary footnote, text[^1], with the note written after the paragraph.
Storyblok's component model handles this cleanly: a note is simply a component that belongs to the text block it annotates. The text keeps the footnote syntax as plain characters, and the note itself is a sidenote component in the same rich_text block's notes list:
{
"component": "rich_text",
"text": { "type": "doc", "content": [ "... until this week it ended there.[^1]" ] },
"notes": [
{ "component": "sidenote", "label": "1", "margin": false,
"text": "The ladder lives in one pure script ..." }
]
}
The renderer replaces each [^label] with the note. Numbered notes count 1, 2, 3 in reading order across the whole article, margin: true gives an unnumbered margin note, and a label used a second time shows the same number again. Markers inside code are examples, not notes, and stay as text.This post shows the marker syntax in code for exactly that reason: the renderer leaves it alone there. An editor in Storyblok types the marker where the note belongs and adds a sidenote below the text. The migration's checker reports markers without a note and notes nobody references.
With the right component model, nothing had to give: every note is its own small block with a label, a margin switch and its text, editable in place right under the paragraph it belongs to, and the page shows the notes exactly where the old site did. The same pattern works for any inline element a design needs.
Migrating through the MCP #
The migration is a rerunnable script, like everything else in the repository's tools/. sb_md.py (about 400 lines) turns Markdown into Storyblok's rich text and the body blocks: a ## heading starts a section, an image alone in a paragraph becomes a figure, a blockquote ending in an em dash an epigraph, a code fence a code block. Footnotes become markers plus sidenote blocks. migrate_blog.py (about 350 lines) runs it over every article, About page and site setting, and writes one JSON file per story.
The scripts never write to Storyblok themselves. There is no management token in the repository.2 All writes went through the MCP connection the developer authorised for the session, which keeps write access out of the repository entirely: a good rule for any project that agents work in. Every write went through the Storyblok MCP server, the connector that lets an agent call Storyblok's management API as a tool. The script's output files are exactly the parameters of that tool's "update story" call, so an agent can read a file and send it as it is.
Images went through Storyblok's asset API, which uploads straight to its storage: for each image the MCP hands out a signed upload form, a small script (s3_upload.py) posts the file to it, and a second MCP call finalises the asset. Agents uploaded in groups of about eight images at a time.
The work split well. Seven subagents pushed the long article stories and five uploaded the images, each checking its own results. A --verify mode reads the space back and compares every story with the local JSON. It caught one mistyped block id, which was fixed. In the end:
| What | Count |
|---|---|
| Stories in the website space (28 articles, About, site settings) | 30 |
| Images uploaded, one asset folder per article | 155 |
| Asset folders | 31 |
Stories identical to the local JSON (--verify --published) |
30 |
| Conversion warnings (orphan notes, missing images) | 0 |
Each image's caption became its asset's alt text, and every uploaded file was checked against the local file's size. Links between articles became Storyblok story links, which point at the target's id rather than its URL, so renaming an article doesn't break them. Block ids are generated from the slug and position, so a rerun of the migration produces byte-identical JSON.
The game space got its content the same way. A sister script turned the six daily changelog entries into news_entry stories. The board needed some content besides patch notes, so the agent also wrote two announcements in the Vale's voice, one of them signed by Captain Rhea, two event posters and one scheduled event.
The Astro port #
The developer had first asked about Next.js and then chose Astro: static output, an official Storyblok SDK, and a Cloudflare adapter for the preview. The new site lives in web/, built with Astro 7.4 and @storyblok/astro 10.3.
The goal was strict: the new site should look exactly like the old one. Everything that made the old site was ported, not redesigned:
- the game's 9-slice UI textures as CSS
border-imageat 2x, generated by the build fromassets/ui; - the three pixel fonts, subset to WOFF2 (a CJK font of 2 MB down to a few kilobytes);
- the Tufte layout: sidenotes in the margin, margin and full-width figures, epigraphs, small caps at the start of each section, booktabs tables;
- the images, now served by Storyblok's image service: each picture is resized and converted to WebP on Storyblok's CDN by its URL alone (an 800-pixel version for phones, a JPEG or PNG fallback, 480 x 270 thumbnails cropped by the service, pixel art left unscaled), which took the deployed site from 44 MB to 4.8 MB; only the 1200 x 630 social cards, drawn with the game's fonts, are still made at build time;
- the code colours: Shiki instead of Pygments, tuned to the same palette.
The blocks render through one TypeScript renderer of about 460 lines, not one Astro component per block. Sidenote numbering runs across blocks, and the small caps rule needs to know which paragraph comes first in a section. The Atom feed also needs the same HTML with the notes as endnotes. One function that walks the whole body handles all three.
The comparison was done the way the first site was reviewed: with screenshots. The link checker was ported along with a Playwright script that photographs every page at desktop, tablet and phone sizes. The same script ran against the old site, and the pairs were put side by side. Desktop and tablet came out effectively pixel-identical. The link checker found the same numbers on both sites: 32 pages, 2,161 references and 701 anchors, none broken.
The port found a few small differences, all listed in web/README.md. Reading times are a minute shorter on some posts, because the old count included the link URLs. On phones, the tab bar now fits 360 pixels; the old one overflowed sideways by 35 pixels and cut off "Feed". And one changelog anchor changed, because the entry for 6 to 9 October became an entry dated 9 October.
A build check also scans the output: it fails if the site's text names the classic MMO the game is styled after,3 The site has always called it "a popular Korean MMO" or "the classic". The check makes sure a migrated paragraph or a hurried edit in the CMS can't change that. or names an AI product where the site's own copy should be neutral. Those rules used to live in the writers' briefs. Now the build enforces them.
Static for readers, live for the editor #
The site has two builds.
Production is static. npm run build reads the published stories from Storyblok's content delivery API at build time and writes plain files to dist/: HTML, fonts, the feed, the sitemap. The pictures load from Storyblok's image CDN, already in the right size and format for the reader's screen. The site still sets no cookies and has no tracking. It is deployed to Cloudflare Pages like the old site, at the same URLs. A fixtures/ folder holds a snapshot of the published content, so the site can also be built offline.
The preview renders on demand. For the visual editor, PREVIEW=1 switches Astro to server rendering with drafts. Storyblok opens the preview URL plus the story's path in a frame, for example articles/28-mastery-past-the-cap. A small dispatcher maps those paths to pages: an article to its page, about to About, site-config to the home page, and a game-space news/... entry to the Changelog.
The page loads the Storyblok Bridge, a script that talks to the editor around the frame. Every keystroke in a field arrives as an input event carrying the unsaved story. The preview posts that story back to its own server, renders the page with it and patches the DOM in place. You type a caption and the figure under it changes. Every block carries the editor's attributes in the preview build only, so clicking a paragraph in the page selects its block in the editor. The static build has none of them, and its HTML matches the old site's.
Two kinds of token #
Storyblok reads content with access tokens, and the two kinds need very different care:
- A public token reads only published content, which anyone can read on the website anyway. It may be committed, and the game ships one too (next post).
- A preview token reads drafts: unfinished posts, announcements nobody has seen yet. It must never be in git or in a build that the public can download.
The rule is that the preview token lives in one git-ignored file, .storyblok.env. The tools and the preview servers read it from there, and nothing else does. In the Astro preview, the token stays on the server and never reaches the browser's JavaScript, which a check verified. But the preview serves drafts to anyone who has its URL, so its hosted version must sit behind Cloudflare Access with a login for the developer alone. The Storyblok frame then needs that login in the same browser.
Access tokens are created by the space's owner in Storyblok's settings, which is how it should be: whoever owns the content decides who may read it. So the developer created the two public tokens themselves; until then every tool used the preview token but asked only for published content. An hour later the tokens were in, and the next build read the live spaces.
What it took #
Four commits on one evening. The contract came first. The second commit holds the migration tools, both spaces' content and the Astro site, and is mostly generated: of its 64,000 added lines, the bulk is the content snapshot in web/fixtures/. The hand-written parts are small: the two migration scripts, a renderer, a stylesheet ported from the old site, a content model of about 250 lines.
The old Python generator is still in the repository. It is the fallback until the switch: deploy web/dist/ instead of website/, write new posts in Storyblok, and only then retire tools/blog/.
Takeaways #
- Write the content contract before the code, especially when several agents share a CMS. One file with every field, folder and rule saved a round of mismatches between the migration, the website and the game.
- Draw the line between content and game data. Words and schedules in the CMS, numbers in tested files. The CMS can schedule an event the game knows, never define one.
- Model inline elements as components. A marker in the text plus a small block next to it gives every sidenote its own editable fields, and the same pattern covers any inline element a design needs.
- Make migrations rerunnable and verifiable. Stable ids, one JSON file per story and a read-back check turned dozens of parallel uploads into something you could trust.
- Keep preview tokens out of every build that leaves your machine, and put any preview that serves drafts behind a login.
What's next #
Publishing should rebuild the site by itself. The plan is a Storyblok webhook on "story published" that calls a Cloudflare Pages deploy hook, so a fixed typo is live a minute later without anyone running a build. The hosted preview needs its Access login before the visual editor can use it away from the developer's machine.
The other half of "one source" is the game. The next post follows the changelog onto a notice board in the town square, and the editor's preview into a game engine running in the browser.