Skip to content

fix(cv): capitalise every word of a heading derived from a record key - #19

Merged
eduardstan merged 4 commits into
mainfrom
fm/lp-header-word-casing
Jul 30, 2026
Merged

eduardstan merged 4 commits into
mainfrom
fm/lp-header-word-casing

Conversation

@eduardstan

Copy link
Copy Markdown
Owner

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.mjs now derives every heading from one headingCase rule that capitalises each word of a record key and leaves the separators alone, replacing tableHeader's first-character-only casing; keyWords keeps its ASCII split for macro names only, and sectionHeading reads separators as spaces (field_work prints "Field Work"). A word is any run of letters or digits in any script, so macronised and accented keys survive.
  • The website applies the same rule to the same keys: headingCase is exported from web/src/lib/cv-schema.ts, web/src/pages/cv.astro uses it for the teaching table's <th> and data-label, and announcements.ts uses 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.
  • New assertions in scripts/build-cv-data.test.mjs and web/src/lib/cv.test.ts cover multi-word and non-ASCII keys and the preserved separator; content/README.md documents that column keys stay lower case in the record. The bundled example is byte-identical — cv/generated/cv-data.tex regenerates 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 / level key 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.

  • Evidence: Built PDF: teaching table header from lowercase record keys ("Programme / Level", "Key Topics", "Hours") (local file: /tmp/no-mistakes-evidence/01KYTA3WJYEVCJ2JP52G8K9XWF/pdf-teaching-table-1.png)
  • Evidence: Built PDF page 1 (full page, demo record) (local file: /tmp/no-mistakes-evidence/01KYTA3WJYEVCJ2JP52G8K9XWF/demo-cv-page-1.png)
  • Evidence: /cv/ with Inspect sources on: provenance line publishes the record keys lowercase while the column heading is title-cased (local file: /tmp/no-mistakes-evidence/01KYTA3WJYEVCJ2JP52G8K9XWF/web-cv-provenance-keys.png)
  • Evidence: /cv/ teaching table rendered from the demo record (local file: /tmp/no-mistakes-evidence/01KYTA3WJYEVCJ2JP52G8K9XWF/web-cv-teaching-table.png)
  • Evidence: /lately/ kind filter chips, showing the "Field Work" label (local file: /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.

Same record, two commits of scripts/build-cv-data.mjs.

Record (content/cv.yaml, teaching[].rows[] — plain lowercase keys, no capitals stored):
        course: GEOL 273
        programme / level: B.Sc. Geology
        key topics: Palaeoclimate archives; proxy calibration
        hours: "18"

cv/generated/cv-data.tex, base 1b9056d (first-character rule):
\cvcourses{Y Y Y Y}{\textbf{Course} & \textbf{Programme / level} & \textbf{Key topics} & \textbf{Hours} \\}{

cv/generated/cv-data.tex, target ce975ec (one shared per-word rule):
\cvcourses{Y Y Y Y}{\textbf{Course} & \textbf{Programme / Level} & \textbf{Key Topics} & \textbf{Hours} \\}{

/cv/ provenance line for the same table, target commit (the record key is still published verbatim):
content/cv.yaml — teaching[], 1 entry holding 3 courses in its rows[] table. ...
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.

Bundled example unchanged: cv/generated/cv-data.tex regenerates byte-identical to the
committed file, all three PDFs rebuilt with latexmk -xelatex, and all three
scripts/check-cv-baseline.sh comparisons pass silently (exit 0) against the untouched
data/cv-baseline/ recordings.

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 teaching rows: table is rendered on the website by label = (key) =&gt; 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 key programme / 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 inside label, 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 now level 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 — every rows: 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: singular still returns stem.charAt(0).toUpperCase() + stem.slice(1). It is a record key becoming a label — the announcements kind, used at announcements.ts:360 and shown as the feed's filter vocabulary (lately/index.astro:28,155) — so a two-word section key field_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 is return 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 cases
  • node --experimental-strip-types web/src/lib/cv.test.ts — includes the new headingCase assertions
  • LEDGERPRESS_RECORD_ROOT=src/lib/fixtures/record node --experimental-strip-types web/src/lib/announcements.test.ts
  • node scripts/build-cv-data.mjs then git status --porcelain — cv/generated/cv-data.tex regenerates byte-identical to the committed file
  • latexmk -xelatex -cd cv/cv.tex (and short.tex, teaching.tex) on a pristine copy, then bash scripts/check-cv-baseline.sh for each of cv.pdf, short.pdf, teaching.pdf — all exit 0 with no baseline modified
  • Adopter demo: copied the repo to /tmp evidence dir, renamed teaching row keys to programme / level, key topics, hours and section fieldwork to field_work, regenerated data and built cv.pdf — header renders "Programme / Level", "Key Topics", "Hours"
  • Same demo record under astro dev: screenshotted /cv/ teaching table and its Inspect sources provenance line (with keys course, programme / level, key topics, hours), and /lately/ showing the "Field Work" kind label
  • Clicked the /lately/ "Field Work" filter chip — filters to 2 items, slug still field-work, so display casing did not move any anchor or CSS rule
  • Regenerated the same demo record with the base commit 1b9056d generator for a before/after: "Programme / level", "Key topics"
⚠️ **Document** - 1 info
  • ℹ️ 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.

eduardstan and others added 4 commits July 30, 2026 22:04
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>
@eduardstan
eduardstan merged commit 17f4773 into main Jul 30, 2026
3 checks passed
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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant