2. Moving 128 Real Articles Into Hugo, and the Code Blocks It Quietly Rewrote
We moved our own 128 published articles into Hugo and got clean builds in under 100 ms. The obvious route also rewrote code blocks in 8 of them, turning shell comments into headings, and exited 0.
The WJS Desk
Sep 23, 2026 · 10 min read

By the end of this part you will have a real Hugo site built from a real CMS export: our own 128 published articles, 173,962 words, 266 tags, rendered by templates you wrote yourself in 29 lines. Clean builds took 91 to 103 ms. Live edits rebuilt in 26 to 35 ms.
You will also know the thing that nearly shipped: the obvious migration route rewrote code blocks in 8 of our 128 articles. It turned shell comments into <h1> headings, turned --config into an en-dash, and curled the straight quotes inside curl commands into typographic ones that no shell will accept. The build exited 0 and printed nothing about it.
| Measurement | Markdown files route | Content adapter route |
|---|---|---|
| Clean build, 5 runs (Hugo's "Total in") | 91 to 98 ms | 93 to 103 ms |
| Articles with altered code blocks | 8 of 128 | 0 of 128 |
| Extra step before each build | Run a converter | None |
| Config needed to render HTML bodies | goldmark unsafe = true | security.allowContent |
| HTML files written | 409 | 409 |
Why we did it this way
The Hugo quickstart builds a site from a theme's sample posts. That tells you nothing about the case people actually search for, which is "I have content somewhere else and I want it in Hugo". So we used ours. WhatJustShipped's published articles live in a Postgres table as HTML bodies with a title, slug, date, excerpt, category and tags. We pulled them read-only through the public API, which gave us a 128 element JSON array: 45 tutorials, 39 news pieces, 25 repo spotlights and 19 pulse pieces, published between 31 August and 22 September 2026.
That shape (JSON rows with an HTML body) is roughly what most headless CMS exports and database dumps give you. It is also the hard case, because Hugo is built around Markdown, and HTML bodies have to get through a Markdown renderer or around it.
You do not have our database, so here is a two article sample in the same shape. The second article is the one that breaks. Everything below works on it.
mkdir -p ~/hugo-course/export
cd ~/hugo-course/export
cat > articles.json <<'EOF'
[
{
"slug": "first-export",
"title": "Our First Exported Article",
"published_at": "2026-09-01T09:00:00Z",
"excerpt": "A normal paragraph and a normal code block.",
"category": "tutorial",
"article_tags": [{"tags": {"name": "Hugo"}}],
"content": "<p>This one survives any route.</p>\n\n<pre><code>hugo version</code></pre>"
},
{
"slug": "the-trap",
"title": "The Article Hugo Rewrites",
"published_at": "2026-09-02T09:00:00Z",
"excerpt": "A code block with a blank line, straight under a paragraph.",
"category": "tutorial",
"article_tags": [{"tags": {"name": "Hugo"}}, {"tags": {"name": "Migration"}}],
"content": "<p>Run this:</p>\n<pre><code>curl -s \"https://example.com/api\" \\\n -H \"Accept: application/json\"\n\n# then check the output\necho done</code></pre>"
}
]
EOF
Prerequisites
- Hugo 0.162.0 or newer, extended edition. We used 0.166.0 from Homebrew, set up in part 1.
- Python 3 for the converter route. We used the one macOS ships.
- About 30 minutes. The builds themselves take a tenth of a second.
Step 1: write the templates yourself
We skipped themes on purpose. A theme hides the three decisions that matter in a migration (URL structure, what the list pages show, how tags work) behind somebody else's opinions. Four files cover a whole blog.
cd ~/hugo-course
hugo new site wjs
cd wjs
mkdir -p layouts
cat > layouts/baseof.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ if .IsHome }}{{ site.Title }}{{ else }}{{ .Title }} | {{ site.Title }}{{ end }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<header><a href="{{ "/" | relURL }}">{{ site.Title }}</a></header>
<main>{{ block "main" . }}{{ end }}</main>
</body>
</html>
EOF
cat > layouts/home.html <<'EOF'
{{ define "main" }}
<h1>{{ site.Title }}</h1>
{{ range (.Paginate (where site.RegularPages "Section" "articles")).Pages }}
<article><h2><a href="{{ .RelPermalink }}">{{ .Title }}</a></h2>
<p>{{ .Date.Format "2 Jan 2006" }} · {{ .ReadingTime }} min</p><p>{{ .Summary }}</p></article>
{{ end }}
{{ partial "pagination.html" . }}
{{ end }}
EOF
cat > layouts/page.html <<'EOF'
{{ define "main" }}
<article><h1>{{ .Title }}</h1>
<p>{{ .Date.Format "2 Jan 2006" }} · {{ .WordCount }} words{{ with .GetTerms "tags" }} · {{ range . }}<a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a> {{ end }}{{ end }}</p>
{{ .Content }}</article>
{{ end }}
EOF
cat > layouts/list.html <<'EOF'
{{ define "main" }}
<h1>{{ .Title }}</h1>
<ul>{{ range .Pages }}<li><a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a></li>{{ end }}</ul>
{{ end }}
EOF
printf '\n[pagination]\n pagerSize = 20\n' >> hugo.toml
list.html does triple duty: the /articles/ section page, the /tags/ index, and every individual tag page. pagination.html is one of Hugo's built-in partials, so you do not write it.
Step 2, route A: convert the export to content files
The route everyone tries first is a script that writes one file per article into content/. Ours was 14 lines of Python and converted all 128 articles in 0.04 seconds.
cp ~/hugo-course/export/articles.json .
cat > convert.py <<'EOF'
import json, pathlib
out = pathlib.Path("content/articles")
out.mkdir(parents=True, exist_ok=True)
for a in json.load(open("articles.json")):
tags = [t["tags"]["name"] for t in a.get("article_tags") or []]
front = {
"title": a["title"],
"date": a["published_at"],
"summary": a["excerpt"],
"categories": [a["category"]],
"tags": tags,
}
body = json.dumps(front, indent=2, ensure_ascii=False) + "\n\n" + a["content"] + "\n"
(out / f"{a['slug']}.md").write_text(body)
print(len(list(out.glob("*.md"))), "files")
EOF
python3 convert.py
hugo
Hugo accepts JSON front matter as-is: a file that opens with { is read as JSON, one that opens with --- as YAML, +++ as TOML. Our first version of the script got this wrong, and it is the first thing that broke.
What broke, part one: the loud failures
JSON inside YAML fences
Our first converter wrapped the JSON object in --- lines, as if it were YAML. Hugo stopped at the first file:
ERROR error building site: assemble: failed to create page from pageMetaSource /articles/ai-course-1-first-10-minutes: "content/articles/ai-course-1-first-10-minutes.md:3:46": [2:46] value is not allowed in this context. map key-value is pre-defined
This one was our fault, and Hugo handled it well: file, line, column, and a caret under the trailing comma. Loud failures are the good kind.
Every article rendered as zero words
With the front matter fixed, the build succeeded in 83 ms with exit status 0. It also printed 128 warnings, one per article:
WARN Raw HTML omitted while rendering "content/articles/typst-1-install.md"; see https://gohugo.io/getting-started/configuration-markup/#rendererunsafe
Every article page said "0 words". Across the site, Hugo had replaced 4,669 blocks of our HTML with <!-- raw HTML omitted -->. Goldmark, Hugo's Markdown renderer, strips raw HTML by default, which is a sensible security default for sites with contributors you do not trust and a baffling one the first time you migrate your own content. The fix is one setting:
printf '\n[markup.goldmark.renderer]\n unsafe = true\n' >> hugo.toml
rm -rf public
hugo
With that, the site built with no warnings and every article had its body back. Five clean builds reported 91, 96, 96, 98 and 98 ms. On to the one that did not warn.
What broke, part two: the silent one
We built the same site both ways (route A here, route B below) and diffed the output. The two routes should have produced identical article bodies. For 120 articles they did. For 8, route A had rewritten the code. Here is the-trap from the sample above, rendered by route A:
<p>Run this:</p>
<pre><code>curl -s "https://example.com/api" \
-H "Accept: application/json"
<h1 id="then-check-the-output">then check the output</h1>
<p>echo done</code></pre></p>
The shell comment became a top-level heading, with an id that would appear in any table of contents. echo done became a paragraph, and the closing tags nest in an order no browser can render correctly. In our longer articles it was worse: after the break, Goldmark's typographer ran over the rest of the code, so "$URL/rest/v1/rls_demo" became curly quotes, --config became an en-dash, and trailing backslashes became <br> tags. A reader copying those commands gets a shell error. Our Supabase row-level security tutorial alone had 8 extra paragraph breaks inside code.
The cause is a CommonMark rule, not a Hugo bug. A <pre> that starts its own HTML block ends at </pre>, blank lines included. But a <pre> on the line directly under another HTML line, like <p>Run this:</p>, is swallowed into that paragraph's block, and that kind of block ends at the first blank line. Everything after the blank line is parsed as Markdown. We checked the rule against our data: it predicted exactly the 8 articles that broke, from 11 code blocks among 363.
Watch out: the trigger is two conditions together: a code block directly under another HTML element with no blank line between them, and a blank line inside the code. Most exports produce both, because HTML serialisers do not add blank lines between elements and code often contains them. There is no warning, and the build exits 0.
If you stay on route A, one regex in the converter fixes it by forcing a blank line before every <pre>:
sed -i '' 's/import json, pathlib/import json, pathlib, re/' convert.py
sed -i '' 's/+ a\["content"\] +/+ re.sub(r"\\n*<pre", "\\n\\n<pre", a["content"]) +/' convert.py
rm -rf content/articles public
python3 convert.py
hugo
After that, all 128 article bodies matched route B once whitespace was ignored. Two articles still reported a word count 1 or 2 words higher, because Hugo counts words in the Markdown source rather than the rendered text.
Step 3, route B: skip the files with a content adapter
Since v0.126, Hugo can create pages straight from data using a content adapter: a template named _content.gotmpl inside a content folder. No converter, no generated files, and the HTML never touches the Markdown renderer.
cd ~/hugo-course
hugo new site wjs-adapter
cp -R wjs/layouts wjs/hugo.toml wjs-adapter/
cd wjs-adapter
mkdir -p assets content/articles
cp ~/hugo-course/export/articles.json assets/
cat > content/articles/_content.gotmpl <<'EOF'
{{ $articles := resources.Get "articles.json" | transform.Unmarshal }}
{{ range $articles }}
{{ $tags := slice }}
{{ range .article_tags }}{{ $tags = $tags | append .tags.name }}{{ end }}
{{ $.AddPage (dict
"path" .slug
"title" .title
"dates" (dict "date" (time.AsTime .published_at))
"summary" .excerpt
"params" (dict "categories" (slice .category) "tags" $tags)
"content" (dict "mediaType" "text/html" "value" .content)
) }}
{{ end }}
EOF
printf '\n[security]\n allowContent = [".*"]\n' >> hugo.toml
hugo
Two things broke on the way to that file, and one was silent.
First, the build refused the HTML outright:
access denied: "text/html" is not whitelisted in policy "security.allowContent"
Hugo 0.162.0 (26 May 2026) started denying text/html content by default, for adapter pages and for plain .html files in content/ alike. We confirmed both. The release notes give the opt-in above. It is a real trade-off: raw HTML content is an XSS route if anyone else can write to your content, so only opt in for content you control. Any tutorial older than May 2026 that shows HTML content files will fail on current Hugo with this error.
Second, our first adapter passed "date" at the top level, the way front matter does. Hugo accepted it without complaint and dated every article 1 January 0001. Adapter pages take dates inside a dates map. There is no warning for an unknown key, so check a rendered date before you check anything else.
With both fixed, five clean builds reported 93 to 103 ms, within noise of route A. Both routes wrote 684 files, 409 of them HTML, 2.44 MB in total.
What the site looks like at this size
Hugo's summary reported 677 pages. What we found on disk: 128 article pages, 267 tag pages (266 tags plus the index), 5 category pages, the home page, 6 more paginator pages, a /page/1/ redirect and the /articles/ section page. That adds up to the 409 HTML files. Tag pages were 31% of the output by bytes (722 KB of 2.44 MB), because our 266 tags include 191 that are used exactly once. Hugo makes a page for each one. That is a content decision, not a Hugo problem, but a migration is the moment to notice it.
hugo --minify took the output from 2,438,589 bytes to 2,349,731, a 3.6% saving. The whole site gzipped to 761 KB.
Under hugo server, five edits to one article rebuilt in 31, 35, 26, 27 and 27 ms by Hugo's count, and editing hugo.toml triggered a full rebuild that picked up the new title without a restart.
Common mistakes
- Wrapping JSON front matter in
---fences. JSON front matter needs no fences. - Migrating HTML bodies without
unsafe = trueand trusting the exit code. - Trusting that
unsafe = truemeans HTML passes through untouched. It does not, as the 8 articles show. - Following a pre-May 2026 guide that uses
.htmlcontent files and hittingsecurity.allowContent. - Passing
dateinstead ofdatestoAddPage. - Importing every tag from the old system, including the 191 used once.
What we did not test
All timings are from one Apple M4 Pro with 48 GB of RAM, Hugo 0.166.0, and a warm filesystem cache. We did not test images, shortcodes, multilingual content, or a third-party theme on this corpus. We did not check the WordPress or Ghost exporters directly; we are saying their output has the same shape, not that we ran them. And 128 articles is a small site. It proves the migration path, not the speed claim at scale.
Next
Route B is the one we would keep: no generated files, no Markdown renderer in the way, and a single JSON file as the source of truth. In part 3 we push it to thousands of pages, feed it bad input on purpose, and find out which mistakes Hugo catches and which it publishes.


