diff --git a/README.md b/README.md index 9144beb..edaedd1 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Every documented section rendered in a single multi-page CV. Source: [`examples/ | Page 1 | Page 2 | | :---: | :---: | -| ![example_full page 1 — header (name, label, summary, contact bar with every profile network), work, volunteer, focus areas, skills, languages, education](examples/example_full-1.png) | ![example_full page 2 — projects, publications grouped by type (Articles, Conference Papers, Talks, Books), certificates with multi-issuer grouping, awards, interests](examples/example_full-2.png) | +| ![example_full page 1 — header (name, label, summary, contact bar with every profile network), work, volunteer, focus areas, skills, languages, education](examples/example_full-1.png) | ![example_full page 2 — projects, publications grouped by type (Articles, Conference Papers, Talks, Books), certificates with multi-issuer grouping, awards, interests, references](examples/example_full-2.png) | ## Installation @@ -114,13 +114,12 @@ An empty or missing `endDate` renders as `Present` (localisable via `labels.pres ISO 8601 date strings (`"2024"`, `"2024-06"`, `"2024-06-15"` — the JSON Resume canonical shape) are formatted according to `preferences.dateFormat` (default `"long"`: e.g. `"Jun 2024"`). Strings that don't parse as ISO (e.g. `"Jan 2022"`, `"May 2016 – Jul 2017"`) pass through verbatim, so pre-formatted data keeps rendering identically. -Top-level keys recognised: `basics`, `focusAreas`, `work`, `volunteer`, `skills`, `languages`, `education`, `certificates`, `awards`, `projects`, `publications`, `interests`, `meta` (PDF metadata only — see [PDF metadata](#pdf-metadata)). Sections with empty input are skipped — no orphan headings. +Top-level keys recognised: `basics`, `focusAreas`, `work`, `volunteer`, `skills`, `languages`, `education`, `certificates`, `awards`, `projects`, `publications`, `interests`, `references`, `meta` (PDF metadata only — see [PDF metadata](#pdf-metadata)). Sections with empty input are skipped — no orphan headings. `basics.url` (JSON Resume's "personal homepage" field) renders in the contact bar with the generic `link` icon, alongside `email`, `phone`, `location`, and `profiles`. It's distinct from a `basics.profiles` entry with `network: "Website"` (a profile *on* a third-party site); supply both if you want both rendered. JSON Resume fields **accepted but not yet rendered** by this template: -- top-level: `references` - `volunteer[].summary`, `volunteer[].url` - `projects[].entity`, `projects[].type`, `projects[].roles` - `meta.canonical`, `meta.version` @@ -256,6 +255,15 @@ interests: ( ) ``` +### References + +| Field | Type | Effect | +|---|---|---| +| `name` | string | Referee name, level-3 heading above the quote. Omitted entries render the quote anonymously. | +| `reference` | string or content | The referee's quote, italic, beneath the name. Missing / empty `reference` → entry silently skipped (no orphan heading). | + +When `references[]` is empty (or every entry lacks a `reference`) and `preferences.referencesAvailableOnRequest` is `true`, the section renders the conventional `References available upon request.` line under the heading instead of being suppressed. The line text is `labels.referencesAvailableOnRequest`, so it localises alongside the other display strings. + ### Profile networks The `network` field of each `basics.profiles` entry is matched case-insensitively against a vendored icon set. Built-in networks: `Bluesky`, `GitHub`, `GitLab`, `Link`, `LinkedIn`, `Mastodon`, `Medium`, `Stackoverflow`, `Twitter` (alias: `X`), `Website`. Use `Link` as a generic fallback for any URL without a brand. Unknown networks panic with a list of the supported set. To add another, drop its SVG (with `fill="#666666"` baked in) into `icons/` and register it in `_network_icon_sources` in `internal/icons.typ`. @@ -309,16 +317,17 @@ Every theme, font, layout, and behaviour knob lives in `preferences`. Override a | `headerTextAlign` | `"left"` | Horizontal alignment of the header text (name, label, contact bar). One of `"left"`, `"right"`, `"center"`. Applies whether or not `basics.image` is set. | | `uppercaseName` | `true` | When `true` (matching AltaCV's visual ancestor), `basics.name` renders in uppercase. Set to `false` for scripts where uppercase is a different glyph set (Turkish dotless-i, etc.), scripts with no case, or when the loud look isn't wanted. | | `lastModifiedFooter` | `false` | When `true` and `meta.lastModified` is set, renders a small right-aligned `: ` line in the page footer (timestamp passed through verbatim). PDF metadata is enriched independently — see [PDF metadata](#pdf-metadata). | +| `referencesAvailableOnRequest` | `false` | When `true` and `references[]` is empty (or every entry has no `reference` quote), renders the conventional `labels.referencesAvailableOnRequest` line under the References heading instead of suppressing the section. When `false` (the default) an empty section is suppressed entirely, matching every other section. | | `dateFormat` | `"long"` | How ISO 8601 dates are rendered wherever the template surfaces a date (`startDate`, `endDate`, `awards[].date`, `publications[].releaseDate`, …). Non-ISO strings pass through verbatim regardless. Accepted: `"long"` (`"Jun 2024"` / `"15 Jun 2024"`, month names from `labels.months`), `"short"` (`"06/2024"` / `"15/06/2024"`), `"iso"` (passthrough), **a bracketed template** in [Typst's `datetime.display()` syntax](https://typst.app/docs/reference/foundations/datetime/#definitions-display) (e.g. `"[day padding:none] [month repr:short] [year]"` → `"15 Jun 2024"`; tokens `year`/`month`/`day` with `padding:` and `repr:long`/`repr:short`/`repr:numerical`, where `month repr:long`/`short` localises via `labels.months`), or a closure `parts => str` receiving `(year, month, day)` (`month` / `day` are `none` for year-only / year-month inputs). | | `linkContactInfo` | `true` | Whether contact-bar entries are wrapped in deep links (`mailto:`, `tel:`, the configured maps URL for location, the supplied URL for `basics.url` and each profile). Accepts a **boolean** (uniform across channels) or a **partial dict** keyed by `"email"`, `"phone"`, `"location"`, `"url"`, `"profiles"` (omitted channels stay linked). E.g. `linkContactInfo: (phone: false)` linkifies everything except the phone. Unknown channel keys panic. | | `mapsProvider` | `maps-providers.google` | URL template for the `basics.location` deep link. `{q}` is replaced with the URL-encoded location at render time. Use a built-in — `maps-providers.{google,apple,bing,duckduckgo,osm}` — or any URL template string. Pass `none` to suppress the link (icon + plain text still render). Strings missing `{q}` panic; non-string / non-`none` values panic. | | `columnRatio` | `0.65` | Left-column width as a fraction of the page, in `(0, 1]`. The right column gets the remainder minus a fixed gutter. Use `1 - r` to invert the layout, or `1` for a [single-column layout](#single-column-layout). | | `pageFooter` | `none` | Optional page footer. `none` — no footer. `"auto"` — multi-page documents only, `basics.name` flush left and `Page N / M` flush right, `0.8em` body colour. Any **content** value (`[…]`, `align(...)`, etc.) — rendered verbatim on every page. Anything else panics. When non-`none`, takes precedence over `lastModifiedFooter`; combine the "last updated" line yourself in a content footer if you want both. | | `leftColumnSections` | `("work", "volunteer", "projects", "publications")` | Sections to render in the left column, in order. Defaults put long-form / bulleted sections on the wider left. | -| `rightColumnSections` | `("focusAreas", "skills", "languages", "education", "certificates", "awards", "interests")` | Sections to render in the right column, in order. Defaults put compact / horizontal sections (pill rows, dot ratings, short metadata) on the right. | +| `rightColumnSections` | `("focusAreas", "skills", "languages", "education", "certificates", "awards", "interests", "references")` | Sections to render in the right column, in order. Defaults put compact / horizontal sections (pill rows, dot ratings, short metadata) on the right. | | `maxRating` | `5` | Number of dots on the language fluency scale. Positive integer. Default matches LinkedIn's 0–5 scale (and the built-in `fluency` string map); set to `6` for CEFR (A1–C2), `4` for ILR-style 0–4, etc. Fluency strings stay anchored to the 0–5 LinkedIn scale, so non-5 `maxRating` requires numeric `languages[].rating` values. | -Both column arrays draw from the same section keys: `"work"`, `"volunteer"`, `"focusAreas"`, `"skills"`, `"languages"`, `"education"`, `"certificates"`, `"awards"`, `"projects"`, `"publications"`, `"interests"`. Sections omitted from both are not rendered even if their data is present; sections listed in both render twice. Unknown keys panic. Renderers are width-agnostic — combined with `columnRatio`, this enables layouts like an inverted CV where the side-panel sections take the narrow left column. +Both column arrays draw from the same section keys: `"work"`, `"volunteer"`, `"focusAreas"`, `"skills"`, `"languages"`, `"education"`, `"certificates"`, `"awards"`, `"projects"`, `"publications"`, `"interests"`, `"references"`. Sections omitted from both are not rendered even if their data is present; sections listed in both render twice. Unknown keys panic. Renderers are width-agnostic — combined with `columnRatio`, this enables layouts like an inverted CV where the side-panel sections take the narrow left column. Example — reorder the right-column sections + tweak theme + use US Letter: @@ -358,7 +367,7 @@ Example — opt out of links per channel (everything stays linked except phone): ### Single-column layout -Set `columnRatio: 1` to collapse the grid to a single full-width column — useful for ATS parsers that struggle with multi-column PDFs. Sections from both `leftColumnSections` and `rightColumnSections` stream top-to-bottom in left-then-right order. With the defaults: `work → volunteer → projects → publications → focusAreas → skills → languages → education → certificates → awards → interests`. +Set `columnRatio: 1` to collapse the grid to a single full-width column — useful for ATS parsers that struggle with multi-column PDFs. Sections from both `leftColumnSections` and `rightColumnSections` stream top-to-bottom in left-then-right order. With the defaults: `work → volunteer → projects → publications → focusAreas → skills → languages → education → certificates → awards → interests → references`. ```typst #alta(cv, preferences: (columnRatio: 1)) @@ -395,6 +404,8 @@ Label keys match the JSON Resume section keys (`work`, `certificates`, …) — | `awards` | `"Awards"` | | `projects` | `"Projects"` | | `interests` | `"Interests"` | +| `references` | `"References"` | +| `referencesAvailableOnRequest` | `"References available upon request."` | | `articles` | `"Articles"` | | `present` | `"Present"` | | `lastModified` | `"Last updated"` | diff --git a/examples/example_full-2.png b/examples/example_full-2.png index 8609e36..028240b 100644 Binary files a/examples/example_full-2.png and b/examples/example_full-2.png differ diff --git a/examples/example_full.pdf b/examples/example_full.pdf index d6d51cf..31f796e 100644 Binary files a/examples/example_full.pdf and b/examples/example_full.pdf differ diff --git a/examples/example_full.typ b/examples/example_full.typ index 48203c9..bab2004 100644 --- a/examples/example_full.typ +++ b/examples/example_full.typ @@ -25,6 +25,9 @@ // type-specific icon — plus an untyped entry that falls under // `labels.articles`. // • Interests: structured `{name, keywords}` form. +// • References: a populated entry plus an anonymous (no `name`) +// quote — exercises the divider rule and the level-3 heading +// above the italic quote. // • Preferences override: bundled tweaks demonstrating accent // palette, ISO-date formatting, custom maps provider, page footer, // and a non-default `maxRating` for the language scale. @@ -273,6 +276,22 @@ (name: "Travel", keywords: ("Japan", "Iceland")), ), + // Populated entry + an anonymous (no `name`) quote — exercises both + // the named-referee path and the fallback-to-bare-quote path. + references: ( + ( + name: "Dr Ada Lovelace", + reference: [ + Seán is a calm, methodical engineer who left every system he + touched in a better state than he found it. Recommended without + reservation. + ], + ), + ( + reference: "Pragmatic, curious, unfailingly kind — would hire again in a heartbeat.", + ), + ), + // Four groups: explicit `Articles`, `Books`, `Talks`, `Conference // Papers`, plus one untyped entry that falls into the default // `labels.articles` group. Each rendered subheading carries its @@ -332,6 +351,6 @@ maxRating: 6, leftColumnSections: ("work", "volunteer", "projects", "publications", "interests"), rightColumnSections: ( - "focusAreas", "skills", "languages", "education", "certificates", "awards", + "focusAreas", "skills", "languages", "education", "certificates", "awards", "references", ), )) diff --git a/examples/labels-ga.toml b/examples/labels-ga.toml index 041c229..fcf41d3 100644 --- a/examples/labels-ga.toml +++ b/examples/labels-ga.toml @@ -17,6 +17,8 @@ publications = "Foilseacháin" awards = "Gradaim" projects = "Tionscadail" interests = "Caitheamh Aimsire" +references = "Teistiméireachtaí" +referencesAvailableOnRequest = "Teistiméireachtaí ar fáil ar iarratas." articles = "Ailt" diff --git a/examples/tests/references.pdf b/examples/tests/references.pdf new file mode 100644 index 0000000..7e55b80 Binary files /dev/null and b/examples/tests/references.pdf differ diff --git a/internal/labels-en.toml b/internal/labels-en.toml index 33da7ab..b68b443 100644 --- a/internal/labels-en.toml +++ b/internal/labels-en.toml @@ -24,6 +24,8 @@ publications = "Publications" awards = "Awards" projects = "Projects" interests = "Interests" +references = "References" +referencesAvailableOnRequest = "References available upon request." # Cluster heading for publications without an explicit `type`. articles = "Articles" diff --git a/internal/layout.typ b/internal/layout.typ index dde8b27..333f08d 100644 --- a/internal/layout.typ +++ b/internal/layout.typ @@ -21,6 +21,7 @@ #import "../sections/awards.typ": _awards #import "../sections/projects.typ": _projects #import "../sections/publications.typ": _publications +#import "../sections/references.typ": _references // Defined after the renderers because Typst binds closure identifiers // eagerly. @@ -74,6 +75,10 @@ column: "right", render: (cv, labels, prefs) => _interests(cv.at("interests", default: ()), labels), ), + references: ( + column: "right", + render: (cv, labels, prefs) => _references(cv.at("references", default: ()), labels, prefs), + ), ) // Defaults derived from `_sections` so adding a section there @@ -137,6 +142,10 @@ // from `_sections` so adding a section there places it automatically. leftColumnSections: _keys_for_column("left"), rightColumnSections: _keys_for_column("right"), + // Opt-in: emit `labels.referencesAvailableOnRequest` under the + // References heading when no entry has a `reference` quote, instead + // of suppressing the section. + referencesAvailableOnRequest: false, // Number of dots on the language fluency scale. Default 5 matches // LinkedIn's scale (and the built-in `fluency` string map). Override // to suit other scales — CEFR (6: A1–C2), ILR (5), or custom. diff --git a/lib.typ b/lib.typ index 23d178e..ebdde65 100644 --- a/lib.typ +++ b/lib.typ @@ -89,6 +89,7 @@ } _check_bool("uppercaseName", preferences.uppercaseName) _check_bool("lastModifiedFooter", preferences.lastModifiedFooter) + _check_bool("referencesAvailableOnRequest", preferences.referencesAvailableOnRequest) let max-rating = preferences.maxRating if type(max-rating) != int or max-rating < 1 { panic("maxRating must be a positive integer, got: " + repr(max-rating)) diff --git a/sections/references.typ b/sections/references.typ new file mode 100644 index 0000000..4c5cd93 --- /dev/null +++ b/sections/references.typ @@ -0,0 +1,25 @@ +// References — referee `name` as a level-3 heading with the +// `reference` quote in italic beneath, joined by the standard divider +// rule. Entries lacking `reference` are skipped (no orphan heading); +// entries lacking `name` render the quote anonymously. + +#import "../internal/text.typ": _present +#import "../internal/primitives.typ": _join_with_dividers + +#let _references(entries, labels, prefs) = { + let valid = entries.filter(r => _present(r.at("reference", default: none))) + let empty = valid.len() == 0 + // Fallback is opt-in so an empty `references` block stays silent by + // default — matching every other section's no-data behaviour. + if empty and not prefs.referencesAvailableOnRequest { return } + [== #labels.references] + if empty { + emph(labels.referencesAvailableOnRequest) + } else { + _join_with_dividers(valid, entry => block(breakable: false, { + let name = entry.at("name", default: none) + if _present(name) [=== #name] + emph(entry.reference) + })) + } +} diff --git a/tests/references.typ b/tests/references.typ new file mode 100644 index 0000000..7cccf5e --- /dev/null +++ b/tests/references.typ @@ -0,0 +1,64 @@ +// JSON Resume `references` section. Three separate documents stacked +// in one fixture to cover the three paths through the renderer: +// +// 1. Populated — `name` + `reference` entries with the divider rule +// between them, plus a quote-only entry rendered anonymously, plus +// a name-only entry that's silently skipped (no orphan heading). +// 2. Empty references + `referencesAvailableOnRequest: true` — +// the section renders the heading with the italic fallback line +// underneath, no entries. +// 3. Empty references + the default (`referencesAvailableOnRequest: +// false`) — the section is suppressed entirely (no heading, no +// fallback line). + +#import "../lib.typ": alta + +#alta(( + basics: (name: "Jane Doe", email: "jane@example.com"), + references: ( + ( + name: "Dr Ada Lovelace", + reference: [ + Jane is a calm, methodical engineer who left every system she + touched in a better state than she found it. Recommended without + reservation. + ], + ), + ( + name: "Grace Hopper", + reference: "An exemplary collaborator — pragmatic, curious, and unfailingly kind.", + ), + ( + // Anonymous quote — no `name`, the `reference` still renders. + reference: "Would hire again in a heartbeat.", + ), + ( + // Skipped — `reference` missing, so no orphan heading. + name: "Alan Turing", + ), + ( + // Skipped — explicit empty quote. + name: "Edsger Dijkstra", + reference: "", + ), + ), +)) + +#pagebreak() + +// Empty references + opt-in fallback. Heading + italic line. +#alta( + ( + basics: (name: "Jane Doe", email: "jane@example.com"), + references: (), + ), + preferences: (referencesAvailableOnRequest: true), +) + +#pagebreak() + +// Empty references + default (suppressed) — header only, no section. +#alta(( + basics: (name: "Jane Doe", email: "jane@example.com"), + references: (), +))