Skip to content

feat: reconcile heading title casing with the rest of the theme - #2104

Merged
markdumay merged 18 commits into
mainfrom
fix/title-case-switch-placement
Aug 1, 2026
Merged

feat: reconcile heading title casing with the rest of the theme#2104
markdumay merged 18 commits into
mainfrom
fix/title-case-switch-placement

Conversation

@markdumay

Copy link
Copy Markdown
Collaborator

Problem

With titleCase = true, hinode casts headings by a different rule than everything else.

Path Where Mechanism
CSS markdown headings _markup/render-heading.html adds .title-case; _styles.scss applies text-transform: capitalize
Go <title>, H1, nav, taxonomy, captions, links, cards, breadcrumbs, sidebar the title filter, inlined at 23 sites

text-transform: capitalize has no stopword awareness, so the same page renders its H1 as
Types of Cookies We Use and its H2 as Types Of Cookies We Use.

More importantly, the CSS path ignores Hugo's titleCaseStyle entirely, though our own
documentation directs users to configure it. With titleCaseStyle = "firstupper", every
<title> obeys and every markdown heading still gets full capitalization.

This reproduces on our own exampleSite, which sets titleCase = true:

  • content/en/cookies.md:22## Types of Cookies we use renders Types Of Cookies We Use
  • content/en/privacy.md:39 — renders ... Use Of Your ...

Change

Headings now use the same filter as everything else, via a new
utilities/TitleCase.html. Applying title to rendered HTML was previously impossible
because it mangles inline markup; the partial avoids that by tokenizing tags and character
entities out of the string, casing the text content once, and mapping the result back by
rune position:

state-of-the-art <code>npm</code> tooling for the win
  -> State-of-the-Art <code>Npm</code> Tooling for the Win

The same partial replaces 23 inline copies of the enable-and-exact guard, which had drifted
into four different spellings of the exact lookup.

Upgrade notes

  • Heading casing changes on sites with titleCase = true. To keep the previous output,
    set titleCaseStyle = "go" in your Hugo configuration. It is close, not exact:
    Set Up A Project And Environments and State-Of-The-Art match, but Go's style
    capitalizes after any non-letter, so apostrophes break — what's new renders
    What'S New where the browser gave What's New.
  • .title-case is no longer emitted. Sites or modules styling that hook lose it.
  • Image captions containing inline markup are now title-cased. They were previously
    skipped, because a string-level filter could not handle them.
  • Locale-aware capitalization is traded away. CSS capitalize used the lang
    attribute; Go's AP casing does not. This affects Turkish dotted-i. AP-style casing on
    Turkish content was already incorrect, and exact: true remains the per-page escape.

Also in this PR

  • main.titleCaseExceptions (list, default empty) — words that keep their exact
    spelling, e.g. ["npm", "pnpm"]. Other spellings are untouched, so a deliberate NPM
    still renders NPM. Must be a list; a scalar now fails the build with an explicit error
    rather than an opaque range panic, which matters because
    HUGO_PARAMS_MAIN_TITLECASEEXCEPTIONS yields a scalar.
  • Title casing may add capitals but never remove them. AP lowercases stopwords
    regardless of how they were authored, which would have turned ## The AND operator into
    and — including inside <code>. Authored uppercase is now preserved.
  • The theme's first template test harness, at tests/templates/. A Hugo site that
    mounts the partials under test and calls errorf on a failed assertion, so it exits
    non-zero. No runner, no new dependency. Wired into CI via build-command; 30 assertions.

markdumay and others added 17 commits August 1, 2026 13:58
Resolves the D-12 title-case divergence and the D-14 cascade gap upstream,
plus two adjacent switch-placement defects found while tracing them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Applies Hugo's title filter to rendered HTML by tokenizing tags and
character entities out of the string, casing the text content once, and
mapping the result back by rune position. This makes the Go title filter
usable on markdown headings, which carry inline markup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 1 review found that omitting character entities from the plain string
joins the words on either side, so the word after an entity never starts a
word: the&nbsp;cat sat cased to The&nbsp;cat Sat. Each entity now stands in
as a single space.

Also records the ruling to commit the test harness into the repo, and the
three mechanics it depends on: relative module mounts reaching above the
site root, errorf exiting non-zero, and CI wiring via build-command.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moves the TitleCase.html test harness from a scratch directory into
tests/templates/ (scope ruling: the harness gets committed, not left
outside the repo). It mounts the real partial under test via a relative
[[module.mounts]] entry rather than a symlink -- Hugo does not follow
symlinks when walking the layouts mount, which defeated the original
harness design. A failed assertion calls errorf, which makes hugo exit
non-zero; no test runner or new dependency is introduced.

Also adds coverage for the primary `(dict "page" PAGE "text" STRING)`
contract, previously untested: a page with no `exact` front matter
(casing applies) and a page with `exact: true` (text unchanged).

Wires the harness into `pnpm test` (now lint + test:templates) and into
CI via the workflow's build-command, the only hook in the shared reusable
workflow that runs with Hugo available.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Omitting character entities from the plain string used for casing joined
the words on either side, so the word right after an entity never got
treated as starting a word: `the&nbsp;cat sat` cased to
`The&nbsp;cat Sat` instead of `The&nbsp;Cat Sat`.

Each entity now contributes a single space to the plain string instead
of nothing, and advances the positional mapping by exactly one rune. The
entity is still always re-emitted verbatim in the output; only the
casing pass sees the space stand-in. Because a space cases to a space,
the total-length safety check still holds.

Adds the two regression cases that exposed this to the template test
harness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Markdown headings were capitalized by text-transform: capitalize, which
has no stopword awareness and ignores Hugo's titleCaseStyle setting. They
now use the same filter as page titles and navigation, so a page no longer
renders its H1 and its H2 by two different rules.

Sites preferring the previous behavior can set titleCaseStyle = "go".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Content renders under /en/, /fr/ and /nl/. The fr and nl locales set
titleCase = false, so locale-less assertions would have passed against
output the change cannot affect.
Sixteen call sites repeated the same enable-and-exact check in four
different spellings of the exact lookup. The partial owns that decision now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TitleCase.html owns the titleCase guard internally, so it always matches the
verification grep. Task 4's gate expected no output at all, which could never
pass.
Also drops image.html's plainify guard, which skipped captions containing
inline markup. The partial handles that markup, so those captions are now
title-cased like every other caption.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gethinode.com dogfood build found npm rendering as Npm on six headings.
The design had dismissed lowercase acronyms because CSS mangles them the
same way, but under CSS the DOM keeps the correct string and the Go filter
does not.
Title casing capitalizes the first letter of every word, so an acronym
authored lowercase renders as Npm rather than npm. Under the previous CSS
mechanism that was a visual artifact only; applying the filter to the text
puts the mangled spelling into the DOM, where copy-paste, screen readers,
and search indexing pick it up.

Words listed in main.titleCaseExceptions keep their exact spelling. Other
spellings are untouched, so a deliberate NPM still renders as NPM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A titleCaseExceptions value that is not a list — a bare string from a
"npm" vs ["npm"] TOML slip, or from setting
HUGO_PARAMS_MAIN_TITLECASEEXCEPTIONS, which Hugo always hands through
as a string — reached the range in TitleCase.html and crashed the
build with an opaque "range can't iterate over ..." error that names
neither the param nor the mistake.

Validate the type where the param is read, following the pattern used
by GetIncludeTOC.html and GetMetadata.html: errorf naming the param and
the value received, then fall back to an empty list so the logged
error is what fails the build rather than a second, unrelated panic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The uppercase-preservation claim was checked against AGENT, which is not a
stopword; AP lowercases authored-uppercase stopwords, which CSS never did.
And titleCaseStyle = go is an approximation, not a reproduction: it breaks
apostrophes.
AP style lowercases stopwords regardless of how they were written, so a
heading naming an operator or keyword lost its casing: AN INTRODUCTION TO
THE THING became AN INTRODUCTION to the THING, and <code>AND</code> became
<code>and</code>. The previous CSS mechanism never lowercased anything.

Title casing may now add capitals but never remove them. This is the
general form of titleCaseExceptions, which cannot fix these because the
stopword is already lowercased by the time the list is applied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous correction fixed the Migration section but left the claim
standing in the verification step, the plan's expected-output note, and the
drafted PR body itself — the one place users would actually read it.
@netlify

netlify Bot commented Aug 1, 2026

Copy link
Copy Markdown

Deploy Preview for gethinode-demo ready!

Name Link
🔨 Latest commit 65fc627
🔍 Latest deploy log https://app.netlify.com/projects/gethinode-demo/deploys/6a6e137491698b0008d6bd58
😎 Deploy Preview https://deploy-preview-2104--gethinode-demo.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@markdumay
markdumay enabled auto-merge August 1, 2026 15:42
@markdumay
markdumay merged commit e2a58a7 into main Aug 1, 2026
17 checks passed
@markdumay

Copy link
Copy Markdown
Collaborator Author

🎉 This PR is included in version 3.14.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant