Post
The Build Said Nothing
Most of the bugs that cost an afternoon are not crashes. A crash tells you where to look. The expensive ones are where the build goes green, the counts look plausible, and something is missing that nobody notices until a visitor does.
1.8.62 is fourteen of those. Eleven came from building one three-language site
by hand, from an empty directory, without ssg init and without a migration to
fill anything in. That is the path with the fewest defaults, so it is where
silence shows.
Pages that were read and then dropped
The site had ten pages. The build said:
1🔄 Loading content...
2 📄 Loaded 0 pages
Nothing else. The rule is documented: a file with frontmatter is published
only when status is exactly publish, and an omitted status counts as a
draft. That rule is fine. What was wrong is that nothing said it had fired.
A draft is something somebody chose. An omitted line is the one case nobody chose, so that is the case the build now names:
1 ⚠️ 3 Markdown file(s) in content/site/pages were parsed but not published — they have no `status: publish` line: about.md, privacy.md, terms.md
A page with status: draft still produces no line. That one was a decision.
The second way a page disappeared was worse, because it looked like success. A description that reads naturally:
1description: Site sensors that feed verdicts with real readings: humidity and a relay.
is not valid YAML. The colon after "readings" starts a nested mapping. The page was dropped with YAML's own sentence, the build printed its green tick, and CI exited 0 with half of a section missing. The message now says what an author would recognise:
1 ⚠️ 1 Markdown file(s) were not published — their frontmatter could not be parsed:
2 content/site/pages/shop.md: yaml: line 3: mapping values are not allowed in this context
3 an unquoted ":" inside a value? Wrap the whole value in double quotes.
With strict: true the build fails on it instead. If your CI already runs
strict, this is the one change in this release that can turn a green build red,
and it will only do that for a page that was already missing from your site.
The third was the first thing a hand-made project hits. Without a
metadata.json the build stopped with a bare open …: no such file or directory. A site with no WordPress export has no categories or authors to put
in that file. A missing one is now an empty set, with one line saying so. A
source: that names a directory that does not exist still fails, because
building an empty site from a typo would be the same silence again.
A multilingual site, checked from the outside
The site is English by default, with Polish and Romanian. Everything below was found by reading the output, not the templates.
The 404 was Romanian. The generated 404.html used whichever language the
build rendered last, and ro sorts after en and pl. One root 404 answers
every dead URL, so a British visitor following an old link got Romanian copy.
It now uses the default language.
Subscribing from /pl/ gave you the English posts. The build wrote
/feed.xml, /pl/feed.xml and /ro/feed.xml correctly, and then every page
advertised /feed.xml in its <head>. With prefix_default_language on, that
file does not exist at all. The injected link now names the feed of the page's
own language:
1<!-- /pl/ -->
2<link rel="alternate" type="application/atom+xml" title="Example Site (Polski)" href="/pl/feed.xml">
The title changed too. It used to be the domain name. The subscribe list in a
feed reader is the one place a person reads that string, so it now uses the
site's title:.
The sitemap disagreed with the page. On three languages, 21 of 27 sitemap
URLs had xhtml:link alternates. The six without were the three front pages and
the three /blog/ listings, which are the URLs search engines weight most. The
front page's HTML already listed all three languages. The sitemap now says the
same thing, and the listings are each other's alternates.
A language switcher highlighted nothing. In a template, .Translations and
.Page.Translations look interchangeable. Only the second one set IsCurrent,
so a switcher built from the first never marked a language as current. Both
mark it now.
That switcher also wanted to print EN PL RO. There was no case helper, so the
theme carried an if/else per language, which is exactly the hardcoding the i18n
config is meant to remove. There are now three helpers:
{{ range .Page.Translations }}
<a href="{{ .URL }}" hreflang="{{ .Lang }}"{{ if .IsCurrent }} aria-current="true"{{ end }}>{{ upper .Lang }}</a>
{{ end }}
lower and title exist too, named as Hugo names them. CSS text-transform
was never a substitute: it changes the glyphs, but not the accessible name.
A page slugged 404 failed its own link check. It was written to
/404.html, but its canonical, og:url and hreflang all pointed at /404/.
check_links: strict then failed the build on the page's own head. The address
now matches the file.
Analytics: a log that lied, and a check that was too broad
The log line for a declared tag manager said:
1 🎯 Site metadata: gtm (set `analytics: true` to render)
The id was already rendering, because an id declared in analytics_ids: never
needed that flag. Only the log was wrong. It now reports each id as rendered,
as a placeholder, or as waiting for the flag.
On the placeholder: a value like GTM-XXXXXXX is no longer injected. You can
commit the key with a stand-in and fill it in later, without shipping a
container that does not exist to every page.
The second analytics bug is one that would have lasted years. To avoid adding a
second copy when a theme had wired the tag itself, the build skipped any page
where the id appeared anywhere. A cookie notice has to list GA4's cookies, and
they are named after the measurement id: _ga_G-XXXXXXXXXX. So the one page
explaining the tracking was the one page with no tracking and no consent-mode
script. Now the id only counts as wired when it appears inside a <script>.
Two things a static site could not see
Links inside CSS. check_links reads HTML. Rename a self-hosted font and
the build stays green while every page falls back to another typeface, because
a missing @font-face source is not an error in any browser. url() and
@import in every stylesheet are now checked, resolved against the stylesheet
the way a browser resolves them:
1 ⚠️ broken link in css/site.css:12 → /fonts/inter-v20-latin-wght.woff2
A header that said no before the visitor could. The default _headers
block sent Permissions-Policy: geolocation=(), microphone=(), camera=(). An
empty list disables the API for every origin, including your own. A "use my
location" button got PERMISSION_DENIED at once, and the browser never showed a
prompt. The same header is served by ssg --http, so the button failed in
preview too, which made it look like a bug in the site's JavaScript.
The default is now (self). Third-party frames are still blocked, which is
what the policy is for, and the visitor is asked.
The fix also exposed a trap. Overriding /* to change that one policy
replaced the whole block, so every other security header had to be restated,
and one you forgot was dropped without a word. Overrides now merge:
1headers:
2 /*:
3 Permissions-Policy: "geolocation=(self), microphone=(), camera=()"
4 X-XSS-Protection: "" # an empty value removes a header
X-Frame-Options and the rest keep their defaults.
Before you upgrade
Four changes can alter what an existing site produces. Each has a section in Upgrading:
11. strict: true now fails on unparseable frontmatter, and check_links: strict on a stylesheet naming a missing file
22. a headers: override that left a header out to remove it now keeps it — use ""
33. Permissions-Policy allows your own origin; set () back if you want the APIs off everywhere
44. analytics_ids values containing XXXX are not rendered
The rest only removes silence. If a 1.8.62 build prints warnings a 1.8.61 build did not, those warnings describe your site as it already was.