Skip to content
Open
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
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
Binary file added examples/tests/cover_letter.pdf
Binary file not shown.
6 changes: 6 additions & 0 deletions internal/labels-en.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 80 additions & 2 deletions internal/validation.typ
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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),
)
}
}
Loading
Loading