feat: reconcile heading title casing with the rest of the theme - #2104
Merged
Conversation
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 cat sat cased to The 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 cat sat` cased to `The cat Sat` instead of `The 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.
✅ Deploy Preview for gethinode-demo ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
markdumay
enabled auto-merge
August 1, 2026 15:42
Collaborator
Author
|
🎉 This PR is included in version 3.14.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
With
titleCase = true, hinode casts headings by a different rule than everything else._markup/render-heading.htmladds.title-case;_styles.scssappliestext-transform: capitalize<title>, H1, nav, taxonomy, captions, links, cards, breadcrumbs, sidebartitlefilter, inlined at 23 sitestext-transform: capitalizehas no stopword awareness, so the same page renders its H1 asTypes of Cookies We Useand its H2 asTypes Of Cookies We Use.More importantly, the CSS path ignores Hugo's
titleCaseStyleentirely, though our owndocumentation 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 userendersTypes Of Cookies We Usecontent/en/privacy.md:39— renders... Use Of Your ...Change
Headings now use the same filter as everything else, via a new
utilities/TitleCase.html. Applyingtitleto rendered HTML was previously impossiblebecause 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:
The same partial replaces 23 inline copies of the enable-and-exact guard, which had drifted
into four different spellings of the
exactlookup.Upgrade notes
titleCase = true. To keep the previous output,set
titleCaseStyle = "go"in your Hugo configuration. It is close, not exact:Set Up A Project And EnvironmentsandState-Of-The-Artmatch, but Go's stylecapitalizes after any non-letter, so apostrophes break —
what's newrendersWhat'S Newwhere the browser gaveWhat's New..title-caseis no longer emitted. Sites or modules styling that hook lose it.skipped, because a string-level filter could not handle them.
capitalizeused thelangattribute; Go's AP casing does not. This affects Turkish dotted-i. AP-style casing on
Turkish content was already incorrect, and
exact: trueremains the per-page escape.Also in this PR
main.titleCaseExceptions(list, default empty) — words that keep their exactspelling, e.g.
["npm", "pnpm"]. Other spellings are untouched, so a deliberateNPMstill renders
NPM. Must be a list; a scalar now fails the build with an explicit errorrather than an opaque
rangepanic, which matters becauseHUGO_PARAMS_MAIN_TITLECASEEXCEPTIONSyields a scalar.regardless of how they were authored, which would have turned
## The AND operatorintoand— including inside<code>. Authored uppercase is now preserved.tests/templates/. A Hugo site thatmounts the partials under test and calls
errorfon a failed assertion, so it exitsnon-zero. No runner, no new dependency. Wired into CI via
build-command; 30 assertions.