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: diff --git a/scripts/build-cv-data.mjs b/scripts/build-cv-data.mjs index 1a13d2c..d791c91 100644 --- a/scripts/build-cv-data.mjs +++ b/scripts/build-cv-data.mjs @@ -220,10 +220,31 @@ 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); + +/** + * 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. 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[0].toUpperCase() + k.slice(1))}}`) + .map((k) => `\\textbf{${escapeLatex(headingCase(k))}}`) .join(" & ")} \\\\`; /** One `\cventry`, plus its bullets and its table where it has them. */ @@ -237,12 +258,16 @@ 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]+/) .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(""); @@ -253,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 6178260..410ae49 100644 --- a/scripts/build-cv-data.test.mjs +++ b/scripts/build-cv-data.test.mjs @@ -152,6 +152,19 @@ 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 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..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(/[^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); + return headingCase(stem); }; function cvEntryLabel(entries: Entry[], index: number): string { 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);