Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions content/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
40 changes: 35 additions & 5 deletions scripts/build-cv-data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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("");
Expand All @@ -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}`;
Expand Down
13 changes: 13 additions & 0 deletions scripts/build-cv-data.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
() =>
Expand Down
12 changes: 10 additions & 2 deletions web/src/lib/announcements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
editionAnnounced,
editionYear,
entriesOf,
headingCase,
isEditorial,
readCv,
sections,
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions web/src/lib/cv-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions web/src/lib/cv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
editionYear,
entriesOf,
groupByTitle,
headingCase,
isEditorial,
kindTally,
labelledCount,
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion web/src/pages/cv.astro
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
cv,
CV_SOURCE,
entriesOf,
headingCase,
keysOf,
noteOf,
optsOutOfCv,
Expand Down Expand Up @@ -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);

Expand Down
Loading