diff --git a/README.md b/README.md index 37eba6d..9022462 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`. 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 ```sh diff --git a/examples/tests/cover_letter.pdf b/examples/tests/cover_letter.pdf new file mode 100644 index 0000000..c1f3a59 Binary files /dev/null and b/examples/tests/cover_letter.pdf differ 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..4c6fdd3 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,135 @@ ) } } + +// 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`. +// 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, + 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 { + _format_date(datetime.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`. + ], +)