Skip to content
Closed
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
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,12 @@ An empty or missing `endDate` is interpreted as the role still being current and

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"`). Any string that doesn't parse as an ISO date (e.g. `"Jan 2022"`, `"May 2016 – Jul 2017"`) passes through verbatim, so pre-formatted data keeps rendering identically. See `preferences.dateFormat` below for the available formats and the closure form.

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)). Any section with empty input is 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)). Any section with empty input is skipped — no orphan headings.

`basics.url` (JSON Resume's canonical "personal homepage" field) is rendered 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"` (which represents 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`
Expand Down Expand Up @@ -226,6 +225,17 @@ interests: (
)
```

### References

Each `references[]` entry follows JSON Resume's schema:

| Field | Type | Effect |
|---|---|---|
| `name` | string | Referee's name. Rendered as the entry heading above the quote. Optional — entries with no `name` still render as an anonymous quote. |
| `reference` | string or content | The quote attributed to the referee. Rendered as an italic paragraph. Entries with missing or empty `reference` are silently skipped (the name on its own would have nothing to attribute). |

If the section is included in the layout but contains no valid entries, it is suppressed by default. Set `preferences.referencesAvailableOnRequest: true` to instead emit the conventional "References available upon request." line under the heading — useful when you want to acknowledge the section without listing referees on the document.

### 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 `lib.typ`.
Expand Down Expand Up @@ -274,6 +284,7 @@ Every theme, font, layout, and behaviour knob lives in `preferences`. Override a
| `margin` | `(x: 0.9cm, y: 1.5cm)` | Page margins. Anything `set page(margin: ...)` accepts works. |
| `accent` | `palettes.teal` | Theme colour for headings, accent rules, tags, dots. Use a built-in preset — `palettes.{teal,navy,crimson,forest,plum,charcoal}`, all exported from the module — or pass any `rgb(...)` value. |
| `groupCertificates` | `true` | When true, group certificates by issuer (2+ certs from the same issuer cluster under a darker issuer-label pill; singletons across distinct issuers pool into a trailing unlabelled group). When false, render flat — each cert sits next to its own issuer label. Certificates with no `issuer` render unlabelled either way. |
| `referencesAvailableOnRequest` | `false` | When true and the `references` section is rendered with no valid entries, emit `labels.referencesAvailableOnRequest` (default "References available upon request.") under the heading instead of suppressing the section. No effect when references are present. |
| `imageSize` | `6em` | Diameter of the circular portrait when `basics.image` is set. Ignored when no image is supplied. |
| `imagePosition` | `"right"` | Where the portrait sits in the header — `"left"` or `"right"` (two-column header) or `"center"` (portrait on its own centred row, stacked with the text block). Ignored when no image is supplied. |
| `imageStackOrder` | `"above"` | Stack order when `imagePosition` is `"center"` — `"above"` puts the portrait above the name/label/contact block; `"below"` puts it underneath (the "photo as sign-off" look). Ignored for `"left"` / `"right"` positions. |
Expand All @@ -286,10 +297,10 @@ Every theme, font, layout, and behaviour knob lives in `preferences`. Override a
| `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 the complement (`1 - r`) to invert the layout, or set to `1` for a [single-column layout](#single-column-layout). |
| `pageFooter` | `none` | Optional page footer. `none` — no footer (default). `"auto"` — emits a footer on **multi-page** documents only, with `basics.name` flush left and `Page N / M` flush right, sized at `0.8em` in the body colour; the single-page case stays clean. Any **content** value (`[…]`, `align(...)`, etc.) — rendered verbatim as the footer on every page. Anything else panics. When set (non-`none`), takes precedence over `lastModifiedFooter` — the two prefs target overlapping surface so a non-default `pageFooter` wins; 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. The defaults put the long-form / bulleted sections on the (wider) left so their highlights and summary paragraphs aren't crammed into the narrower right column. |
| `rightColumnSections` | `("focusAreas", "skills", "languages", "education", "certificates", "awards", "interests")` | Sections to render in the right column, in order. The defaults put the compact / horizontal-by-nature sections (pill rows, dot ratings, short metadata blocks) on the right. |
| `rightColumnSections` | `("focusAreas", "skills", "languages", "education", "certificates", "awards", "interests", "references")` | Sections to render in the right column, in order. The defaults put the compact / horizontal-by-nature sections (pill rows, dot ratings, short metadata blocks) on the right. |
| `maxRating` | `5` | Number of dots on the language fluency scale. Must be a positive integer. The 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, or any other positive integer for a custom scale. Fluency strings remain anchored to the 0–5 LinkedIn scale, so callers using a non-5 `maxRating` must supply numeric `languages[].rating` values. |

Both column arrays draw from the same set of section keys: `"work"`, `"volunteer"`, `"focusAreas"`, `"skills"`, `"languages"`, `"education"`, `"certificates"`, `"awards"`, `"projects"`, `"publications"`, `"interests"`. Sections omitted from both arrays are not rendered, even if their data is present; sections listed in both render twice. Unknown keys panic.
Both column arrays draw from the same set of section keys: `"work"`, `"volunteer"`, `"focusAreas"`, `"skills"`, `"languages"`, `"education"`, `"certificates"`, `"awards"`, `"projects"`, `"publications"`, `"interests"`, `"references"`. Sections omitted from both arrays are not rendered, even if their data is present; sections listed in both render twice. Unknown keys panic.

Section renderers are width-agnostic — they fill whichever column they end up in. Combined with `columnRatio`, this enables layouts like an inverted CV where the side-panel sections take the narrow left column and the experience block spans a wider right column.

Expand Down Expand Up @@ -382,9 +393,11 @@ Label keys match the JSON Resume section keys (`work`, `certificates`, …) so t
| `awards` | `"Awards"` |
| `projects` | `"Projects"` |
| `interests` | `"Interests"` |
| `references` | `"References"` |
| `articles` | `"Articles"` |
| `present` | `"Present"` |
| `lastModified` | `"Last updated"` |
| `referencesAvailableOnRequest` | `"References available upon request."` |
| `months` | `("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")` |
| `publicationIcons` | `(:)` |

Expand Down
Binary file modified examples/preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/tests/references.pdf
Binary file not shown.
38 changes: 38 additions & 0 deletions lib.typ
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@
awards: "Awards",
projects: "Projects",
interests: "Interests",
references: "References",
articles: "Articles",
present: "Present",
lastModified: "Last updated",
referencesAvailableOnRequest: "References available upon request.",
// Twelve abbreviated month names, January–December. Used by the
// built-in `dateFormat: "long"` formatter to render ISO 8601 inputs
// (e.g. "2024-06" → "Jun 2024"). Override to localise; the array
Expand Down Expand Up @@ -1164,6 +1166,28 @@
paper: "newspaper",
)

// JSON Resume `references[]`: each entry is `{name, reference}` — a
// referee's name and a quote attributed to them. Entries without a
// `reference` quote are skipped to avoid a name with nothing under
// it. When `availableOnRequest` is true and no valid entries remain
// (empty list, missing list, or all entries lacked a quote), render
// the conventional "References available upon request" line instead.
#let _references(entries, labels, available_on_request: false) = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Use kebab-case for the parameter name to match file convention.

The parameter available_on_request uses snake_case, but the file's established convention for multi-word parameter names is kebab-case (e.g., image-size, link-contact-info, header-text-align in _header at lines 659–665). Rename it to available-on-request for consistency.

♻️ Proposed fix
-#let _references(entries, labels, available_on_request: false) = {
+#let _references(entries, labels, available-on-request: false) = {
   let valid = entries.filter(r => _present(r.at("reference", default: none)))
   if valid.len() == 0 {
-    if not available_on_request { return }
+    if not available-on-request { return }
     [== `#labels.references`]

Also update the call site at line 1329:

     render: (cv, labels, prefs) => _references(
       cv.at("references", default: ()),
       labels,
-      available_on_request: prefs.referencesAvailableOnRequest,
+      available-on-request: prefs.referencesAvailableOnRequest,
     ),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib.typ` at line 1175, Rename the parameter `available_on_request` to
`available-on-request` in the `_references` function signature at line 1175 to
match the file's established kebab-case convention for multi-word parameter
names. Then update the function call site at line 1329 to use the new kebab-case
parameter name `available-on-request` instead of snake_case.

let valid = entries.filter(r => _present(r.at("reference", default: none)))
if valid.len() == 0 {
if not available_on_request { return }
[== #labels.references]
emph(labels.referencesAvailableOnRequest)
return
}
[== #labels.references]
_join_with_dividers(valid, ref => block(breakable: false, {
let referee = ref.at("name", default: none)
if _present(referee) [=== #referee]
par(emph[#ref.reference])
}))
}

// `pub.type` is a local extension. The grouping key is rendered
// verbatim as the subheading; groups appear in first-occurrence order
// (Typst dicts preserve insertion order). Untyped entries fall under
Expand Down Expand Up @@ -1297,6 +1321,14 @@
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,
available_on_request: prefs.referencesAvailableOnRequest,
),
),
)

// Defaults derived from `_sections` so adding a section there
Expand All @@ -1321,6 +1353,11 @@
// (`teal`, `navy`, `crimson`, `forest`, `plum`, `charcoal`).
accent: palettes.teal,
groupCertificates: true,
// When `true` and the `references` section is rendered with no
// valid entries, emit the conventional "References available upon
// request" line under the heading instead of suppressing the
// section. Has no effect when references are present.
referencesAvailableOnRequest: false,
imageSize: 6em,
linkContactInfo: true,
// `{q}` is substituted with the URL-encoded location. A string
Expand Down Expand Up @@ -1423,6 +1460,7 @@
+ repr(page-footer),
)
}
_check_bool("referencesAvailableOnRequest", preferences.referencesAvailableOnRequest)
let df = preferences.dateFormat
if type(df) == str {
// Bracketed templates (`[year]`, `[month repr:long]`, …) defer to
Expand Down
1 change: 1 addition & 0 deletions tests/empty_sections.typ
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@
certificates: (),
interests: (),
publications: (),
references: (),
))
57 changes: 57 additions & 0 deletions tests/references.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// JSON Resume `references` section. Each entry is `{name, reference}`.
// Three documents exercise the API surface:
// 1. Multiple entries — referee name + quote, with skip cases.
// 2. Empty references + `referencesAvailableOnRequest: true` —
// renders the conventional fallback line under the heading.
// 3. Empty references + `referencesAvailableOnRequest: false` —
// section is suppressed entirely (no orphan heading).

#import "../lib.typ": alta

#let cv = (
basics: (name: "Jane Doe", email: "jane@example.com"),
references: (
(
name: "Aoife Ní Bhriain",
reference: [Jane is one of the most thoughtful engineers I have
worked with — equally at home in distributed systems design
and in mentoring the team around her.],
),
(
// Name-only minimal: quote is mandatory, so this is skipped.
name: "Should Not Render",
),
(
// Anonymous reference — no name, quote still renders.
reference: [A consistently strong contributor with an unusually
broad technical range.],
),
(
// Explicit nil reference — skipped.
name: "Also Not Rendered",
reference: none,
),
),
)

// 1. Multiple entries.
#alta(cv)

#pagebreak()

// 2. Empty list + opt-in fallback.
#alta(
(
basics: (name: "Jane Doe", email: "jane@example.com"),
references: (),
),
preferences: (referencesAvailableOnRequest: true),
)

#pagebreak()

// 3. Empty list, default preference — section suppressed.
#alta((
basics: (name: "Jane Doe", email: "jane@example.com"),
references: (),
))
Loading