fix(cv): capitalise every word of a heading derived from a record key - #19
Merged
Merged
Conversation
The generator had two casing rules. `tableHeader` capitalised the first character of a row key; `keyWords`, forty lines below it, capitalised every word. Neither was documented as deliberate, and the first-character rule made a two-word column header - "Programme / Level" - unreachable from any plain record key: the only way to get it was to store the capital in the key itself, which the website then publishes verbatim on its provenance line. That is presentation leaking into the record and onto a public page. Both now share one `capitalise` helper. The header applies it per word through a replace, so separators survive: `programme / level` prints as "Programme / Level" and the key stays lowercase. No acronym, joining-word or configurable policy - a heading the simple rule cannot express is a finding, not a reason for a casing system. `cv/generated/cv-data.tex` is unchanged, so the bundled example's three PDFs are byte-identical and all three baselines pass untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ipt, on both surfaces
eduardstan
added a commit
that referenced
this pull request
Jul 31, 2026
…#19) * fix(cv): capitalise every word of a derived table header The generator had two casing rules. `tableHeader` capitalised the first character of a row key; `keyWords`, forty lines below it, capitalised every word. Neither was documented as deliberate, and the first-character rule made a two-word column header - "Programme / Level" - unreachable from any plain record key: the only way to get it was to store the capital in the key itself, which the website then publishes verbatim on its provenance line. That is presentation leaking into the record and onto a public page. Both now share one `capitalise` helper. The header applies it per word through a replace, so separators survive: `programme / level` prints as "Programme / Level" and the key stays lowercase. No acronym, joining-word or configurable policy - a heading the simple rule cannot express is a finding, not a reason for a casing system. `cv/generated/cv-data.tex` is unchanged, so the bundled example's three PDFs are byte-identical and all three baselines pass untouched. * no-mistakes(review): fix(cv): capitalise headings per word in any script, on both surfaces * no-mistakes(review): fix(web): capitalise every word of a feed kind label * no-mistakes(document): document per-word column-header casing in content/README.md ---------
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.
Intent
Unify the two heading-capitalisation rules in scripts/build-cv-data.mjs. The generator had two: tableHeader capitalised only the first character of a row key, while keyWords (used for macro names and section headings) capitalised every word. Neither was documented as deliberate. The first-character rule made a two-word column header - 'Programme / Level' - unreachable from any plain record key: the only way to get it was to store the capital in the record key itself, which the website then publishes verbatim on its provenance line ('course, programme / Level, key topics, hours'). That is presentation leaking into the record and onto a public page, against this repository's facts-versus-design boundary.
Decisions made: both call sites now share one 'capitalise' helper - deliberately one rule, no third invented. tableHeader applies it per word via k.replace(/[A-Za-z0-9]+/g, capitalise) rather than reusing keyWords, because keyWords splits on non-alphanumerics and its join would eat the separator ('programme / level' -> 'Programme Level'); the replace preserves slashes and spacing. Explicitly ruled out of scope by the task: acronym handling, lower-casing joining words ('of', 'and'), locale rules, and any configurable casing policy - a heading the simple per-word rule cannot express is a finding to report, not a reason to build a casing system.
Constraint honoured and proven: the bundled example's printed output must stay byte-identical. cv/generated/cv-data.tex regenerates unchanged (git clean), all three PDFs were rebuilt with latexmk -xelatex, and npm run check:maintainer passes all three recorded baselines untouched - no baseline was updated. npm run check, npm test and npm run check:format are green. One new test in scripts/build-cv-data.test.mjs covers the multi-word key and the preserved separator; a third casing site at line ~331 was checked and is an unrelated bibliography prefix letter, left alone. AGENTS.md was deliberately not edited: the rule is documented at its call site and AGENTS.md forbids restating values that live in another file.
What Changed
scripts/build-cv-data.mjsnow derives every heading from oneheadingCaserule that capitalises each word of a record key and leaves the separators alone, replacingtableHeader's first-character-only casing;keyWordskeeps its ASCII split for macro names only, andsectionHeadingreads separators as spaces (field_workprints "Field Work"). A word is any run of letters or digits in any script, so macronised and accented keys survive.headingCaseis exported fromweb/src/lib/cv-schema.ts,web/src/pages/cv.astrouses it for the teaching table's<th>anddata-label, andannouncements.tsuses it for the/lately/feed kind label — display casing only, since the slug still lowercases and strips to ASCII. Both copies document each other; no module crosses the plain-node/Vite build boundary.scripts/build-cv-data.test.mjsandweb/src/lib/cv.test.tscover multi-word and non-ASCII keys and the preserved separator;content/README.mddocuments that column keys stay lower case in the record. The bundled example is byte-identical —cv/generated/cv-data.texregenerates clean and all three recorded PDF baselines pass untouched.Risk Assessment
✅ Low: The final round changes one display label expression to the rule already shared by both surfaces, and every identity form (slug, anchor, CSS rule, radio id) is provably casing-invariant because it passes through slug(), with no effect on the bundled example's site or printed output.
Testing
Ran the targeted generator, cv and announcements self-checks (all green), confirmed cv/generated/cv-data.tex regenerates byte-identical and that all three PDFs rebuild and pass their untouched baselines, then proved the user intent end-to-end on an adopter-style record copy: a plain lowercase
programme / levelkey now prints "Programme / Level" in the built PDF and on the rendered /cv/ page while the provenance line still publishes the key verbatim in lowercase, with the base commit producing "Programme / level" for the identical record as the before-shot; /lately/ shows the "Field Work" label with its filter slug unchanged. Visual evidence captured as PDF page renders and browser screenshots; no failures or flakes./tmp/no-mistakes-evidence/01KYTA3WJYEVCJ2JP52G8K9XWF/pdf-teaching-table-1.png)/tmp/no-mistakes-evidence/01KYTA3WJYEVCJ2JP52G8K9XWF/demo-cv-page-1.png)/tmp/no-mistakes-evidence/01KYTA3WJYEVCJ2JP52G8K9XWF/web-cv-provenance-keys.png)/tmp/no-mistakes-evidence/01KYTA3WJYEVCJ2JP52G8K9XWF/web-cv-teaching-table.png)/tmp/no-mistakes-evidence/01KYTA3WJYEVCJ2JP52G8K9XWF/web-lately-kind-chip.png)Evidence: Before/after: same record generated by base vs target commit
base 1b9056d: \cvcourses{Y Y Y Y}{\textbf{Course} & \textbf{Programme / level} & \textbf{Key topics} & \textbf{Hours} \}{ target ce975ec: \cvcourses{Y Y Y Y}{\textbf{Course} & \textbf{Programme / Level} & \textbf{Key Topics} & \textbf{Hours} \}{ /cv/ provenance (target): ... with keys course, programme / level, key topics, hours — and those keys, in the order the file writes them, are also the columns of the same table in the printed CV.Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 3 issues found → auto-fixed (2) ✅
scripts/build-cv-data.mjs:236- tableHeader's new per-word rule uses the ASCII-only class /[A-Za-z0-9]+/g, so a non-ASCII letter ends a match and the remainder is capitalised as a new word: key 'français niveau' prints '\textbf{FrançAis Niveau}', 'kōwhai level' prints 'KōWhai Level'. The previous rule (k[0].toUpperCase() + k.slice(1)) left the tail untouched and rendered these correctly, so this is a regression for any adopter whose column key contains an accented or macronised letter — the bundled record already carries macrons in values (content/cv.yaml Kārearea, Ōtepoti). Fix: k.replace(/[\p{L}\p{N}]+/gu, capitalise); capitalise itself is already Unicode-safe.web/src/pages/cv.astro:75- The motivating leak is only half closed. The same teachingrows:table is rendered on the website bylabel = (key) => key[0].toUpperCase() + key.slice(1)(used at cv.astro:202 for <th> and :210 for data-label) — the exact first-character rule this change removed from the generator. With the plain record keyprogramme / level, the PDF now prints 'Programme / Level' but the site prints 'Programme / level', so an adopter who wants both to read correctly is still pushed to store the capital in the record key, which is the presentation-in-the-record failure the intent names. The intent scoped the change to scripts/build-cv-data.mjs, so this is a scope question rather than a defect in the code written: there is no module shared across scripts/ and web/ (AGENTS.md keeps the website reader boundary separate), so the earliest supported fix is applying the same per-word replace insidelabel, ideally with the Unicode class from the finding above.scripts/build-cv-data.mjs:224- Acknowledged ceiling, reported because the intent asks for it: a key the single per-word rule cannot express is nowlevel of study, which prints 'Level Of Study' where the old rule printed 'Level of study'. Joining-word lowercasing is explicitly out of scope; noting it so the tradeoff is on record rather than proposing a casing policy. Bundled example unaffected — everyrows:key in content/cv.yaml is one lowercase ASCII word, so cv/generated/cv-data.tex is unchanged.🔧 Fix: fix(cv): capitalise headings per word in any script, on both surfaces
1 warning still open:
web/src/lib/announcements.ts:256- The sweep updated this line's character class to /[^\p{L}\p{N}]+/gu but left the casing first-character-only:singularstill returnsstem.charAt(0).toUpperCase() + stem.slice(1). It is a record key becoming a label — the announcementskind, used at announcements.ts:360 and shown as the feed's filter vocabulary (lately/index.astro:28,155) — so a two-word section keyfield_work:now reads 'Field Work' as the printed section heading (sectionHeading, build-cv-data.mjs:287) and 'Field work' as the feed kind on the site: the same key, two casings, across the two surfaces. It also leaves the original leak reachable there — the only way to get 'Field Work' in the feed is to put the capital in the record key. Pre-existing rather than introduced (sectionHeading already capitalised every word via keyWords), and no bundled output changes because every section key in content/cv.yaml is one word, so this is a follow-up, not a blocker. Fix isreturn headingCase(stem)after the plural strip; flagged ask-user because it changes a user-visible label for multi-word keys and the author edited this exact line and stopped short, which may have been deliberate.🔧 Fix: fix(web): capitalise every word of a feed kind label
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
node --test scripts/build-cv-data.test.mjs— 46 pass, including the two new multi-word/non-ASCII header casesnode --experimental-strip-types web/src/lib/cv.test.ts— includes the newheadingCaseassertionsLEDGERPRESS_RECORD_ROOT=src/lib/fixtures/record node --experimental-strip-types web/src/lib/announcements.test.tsnode scripts/build-cv-data.mjsthengit status --porcelain— cv/generated/cv-data.tex regenerates byte-identical to the committed filelatexmk -xelatex -cd cv/cv.tex(and short.tex, teaching.tex) on a pristine copy, thenbash scripts/check-cv-baseline.shfor each of cv.pdf, short.pdf, teaching.pdf — all exit 0 with no baseline modifiedAdopter demo: copied the repo to /tmp evidence dir, renamed teaching row keys toprogramme / level,key topics,hoursand sectionfieldworktofield_work, regenerated data and built cv.pdf — header renders "Programme / Level", "Key Topics", "Hours"Same demo record underastro dev: screenshotted /cv/ teaching table and itsInspect sourcesprovenance line (with keys course, programme / level, key topics, hours), and /lately/ showing the "Field Work" kind labelClicked the /lately/ "Field Work" filter chip — filters to 2 items, slug stillfield-work, so display casing did not move any anchor or CSS ruleRegenerated the same demo record with the base commit 1b9056d generator for a before/after: "Programme / level", "Key topics"AGENTS.md- Judgment call, agreed with the author's stated decision: AGENTS.md was left unedited. The change introduces two copies of one casing rule across the build boundary (headingCase in scripts/build-cv-data.mjs and web/src/lib/cv-schema.ts), kept in agreement only by parallel assertions in scripts/build-cv-data.test.mjs and web/src/lib/cv.test.ts. Both call sites document the duplication and name each other, and AGENTS.md forbids restating a value that lives in another file, so no entry was added. If a third consumer of the rule ever appears, the invariant would earn a load-bearing-constraints line.✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.