Skip to content

Commit 09d3dfb

Browse files
yunanwgclaude
andcommitted
feat: add deep-merge utility for profile-based metadata overrides
Replace the full-copy-per-profile approach with a deep-merge function that recursively merges sparse profile TOML files on top of a shared root metadata.toml. This lets users vary any field (personal.info, layout, inject, etc.) per language or target role without duplicating the entire configuration. - Add src/utils/merge.typ with recursive deep-merge function - Export deep-merge from src/lib.typ - Update template/cv.typ and letter.typ with profile-aware loading - Restore root metadata.toml and modules_<lang>/ directories - Remove profile_<lang>/ directories (replaced by profiles/ overrides) - Add example profile files (en, fr, zh) in template/profiles/ - Update docs: recipes, getting-started, migration guide, API reference Closes #142 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dc5e4e3 commit 09d3dfb

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+289
-328
lines changed

docs/web/docs/api-reference.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,29 @@ When `ref-full` is `false`, only the entries whose keys appear in
231231

232232
## Utility Functions
233233

234+
### `deep-merge()`
235+
236+
Recursively merge two dictionaries. Values in `override` take precedence.
237+
For nested dictionaries, merging is recursive (deep merge). For all other
238+
value types, the override value replaces the base value entirely.
239+
240+
This is useful for layering a sparse profile configuration on top of a
241+
complete base `metadata.toml`, so that only the fields that differ need
242+
to be specified in the profile.
243+
244+
| Parameter | Type | Description |
245+
|-----------|------|-------------|
246+
| `base` | dictionary | The base dictionary (e.g. root metadata). |
247+
| `override` | dictionary | The override dictionary whose values win on conflict. |
248+
249+
```typ
250+
#import "@preview/brilliant-cv:3.0.0": deep-merge
251+
252+
#let base = toml("./metadata.toml")
253+
#let profile = toml("./profiles/fr.toml")
254+
#let metadata = deep-merge(base, profile)
255+
```
256+
234257
### `h-bar()`
235258

236259
Renders a vertical bar separator (`|`) for use inside skill entries.

docs/web/docs/getting-started.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,11 @@ The most important keys to set first:
7171
typst compile cv.typ
7272
```
7373

74-
## Step 7: Go Beyond
74+
## Step 7: (Optional) Set Up Profiles
75+
76+
If you maintain CVs in multiple languages or for different target roles, you can create **profile overrides** — sparse TOML files that only contain the fields that differ from your root `metadata.toml`. See [Recipes → Profile-Based Overrides](recipes.md#profile-based-overrides) for details.
77+
78+
## Step 8: Go Beyond
7579

7680
It is recommended to:
7781

docs/web/docs/migration.md

Lines changed: 34 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,15 @@
22

33
## Migration from v3
44

5-
v4 replaces the language-based switching system with a **profile-based** architecture. Each profile is a self-contained folder with its own `metadata.toml` and module files, enabling full customization per variant — not just language-specific quotes, but also different personal info, layout, and keywords per target role or industry.
5+
v4 adds a **`deep-merge` utility function** to the package, enabling profile-based overrides. This lets you vary any metadata field per language or target role — not just `header_quote` and footers, but also `[personal.info]`, `[layout]`, `[inject]`, and anything else ([#142](https://github.com/yunanwg/brilliant-CV/issues/142)).
66

7-
### Why this change?
7+
### What changed?
88

9-
In v3, all CV variants share a single `metadata.toml`. The `[lang.<code>]` sections only allow varying `header_quote`, `cv_footer`, and `letter_footer` per language. Fields like `[personal.info]`, `[layout]`, and `[inject]` are global and cannot differ between languages or target roles ([#142](https://github.com/yunanwg/brilliant-CV/issues/142)).
9+
The package now exports `deep-merge`, a recursive dictionary merge function. Your root `metadata.toml` stays as the single source of truth; optional profile files contain only the fields that differ.
1010

11-
The new profile system makes each variant fully independent: a `profile_en/` folder for your English CV, a `profile_fr/` for French, a `profile_swe/` tailored for software engineering roles — each with its own metadata, personal info, and content modules.
11+
### Zero-effort upgrade
1212

13-
### Upgrade paths
14-
15-
#### Option A: Zero-effort upgrade (keep v3 structure)
16-
17-
The v4 package is **fully backward compatible** with the v3 metadata format. If you don't need per-profile customization, just update the version number:
13+
v4 is **fully backward compatible**. If you don't need per-profile customization, just update the version number:
1814

1915
```typ
2016
// Before
@@ -24,97 +20,62 @@ The v4 package is **fully backward compatible** with the v3 metadata format. If
2420
#import "@preview/brilliant-cv:4.0.0": cv
2521
```
2622

27-
Your existing `metadata.toml` with `[lang.<code>]` sections, `modules_<lang>/` folders, and `--input language=xx` CLI pattern will continue to work as before.
28-
29-
#### Option B: Migrate to profile-based structure
23+
Your existing `metadata.toml`, `modules_<lang>/` folders, `[lang.<code>]` sections, and `--input language=xx` all continue to work unchanged.
3024

31-
Follow these steps to adopt the new architecture:
25+
### Adopting profile overrides (optional)
3226

33-
**1. Rename module folders**
34-
35-
```
36-
modules_en/ → profile_en/
37-
modules_fr/ → profile_fr/
38-
```
27+
To vary fields like `personal.info.location` per language:
3928

40-
**2. Create per-profile `metadata.toml`**
41-
42-
Copy your root `metadata.toml` into each profile folder and make these changes:
29+
**1. Create sparse profile files** in a `profiles/` directory:
4330

4431
```toml
45-
# Before (v3 root metadata.toml)
46-
language = "en"
47-
48-
[lang.en]
49-
header_quote = "Experienced Data Analyst..."
50-
cv_footer = "Curriculum vitae"
51-
letter_footer = "Cover letter"
52-
53-
[lang.non_latin]
54-
name = "王道尔"
55-
font = "Heiti SC"
56-
57-
# After (profile_en/metadata.toml) — flat, no [lang] nesting
58-
language = "en"
59-
header_quote = "Experienced Data Analyst..."
60-
cv_footer = "Curriculum vitae"
61-
letter_footer = "Cover letter"
62-
```
32+
# profiles/fr.toml — only the fields that differ
33+
language = "fr"
6334

64-
For non-Latin profiles (zh, ja, ko, ru), move the non-Latin settings to top-level:
35+
[personal.info]
36+
location = "Paris, France"
6537

66-
```toml
67-
# profile_zh/metadata.toml
68-
language = "zh"
69-
header_quote = "具有丰富经验的数据分析师,随时可入职"
70-
cv_footer = "简历"
71-
letter_footer = "申请信"
72-
non_latin_name = "王道尔"
73-
non_latin_font = "Heiti SC"
38+
[personal.info.custom-1]
39+
awesomeIcon = "car"
40+
text = "Permis B"
7441
```
7542

76-
Remove the `[lang.*]` sections entirely from each profile's `metadata.toml`. You can now customize `[personal]`, `[layout]`, `[inject]`, and all other sections independently per profile.
77-
78-
**3. Update `cv.typ`**
43+
**2. Update your `cv.typ` import and preamble:**
7944

8045
```typ
8146
// Before (v3)
47+
#import "@preview/brilliant-cv:3.2.0": cv
48+
49+
// After (v4) — add deep-merge to the import
50+
#import "@preview/brilliant-cv:4.0.0": cv, deep-merge
8251
#let metadata = toml("./metadata.toml")
52+
53+
// Profile override (new)
54+
#let cv-profile = sys.inputs.at("profile", default: metadata.at("profile", default: none))
55+
#let metadata = if cv-profile != none {
56+
deep-merge(metadata, toml("./profiles/" + cv-profile + ".toml"))
57+
} else {
58+
metadata
59+
}
60+
61+
// --input language=xx still works as before
8362
#let cv-language = sys.inputs.at("language", default: none)
8463
#let metadata = if cv-language != none {
8564
metadata + (language: cv-language)
8665
} else {
8766
metadata
8867
}
89-
#let import-modules(modules, lang: metadata.language) = {
90-
for module in modules {
91-
include { "modules_" + lang + "/" + module + ".typ" }
92-
}
93-
}
94-
95-
// After (v4)
96-
#let profile = sys.inputs.at("profile", default: "en")
97-
#let metadata = toml("profile_" + profile + "/metadata.toml")
98-
#let import-modules(modules) = {
99-
for module in modules {
100-
include { "profile_" + profile + "/" + module + ".typ" }
101-
}
102-
}
10368
```
10469

105-
**4. Update `letter.typ`** — same pattern as `cv.typ`.
70+
**3. Update `letter.typ`** — same preamble pattern.
10671

107-
**5. Update CLI commands**
72+
**4. Build with a profile:**
10873

10974
```bash
110-
# Before
111-
typst compile cv.typ --input language=fr
112-
113-
# After
11475
typst compile cv.typ --input profile=fr
11576
```
11677

117-
**6. Clean up** — delete the root `metadata.toml` (each profile folder now has its own).
78+
See [Recipes → Profile-Based Overrides](recipes.md#profile-based-overrides) for full details and examples.
11879

11980
---
12081

docs/web/docs/recipes.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,65 @@ typst compile cv.typ --input language=fr
1414

1515
For Chinese, Japanese, Korean, or Russian, also configure `[lang.non_latin]` in `metadata.toml` with `name` and `font`.
1616

17+
## Profile-Based Overrides
18+
19+
If you need different `personal.info` fields per language or target role — for example, "Permis B" instead of "Driver License", or "Paris" instead of "San Francisco" — you can use **profile overrides**.
20+
21+
A profile is a sparse TOML file that gets [deep-merged](api-reference.md) on top of your root `metadata.toml`. Only the fields that differ need to be specified.
22+
23+
### Setup
24+
25+
**1. Create a `profiles/` directory** with one TOML file per variant:
26+
27+
```
28+
profiles/
29+
en.toml ← minimal (language is already "en" in root)
30+
fr.toml ← overrides language + French-specific fields
31+
```
32+
33+
**2. Write sparse overrides.** For example, `profiles/fr.toml`:
34+
35+
```toml
36+
language = "fr"
37+
38+
[personal.info]
39+
location = "Paris, France"
40+
41+
[personal.info.custom-1]
42+
awesomeIcon = "car"
43+
text = "Permis B"
44+
```
45+
46+
Everything not specified (layout, fonts, email, GitHub, etc.) is inherited from root `metadata.toml`.
47+
48+
**3. Build with a profile:**
49+
50+
```bash
51+
typst compile cv.typ --input profile=fr
52+
```
53+
54+
Or set a default in `metadata.toml`:
55+
56+
```toml
57+
profile = "en"
58+
```
59+
60+
### How deep-merge works
61+
62+
The `deep-merge` function recursively combines two dictionaries:
63+
64+
- **No conflict** (key only in one dict) → value is kept as-is
65+
- **Both have the key, both are dicts** → merge recursively (go deeper)
66+
- **Both have the key, not both dicts** → profile value wins
67+
68+
This means `profiles/fr.toml` only overrides `personal.info.location` and `personal.info.custom-1` — all other `personal.info` fields (email, phone, github, etc.) are preserved from root.
69+
70+
### Tips
71+
72+
- **Profile ≠ language.** You can have `profiles/us.toml` and `profiles/uk.toml` both with `language = "en"` but different locations or phone numbers.
73+
- **`--input language=xx` still works** as a final override on top of a profile, for backward compatibility.
74+
- **Profiles are optional.** If you don't use them, everything works exactly as before.
75+
1776
## Skills with Inline Separators
1877

1978
Use `#h-bar()` to separate skill items within `cv-skill`:

docs/web/generate-api-reference.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,29 @@
3333
3434
## Utility Functions
3535
36+
### `deep-merge()`
37+
38+
Recursively merge two dictionaries. Values in `override` take precedence.
39+
For nested dictionaries, merging is recursive (deep merge). For all other
40+
value types, the override value replaces the base value entirely.
41+
42+
This is useful for layering a sparse profile configuration on top of a
43+
complete base `metadata.toml`, so that only the fields that differ need
44+
to be specified in the profile.
45+
46+
| Parameter | Type | Description |
47+
|-----------|------|-------------|
48+
| `base` | dictionary | The base dictionary (e.g. root metadata). |
49+
| `override` | dictionary | The override dictionary whose values win on conflict. |
50+
51+
```typ
52+
#import "@preview/brilliant-cv:3.0.0": deep-merge
53+
54+
#let base = toml("./metadata.toml")
55+
#let profile = toml("./profiles/fr.toml")
56+
#let metadata = deep-merge(base, profile)
57+
```
58+
3659
### `h-bar()`
3760
3861
Renders a vertical bar separator (`|`) for use inside skill entries.

src/lib.typ

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#import "./letter.typ": *
88
#import "./utils/lang.typ": *
99
#import "./utils/styles.typ": *
10+
#import "./utils/merge.typ": deep-merge
1011

1112
/* Layout */
1213

src/utils/merge.typ

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/// Recursively merge two dictionaries. Values in `override` take precedence.
2+
/// For nested dictionaries, merging is recursive (deep merge). For all other
3+
/// value types, the override value replaces the base value entirely.
4+
///
5+
/// This is useful for layering a sparse profile configuration on top of a
6+
/// complete base `metadata.toml`, so that only the fields that differ need
7+
/// to be specified in the profile.
8+
///
9+
/// - base (dictionary): The base dictionary (e.g. root metadata).
10+
/// - override (dictionary): The override dictionary whose values win on conflict.
11+
/// -> dictionary
12+
#let deep-merge(base, override) = {
13+
let result = base
14+
for (key, value) in override {
15+
if key in result and type(result.at(key)) == dictionary and type(value) == dictionary {
16+
result.insert(key, deep-merge(result.at(key), value))
17+
} else {
18+
result.insert(key, value)
19+
}
20+
}
21+
result
22+
}

template/cv.typ

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,28 @@
11
// Imports
2-
#import "@preview/brilliant-cv:3.2.0": cv
2+
#import "@preview/brilliant-cv:3.2.0": cv, deep-merge
3+
#let metadata = toml("./metadata.toml")
34

4-
// Select which profile to build. Each profile lives in its own folder
5-
// (e.g. profile_en/, profile_fr/, profile_solution_engineer/) with its
6-
// own metadata.toml and module files.
7-
//
5+
// Profile override: deep-merge a sparse profile TOML on top of root config.
86
// Override via CLI: typst compile cv.typ --input profile=fr
9-
#let profile = sys.inputs.at("profile", default: "en")
10-
#let metadata = toml("profile_" + profile + "/metadata.toml")
7+
#let cv-profile = sys.inputs.at("profile", default: metadata.at("profile", default: none))
8+
#let metadata = if cv-profile != none {
9+
deep-merge(metadata, toml("./profiles/" + cv-profile + ".toml"))
10+
} else {
11+
metadata
12+
}
13+
14+
// Backward compat: --input language=xx still works as a final override
15+
#let cv-language = sys.inputs.at("language", default: none)
16+
#let metadata = if cv-language != none {
17+
metadata + (language: cv-language)
18+
} else {
19+
metadata
20+
}
1121

12-
#let import-modules(modules) = {
22+
#let import-modules(modules, lang: metadata.language) = {
1323
for module in modules {
1424
include {
15-
"profile_" + profile + "/" + module + ".typ"
25+
"modules_" + lang + "/" + module + ".typ"
1626
}
1727
}
1828
}
@@ -27,7 +37,6 @@
2737
// ),
2838
)
2939

30-
// Add, remove, or reorder modules to customize your CV content
3140
#import-modules((
3241
"education",
3342
"professional",

template/letter.typ

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
11
// Imports
2-
#import "@preview/brilliant-cv:3.2.0": letter
2+
#import "@preview/brilliant-cv:3.2.0": letter, deep-merge
3+
#let metadata = toml("./metadata.toml")
34

4-
// Select which profile to build (must match a profile_<name>/ folder)
5+
// Profile override: deep-merge a sparse profile TOML on top of root config.
56
// Override via CLI: typst compile letter.typ --input profile=fr
6-
#let profile = sys.inputs.at("profile", default: "en")
7-
#let metadata = toml("profile_" + profile + "/metadata.toml")
7+
#let letter-profile = sys.inputs.at("profile", default: metadata.at("profile", default: none))
8+
#let metadata = if letter-profile != none {
9+
deep-merge(metadata, toml("./profiles/" + letter-profile + ".toml"))
10+
} else {
11+
metadata
12+
}
13+
14+
// Backward compat: --input language=xx still works as a final override
15+
#let letter-language = sys.inputs.at("language", default: none)
16+
#let metadata = if letter-language != none {
17+
metadata + (language: letter-language)
18+
} else {
19+
metadata
20+
}
821

922

1023
#show: letter.with(

0 commit comments

Comments
 (0)