article
Moving markdown-style Storyblok content to richtext through the MCP
TL;DR Convert offline, push drafts with publish: false, diff each draft mechanically against the converter output, then publish in a separate step. Turn off markdown-it's typographer, and do not fall back to first_published_at for article dates.
This is part 3 of 4. Part 1 is the overview and part 2 the Astro SSR setup. The content was already in Storyblok, but not as richtext, and this part is about moving it, with the Storyblok MCP doing the pushes.
What you need
The Storyblok MCP connected to Claude Code. It signs in with OAuth in the browser, so there is no token to create or paste (setup docs).
A preview access token in your environment as
STORYBLOK_PREVIEW_TOKEN, plusSTORYBLOK_REGION. The MCP cannot read the draft CDN, so the diff script uses this token to fetch each draft. Where it comes from and where to keep it is in the secrets section of part 2.The old content as source of truth on disk: here the markdown of the 20 stories and a converter that turns it into richtext.
Credentials stay out of the MCP session. I never pasted a token into a prompt, and the scripts read theirs from the environment and print counts and statuses only.
What had to move
The old site was a Nuxt 2 static export from June 2023, with URLs of the form /a/<slug>/. The space held 16 articles and 4 pages (home, about, license, legal notice). Two groups of articles are series: Protect Secrets has 3 parts and GitHub Actions has 7.
The old Article stored its text in a body field made of nested bloks: ArticleText held markdown, ArticleCode a code string with a type, ArticleImage an image with a caption, and ArticleQuote a quote with a link. The new Article has one richtext field, content, that allows only Figure, Callout, CodeTabs, Steps, Terminal, EdgeProbe and RequestFlow. Richtext keeps the whole article in one editable document, and the blok whitelist is what makes the interactive parts of this series possible.
Converting markdown to richtext
The converter ran offline in the container, from a backup of each story's original JSON. Storyblok publishes @storyblok/richtext with markdownToStoryblokRichtext and htmlToStoryblokRichtext, so I did not write a markdown parser. One option inside it had to go.
const md = new MarkdownIt({ html: false, linkify: true, typographer: false, breaks: true });
const markdownToStoryblokRichtext = (src) => htmlToStoryblokRichtext(md.render(src));} else if (b.component === 'ArticleImage') {
content.push({ type: 'blok', attrs: { id: randomUUID(), body: [{
_uid: randomUUID(), component: 'Figure', image: b.image, caption: b.caption ?? '', attribution: '' }] } });As far as I know, the package's own markdown parser needs a DOM, which Workers lack, so the site's legacy fallback path uses marked instead.
The other mappings:
Code. An
ArticleCodebecame acode_blockwith a language class from the old type string. The converter mapsymltoyaml,jstojavascript,kttokotlin,mktomakefile,gradletogroovyandcplustocpp. Nine fences have no language, and the converter does not invent one.Images. An
ArticleImagebecame aFigureblok. The caption and the asset object moved over unchanged, soaltis whatever the old asset had, mostly empty. Alt text for the 2020 screenshots is still open.Quotes. An
ArticleQuotebecame a blockquote followed by a paragraph that starts with an em dash and links to the source, when there was one.Removed on purpose. The trailing
## Feedbacksection (a call to tweet at me) and the hand-writtenContinue ReadingandPrevious partslists. The new site builds series navigation from theseriesandseries_partfields.Cover images. The Unsplash credit lives in the cover image's
copyrightfield, so it moved with the cover.
The check compares text, not layout: extract plain text from the old side (with marked) and from the new richtext, strip all whitespace, compare. 20 of 20 passed. That proves text and order, not nesting or heading levels, so look at the pages too. In caching-in-ga the site's heading normalization (smallest heading becomes h2, jumps clamped to one level) turned sibling sections into parent and child, because the article opens with a deeper heading than it later uses.
Pushing through the MCP
The MCP has no file input, so the converted stories (about 277 KB together) had to be typed into the calls, and the model retypes every payload. Moving the 20 stories produced three typing slips in two of them: a duplicated, truncated first paragraph in license, a starred_url that lost {/repo}, and a url that came out as api.github.com/repos/… instead of github.com/…. The defence is a fixed order:
Step 1 of 5
Back up the originals
Save every story as JSON before any write. Use a field subset, or strip preview_token from full responses.
Push drafts with publish false
New content, date, type, series and series_part go in with updateStory and publish: false. The update-story docs say false "does not unpublish a story that is already published". All 20 stayed published: true with unpublished_changes: true, so the live site was untouched.
Diff the drafts mechanically
Read each draft from the CDN with ?version=draft, strip _uid, attrs.id and _editable, and compare with the converter output. Re-push until there are zero differences.
Publish in a separate stage
Call publishStory for the 22 stories (the 20 plus two Series stories), series first, with no updateStory in that stage. Then compare again: published equals draft, and richtext equals the converter output.
Look at the live site
All 24 URLs (index, 16 articles, 3 pages, 2 series, RSS, sitemap) returned 200 with the new titles and no leftover legacy body.
The mechanical diff caught two of the three typos; the duplicated paragraph in license was caught during the push, before the diff ran. Only versions with zero differences were published.
What else the MCP taught me
Operation ids.
listComponentsdoes not exist, the real one islistManagementComponents; the error tells you tosearch.fieldsis top level only.story.content.seriesis rejected withField selection failed; selectstory.*fields.sort_by.describeshows a plain string, andfull_slug:ascreturns a 422Not sortable by this column.updateStorytakesid, notstory_id.updateComponentreplaces the whole schema. Fetch the current one, merge your change, send all of it. Storyblok adds a generated id to each field.Credentials stay out of reach. Claude Code denied a read of the
first_tokenfield ofgetSpaceas credential access. With OAuth, the MCP needs no token from you.
The date trap
Storyblok gives a story three date candidates: my own date field, created_at and first_published_at. The first version of the new site fell back to first_published_at when date was missing. That was wrong for 7 of the 16 articles, and production showed those dates for a while after the cutover.
It showed up as a preview and a production render of the same article one day apart, which looked like a timezone bug. Timezones do matter a little: in this space datetime fields come back in UTC, and the site renders the calendar day in Europe/Vienna. Migrated dates are stored as YYYY-MM-DD 00:00 UTC, which is 01:00 or 02:00 in Vienna, the same day.
URL compatibility
The old URLs were /a/<slug>/ with a trailing slash, and /a/<slug> answered with a 301 to the slash form. The new site keeps both, with a middleware rather than Astro's trailingSlash: 'always' (part 2 explains why). On the first deployment, /a/adb-tricks/ returned 200 and /a/adb-tricks returned 301.
Two things changed on purpose. Series have their own pages, /s/<series>/, and series parts are listed under the series, not individually on the index. I did not check external links to old pages beyond the site's own 24 URLs.
Cleaning up the schema
With the content moved, the old body field and its components could go. Deleting components is destructive, so it ran in two stages, the second only after an explicit go from me. First, without touching the schema:
Save the old component schemas (
ArticleText,ArticleCode,ArticleImage,ArticleQuote,ArticleReferences,intro,Newsletter,page,TextSection, plus the oldbodyfield definitions) tomigration/backup/components-old.json.Strip the legacy
bodyfrom the six stories that still had it, publish, and check each result on the CDN.Scan all 22 stories on the draft CDN: no
bodykey left, and no old component in use except lowercasepage, still the root component of the four pages.
Then the schema change:
Re-check that none of the eight old components is used at any depth.
Remove only the
bodyfield fromArticleandPage, re-reading each schema afterwards.Delete the eight components one at a time, re-listing after each. Lowercase
pagestays.
A read-only listing confirms the result:
⏺ execute_readonly operation="listManagementComponents" parameters={"space_id":93903} fields=["components.name"] region="eu" {"components":[{"name":"Article"},{"name":"Callout"},{"name":"CodeTab"},{"name":"CodeTabs"},{"name":"EdgeProbe"},{"name":"Figure"},{"name":"FlowNode"},{"name":"page"},{"name":"Page"},{"name":"RequestFlow"},{"name":"Series"},{"name":"Step"},{"name":"Steps"},{"name":"Terminal"},{"name":"TermLine"}]}
Eight components are gone compared with before the cleanup: ArticleCode, ArticleImage, ArticleQuote, ArticleReferences, ArticleText, intro, Newsletter and TextSection.
What I would do again
The order: backup, drafts, diff, publish, diff again, then delete. Each failure here was caught by comparing against something independent: the drafts against the converter output, the text against a second parser, the dates against the old site. Part 4 puts the finished site behind an edge cache.