<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Hugo on My Little Blog</title><link>https://www.parkerchenca.com/tags/hugo/</link><description>Recent content in Hugo on My Little Blog</description><generator>Hugo</generator><language>en-us</language><copyright>Parker Chen</copyright><lastBuildDate>Thu, 10 Sep 2026 13:00:00 +0000</lastBuildDate><atom:link href="https://www.parkerchenca.com/tags/hugo/index.xml" rel="self" type="application/rss+xml"/><item><title>Migrating to Hugo</title><link>https://www.parkerchenca.com/tech/migrating-to-hugo/</link><pubDate>Thu, 10 Sep 2026 13:00:00 +0000</pubDate><guid>https://www.parkerchenca.com/tech/migrating-to-hugo/</guid><description>Why NotionNext had to go, and few problems Hugo handed me on the way in.</description><content:encoded><![CDATA[<p>I moved my blog to Hugo. I&rsquo;ve had some free time in the gap between
graduating and landing a job, so I finally got around to something I&rsquo;d been
putting off for a while.</p>
<h2 id="why-migrate-at-all">Why migrate at all</h2>
<p>A few things pushed me toward it. A couple of years into the Computer
Programming and Analysis program, I&rsquo;d seen enough tools and tradeoffs that
&ldquo;run a lighter stack&rdquo; stopped being an abstract idea and turned into an
actual plan. <a href="https://github.com/notionnext-org/NotionNext">NotionNext</a> is a
good project. I&rsquo;ll get into why it stopped working for me, but it&rsquo;s more machinery than a
personal blog needs.</p>
<p>I&rsquo;d also run out of excuses not to touch it. One of my rules is: if it works, don&rsquo;t
touch it, and that&rsquo;s exactly why this sat on my todo list for so long.
But &ldquo;I don&rsquo;t fully understand web app internals yet&rdquo; was not true
for me anymore, and once that excuse is gone, all that&rsquo;s left is a task
I&rsquo;m avoiding.</p>
<p>And practically, between graduation and the next job I have more free time
than I&rsquo;ll have again for a while, so it made sense to sit with my notes and
actually figure out what I&rsquo;d learned. Getting back into regular blogging
needed a platform I&rsquo;d actually want to open.</p>
<p>I also looked at Hexo and Astro. The short version of why Hugo won is in
<a href="/tech/building-this-blog/">Building This Blog</a>: it comes down to how content
maps to URLs and how each one handles Org-mode.</p>
<p>The longer version is that this year I went through IDEA, VS Code, Neovim, and Emacs for different kinds
of work. IDEA is still my favorite for Java: its indexing and language
support make everything else feel like it&rsquo;s guessing. VS Code is the
balanced choice once you&rsquo;re off Java or working on something large; the
ecosystem covers file browsing, Markdown rendering, and even reading a PDF
without leaving the editor. And since I live in a terminal (WSL as my main
environment, so I get fzf, rg, and the Linux tooling that things like Claude
Code supported first) right now, Neovim is hard to pass up: it&rsquo;s light, fast, and
everything stays on the keyboard.</p>
<p>None of that is actually why I picked Hugo, though. I picked Hugo because I
fell for <em>Emacs</em>. It&rsquo;s the first tool that gave me what &ldquo;one unified
environment&rdquo; actually means. The Org mode is the part I like most: think of
it as Markdown crossed with a Jupyter notebook. When I want code output
sitting inside my notes instead of copy-pasted from a terminal, Org is about
the best format for that. Hugo happens to parse Org natively, so the fit was
already there before I went looking for it.</p>
<h3 id="why-not-just-stay-on-notionnext">Why not just stay on NotionNext</h3>
<p>This is the part with real evidence behind it, not just preference:</p>
<ol>
<li>
<p>It&rsquo;s a React app with a long dependency chain.</p>
</li>
<li>
<p>It&rsquo;s hard-coupled to the Notion API. A major API update once took the
whole blog down in one shot, and there was nothing I could do about it on
my end.</p>
</li>
<li>
<p>Forking is the wrong shape for this. Maintaining my own fork meant every
upstream change was a potential merge conflict and a version-bump
decision I didn&rsquo;t want to make.</p>
</li>
<li>
<p>Error rates I never got to the bottom of. Vercel&rsquo;s dashboard would
occasionally show something like this:</p>
<figure>
       <img loading="lazy" src="high-error-rate.png"
            alt="Vercel observability panel showing a 58% error rate"/> <figcaption>
               <p>Vercel&rsquo;s own dashboard, mid outage.</p>
           </figcaption>
   </figure>

</li>
<li>
<p>The deployments themselves were flaky, on top of everything else. A run
of &ldquo;Updated&rdquo; deploys failing back to back on production isn&rsquo;t something
you want in your history:</p>
<figure>
       <img loading="lazy" src="deployment-errors.png"
            alt="A string of failed Vercel deployments"/> <figcaption>
               <p>A run of production deploys failing back to back.</p>
           </figcaption>
   </figure>

</li>
<li>
<p>React&rsquo;s CVE treadmill. Every so often there was a fresh CVE to patch that
had nothing to do with anything I&rsquo;d written:</p>
<figure>
       <img loading="lazy" src="cve-fix.png"
            alt="A commit titled &#39;Fix React Server Components CVE vulnerabilities&#39;"/> <figcaption>
               <p>A CVE patch that had nothing to do with anything I&rsquo;d written.</p>
           </figcaption>
   </figure>

</li>
</ol>
<p>None of that is exotic. It&rsquo;s just the cost of running someone else&rsquo;s React
app instead of a static site, and I&rsquo;d paid it long enough.</p>
<h2 id="how-i-migrated">How I migrated</h2>
<h3 id="getting-oriented">Getting oriented</h3>
<p>I started at Hugo&rsquo;s own site, found the themes gallery, and looked for
something that fit a personal, bilingual blog. PaperMod stood out: MIT
licensed, 13.9k GitHub stars, still getting updates, and the demo already
looked close to what I wanted without any customization.</p>
<figure>
    <img loading="lazy" src="hugo-themes.png"
         alt="Hugo&#39;s themes page showing the PaperMod theme"/> <figcaption>
            <p>PaperMod&rsquo;s listing in Hugo&rsquo;s theme gallery.</p>
        </figcaption>
</figure>

<p>From there it was the usual mix: reading the theme&rsquo;s own docs, skimming its
GitHub repo for how people actually configure it, and asking an AI assistant
questions that would&rsquo;ve otherwise cost an evening of trial and error, mostly
which config keys are current versus deprecated, and how Hugo&rsquo;s page bundles
are supposed to be laid out.</p>
<h3 id="actually-doing-it">Actually doing it</h3>
<p>The content move itself was mechanical: 45 posts pulled out of a Notion
export, cleaned up, and dropped into <code>content/</code>. The interesting part was
everything that broke after the content was in place, and none of it showed
up until I actually tried to use the feature.</p>
<h4 id="one-issue-oversized-record-failed-the-whole-deploy">One Issue: oversized record failed the whole deploy</h4>
<p>My Algolia push script truncates each post&rsquo;s content to stay under Algolia&rsquo;s
10KB per-record limit, but I&rsquo;d only truncated <code>content</code>, not <code>summary</code>. Hugo
builds an automatic summary by grabbing whole content blocks, and one post
that opened with a long block produced an 11KB summary. Algolia rejected the
record, the push script exited non-zero, and that failed the entire Vercel
build: not a partial deploy with one missing search result, a hard stop.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="s">&#34;summary&#34;</span><span class="w"> </span><span class="p">(.</span><span class="nx">Summary</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nx">plainify</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nx">truncate</span><span class="w"> </span><span class="mi">300</span><span class="w"> </span><span class="s">&#34;&#34;</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nx">htmlUnescape</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nx">htmlUnescape</span><span class="p">)</span><span class="w">
</span></span></span></code></pre></div><h3 id="cutting-over">Cutting over</h3>
<p>The old blog was still live on Vercel, wired to <a href="https://www.parkerchenca.com">www.parkerchenca.com</a> and
parkerchenca.com. First I paused that project.</p>
<p>Then it was straightforward: pull the domain off the old project, import the
new repo as its own Vercel project, add the environment variables, and point
the domain back at the new one. Vercel won&rsquo;t let the same domain sit on two
projects at once, so the order matters.</p>
<p>Comments were the last piece: giscus needed the repository to be public with
GitHub Discussions turned on and its own GitHub App installed before it would
actually render anything.</p>
<p>And then, boom, it was done. Simpler than I expected, and the environment
variables even came out to about half of what NotionNext needed.</p>
<h3 id="where-that-leaves-things">Where that leaves things</h3>
<p>The blog is live, search works, comments work (I think the light/dark toggle
also flips the comment widget now, but I haven&rsquo;t actually tested it in a
browser myself. If you&rsquo;ve read this far, give it a try and tell me in the
comments below ;D ).</p>
<h2 id="useful-links">Useful links</h2>
<p>Hugo:</p>
<ul>
<li><a href="https://github.com/adityatelange/hugo-PaperMod">https://github.com/adityatelange/hugo-PaperMod</a></li>
<li><a href="https://github.com/adityatelange/hugo-PaperMod/wiki">https://github.com/adityatelange/hugo-PaperMod/wiki</a></li>
</ul>
<p>My Blog:</p>
<ul>
<li><a href="https://github.com/BHChen24/MyLittleBlog-Hugo/">https://github.com/BHChen24/MyLittleBlog-Hugo/</a></li>
</ul>
]]></content:encoded></item><item><title>Building This Blog</title><link>https://www.parkerchenca.com/tech/building-this-blog/</link><pubDate>Wed, 09 Sep 2026 00:00:00 +0000</pubDate><guid>https://www.parkerchenca.com/tech/building-this-blog/</guid><description>Hugo, PaperMod, Algolia, and the four things that broke along the way.</description><content:encoded><![CDATA[<p>This site runs on Hugo with the PaperMod theme, bilingual content, and Algolia
for search. None of those choices were difficult. Wiring them together produced
four bugs that were, so this post is mostly about those.</p>
<h2 id="why-hugo">Why Hugo</h2>
<p>The decision came down to Hugo against Hexo. Two requirements settled it: content
organised by directory, and a tolerable path for Org-mode files.</p>
<p>Hugo maps <code>content/</code> directly onto URL structure. A file at
<code>content/tech/foo.md</code> becomes <code>/tech/foo/</code> and the <code>tech</code> directory becomes a
section with its own list page. No plugin, no configuration. Hexo can be pushed
into the same shape with <code>permalink</code> rules and a category plugin, but it is a
workaround rather than the design.</p>
<h2 id="the-org-mode-detour">The Org-mode detour</h2>
<p>Hugo ships Org support through go-org, so <code>.org</code> files render without a plugin.
That worked, briefly:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">ERROR access denied: &#34;text/org&#34; is not whitelisted in policy &#34;security.allowContent&#34;
</span></span></code></pre></div><p>Hugo 0.166 denies <code>text/org</code> by default. The parser is present; the security
policy blocks it. Adding an override fixed it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">security</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">allowContent</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="s1">&#39;! ^text/html$&#39;</span><span class="w">
</span></span></span></code></pre></div><p>The override came back out later. Writing Org and exporting to Markdown with
ox-hugo keeps a single renderer in play, so Hugo only ever sees Markdown and the
security policy stays at its default. One less deviation from upstream.</p>
<h2 id="configuration-renames">Configuration renames</h2>
<p>Two config keys changed name in Hugo 0.158 and the older names still appear in
most tutorials:</p>
<table>
	<thead>
			<tr>
					<th>Old</th>
					<th>New</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>languageCode</code></td>
					<td><code>locale</code></td>
			</tr>
			<tr>
					<td><code>languages.&lt;lang&gt;.languageName</code></td>
					<td><code>languages.&lt;lang&gt;.label</code></td>
			</tr>
	</tbody>
</table>
<p>Both still work and both emit deprecation warnings. PaperMod&rsquo;s templates still
call <code>.Language.LanguageCode</code> internally, so the warnings appear even with a
fully modern config. They are the theme&rsquo;s, not yours.</p>
<h2 id="search">Search</h2>
<p>PaperMod bundles Fuse.js. It builds a JSON index at compile time, ships it to
the browser, and matches locally. Zero infrastructure, and for a small English
site it is the correct choice.</p>
<p>Two things pushed this site to Algolia instead. The whole index downloads on
every visit to the search page, which does not scale past a few hundred posts.
And Fuse does character-level fuzzy matching with no CJK tokenisation, which
matters for a site that is half Chinese.</p>
<p>There is no official Algolia integration for Hugo. The community packages —
<code>hugo-algolia</code>, <code>atomic-algolia</code> — stopped being maintained years ago and are
pinned to client v4 while current is v5. So: a template that emits records, a
search page built on InstantSearch, and a push script. Under 200 lines total,
all of it sitting on official libraries.</p>
<h3 id="the-escaping-bug">The escaping bug</h3>
<p>The search page passes configuration into JavaScript:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="kd">var</span><span class="w"> </span><span class="nx">cfg</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="p">{{</span><span class="w"> </span><span class="nx">dict</span><span class="w"> </span><span class="s">&#34;appId&#34;</span><span class="w"> </span><span class="err">$</span><span class="nx">appId</span><span class="w"> </span><span class="s">&#34;indexName&#34;</span><span class="w"> </span><span class="err">$</span><span class="nx">index</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nx">jsonify</span><span class="w"> </span><span class="p">}};</span><span class="w">
</span></span></span></code></pre></div><p>Go&rsquo;s <code>html/template</code> knows this is a JavaScript context and escapes the value
into a <em>string literal</em>. The result:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="kd">var</span> <span class="nx">cfg</span> <span class="o">=</span> <span class="s2">&#34;{\&#34;appId\&#34;:\&#34;...\&#34;,\&#34;indexName\&#34;:\&#34;blog_en\&#34;}&#34;</span><span class="p">;</span>
</span></span></code></pre></div><p><code>cfg.indexName</code> is <code>undefined</code>. InstantSearch initialises against an undefined
index and the page renders an empty search box with no error. Wrapping the value
in <code>JSON.parse()</code> fixes it without reaching for <code>safeJS</code>, which would have
disabled the escaping that exists for good reason.</p>
<h3 id="the-truncate-bug">The truncate bug</h3>
<p>Records are truncated to stay under Algolia&rsquo;s 10KB limit:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">.</span><span class="nx">Content</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nx">plainify</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nx">htmlUnescape</span><span class="w"> </span><span class="p">|</span><span class="w"> </span><span class="nx">truncate</span><span class="w"> </span><span class="mi">2000</span><span class="w"> </span><span class="s">&#34;&#34;</span><span class="w">
</span></span></span></code></pre></div><p>Entities kept appearing in the index as <code>&amp;#34;</code>. Hugo&rsquo;s <code>truncate</code> is HTML-aware
and re-escapes on output, so it has to run <em>before</em> the unescaping, not after.</p>
<h3 id="the-leftover-fuse-bug">The leftover Fuse bug</h3>
<p>PaperMod injects its Fuse bundle conditionally:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="cl"><span class="p">{{</span><span class="o">-</span><span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="nx">eq</span><span class="w"> </span><span class="p">.</span><span class="nx">Layout</span><span class="w"> </span><span class="s">`search`</span><span class="p">)</span><span class="w"> </span><span class="o">-</span><span class="p">}}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nx">link</span><span class="w"> </span><span class="nx">rel</span><span class="p">=</span><span class="s">&#34;preload&#34;</span><span class="w"> </span><span class="nx">as</span><span class="p">=</span><span class="s">&#34;fetch&#34;</span><span class="w"> </span><span class="nx">href</span><span class="p">=</span><span class="s">&#34;../index.json&#34;</span><span class="p">&gt;</span><span class="w">
</span></span></span></code></pre></div><p>The Algolia search page still declared <code>layout: &quot;search&quot;</code>, so the theme injected
15KB of Fuse and preloaded an <code>index.json</code> that no longer existed. Renaming the
layout to <code>algolia</code> sidesteps the condition entirely.</p>
<h2 id="what-is-left">What is left</h2>
<p>Comments are wired to giscus but switched off — the partial renders nothing
until a repository is configured. Analytics is a commented-out block waiting on
a GA4 property. Both are switches rather than commitments.</p>
<p>The remaining duplication is the Algolia index name, which appears in
<code>hugo.yaml</code> and again in the push script. Removing it would mean teaching the
script to parse YAML. Two constants do not justify a dependency.</p>
<h2 id="notes-to-self">Notes to self</h2>
<ul>
<li>Run <code>hugo server -e production</code> when checking SEO tags; the default
development environment omits OpenGraph, Twitter Cards, and JSON-LD entirely.</li>
<li>The index has to be pushed after every deploy. Fuse indexes travel with the
build; Algolia indexes live on someone else&rsquo;s server.</li>
<li>Test content needs to be long enough to scroll. The back-to-top button only
appears after a full viewport of scrolling, which a sixty-word post will never
trigger.</li>
</ul>
]]></content:encoded></item></channel></rss>