When I looked into the beautifulhugo repository, I noticed that the current master (referred to as the current version below), which is separate from the legacy version I have been using, bundles dark mode, search and a table of contents (TOC) all together.
I had actually tried once before, in a commit called “Update Beautifulhugo”, to bump only the pin in go.mod, but 34 minutes later I reverted it. The commit log does not record what happened, but at that time I swapped only the pin without thinking about things like the breaking changes from Bootstrap 3 to 5, so the display probably broke and I rolled it back immediately.
This time, as a rematch, I decided to redo the migration from the legacy version to the current version in separate stages.
The first attempt: I bumped only the pin and reverted 34 minutes later
My first attempt was simple: I only rewrote the version string in go.mod.
-require github.com/halogenica/beautifulhugo v0.0.0-20260331144909-84b4ad9e12e6 // indirect
+require github.com/halogenica/beautifulhugo v0.0.0-20260706190448-b2d547f7a61c // indirect
It became a perfectly ordinary update commit as far as Hugo Modules go, one that only appends the hash of the new version to go.sum.
Yet on the same day I went back to the original version with a “Revert” commit. The current version is a large-scale overhaul that includes the move from Bootstrap 3 to 5, and I swapped only the theme while my own layouts (layouts/) had not caught up, so I think the display broke because of CSS falling apart or JS not being loaded. The short interval of 34 minutes tells you how flustered I was.
Noticing the _vendor directory trap
While bumping the pin up and down, I added one note to the article I wrote earlier about migrating to Hugo Modules.
When you run hugo mod vendor, the contents of the theme are copied into the _vendor directory, but if you put that in .gitignore and keep it out of version control, it becomes a cause of build results diverging between local and Cloudflare Pages. Because Hugo gives priority to _vendor when it exists, even if you bump the version in go.mod, as long as the local _vendor stays old the build keeps using the pre-update theme. Cloudflare Pages, on the other hand, clones the repository fresh every time, so _vendor does not exist there and the build uses the latest theme as specified in go.mod.
This repository never used hugo mod vendor in the first place, so it had no direct bearing on this revert incident, but so as not to step on the “it does not break locally but breaks only on Cloudflare” kind of accident in the future, I wrote down explicitly that the policy is not to create _vendor.
The real migration: from the legacy version to the current version
After regrouping, I updated the pin in go.mod to the same version once again.
-require github.com/halogenica/beautifulhugo v0.0.0-20260331144909-84b4ad9e12e6 // indirect
+require github.com/halogenica/beautifulhugo v0.0.0-20260706190448-b2d547f7a61c // indirect
This time I verified it in a clean state without creating a _vendor directory. The current version is the same one I had tried to adopt and reverted before, but unlike that time, I decided to deal with the breaking changes from Bootstrap 3 to 5 myself before pulling it in.
Handling the moved JS loading location
This was the one that mattered most. In the current version the place where JS is loaded has moved out of footer.html, and unless you add that call on the layouts/_default/baseof.html side, jQuery, Bootstrap, KaTeX and the rest all stop being loaded.
- {{ partial "footer.html" . }}
- {{ block "footer" . }}{{ end }}
+ {{ block "footer" . }}
+ {{ partial "footer.html" . }}
+ {{ partial "footer_custom.html" . }}
+ {{ partial "scripts.html" . }}
+ {{ end }}
I think the missing call was probably the main reason the display broke on the first attempt where I only bumped the pin (I have not confirmed it).
Bootstrap 3 to 5 class name changes
I rewrote the grid classes and data attributes in layouts/_default/single.html.
- <div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1">
+ <div class="col-lg-8 offset-lg-2 col-md-10 offset-md-1">
Pre-Bootstrap 4 data attributes such as data-toggle="tooltip" also need to be changed to data-bs-toggle, and at this point I only handled the pager part (I would notice the class names of the related posts list and the Disqus button in a later fix).
Updating the CDN versions
I updated the version strings of the CDNs loaded in layouts/partials/head.html.
| Library | Old | New |
|---|---|---|
| Bootstrap | 3.4.1 | 5.3.8 |
| Font Awesome | 5.5.0 | 7.2.0 |
| PhotoSwipe | 4.1.2 | 5.4.4 |
| KaTeX | 0.16.7 | 0.16.45 |
The policy for taking in CSS and JS
For static/css/main.css I used the current version of the theme itself as the base and only re-applied the header background color tweak I had added myself (the diff came to over 1,800 lines, so I am not listing it individually). For static/js/main.js I took in the current version’s Bootstrap 5 tooltip initialization, keyboard operation support and prefers-reduced-motion support, while I did not take in the theme’s code copy feature (copyCodeButton) because it duplicates the functionality of my existing render hook implementation (codeblock-copy).
I deliberately did not enable dark mode, search or TOC at this point, and stated colorScheme = "light" / toc = false explicitly in hugo.toml to keep the appearance and behavior as they were. I had confirmed that my own gist/imgur shortcodes, the render hooks and the llms.txt output all keep working without modification, and the goal of the migration up to this point was that the appearance would not change.
Enabling dark mode, search and TOC
With the migration that does not change the appearance finished, I enabled the new features that had been the point all along. Just setting the parameters in hugo.toml was not enough to make them work; the cause was that the implementation on the layouts side had not caught up.
useHLJS = false
- socialShare = true
+ socialShare = false
delayDisqus = true
showRelatedPosts = true
gcse = "partner-pub-0357592386795601:0134336397"
googleAnalytics = "G-SWZR4GT5FQ"
+ # New features added in the current version of beautifulhugo
+ colorScheme = "auto" # Dark mode: follows the OS setting, and can also be switched manually with the navbar toggle
+ toc = true # Enable the table of contents panel
+
+ [Params.search]
+ provider = "fuse" # The navbar search overlay (this is the default, but stated explicitly)
[outputs]
- home = ["html", "rss", "llms", "llmsfull"]
+ home = ["html", "rss", "llms", "llmsfull", "json"] # json is required to generate the search index (index.json)
Dark mode
I ported the current version’s data-theme switching and dark.css loading into layouts/_default/baseof.html and layouts/partials/head.html. static/js/main.js had no click handler for the theme toggle to begin with, so I added one. I made it cycle through the three states auto / light / dark on button clicks, and restore the state saved in localStorage on the next visit. Along with that, I added dark mode colors for my own heading decorations and tag display to static/css/custom.css.
Search
I added json to outputs.home in hugo.toml so that index.json (the search index) is generated from the home page, and enabled the navbar overlay search (fuse.js).
TOC
layouts/_default/single.html had no call to toc.html at all, so I added one to make the table of contents panel open on article pages.
Fixing the CSS class mismatches left over right after the migration
I thought everything was working by this point, but then I noticed that only the pager, the related posts list and the Disqus button on article pages were displayed without any styling. The cause was that when I did the Bootstrap 3 to 5 work, only those parts of layouts/_default/single.html had been left behind with the legacy version’s class names. Because static/css/main.css assumes the current version’s new class names (post-pager / see-also-list / btn-secondary and so on), the old class names simply ended up in a state where not a single style applied.
- <ul class="pager blog-pager">
+ {{ if .PrevInSection }}
+ <a href="{{ .PrevInSection.RelPermalink }}" class="nav-side-arrow nav-side-prev" ...>
+ <i class="fas fa-chevron-left"></i>
+ </a>
+ {{ end }}
+ {{ if .NextInSection }}
+ <a href="{{ .NextInSection.RelPermalink }}" class="nav-side-arrow nav-side-next" ...>
+ <i class="fas fa-chevron-right"></i>
+ </a>
+ {{ end }}
+ <ul class="post-pager blog-post-pager">
{{ if .PrevInSection }}
- <li class="previous">
- <a ... data-toggle="tooltip" ...>← {{ i18n "previousPost" }}</a>
+ <li class="pager-prev">
+ <a ... data-bs-toggle="tooltip" ...>← {{ i18n "previousPost" }}</a>
</li>
+ {{ else }}
+ <li class="pager-prev"></li>
{{ end }}
The fix consisted of the following five points.
- Changed the pager class names to
post-pager/pager-prev/pager-next - Added
nav-side-arrow(the side navigation arrows) that the current version’s CSS assumes at 768px and above. Without it, the previous/next article navigation disappears at 768px and above - Changed the markup of the related posts list to the
see-also-list/see-also-itemform - Changed the Disqus button class from
btn-default, which does not exist in Bootstrap 5, tobtn-secondary - Added a block that displays the
categoriesan article has in its frontmatter (the theme itself had the styles, but the display logic did not exist)
Along with that I newly added i18n/en.yaml, and translated only the related posts heading (seeAlso) into Japanese as 「関連項目」. This site is still configured with DefaultContentLanguage = "en", so the other UI wording (such as the previous/next article navigation wording) is left as it was.
# Site-specific UI string overrides
# Because DefaultContentLanguage = "en", the theme's own i18n/en.yaml is used.
# Add entries here when you want to replace individual strings with Japanese.
- id: seeAlso
translation: "関連項目"Fixing the broken Gist embeds
Just when I thought the CSS mismatch fix had settled things down, I noticed that in articles using Gist embeds the table and the text blended together in dark mode and became unreadable.
The cause was that the striped style for ordinary tables in articles (table tr / table tr:nth-child(2n)) was also being applied unintentionally to some of the rows of Gist’s HTML (<table class="highlight">) because of specificity. In dark mode in particular, the site side darkens only the row background while Gist’s own text color (a dark color fixed for light mode) stays as it is, so every other row the text blends into the background color. Gist is always rendered with its own light theme and does not follow the site’s dark mode switch.
/* GitHub Gist embed support
Gist is always rendered with its own light theme, so the site side does not
touch the row backgrounds or borders at all and lets GitHub's styles stand. */
.gist table tr,
.gist table tr:nth-child(2n) {
background-color: transparent !important;
border-top: none !important;
}
.gist-file {
margin-top: 24px !important;
border-radius: 6px !important;
overflow: hidden !important;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12) !important;
}
[data-theme="dark"] .gist-file {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15);
}The reason I use !important so much is that GitHub’s external CSS (.gist .gist-file, specificity 2) and the theme’s shared reset .blog-post :first-child { margin-top: 0 } win on specificity, and I limited the selectors to ones with .gist-file as an ancestor so that Hugo’s standard code blocks (div.highlight) are not affected. Along with that I also added a style that makes the scrollbar thinner when a long code line appears. While I was at it I changed socialShare from true to false, but I did not leave a reason for that in the commit log.
Summary
| Step | Details |
|---|---|
| The first pin update | Swapped it without verification and reverted 34 minutes later |
The _vendor caveat | Wrote down the policy of not creating it, to prevent build differences between local and Cloudflare Pages |
| legacy → current migration | Moved JS loading location, handled Bootstrap 3 to 5. Completed with the appearance preserved |
| Enabling the new features | Ported dark mode, search (fuse.js) and TOC, including the implementation on the layouts side |
| Fixing the CSS class mismatches | Found the pager, related posts and Disqus button I had missed only afterwards, and fixed them |
| Fixing the Gist embeds | Resolved the text color blending in dark mode in a way that respects Gist’s own styles |
Not trying to do everything at once and instead separating “the migration that does not change the appearance” from “enabling the new features” turned out to be a good decision. That said, handling Bootstrap 3 to 5 class names tends to become a cross-cutting change, and it is easy for something like missing only the pager part, as happened this time, to occur. When following a large-scale overhaul of a theme, it seems better to go in with the mindset of eyeballing every page type once right after the migration.
The narrow content width of the beautifulhugo theme had bothered me all along, and the thing I am happiest about after the update is that the width got wider. I actually started up hugo server on the commits before and after the migration and measured the real size of article.blog-post on an article page at each browser width, with the following results.
| Browser width | Old (Bootstrap 3) | New (Bootstrap 5) |
|---|---|---|
| 1280px | 750px | 736px |
| 1440px and above | 750px (capped) | 856px |
The cause was a difference in the breakpoint design of Bootstrap’s own .container. Bootstrap 3’s .container caps out at a width of 1170px at 1200px and above, and does not get any wider from there. Bootstrap 5’s .container, by contrast, has an additional breakpoint that widens it further to 1320px at 1400px and above, so the real size of the content column (col-lg-8) also widened from 750px to 856px (+106px, about a 14% increase).
On the other hand, Bootstrap 5’s lg breakpoint threshold itself is lower than Bootstrap 3’s (992px vs 1200px), so at a width of around 1280px there is even a reversal where it becomes 14px narrower. I usually view this blog on an external monitor of 1440px or more, so the “it got wider” that I felt seems to have been this +106px.