From b14eb508e882ab5679000687cc21853247d01e3d Mon Sep 17 00:00:00 2001 From: Eduard Ionel STAN Date: Thu, 30 Jul 2026 22:04:18 +0200 Subject: [PATCH 1/4] 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. Co-Authored-By: Claude Opus 5 --- scripts/build-cv-data.mjs | 16 +++++++++++++--- scripts/build-cv-data.test.mjs | 7 +++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/scripts/build-cv-data.mjs b/scripts/build-cv-data.mjs index 1a13d2c..58aa2b6 100644 --- a/scripts/build-cv-data.mjs +++ b/scripts/build-cv-data.mjs @@ -220,10 +220,20 @@ const tableRows = (rows, strict = true) => { return rows.map((r) => `${Object.values(r).map(cell).join(" & ")} \\\\`).join("\n"); }; -/** The header row for those columns: the key names, capitalised. */ +/** One word, capitalised. The generator's only casing rule; every heading uses it. */ +const capitalise = (word) => word[0].toUpperCase() + word.slice(1); + +/** + * The header row for those columns: the key names, every word capitalised. + * + * Per word, not first character only, so `programme / level` prints as + * "Programme / Level" without the record key carrying the capitals. Separators + * survive untouched: casing is presentation, the key stays the fact it is - and + * that key is what the website's provenance line publishes. + */ const tableHeader = (rows, strict = true) => `${tableKeys(rows, strict) - .map((k) => `\\textbf{${escapeLatex(k[0].toUpperCase() + k.slice(1))}}`) + .map((k) => `\\textbf{${escapeLatex(k.replace(/[A-Za-z0-9]+/g, capitalise))}}`) .join(" & ")} \\\\`; /** One `\cventry`, plus its bullets and its table where it has them. */ @@ -242,7 +252,7 @@ const keyWords = (key) => key .split(/[^A-Za-z0-9]+/) .filter(Boolean) - .map((w) => w[0].toUpperCase() + w.slice(1)); + .map(capitalise); /** `field_work` -> `FieldWork`, so a section key becomes a legal macro name. */ const macroName = (key) => keyWords(key).join(""); diff --git a/scripts/build-cv-data.test.mjs b/scripts/build-cv-data.test.mjs index 6178260..b55f7c3 100644 --- a/scripts/build-cv-data.test.mjs +++ b/scripts/build-cv-data.test.mjs @@ -152,6 +152,13 @@ test("a table's header is its own row keys, so renaming a key renames a column", assert.equal(tableHeader([{ course: "Databases", points: "18 points" }]), "\\textbf{Course} & \\textbf{Points} \\\\"); }); +test("a multi-word key capitalises every word, so no key carries its own capitals", () => { + // Found in the wild: "Programme / Level" was only reachable by storing the + // capital in the record key, which the website then published verbatim on its + // provenance line. Casing is presentation; separators are the key's own. + assert.equal(tableHeader([{ "programme / level": "B.Sc.", "key topics": "proxies" }]), "\\textbf{Programme / Level} & \\textbf{Key Topics} \\\\"); +}); + test("a table refuses rows whose columns or key order disagree", () => { assert.throws( () => From 2604a512cc543335acc7305bced22c49bb8355c1 Mon Sep 17 00:00:00 2001 From: Eduard Ionel STAN Date: Thu, 30 Jul 2026 22:13:48 +0200 Subject: [PATCH 2/4] no-mistakes(review): fix(cv): capitalise headings per word in any script, on both surfaces --- scripts/build-cv-data.mjs | 30 +++++++++++++++++++++++++----- scripts/build-cv-data.test.mjs | 6 ++++++ web/src/lib/announcements.ts | 2 +- web/src/lib/cv-schema.ts | 16 ++++++++++++++++ web/src/lib/cv.test.ts | 11 +++++++++++ web/src/pages/cv.astro | 3 ++- 6 files changed, 61 insertions(+), 7 deletions(-) diff --git a/scripts/build-cv-data.mjs b/scripts/build-cv-data.mjs index 58aa2b6..d791c91 100644 --- a/scripts/build-cv-data.mjs +++ b/scripts/build-cv-data.mjs @@ -224,16 +224,27 @@ const tableRows = (rows, strict = true) => { const capitalise = (word) => word[0].toUpperCase() + word.slice(1); /** - * The header row for those columns: the key names, every word capitalised. + * A record key as a heading: every word capitalised, separators untouched. * * Per word, not first character only, so `programme / level` prints as * "Programme / Level" without the record key carrying the capitals. Separators * survive untouched: casing is presentation, the key stays the fact it is - and - * that key is what the website's provenance line publishes. + * that key is what the website's provenance line publishes. A word is any run of + * letters or digits in any script, so `kōwhai level` is two words and not three. + * + * The website applies this same rule to these same keys: `headingCase` in + * `web/src/lib/cv-schema.ts`. Nothing crosses that build boundary - this is + * plain node, that is a Vite module - so they are two copies of one rule and + * they must agree. A column that reads "Programme / Level" in the PDF and + * "Programme / level" on /cv/ is the contradiction this repository exists to + * make impossible. */ +const headingCase = (key) => key.replace(/[\p{L}\p{N}]+/gu, capitalise); + +/** The header row for those columns: the key names, as headings. */ const tableHeader = (rows, strict = true) => `${tableKeys(rows, strict) - .map((k) => `\\textbf{${escapeLatex(k.replace(/[A-Za-z0-9]+/g, capitalise))}}`) + .map((k) => `\\textbf{${escapeLatex(headingCase(k))}}`) .join(" & ")} \\\\`; /** One `\cventry`, plus its bullets and its table where it has them. */ @@ -247,7 +258,11 @@ function entry(item) { return [head, itemList(item.items), table].filter(Boolean).join("\n"); } -/** The words of a section key, each capitalised: `field_work` -> [Field, Work]. */ +/** + * The words of a section key, each capitalised: `field_work` -> [Field, Work]. + * + * ASCII-only, because a macro name is: this feeds `macroName`, not a heading. + */ const keyWords = (key) => key .split(/[^A-Za-z0-9]+/) @@ -263,8 +278,13 @@ const macroName = (key) => keyWords(key).join(""); * `fieldwork:` prints as "Fieldwork" with nothing declared, which is what makes a * new section print without a LaTeX edit. A section whose heading is not its key * spelt out - "Awards & Scholarships" - says so with `heading:`. + * + * The key becomes a heading by the same `headingCase` a column header does, with + * its separators read as spaces: `field_work` prints "Field Work", not + * "Field_Work". Not `keyWords`, which is ASCII because macro names are. */ -const sectionHeading = (key, value) => arg((Array.isArray(value) ? undefined : value.heading) ?? keyWords(key).join(" ")); +const sectionHeading = (key, value) => + arg((Array.isArray(value) ? undefined : value.heading) ?? headingCase(key.replace(/[^\p{L}\p{N}]+/gu, " ").trim())); function macro(name, body) { return `\\newcommand{\\${name}}{%\n${body}%\n}`; diff --git a/scripts/build-cv-data.test.mjs b/scripts/build-cv-data.test.mjs index b55f7c3..410ae49 100644 --- a/scripts/build-cv-data.test.mjs +++ b/scripts/build-cv-data.test.mjs @@ -159,6 +159,12 @@ test("a multi-word key capitalises every word, so no key carries its own capital assert.equal(tableHeader([{ "programme / level": "B.Sc.", "key topics": "proxies" }]), "\\textbf{Programme / Level} & \\textbf{Key Topics} \\\\"); }); +test("a word is a run of letters in any script, so an accent does not start a new one", () => { + // A record is not written in English. "kōwhai" is one word, and capitalising + // its "whai" would misspell the adopter's own key back at them. + assert.equal(tableHeader([{ "kōwhai level": "3", "français niveau": "B2" }]), "\\textbf{Kōwhai Level} & \\textbf{Français Niveau} \\\\"); +}); + test("a table refuses rows whose columns or key order disagree", () => { assert.throws( () => diff --git a/web/src/lib/announcements.ts b/web/src/lib/announcements.ts index d97367f..8e10995 100644 --- a/web/src/lib/announcements.ts +++ b/web/src/lib/announcements.ts @@ -251,7 +251,7 @@ const md = (value: string | undefined) => * names no section: an adopter's `fieldwork:` announces as `Fieldwork`. */ const singular = (key: string) => { - const word = key.replace(/[^A-Za-z0-9]+/g, ' ').trim(); + const word = key.replace(/[^\p{L}\p{N}]+/gu, ' ').trim(); const stem = /(?:ss|is|us)$/.test(word) ? word : word.replace(/s$/, ''); return stem.charAt(0).toUpperCase() + stem.slice(1); }; diff --git a/web/src/lib/cv-schema.ts b/web/src/lib/cv-schema.ts index f955b0b..1b7811f3 100644 --- a/web/src/lib/cv-schema.ts +++ b/web/src/lib/cv-schema.ts @@ -607,6 +607,22 @@ export const sections = (source: CV): [string, Section][] => export const keysOf = (rows: object[]) => [...new Set(rows.flatMap((row) => Object.keys(row)))].join(', '); +/** + * A record key as a heading: every word capitalised, separators untouched, so + * `programme / level` reads "Programme / Level" without the key itself carrying + * the capitals — the key is a fact, and `keysOf` above publishes it verbatim on + * this site's provenance lines. A word is any run of letters or digits in any + * script, so `kōwhai level` is two words and not three. + * + * The printed CV applies this same rule to these same keys: `headingCase` in + * `scripts/build-cv-data.mjs`. Nothing crosses that build boundary — that is + * plain node, this is a Vite module — so they are two copies of one rule and + * they must agree. A column that reads one way in the PDF and another here is + * the contradiction this repository exists to make impossible. + */ +export const headingCase = (key: string) => + key.replace(/[\p{L}\p{N}]+/gu, (word) => word[0].toUpperCase() + word.slice(1)); + export interface CountPhrase { count: number; words: string; diff --git a/web/src/lib/cv.test.ts b/web/src/lib/cv.test.ts index 19d9410..20900b7 100644 --- a/web/src/lib/cv.test.ts +++ b/web/src/lib/cv.test.ts @@ -26,6 +26,7 @@ import { editionYear, entriesOf, groupByTitle, + headingCase, isEditorial, kindTally, labelledCount, @@ -195,6 +196,16 @@ assert.ok( 'no teaching post dates run to Present', ); +// The column headings of that table, here and in the printed CV, are one rule +// held in two copies across the build boundary. These are the same cases +// `scripts/build-cv-data.test.mjs` asserts of `tableHeader`: if one copy is +// edited and the other is not, one of the two files fails. +assert.equal(headingCase('programme / level'), 'Programme / Level'); +assert.equal(headingCase('key topics'), 'Key Topics'); +assert.equal(headingCase('kōwhai level'), 'Kōwhai Level'); +assert.equal(headingCase('français niveau'), 'Français Niveau'); +assert.equal(headingCase('course'), 'Course'); + // `printed: false` is the record's own opt-out, and only the section that states // it opts out: an absent key and an empty list say nothing either way, so /cv/ // cannot describe one of those three states as another. diff --git a/web/src/pages/cv.astro b/web/src/pages/cv.astro index 622d0b7..80e8da7 100644 --- a/web/src/pages/cv.astro +++ b/web/src/pages/cv.astro @@ -22,6 +22,7 @@ import { cv, CV_SOURCE, entriesOf, + headingCase, keysOf, noteOf, optsOutOfCv, @@ -72,7 +73,7 @@ const leadership = entriesOf(cv.leadership); /** Every `rows:` table under a teaching post — the courses themselves. */ const courses = teaching.flatMap((block) => block.rows ?? []); const withItems = (rows: { items?: string[] }[]) => rows.filter((row) => row.items?.length).length; -const label = (key: string) => key[0].toUpperCase() + key.slice(1); +const label = headingCase; const appointmentEntries = countPhrase(appointments.length); const awardEntries = countPhrase(awards.length); From ce975ecf0f3fce09ae95d7fd862d9c6498914f63 Mon Sep 17 00:00:00 2001 From: Eduard Ionel STAN Date: Thu, 30 Jul 2026 22:20:31 +0200 Subject: [PATCH 3/4] no-mistakes(review): fix(web): capitalise every word of a feed kind label --- web/src/lib/announcements.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/src/lib/announcements.ts b/web/src/lib/announcements.ts index 8e10995..b130a26 100644 --- a/web/src/lib/announcements.ts +++ b/web/src/lib/announcements.ts @@ -25,6 +25,7 @@ import { editionAnnounced, editionYear, entriesOf, + headingCase, isEditorial, readCv, sections, @@ -249,11 +250,18 @@ const md = (value: string | undefined) => * `appointments` → `Appointment`, `awards` → `Award`, `teaching` → `Teaching`. * The kind of a CV fact is the name of the section it is in, and this module * names no section: an adopter's `fieldwork:` announces as `Fieldwork`. + * + * A kind is a label — the filter chip on /lately/ — so it takes `headingCase`, + * the one rule every heading derived from a record key takes, and `field_work:` + * reads "Field Work" here and in the printed CV alike. The one prose consumer + * lowercases it where it writes it. This is the display form only: `slug` below + * lowercases and strips to ASCII, so no id, anchor, filter slug or generated CSS + * rule can move with it. */ const singular = (key: string) => { const word = key.replace(/[^\p{L}\p{N}]+/gu, ' ').trim(); const stem = /(?:ss|is|us)$/.test(word) ? word : word.replace(/s$/, ''); - return stem.charAt(0).toUpperCase() + stem.slice(1); + return headingCase(stem); }; function cvEntryLabel(entries: Entry[], index: number): string { From f1533974df58c4bbde7ce82d154f5b983d60ca40 Mon Sep 17 00:00:00 2001 From: Eduard Ionel STAN Date: Thu, 30 Jul 2026 22:31:27 +0200 Subject: [PATCH 4/4] no-mistakes(document): document per-word column-header casing in content/README.md --- content/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/content/README.md b/content/README.md index 94864fa..9273af1 100644 --- a/content/README.md +++ b/content/README.md @@ -330,8 +330,11 @@ years: [2024, { year: 2025, announced: 2024-06-10 }, 2026] `rows:` turns an entry into a heading over a table. **Each row's keys, in the order you write them, are the columns, and the key name becomes the heading.** Write `points:` and the column says -"Points". The build refuses rows whose columns or key order disagree, and generates equal-width -columns for however many keys you use. +"Points". Every word of the key is capitalised and whatever separates them is kept, so +`programme / level` heads a column "Programme / Level": write the key in lower case, because the +capitals are presentation and this key is a fact the site publishes verbatim where it says which +columns a table has. The build refuses rows whose columns or key order disagree, and generates +equal-width columns for however many keys you use. ```yaml teaching: