From 339e8d778653f5dfbe3528b230adf250ca8cd7aa Mon Sep 17 00:00:00 2001 From: Shane Murphy Date: Sun, 14 Jun 2026 21:01:35 +0200 Subject: [PATCH 1/2] feat: add cover-letter companion entrypoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders a single-column letter that shares the masthead, accent, and contact bar with `alta()`. Same `cv` dict (only `basics` is consumed — other top-level keys are ignored), same `labels` / `preferences` shape, so callers keep one data file and one set of theme overrides for both documents. Cross-entrypoint validation is now `_validate_shared_preferences` in `internal/validation.typ` (pure extraction from `alta()` with no behaviour change; `columnRatio` stays inline in `alta` because cover-letter is single-column). The `auto` date is routed through `_format_date`, so the user's `dateFormat` preference and `labels.months` translation apply to the cover-letter date too — set once, follows everywhere. New label key `closing` (default "Sincerely,") sources the default valediction; `closing: auto` (default) reads it, `none` suppresses the closing + signature block entirely, and a string / content overrides inline. Mirrors the `date: auto / none` sentinel pair. Closes #53. --- README.md | 40 +++++++ examples/tests/cover_letter.pdf | Bin 0 -> 37093 bytes internal/labels-en.toml | 6 + internal/validation.typ | 82 ++++++++++++- lib.typ | 203 +++++++++++++++++++++----------- tests/cover_letter.typ | 38 ++++++ 6 files changed, 298 insertions(+), 71 deletions(-) create mode 100644 examples/tests/cover_letter.pdf create mode 100644 tests/cover_letter.typ diff --git a/README.md b/README.md index 37eba6d..6868d01 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ - **Six built-in accent palettes** (`teal`, `navy`, `crimson`, `forest`, `plum`, `charcoal`) plus any `rgb(...)` value. - **Full label localisation** via inline dict or TOML file — every display string the template emits is overridable, with a worked Irish translation under [`examples/labels-ga.toml`](https://github.com/smur89/alta-typst/blob/main/examples/labels-ga.toml). - **PDF metadata baked in** — title, author, subject, keywords (auto-derived from skills), and document date populate from the same data dict. +- **Matching cover letter** via `cover-letter(cv, …)` — shares the masthead, accent, and contact bar with the CV from a single `basics` dict. ## Gallery @@ -400,6 +401,7 @@ Label keys match the JSON Resume section keys (`work`, `certificates`, …) — | `lastModified` | `"Last updated"` | | `months` | `("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")` | | `publicationIcons` | `(:)` | +| `closing` | `"Sincerely,"` — cover letter only | `labels.months` is the twelve abbreviated month names (January–December). Consumed by the `dateFormat: "long"` formatter and the `[month repr:long]` / `[month repr:short]` template tokens. Override to localise; must keep length 12. @@ -467,6 +469,44 @@ The defaults live in [`internal/labels-en.toml`](internal/labels-en.toml) — a The contact bar is rendered from `basics.email`, `basics.phone`, `basics.location`, `basics.url`, `basics.profiles`. Visual separators are stripped from the `tel:` dialable part. Suppress or swap deep links via `preferences.linkContactInfo` and `preferences.mapsProvider`. +## Cover letter + +`cover-letter` is the matching companion entrypoint — a single-column letter that reuses the same `cv` dict (only `basics` is consumed), the same `preferences` knobs, and the same `labels` overrides as `alta`. One data file and one set of theme overrides drives both documents. + +```typst +#import "@preview/altacv:1.1.0": cover-letter // x-release-please-version + +#cover-letter( + cv, + recipient: [ + Hiring Manager \ + Acme Corp \ + Dublin 2, Ireland + ], + // `auto` substitutes today's date; pass a string / content to pin + // one, or `none` to suppress entirely. + date: auto, + salutation: [Dear Hiring Manager,], + [ + I am writing to express my interest in the Senior Backend Engineer + role at Acme Corp. … + ], +) +``` + +Layout: same masthead as `alta` (name, label, contact bar, optional portrait), then right-aligned date, recipient block, salutation, body, closing valediction, accent-coloured signature. + +| Argument | Default | Effect | +|---|---|---| +| `cv` | — | Same data dict accepted by `alta`. Only `basics` is consumed; any other top-level keys are ignored. | +| `body` | — | Letter body (positional, required). Markup content — paragraphs, lists, emphasis. Trailing-content sugar works: `#cover-letter(cv)[Letter …]`. | +| `recipient` | `none` | Optional addressee block (markup content). Use `\` line breaks for "Name / Company / Address" stacks. | +| `date` | `auto` | `auto` substitutes today's date routed through the configured `dateFormat` (so `labels.months` translation applies). Pass a string / content to pin a value, or `none` to suppress. | +| `salutation` | `none` | Optional greeting line, e.g. `[Dear Hiring Manager,]`. No default — no defensible neutral works across languages and registers. | +| `closing` | `auto` | Valediction printed above the signature. `auto` uses `labels.closing` (default `"Sincerely,"`); pass `none` to suppress the closing + signature entirely; pass a string / content to override inline without touching `labels`. | +| `labels` | `(:)` | Same shape as `alta`. The new `closing` key sources the default valediction. | +| `preferences` | `(:)` | Same shape as `alta`. Cover letter is single-column, so `columnRatio` / `leftColumnSections` / `rightColumnSections` are accepted (for a single shared preferences dict across both documents) but ignored here. | + ## Building the examples ```sh diff --git a/examples/tests/cover_letter.pdf b/examples/tests/cover_letter.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c1f3a59e82afc9c0473eed46b9c4e81997a6d272 GIT binary patch literal 37093 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 literal 0 HcmV?d00001 diff --git a/internal/labels-en.toml b/internal/labels-en.toml index 33da7ab..9e5122c 100644 --- a/internal/labels-en.toml +++ b/internal/labels-en.toml @@ -34,6 +34,12 @@ present = "Present" # Footer label when `preferences.lastModifiedFooter` is true. lastModified = "Last updated" +# Cover-letter only — default closing valediction printed above the +# signature. Override via `labels: (closing: "…")` to localise or pick +# a different register ("Best regards,", "Yours sincerely,", "Le meas,", +# …). Consumed only by `cover-letter`; ignored by `alta`. +closing = "Sincerely," + # Twelve abbreviated month names (January–December). Consumed by the # built-in `dateFormat: "long"` formatter and the `[month repr:long]` / # `[month repr:short]` template tokens. Must keep length 12 (validated diff --git a/internal/validation.typ b/internal/validation.typ index 93f2bcf..2add37c 100644 --- a/internal/validation.typ +++ b/internal/validation.typ @@ -1,8 +1,12 @@ // Shared validators. `_strict_merge` is the typo-catcher used to // merge user overrides over the built-in defaults dicts; `_check_bool` // is the uniform bool-validation helper for individual preference -// fields. Both panic on misuse so errors surface at the caller rather -// than as cryptic render-time failures. +// fields; `_validate_shared_preferences` runs the cross-entrypoint +// checks every public entrypoint (`alta`, `cover-letter`, …) needs. +// All panic on misuse so errors surface at the caller rather than as +// cryptic render-time failures. + +#import "dates.typ": _date_format_aliases // Panics on the wrong override-shape (non-dictionary) up front, then // on unknown keys so typos surface as errors instead of being silently @@ -29,3 +33,77 @@ panic(name + " must be a bool, got: " + repr(value)) } } + +// Validates the subset of `preferences` shared by every public +// entrypoint (`alta`, `cover-letter`, …). Per-entrypoint checks +// (`columnRatio` is `alta`-only because cover-letter is single-column) +// stay at the call site. `labels` is taken so the `months` shape +// check — which the date formatter depends on — can run here too. +#let _validate_shared_preferences(preferences, labels) = { + let mp = preferences.mapsProvider + if mp != none { + if type(mp) != str { + panic( + "mapsProvider must be a URL template string (containing `{q}`) or `none`, got: " + + repr(mp), + ) + } + if "{q}" not in mp { + panic( + "mapsProvider URL template must contain the `{q}` placeholder, got: " + + repr(mp), + ) + } + } + _check_bool("uppercaseName", preferences.uppercaseName) + _check_bool("lastModifiedFooter", preferences.lastModifiedFooter) + 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)) + } + // `pageFooter` accepts `none`, the string `"auto"`, or any content + // value. Any other type — bools, dicts, numbers — panics so a typo + // like `pageFooter: true` surfaces at the call site rather than + // falling through to a render-time failure inside `set page(...)`. + let page-footer = preferences.pageFooter + let footer-ok = ( + page-footer == none + or page-footer == "auto" + or type(page-footer) == content + ) + if not footer-ok { + panic( + "pageFooter must be `none`, the string \"auto\", or a content value, got: " + + repr(page-footer), + ) + } + let df = preferences.dateFormat + if type(df) == str { + // Bracketed templates (`[year]`, `[month repr:long]`, …) defer to + // `_apply_date_template`; bare strings must be one of the named + // formatters or the literal `"iso"` passthrough. + if "[" not in df and df != "iso" and df not in _date_format_aliases { + panic( + "dateFormat must be \"long\", \"short\", \"iso\", a bracketed template " + + "(e.g. \"[day]/[month]/[year]\"), or a closure; got: " + + repr(df), + ) + } + } else if type(df) != function { + panic( + "dateFormat must be a string (named formatter or bracketed template) " + + "or a closure, got: " + repr(df), + ) + } + // `labels.months` is consumed by the "long" formatter and by the + // bracketed-template `[month repr:long]` / `[month repr:short]` + // tokens; validate shape and element types up front so a malformed + // override panics with a clear message rather than failing inside + // `array.at()` or string slicing at render time. + let months = labels.months + if type(months) != array or months.len() != 12 or months.any(m => type(m) != str) { + panic( + "labels.months must be an array of 12 strings, got: " + repr(months), + ) + } +} diff --git a/lib.typ b/lib.typ index 23d178e..494cb80 100644 --- a/lib.typ +++ b/lib.typ @@ -21,12 +21,12 @@ #import "internal/presets.typ": palettes, maps-providers #import "internal/state.typ": _body_size_state, _accent_state, _max_rating_state, _body_colour, _emphasis_colour #import "internal/defaults.typ": _default_labels -#import "internal/validation.typ": _strict_merge, _check_bool +#import "internal/validation.typ": _strict_merge, _validate_shared_preferences #import "internal/text.typ": _present, styled-link #import "internal/icons.typ": icon #import "internal/primitives.typ": name, term, tag, divider #import "internal/ratings.typ": rating -#import "internal/dates.typ": _date_format_aliases, _iso_datetime +#import "internal/dates.typ": _iso_datetime, _format_date #import "internal/header.typ": _header, _summary #import "internal/footer.typ": _auto_page_footer #import "internal/layout.typ": _sections, _default_preferences @@ -72,77 +72,12 @@ if type(column-ratio) not in (int, float) or column-ratio <= 0 or column-ratio > 1 { panic("columnRatio must be a number in (0, 1], got: " + repr(column-ratio)) } - let mp = preferences.mapsProvider - if mp != none { - if type(mp) != str { - panic( - "mapsProvider must be a URL template string (containing `{q}`) or `none`, got: " - + repr(mp), - ) - } - if "{q}" not in mp { - panic( - "mapsProvider URL template must contain the `{q}` placeholder, got: " - + repr(mp), - ) - } - } - _check_bool("uppercaseName", preferences.uppercaseName) - _check_bool("lastModifiedFooter", preferences.lastModifiedFooter) - 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)) - } - // `pageFooter` accepts `none`, the string `"auto"`, or any content - // value. Any other type — bools, dicts, numbers — panics so a typo - // like `pageFooter: true` surfaces at the call site rather than - // falling through to a render-time failure inside `set page(...)`. - let page-footer = preferences.pageFooter - let footer-ok = ( - page-footer == none - or page-footer == "auto" - or type(page-footer) == content - ) - if not footer-ok { - panic( - "pageFooter must be `none`, the string \"auto\", or a content value, got: " - + repr(page-footer), - ) - } - let df = preferences.dateFormat - if type(df) == str { - // Bracketed templates (`[year]`, `[month repr:long]`, …) defer to - // `_apply_date_template`; bare strings must be one of the named - // formatters or the literal `"iso"` passthrough. - if "[" not in df and df != "iso" and df not in _date_format_aliases { - panic( - "dateFormat must be \"long\", \"short\", \"iso\", a bracketed template " - + "(e.g. \"[day]/[month]/[year]\"), or a closure; got: " - + repr(df), - ) - } - } else if type(df) != function { - panic( - "dateFormat must be a string (named formatter or bracketed template) " - + "or a closure, got: " + repr(df), - ) - } - // `labels.months` is consumed by the "long" formatter and by the - // bracketed-template `[month repr:long]` / `[month repr:short]` - // tokens; validate shape and element types up front so a malformed - // override panics with a clear message rather than failing inside - // `array.at()` or string slicing at render time. - let months = labels.months - if type(months) != array or months.len() != 12 or months.any(m => type(m) != str) { - panic( - "labels.months must be an array of 12 strings, got: " + repr(months), - ) - } + _validate_shared_preferences(preferences, labels) let accent = preferences.accent let body-size = preferences.bodySize _accent_state.update(accent) _body_size_state.update(body-size) - _max_rating_state.update(max-rating) + _max_rating_state.update(preferences.maxRating) // PDF metadata is sourced from `basics` (title, author, description) // and the JSON Resume `meta` block (date, keywords). Each optional @@ -171,6 +106,7 @@ // `none` — no footer // auto renderer — name + "Page N / M", multi-page only // verbatim content — rendered on every page + let page-footer = preferences.pageFooter let resolved-footer = if page-footer != none { if page-footer == "auto" { _auto_page_footer(cv.basics.name) @@ -283,3 +219,132 @@ ) } } + +// Cover-letter companion entrypoint. Renders a single-column letter +// that shares the masthead and theme with `alta()`. Same `cv` dict +// (only `basics` is consumed — other top-level keys are ignored), same +// `labels` / `preferences` shape, so a caller keeps one data file and +// one set of theme overrides for both documents. +// +// Layout: +//
+// +// +// +// +// +// +// +// +// +// +// Parameters: +// cv — same data dict accepted by alta(); only `basics` is +// consumed here, the rest is ignored. Keeps a single +// source-of-truth for the masthead. +// body — letter body (markup content). Required. Trailing- +// content sugar works: `#cover-letter(cv)[Letter …]`. +// recipient — optional addressee block (markup content). Use `\` +// line breaks for "Name / Company / Address" stacks. +// date — optional date. `auto` (default) substitutes today's +// date, routed through the same `dateFormat` + +// `labels.months` path as every other date in the +// template (so a German caller sets `dateFormat` once +// and the cover letter follows). `none` suppresses the +// date row. A string / content overrides explicitly. +// salutation — optional greeting (content), e.g. +// `[Dear hiring manager,]`. No defensible default +// across languages/registers, so omitted unless +// supplied. +// closing — optional valediction. `auto` (default) uses +// `labels.closing` ("Sincerely,") so localisation +// flows through the same path as every other display +// string; `none` suppresses the closing + signature +// block entirely; a string / content overrides +// inline without touching `labels`. Mirrors the +// `date: auto / none` sentinel pair. +// labels — partial dict; merged over `_default_labels`. +// preferences — partial dict; merged over `_default_preferences`. +// Cover-letter is single-column, so `columnRatio` / +// `leftColumnSections` / `rightColumnSections` are +// accepted (so the same prefs dict drives both +// documents) but ignored here. +#let cover-letter( + cv, + body, + recipient: none, + date: auto, + salutation: none, + closing: auto, + labels: (:), + preferences: (:), +) = { + let labels = _strict_merge(_default_labels, labels, "labels") + let preferences = _strict_merge(_default_preferences, preferences, "preferences") + _validate_shared_preferences(preferences, labels) + let accent = preferences.accent + let body-size = preferences.bodySize + _accent_state.update(accent) + _body_size_state.update(body-size) + + set document( + title: cv.basics.name + " --- Cover Letter", + author: cv.basics.name, + ) + set text(body-size, font: preferences.font, fill: _body_colour) + set page(paper: preferences.paper, margin: preferences.margin) + set par(leading: 0.65em, spacing: 1.0em, justify: true) + + _header( + cv.basics, + image-size: preferences.imageSize, + image-position: preferences.imagePosition, + image-stack-order: preferences.imageStackOrder, + header-text-align: preferences.headerTextAlign, + link-contact-info: preferences.linkContactInfo, + maps-provider: preferences.mapsProvider, + uppercase-name: preferences.uppercaseName, + ) + + v(0.8 * body-size) + + // `auto` substitutes today's date, routed through `_format_date` so + // the user's `dateFormat` preference + `labels.months` translation + // apply here too — a German caller sets `dateFormat` once and gets a + // consistently localised CV and cover letter. Right-aligned per the + // conventional business-letter shape; not tied to `headerTextAlign` + // because the date is its own visual unit. + let resolved-date = if date == auto { + let today = datetime.today() + _format_date(today.display("[year]-[month]-[day]"), preferences, labels) + } else { date } + if _present(resolved-date) { + align(right, text(fill: _emphasis_colour, resolved-date)) + v(0.4 * body-size) + } + + if _present(recipient) { + block(below: 1.2 * body-size, recipient) + } + + if _present(salutation) { + block(below: 0.8 * body-size, salutation) + } + + body + + // `auto` resolves to `labels.closing` so localisation works via the + // same path as every other display string; explicit `none` suppresses + // the closing + signature block entirely (mirrors the `date: auto / + // none` sentinel pair above). `_present` keeps empty strings / empty + // content blocks behaving the same as `none`. + let resolved-closing = if closing == auto { labels.closing } else { closing } + if _present(resolved-closing) { + v(0.8 * body-size) + block(below: 1.6 * body-size, resolved-closing) + // Signature — matches the accent-coloured weight of the masthead + // name (without uppercasing) so the letter visually closes back to + // where it opened. + text(weight: "bold", fill: accent, cv.basics.name) + } +} diff --git a/tests/cover_letter.typ b/tests/cover_letter.typ new file mode 100644 index 0000000..2a69c27 --- /dev/null +++ b/tests/cover_letter.typ @@ -0,0 +1,38 @@ +// Cover-letter fixture. Covers the `auto` date path (routed through +// `_format_date` so it picks up `dateFormat` + `labels.months`), the +// `labels.closing` override, an explicit salutation, and a multi-line +// recipient block. Mirrors the cover-letter usage a real caller would +// reach for off the same `basics` dict that drives `alta()`. + +#import "../lib.typ": cover-letter + +#cover-letter( + ( + basics: ( + name: "Oisín Mac Cárthaigh", + label: "Innealtóir Bogearraí", + email: "oisin@example.com", + phone: "+353 1 555 0100", + location: "Baile Átha Cliath", + ), + ), + // `auto` substitutes today's date at compile time. tests/*.typ + // outputs aren't byte-pinned in CI, so the floating value is fine. + date: auto, + recipient: [ + Forge Liffey \ + Cé Bhaile Átha Cliath \ + Baile Átha Cliath 2 + ], + salutation: [A chara,], + labels: ( + closing: "Le meas,", + ), + [ + Litir bheag thástála. Ní gá gur foirfe an leagan amach — is é an + aidhm ná an cosán a chuir trí gach craobh den fheidhm + `cover-letter` chun deimhniú go n-oibríonn an ceanntásc roinnte, + an dáta `auto`, an seoladh, an beannú, agus an chríoch faoi + `labels.closing`. + ], +) From b5d4e52c57f6ae0b45d5ce38e9d6cd8693cea9c5 Mon Sep 17 00:00:00 2001 From: Shane Murphy Date: Sun, 14 Jun 2026 21:04:07 +0200 Subject: [PATCH 2/2] docs(cover-letter): spell out which preferences keys are inert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer-pass clarification: the original "single-column only" note in the `preferences` row of the cover-letter table glossed over `pageFooter`, `lastModifiedFooter`, `groupCertificates`, and `maxRating`, which are also accepted-but-inert here. Also call out that `dateFormat` + `labels.months` still apply to `date: auto` so the substitution localises with the rest of the document. Also inline the `let today` temp binding in the auto-date path — single use, more readable as one expression. --- README.md | 2 +- lib.typ | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6868d01..9022462 100644 --- a/README.md +++ b/README.md @@ -505,7 +505,7 @@ Layout: same masthead as `alta` (name, label, contact bar, optional portrait), t | `salutation` | `none` | Optional greeting line, e.g. `[Dear Hiring Manager,]`. No default — no defensible neutral works across languages and registers. | | `closing` | `auto` | Valediction printed above the signature. `auto` uses `labels.closing` (default `"Sincerely,"`); pass `none` to suppress the closing + signature entirely; pass a string / content to override inline without touching `labels`. | | `labels` | `(:)` | Same shape as `alta`. The new `closing` key sources the default valediction. | -| `preferences` | `(:)` | Same shape as `alta`. Cover letter is single-column, so `columnRatio` / `leftColumnSections` / `rightColumnSections` are accepted (for a single shared preferences dict across both documents) but ignored here. | +| `preferences` | `(:)` | Same shape as `alta`. Theme / typography / header keys apply; CV-only keys (`columnRatio`, `leftColumnSections` / `rightColumnSections`, `pageFooter`, `lastModifiedFooter`, `groupCertificates`, `maxRating`) are accepted — so the same prefs dict drives both documents — but inert here. `dateFormat` and `labels.months` still apply to the `date: auto` substitution. | ## Building the examples diff --git a/lib.typ b/lib.typ index 494cb80..4c6fdd3 100644 --- a/lib.typ +++ b/lib.typ @@ -265,10 +265,14 @@ // `date: auto / none` sentinel pair. // labels — partial dict; merged over `_default_labels`. // preferences — partial dict; merged over `_default_preferences`. -// Cover-letter is single-column, so `columnRatio` / -// `leftColumnSections` / `rightColumnSections` are -// accepted (so the same prefs dict drives both -// documents) but ignored here. +// Theme / typography / header keys apply here as they +// do in alta(); CV-only keys (`columnRatio`, +// `leftColumnSections`, `rightColumnSections`, +// `pageFooter`, `lastModifiedFooter`, +// `groupCertificates`, `maxRating`) are accepted so +// the same prefs dict drives both documents, but +// inert here. `dateFormat` + `labels.months` still +// apply to the `date: auto` substitution. #let cover-letter( cv, body, @@ -315,8 +319,7 @@ // conventional business-letter shape; not tied to `headerTextAlign` // because the date is its own visual unit. let resolved-date = if date == auto { - let today = datetime.today() - _format_date(today.display("[year]-[month]-[day]"), preferences, labels) + _format_date(datetime.today().display("[year]-[month]-[day]"), preferences, labels) } else { date } if _present(resolved-date) { align(right, text(fill: _emphasis_colour, resolved-date))