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
18 changes: 8 additions & 10 deletions content/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,23 +329,21 @@ 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". 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.
them, are the columns, and the key name becomes the heading.** Write `Points:` and the column says
"Points". Column headers are printed verbatim as written in the record (e.g. `Programme / level` or
`Programme / Level`), so write row keys exactly as they should print. The build refuses rows whose
columns or key order disagree, and generates equal-width columns for however many keys you use.

```yaml
teaching:
- title: Lecturer
org: University of Example
dates: 2024 – Present
rows:
- course: Databases
programme: B.Sc. Computer Science
topics: SQL; relational algebra
hours: 30 h/yr
- Course: Databases
Programme: B.Sc. Computer Science
Topics: SQL; relational algebra
Hours: 30 h/yr
```

A section-level `note` is not an entry field. A section that needs a paragraph of its own above
Expand Down
24 changes: 12 additions & 12 deletions content/cv.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -125,18 +125,18 @@ teaching:
place: Ōtepoti Dunedin, Aotearoa New Zealand
dates: 2021 – Present
rows:
- course: GEOL 273
programme: B.Sc. Geology
topics: Palaeoclimate archives; proxy calibration
points: "18"
- course: GEOL 472
programme: B.Sc. (Hons)
topics: Marine cores; age-depth modelling
points: "18"
- course: OCEN 405
programme: M.Sc. Oceanography
topics: Southern Ocean circulation; uncertainty
points: "20"
- Course: GEOL 273
Programme: B.Sc. Geology
Topics: Palaeoclimate archives; proxy calibration
Points: "18"
- Course: GEOL 472
Programme: B.Sc. (Hons)
Topics: Marine cores; age-depth modelling
Points: "18"
- Course: OCEN 405
Programme: M.Sc. Oceanography
Topics: Southern Ocean circulation; uncertainty
Points: "20"

supervision:
note:
Expand Down
35 changes: 17 additions & 18 deletions scripts/build-cv-data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -220,31 +220,30 @@ const tableRows = (rows, strict = true) => {
return rows.map((r) => `${Object.values(r).map(cell).join(" & ")} \\\\`).join("\n");
};

/** One word, capitalised. The generator's only casing rule; every heading uses it. */
/** One word, capitalised. The generator's only casing rule. */
const capitalise = (word) => word[0].toUpperCase() + word.slice(1);

/**
* A record key as a heading: every word capitalised, separators untouched.
* A section 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.
* Section keys derive from identifiers (e.g. `field_work` -> "Field Work")
* when a section does not declare an explicit `heading:`, and the generated
* per-section table header names those schema fields. Course-table column
* headers, by contrast, are printed verbatim as written in the record: a row
* key is a fact the website publishes on its provenance line, so it prints as
* the adopter wrote it, here and there.
*
* 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.
* The website has its own copy of this rule (`headingCase` in
* `web/src/lib/cv-schema.ts`) for the announcement kind chips on /lately/. No
* heading passes through both, so the two are independent, not a pair to keep
* in step.
*/
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) =>
/** Course-table labels stay verbatim; per-section schema fields explicitly pass `headingCase`. */
const tableHeader = (rows, strict = true, displayKey = (key) => key) =>
`${tableKeys(rows, strict)
.map((k) => `\\textbf{${escapeLatex(headingCase(k))}}`)
.map((key) => `\\textbf{${escapeLatex(displayKey(key))}}`)
.join(" & ")} \\\\`;

/** One `\cventry`, plus its bullets and its table where it has them. */
Expand Down Expand Up @@ -279,7 +278,7 @@ const macroName = (key) => keyWords(key).join("");
* 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
* The key becomes a heading by `headingCase`, with
* its separators read as spaces: `field_work` prints "Field Work", not
* "Field_Work". Not `keyWords`, which is ASCII because macro names are.
*/
Expand Down Expand Up @@ -592,7 +591,7 @@ function render(cv) {
macro(`cv${name}Note`, note.map(renderInline).join("\n\\cvnotesep\n")),
macro(`cv${name}`, rows.map((r, i) => `\\ifnum${i + 1}>\\cvmax\\else\n${entry(r)}\n\\fi`).join("\n\n")),
macro(`cv${name}Rows`, rows.length ? tableRows(rows, false) : ""),
macro(`cv${name}Header`, rows.length ? tableHeader(rows, false) : ""),
macro(`cv${name}Header`, rows.length ? tableHeader(rows, false, headingCase) : ""),
macro(`cv${name}Inline`, rows.map((r) => arg(r.detail ? `${r.title} (${r.detail})` : r.title)).join(", ")),
`\\newcommand{\\cv${name}Count}{${rows.length}}`
);
Expand Down
25 changes: 10 additions & 15 deletions scripts/build-cv-data.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -146,23 +146,18 @@ test("a section key becomes a legal macro name", () => {
assert.equal(macroName("teaching"), "Teaching");
});

test("a table's header is its own row keys, so renaming a key renames a column", () => {
// The friction log's F12: an NZ adopter writes `points:` and the column says
// "Points" without a LaTeX edit.
assert.equal(tableHeader([{ course: "Databases", points: "18 points" }]), "\\textbf{Course} & \\textbf{Points} \\\\");
test("a table's header is its own row keys verbatim, so renaming a key renames a column", () => {
// Column headers print the row key exactly as written in the record.
assert.equal(tableHeader([{ Course: "Databases", Points: "18 points" }]), "\\textbf{Course} & \\textbf{Points} \\\\");
assert.equal(tableHeader([{ "Programme / level": "B.Sc.", "Key topics": "proxies" }]), "\\textbf{Programme / level} & \\textbf{Key topics} \\\\");
});

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 section header macro title-cases its schema field names", () => {
const tex = render({
profile: { name: "Alex Newcomer" },
appointments: [{ title: "Postdoctoral Researcher", org: "University of Example" }],
});
assert.match(tex, /\\newcommand\{\\cvAppointmentsHeader\}\{%\n\\textbf\{Title\} & \\textbf\{Org\} \\\\%\n\}/);
});

test("a table refuses rows whose columns or key order disagree", () => {
Expand Down
19 changes: 9 additions & 10 deletions web/src/lib/cv-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,17 +608,16 @@ 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.
* An identifier key as a heading label: every word capitalised, separators untouched.
*
* 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.
* The one caller is the announcement kind chip on `/lately/`, whose kind is an
* identifier this site derives. Record keys are not recased: a course table's
* column headers are its row keys verbatim, the same keys `keysOf` above
* publishes on the provenance lines.
*
* The printed CV has its own copy of this rule (`headingCase` in
* `scripts/build-cv-data.mjs`) for section keys. No heading passes through
* both, so the two are independent, not a pair to keep in step.
*/
export const headingCase = (key: string) =>
key.replace(/[\p{L}\p{N}]+/gu, (word) => word[0].toUpperCase() + word.slice(1));
Expand Down
7 changes: 2 additions & 5 deletions web/src/lib/cv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ for (const block of teaching) {
for (const row of block.rows ?? [])
assert.deepEqual(
Object.keys(row),
['course', 'programme', 'topics', 'points'],
['Course', 'Programme', 'Topics', 'Points'],
`teaching "${block.org}": row keys are the printed table's columns, in this order`,
);
}
Expand All @@ -196,10 +196,7 @@ 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.
// `headingCase` transforms identifier keys into title-cased labels (e.g. for announcement kinds).
assert.equal(headingCase('programme / level'), 'Programme / Level');
assert.equal(headingCase('key topics'), 'Key Topics');
assert.equal(headingCase('kōwhai level'), 'Kōwhai Level');
Expand Down
24 changes: 12 additions & 12 deletions web/src/lib/fixtures/record/content/cv.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,18 +108,18 @@ teaching:
place: Ōtepoti Dunedin, Aotearoa New Zealand
dates: 2021 – Present
rows:
- course: GEOL 273
programme: B.Sc. Geology
topics: Palaeoclimate archives; proxy calibration
points: '18'
- course: GEOL 472
programme: B.Sc. (Hons)
topics: Marine cores; age-depth modelling
points: '18'
- course: OCEN 405
programme: M.Sc. Oceanography
topics: Southern Ocean circulation; uncertainty
points: '20'
- Course: GEOL 273
Programme: B.Sc. Geology
Topics: Palaeoclimate archives; proxy calibration
Points: '18'
- Course: GEOL 472
Programme: B.Sc. (Hons)
Topics: Marine cores; age-depth modelling
Points: '18'
- Course: OCEN 405
Programme: M.Sc. Oceanography
Topics: Southern Ocean circulation; uncertainty
Points: '20'

supervision:
note:
Expand Down
6 changes: 2 additions & 4 deletions web/src/pages/cv.astro
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import {
cv,
CV_SOURCE,
entriesOf,
headingCase,
keysOf,
noteOf,
optsOutOfCv,
Expand Down Expand Up @@ -73,7 +72,6 @@ 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 = headingCase;
const appointmentEntries = countPhrase(appointments.length);
const awardEntries = countPhrase(awards.length);

Expand Down Expand Up @@ -200,15 +198,15 @@ const description =
<thead>
<tr>
{Object.keys(block.rows![0]).map((key) => (
<th scope="col">{label(key)}</th>
<th scope="col">{key}</th>
))}
</tr>
</thead>
<tbody>
{block.rows!.map((row) => (
<tr>
{Object.entries(row).map(([key, value]) => (
<td data-label={label(key)} set:html={render(value)} />
<td data-label={key} set:html={render(value)} />
))}
</tr>
))}
Expand Down
Loading