@cavuno/board 2.0.3 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CONTRIBUTING.md +0 -11
  3. package/README.md +8 -7
  4. package/dist/{_spec-BKtVFcV9.d.mts → _spec-nkWAraS0.d.mts} +4 -18
  5. package/dist/{_spec-BKtVFcV9.d.ts → _spec-nkWAraS0.d.ts} +4 -18
  6. package/dist/bin.mjs +40 -57
  7. package/dist/{board-3Xj_Y-Tx.d.ts → board-NE98PlwN.d.ts} +2 -3
  8. package/dist/{board-pNN710Zc.d.mts → board-muu8BI8P.d.mts} +2 -3
  9. package/dist/doctor.js +21 -47
  10. package/dist/doctor.mjs +24 -50
  11. package/dist/filters.d.mts +2 -2
  12. package/dist/filters.d.ts +2 -2
  13. package/dist/format.d.mts +3 -3
  14. package/dist/format.d.ts +3 -3
  15. package/dist/index.d.mts +11 -27
  16. package/dist/index.d.ts +11 -27
  17. package/dist/index.js +3 -3
  18. package/dist/index.mjs +3 -3
  19. package/dist/{jobs-C4qRfCdP.d.ts → jobs-BiEvL9d7.d.ts} +1 -1
  20. package/dist/{jobs-k84C0v_8.d.mts → jobs-lVvKCBCU.d.mts} +1 -1
  21. package/dist/{salaries--J5mTylN.d.ts → salaries-BahBpxWT.d.ts} +2 -2
  22. package/dist/{salaries-C9YLO5NG.d.mts → salaries-ChSqDBAM.d.mts} +2 -2
  23. package/dist/{search-CE_qQiM7.d.mts → search-CCyFWPi3.d.mts} +1 -1
  24. package/dist/{search-CLRQ_El9.d.ts → search-CyRvVMUp.d.ts} +1 -1
  25. package/dist/seo.d.mts +4 -4
  26. package/dist/seo.d.ts +4 -4
  27. package/dist/server.d.mts +5 -5
  28. package/dist/server.d.ts +5 -5
  29. package/dist/sitemap.d.mts +5 -5
  30. package/dist/sitemap.d.ts +5 -5
  31. package/dist/skills.d.mts +15 -4
  32. package/dist/skills.d.ts +15 -4
  33. package/dist/skills.js +30 -7
  34. package/dist/skills.mjs +30 -8
  35. package/dist/suggest.d.mts +2 -2
  36. package/dist/suggest.d.ts +2 -2
  37. package/package.json +3 -13
  38. package/skills/cavuno-board-account/SKILL.md +54 -66
  39. package/skills/cavuno-board-api-client/SKILL.md +74 -0
  40. package/skills/cavuno-board-applications/SKILL.md +38 -52
  41. package/skills/cavuno-board-auth/SKILL.md +36 -61
  42. package/skills/cavuno-board-blog/SKILL.md +60 -49
  43. package/skills/cavuno-board-companies/SKILL.md +71 -53
  44. package/skills/cavuno-board-errors/SKILL.md +29 -46
  45. package/skills/cavuno-board-filters/SKILL.md +64 -75
  46. package/skills/cavuno-board-format/SKILL.md +63 -68
  47. package/skills/cavuno-board-i18n/SKILL.md +33 -62
  48. package/skills/cavuno-board-job-alerts/SKILL.md +68 -57
  49. package/skills/cavuno-board-jobs/SKILL.md +63 -55
  50. package/skills/cavuno-board-messaging/SKILL.md +53 -59
  51. package/skills/cavuno-board-paywall/SKILL.md +48 -54
  52. package/skills/cavuno-board-post-a-job/SKILL.md +118 -0
  53. package/skills/cavuno-board-salaries/SKILL.md +73 -50
  54. package/skills/cavuno-board-search-suggestions/SKILL.md +82 -0
  55. package/skills/cavuno-board-seo/BLOG.md +40 -0
  56. package/skills/cavuno-board-seo/JOB_AND_BREADCRUMBS.md +49 -0
  57. package/skills/cavuno-board-seo/LISTING.md +36 -0
  58. package/skills/cavuno-board-seo/SALARY.md +61 -0
  59. package/skills/cavuno-board-seo/SKILL.md +32 -163
  60. package/skills/cavuno-board-server-sessions/SKILL.md +143 -0
  61. package/skills/cavuno-board-setup/SKILL.md +43 -80
  62. package/skills/cavuno-board-sitemap/SKILL.md +76 -93
  63. package/skills/cavuno-board-smoke-test/SKILL.md +34 -69
  64. package/skills/manifest.json +43 -57
  65. package/dist/theme.d.mts +0 -115
  66. package/dist/theme.d.ts +0 -115
  67. package/dist/theme.js +0 -195
  68. package/dist/theme.mjs +0 -174
  69. package/skills/cavuno-board-client/SKILL.md +0 -89
  70. package/skills/cavuno-board-job-posting/SKILL.md +0 -116
  71. package/skills/cavuno-board-server/SKILL.md +0 -229
  72. package/skills/cavuno-board-suggest/SKILL.md +0 -119
  73. package/skills/cavuno-board-theme/SKILL.md +0 -57
  74. package/skills/flavors/tanstack-start/SKILL.md +0 -100
@@ -1,100 +1,89 @@
1
1
  ---
2
2
  name: cavuno-board-filters
3
- description: The canonical listing-filter vocabulary and URL search-param parsing with @cavuno/board/filters — remote/employment/seniority/company/sort sets, localized seniority labels, and parseListingFilters for jobs-listing routes. Use when building listing pages, filter sidebars, sort dropdowns, or validating listing URL params.
3
+ description: Listing-filter contracts with @cavuno/board. Use for job filter controls, sort controls, listing URL validation, or taxonomy-backed filter options.
4
4
  ---
5
5
 
6
- # Filters: vocabulary + URL parsing
6
+ # Listing filters
7
7
 
8
- `@cavuno/board/filters` is the one filter vocabulary every tenant frontend
9
- shares — the seniority set is tested against the platform source, and
10
- URL parsing follows the hosted semantics (hand-typed URLs are messy; parsing
11
- never throws).
8
+ `@cavuno/board/filters` is the shared vocabulary and parser for every job
9
+ listing route, including category, skill, and location pages. Display labels
10
+ live in `@cavuno/board/format`; search POST bodies use `jobs.search`; typeahead
11
+ UI behavior lives in `cavuno-board-search-suggestions`.
12
12
 
13
- ## When to use
13
+ ## Parse public URL input
14
14
 
15
- - The jobs index and every programmatic listing page (category / skill /
16
- location) — they all layer the same cross-cutting filters on any seed.
17
- - `validateSearch`-style route param validation (server-safe, SSR-ready).
18
-
19
- ## When not to use
20
-
21
- - Turning values into card display text — `cavuno-board-format`.
22
- - Search QUERIES (`jobs.search` bodies) — this module is about the listing
23
- URL contract, not the search POST body.
24
- - Search-dropdown typeahead — `cavuno-board-suggest`.
25
-
26
- ## Parse listing URLs
15
+ Run every listing URL through `parseListingFilters`. Public input is
16
+ permissive: unknown values are dropped and parsing does not throw. Seniority
17
+ and company accept repeated parameters or comma-separated strings, then trim,
18
+ lowercase, deduplicate, and preserve order. Company is an open set of public
19
+ slugs capped at the first 10 values.
27
20
 
28
21
  ```ts snippet
29
- import { parseListingFilters, DEFAULT_SORT } from '@cavuno/board/filters';
22
+ import {
23
+ DEFAULT_SORT,
24
+ parseListingFilters,
25
+ } from '@cavuno/board/filters';
30
26
 
31
27
  const filters = parseListingFilters(rawSearchParams);
32
- // { q?, remoteOption?, employmentType?, seniority?: Seniority[], company?: string[], sort? }
28
+ const selectedSort = filters.sort ?? DEFAULT_SORT;
33
29
 
34
30
  const page = await board.jobs.list({
35
31
  limit: 20,
36
32
  seniority: filters.seniority,
37
33
  companySlug: filters.company,
38
34
  remoteOption: filters.remoteOption ? [filters.remoteOption] : undefined,
39
- employmentType: filters.employmentType ? [filters.employmentType] : undefined,
35
+ employmentType: filters.employmentType
36
+ ? [filters.employmentType]
37
+ : undefined,
40
38
  });
41
39
  ```
42
40
 
43
- Unknown values are dropped silently (public URLs, never throw). Seniority
44
- and `company` accept repeated params or a comma-string and normalize with
45
- the hosted rules: trim, lowercase, dedupe, keep order. `company` is an open
46
- value set (public slugs) capped at 10 (wire max; first 10 kept).
41
+ Company slugs are the URL identity. Map `filters.company` directly to
42
+ `companySlug` in `jobs.list` queries or `jobs.search` filters.
47
43
 
48
- **Slugs are the URL identity.** Map `filters.company` to the wire as
49
- `companySlug` (`jobs.list` query / `jobs.search` filters). The API accepts
50
- slugs directly — never resolve slug→id client-side.
51
-
52
- ## Render the filter UI from the vocabulary
44
+ ## Render controls from the vocabulary
53
45
 
54
46
  ```ts snippet
55
47
  import {
56
- REMOTE_OPTIONS,
57
48
  EMPLOYMENT_TYPES,
58
- SENIORITIES,
59
49
  JOB_SORTS,
50
+ REMOTE_OPTIONS,
51
+ SENIORITIES,
60
52
  seniorityLabels,
61
53
  sortLabels,
62
54
  } from '@cavuno/board/filters';
63
55
 
64
56
  const { language } = await board.context();
65
- const labels = seniorityLabels(language); // de: executive → "Führungskraft"
66
- const sorts = sortLabels(language); // English defaults on every locale
57
+ const seniorityCopy = seniorityLabels(language);
58
+ const sortCopy = sortLabels(language);
67
59
  ```
68
60
 
69
- Seniority renders as a MULTI-select (hosted parity). `EMPLOYMENT_TYPES`
70
- deliberately offers 5 of the 7 wire values (`volunteer`/`other` exist on
71
- jobs but are not filter options). `JOB_SORTS` deliberately excludes
72
- `oldest`; `relevance` is the featured-ranked default.
61
+ Render seniority as a multi-select with all eight `SENIORITIES`.
62
+ `EMPLOYMENT_TYPES` contains five listing options; `volunteer` and `other`
63
+ remain valid job wire values but are absent from the filter control.
64
+ `JOB_SORTS` contains exactly `relevance`, `newest`, and `salary_high`;
65
+ `relevance` is the featured-ranked default. Sort labels follow the board copy
66
+ catalog and operator overrides supplied to `sortLabels`.
73
67
 
74
- ## Load category, skill, and keyword options
68
+ ## Load taxonomy options
75
69
 
76
- Use the Board API taxonomy collections for filter/autocomplete options. Items
77
- are backed by published jobs on this board and already carry the board-language
78
- display name and canonical URL slug. Keep `sourceSlug` for job filtering and
79
- `canonicalSlug` for links.
70
+ Category and skill collections contain terms backed by published jobs. Each
71
+ term already carries a board-language `displayName`, immutable English
72
+ `sourceSlug` for filtering, and board-language `canonicalSlug` for links.
80
73
 
81
74
  ```ts snippet
82
75
  const categories = await board.taxonomy.categories.list({ limit: 50 });
83
76
  const skills = await board.taxonomy.skills.list({ limit: 50 });
84
- const suggestions = await board.taxonomy.suggestions.list({
85
- q: searchText,
86
- limit: 10,
87
- });
88
77
 
89
78
  const categoryOptions = categories.data.map((term) => ({
90
- label: term.displayName,
91
- filterValue: term.sourceSlug,
92
- href: `/jobs/${term.canonicalSlug}`,
79
+ label: term.displayName,
80
+ filterValue: term.sourceSlug,
81
+ href: `/jobs/${term.canonicalSlug}`,
93
82
  }));
94
83
  ```
95
84
 
96
- Category and skill lists accept `q`, `limit` (1–100), and the opaque
97
- `cursor` request field. Pass the previous response's `nextCursor` into it:
85
+ Category and skill lists accept `q`, `limit` (1–100), and opaque `cursor`.
86
+ Pass `nextCursor` unchanged to the next request.
98
87
 
99
88
  ```ts snippet
100
89
  const first = await board.taxonomy.categories.list({ limit: 50 });
@@ -106,31 +95,31 @@ const second = first.nextCursor
106
95
  : null;
107
96
  ```
108
97
 
109
- Suggestions accept `q` and `limit`;
110
- a present query under two characters returns no suggestions. Suggestions contain
111
- categories and skills only. A category and skill may legitimately share a slug,
112
- so use `type + canonicalSlug` as the option identity.
113
-
114
- ## Anti-patterns
98
+ Keyword suggestions accept `q` and `limit`. A present query shorter than two
99
+ characters returns no results. Suggestions contain categories and skills only;
100
+ because both types may share a slug, key each option by
101
+ `type + canonicalSlug`.
115
102
 
116
- ```ts no-check
117
- // NEVER hand-roll the vocab per page — one page offering 'oldest' or a
118
- // 6th employment type diverges from every other board frontend:
119
- const sorts = ['relevance', 'newest', 'oldest'];
120
- // NEVER trust raw search params into the SDK query:
121
- board.jobs.list({ seniority: rawSearch.seniority });
103
+ ```ts snippet
104
+ const suggestions = await board.taxonomy.suggestions.list({
105
+ q: searchText,
106
+ limit: 10,
107
+ });
122
108
  ```
123
109
 
124
- ## Out of scope — do not invent exports
110
+ The host router owns URL serialization and saved-filter persistence.
111
+ Locations come from `board.taxonomy.places` rather than a static filter export.
125
112
 
126
- No URL BUILDER (routes are app-owned — serialize with your router), no location
127
- taxonomy vocabulary (locations come from `board.taxonomy.places`), no
128
- saved-filter persistence.
113
+ ## Completion gate
129
114
 
130
- ## Verify
115
+ Finish only after every applicable check passes:
131
116
 
132
- - [ ] `/jobs?seniority=Senior,%20lead&sort=oldest` renders the senior+lead
133
- selection and the default sort — no crash, no leaked invalid value.
134
- - [ ] The seniority multi-select shows all 8 levels, labeled in the board
135
- language.
136
- - [ ] The sort dropdown offers exactly relevance / newest / salary_high.
117
+ - `/jobs?seniority=Senior,%20lead&sort=oldest` selects senior and lead, falls
118
+ back to `DEFAULT_SORT`, and passes no invalid value to the SDK.
119
+ - The seniority control has all eight localized levels and supports multiple
120
+ selections.
121
+ - The employment control has five options; the sort control has exactly
122
+ relevance, newest, and salary high.
123
+ - Category and skill filtering sends `sourceSlug`, while links use
124
+ `canonicalSlug`.
125
+ - Every paged taxonomy request forwards the previous opaque `nextCursor`.
@@ -1,104 +1,99 @@
1
1
  ---
2
2
  name: cavuno-board-format
3
- description: Display formatting for board frontends with @cavuno/board/format — salary ranges, seniority/employment/remote labels, location labels, dates, and the board-language salary lexicon. Use when rendering job cards, detail pages, or salary figures, and whenever a value like salaryMin/salaryTimeframe/seniority needs to become display text.
3
+ description: Board-language display formatting with @cavuno/board/format. Use for salary ranges, dates, job labels, location labels, card conversion, or salary metadata copy.
4
4
  ---
5
5
 
6
- # Format: board-language display text
6
+ # Display formatting
7
7
 
8
- `@cavuno/board/format` turns wire values into the exact display text the
9
- hosted board renders — tested against the hosted formatters, so a
10
- tenant frontend cannot drift from the platform's rendering rules.
8
+ `@cavuno/board/format` matches the hosted board's display rules. Read the
9
+ board language once and pass it as the required first argument to every
10
+ label-producing helper.
11
11
 
12
- ## When to use
12
+ Filter controls use `cavuno-board-filters`. Salary values remain in their wire
13
+ currency and retain their server-computed amounts.
13
14
 
14
- - Rendering salary ranges, dates, locations, or enum labels anywhere.
15
- - Building salary-page metadata sentences (the lexicon frames).
16
-
17
- ## When not to use
18
-
19
- - Filter vocabulary and URL parsing — `cavuno-board-filters`.
20
- - Money math. Never compute or convert amounts client-side; format what the
21
- wire provides.
22
-
23
- ## The board language is a required first argument
24
-
25
- Every label-producing helper takes the board language — read it once from
26
- context. There is no `en` default: a German-native board must never
27
- silently render English.
15
+ ## Format job and blog fields
28
16
 
29
17
  ```ts snippet
30
18
  import {
31
- formatSalaryRange,
32
- formatPublishedRelativeDate,
33
- formatDate,
34
19
  fieldLabel,
20
+ formatDate,
21
+ formatPublishedRelativeDate,
22
+ formatSalaryRange,
35
23
  } from '@cavuno/board/format';
36
24
 
37
25
  const { language } = await board.context();
38
26
 
39
- formatSalaryRange(language, job.salaryMin, job.salaryMax, job.salaryTimeframe, job.salaryCurrency);
40
- // en: "$90K – $120K Yearly" · de: "90.000 € – 120.000 € pro Jahr"
41
- formatPublishedRelativeDate(language, job.publishedAt); // "5d ago" — what job CARDS render
42
- formatDate(language, job.publishedAt); // "Jun 24, 2026" — blog metadata / detail facts (UTC-pinned)
43
- fieldLabel(language, job.seniority); // de: 'executive' → "Führungskraft"
27
+ formatSalaryRange(
28
+ language,
29
+ job.salaryMin,
30
+ job.salaryMax,
31
+ job.salaryTimeframe,
32
+ job.salaryCurrency);
33
+ formatPublishedRelativeDate(language, job.publishedAt);
34
+ formatDate(language, job.publishedAt);
35
+ fieldLabel(language, job.seniority);
44
36
  ```
45
37
 
46
- Job cards and the job-detail header show the RELATIVE form; the absolute
47
- medium date is for blog metadata and detail fact rows — matching hosted.
38
+ `formatPublishedRelativeDate` produces the compact relative value used on job
39
+ cards and the job-detail header, such as `5d ago`. `formatDate` produces the
40
+ UTC-pinned medium date used in blog metadata and detail facts, such as
41
+ `Jun 24, 2026`. This format surface limits relative time to the compact
42
+ job-publication label.
48
43
 
49
- Localization matches hosted exactly: seniority + timeframe words are
50
- localized (en+de lexicon, other locales fall back to English words with
51
- locale-correct number formatting); employment/remote labels are English on
52
- every board because hosted ships no other vocabulary yet.
44
+ Salary timeframe and seniority copy is localized for English and German;
45
+ other locales use English words with locale-correct number formatting.
46
+ Employment and remote labels remain English across board languages because
47
+ the hosted board currently has no translated vocabulary for them.
53
48
 
54
- ## Location labels
49
+ ## Render locations and saved-job cards
55
50
 
56
- Cards carry server-computed labels — use them via `cardLocationLabel`. Only
57
- the full `PublicJob` needs client-side derivation:
51
+ Cards already contain server-computed location labels. Full `PublicJob`
52
+ detail requires the full-job formatter. Convert a full saved job before
53
+ placing it in a card collection.
58
54
 
59
55
  ```ts snippet
60
- import { cardLocationLabel, locationLabel, fullJobToCard } from '@cavuno/board/format';
56
+ import {
57
+ cardLocationLabel,
58
+ fullJobToCard,
59
+ locationLabel,
60
+ } from '@cavuno/board/format';
61
61
 
62
- cardLocationLabel(language, card); // "Remote · Europe" | "Berlin, Germany"
63
- locationLabel(language, job); // full PublicJob (detail page)
64
- const card = fullJobToCard(language, job); // embed a saved job in a card list
62
+ cardLocationLabel(language, card);
63
+ locationLabel(language, job);
64
+ const savedCard = fullJobToCard(language, job);
65
65
  ```
66
66
 
67
- ## Salary lexicon (metadata sentences)
67
+ ## Build salary metadata copy
68
68
 
69
- `getSalaryLexicon(language)` exposes the words and the `<title>`/meta
70
- sentence frames the hosted salary pages use:
69
+ `getSalaryLexicon` exposes hosted salary terminology and sentence frames.
71
70
 
72
71
  ```ts snippet
73
72
  import { getSalaryLexicon } from '@cavuno/board/format';
74
73
 
75
74
  const lexicon = getSalaryLexicon(language);
76
- lexicon.frames.entitySalariesTitle({ entity: 'JavaScript', range: '$70K – $90K' });
77
- // "JavaScript Salaries ($70K – $90K/yr)"
78
- lexicon.seniority.senior; // "Senior"
79
- ```
80
-
81
- ## Anti-patterns
82
-
83
- ```ts no-check
84
- // NEVER hand-format money — symbol placement and words are locale rules:
85
- `$${(job.salaryMin / 1000).toFixed(0)}k`; // wrong on de boards
86
- // NEVER default the locale:
87
- formatSalaryRange('en', ...); // hardcoded 'en' on a de board
88
- // NEVER rebuild card location labels from scratch — the wire pre-computes them.
75
+ lexicon.frames.entitySalariesTitle({
76
+ entity: 'JavaScript',
77
+ range: '$70K – $90K',
78
+ });
79
+ lexicon.seniority.senior;
89
80
  ```
90
81
 
91
- ## Out of scope — do not invent exports
82
+ Use the helpers as the formatting boundary: pass the board language, wire
83
+ amounts, wire currency, and server location data in; render the returned text.
84
+ This keeps symbol placement, timeframe copy, number rules, and hosted labels
85
+ aligned. Sort-dropdown copy remains in `cavuno-board-filters`.
92
86
 
93
- No currency conversion, no relative-time ("3 days ago") helper, no
94
- number-only formatter — the SDK formats exactly what the hosted board
95
- formats. Sort-dropdown copy lives in `cavuno-board-filters`.
87
+ ## Completion gate
96
88
 
97
- ## Verify
89
+ Finish only after every applicable check passes:
98
90
 
99
- - [ ] A de board renders "pro Jahr"/"ab"/"Führungskraft" where an en board
100
- renders "Yearly"/"From"/"Executive" — same code path, different
101
- `language`.
102
- - [ ] An en board's salary strings are byte-identical to the hosted board's
103
- for the same job.
104
- - [ ] No call site passes a hardcoded locale string.
91
+ - Every label-producing call receives `board.context().language`; no call site
92
+ substitutes a hardcoded locale.
93
+ - A German board renders `pro Jahr`, `ab`, and `Führungskraft` where the same
94
+ path renders `Yearly`, `From`, and `Executive` in English.
95
+ - English salary strings are byte-identical to the hosted board for the same
96
+ wire job.
97
+ - Cards use `cardLocationLabel`; full jobs use `locationLabel`; saved full jobs
98
+ are converted with `fullJobToCard` before card rendering.
99
+ - Formatting changes neither salary amounts nor currency.
@@ -1,81 +1,52 @@
1
1
  ---
2
2
  name: cavuno-board-i18n
3
- description: Multi-language chrome for a board frontend — the single-language board model, the uiCopy catalog ⊕ operator labels ⊕ generated copy layering, the one copy seam, and how to add path-prefixed locales (/de/, /fr/) with Paraglide JS on TanStack Start. Use when localizing a starter's chrome, adding a language, or wiring locale routing. Do NOT use to translate job content — that stays the board's language.
3
+ description: One-seam board localization with @cavuno/board. Use when localizing UI chrome, adding locale routing, merging operator labels, or preserving board-language content.
4
4
  ---
5
5
 
6
- # Board i18n: localize the chrome, never the content
6
+ # One-seam board localization
7
7
 
8
- A Cavuno board has **one content language** (`board.context().language`).
9
- Job titles, descriptions, and company text are that single language and
10
- never translated by the frontend. What you localize is the **chrome** —
11
- labels, headings, FAQ scaffolding, filter vocabulary — plus the entity
12
- data the API already translates (taxonomy, places, salary names).
8
+ A Cavuno board has one content language: `board.context().language`. Jobs, companies, and blog content remain in that API-served language. Multi-locale frontends localize chrome—labels, headings, filters, and FAQ scaffolding—plus entity data the API already translates, such as taxonomy, places, and salary names.
13
9
 
14
- So there are two separate things:
15
- - **Content** (jobs, companies, blog): board-language, API-served, single.
16
- - **Chrome** (your authored UI strings): can be multi-locale, path-routed.
10
+ ## Build the copy seam
17
11
 
18
- ## The copy layers (resolve in order)
12
+ All UI strings resolve through one application module. Layer copy in this order:
19
13
 
20
- 1. **`uiCopy(language)`** — the versioned SDK catalog (`@cavuno/board/format`,
21
- `en`/`de`/`fr`, functions-per-key for plurals; no runtime, no provider).
22
- The floor.
23
- 2. **`⊕ board.context().labels`** — the operator's per-board overrides,
24
- API-served (e.g. `featuredLabel` → "Top Job").
25
- 3. **`⊕ generated code`** — per-board copy the AI builder ejects to native
26
- code. Supersedes the catalog for that board.
27
-
28
- Every surface resolves copy through **one seam module** (`src/copy.ts`),
29
- never by calling the catalog inline:
14
+ 1. `uiCopy(language)` provides the versioned `en`, `de`, and `fr` catalog, including functions for pluralized keys.
15
+ 2. `board.context().labels` overlays operator-specific labels.
16
+ 3. Generated application copy overlays both when the builder ejects native code.
30
17
 
31
18
  ```ts snippet
32
- import { uiCopy, type BoardLabelOverrides } from '@cavuno/board/format'
33
- export function boardCopy(language: string | undefined, labels?: BoardLabelOverrides) {
34
- return uiCopy(language, labels)
19
+ import {
20
+ uiCopy,
21
+ type BoardLabelOverrides,
22
+ } from '@cavuno/board/format';
23
+
24
+ export function boardCopy(
25
+ language: string | undefined,
26
+ labels?: BoardLabelOverrides) {
27
+ return uiCopy(language, labels);
35
28
  }
36
29
  ```
37
30
 
38
- The seam is the **single swap point** for the whole copy lifecycle: catalog
39
- today → Paraglide messages (below) → builder-generated code later. Components
40
- never change; only the seam's backing source does.
31
+ Components import this seam rather than the catalog. That keeps the catalog-to-generated-code transition to one module.
41
32
 
42
- ## Add path-prefixed locales with Paraglide JS (TanStack Start)
33
+ ## Add locales
43
34
 
44
- The starters are TanStack Start, and the TanStack-recommended i18n is
45
- **Paraglide JS** — compile-time (messages become tree-shakeable functions),
46
- **no runtime provider** (matches the catalog's no-provider stance), with
47
- built-in locale routing. The board's language is served unprefixed at `/`;
48
- extra locales are prefixed (`/de/`, `/fr/`).
35
+ Use the installed framework and i18n guidance for routing, SSR locale detection, link rewriting, and compiled messages. Keep the board language unprefixed at `/` and prefix additional locales such as `/de/` or `/fr/`. Preserve these Cavuno invariants while doing so:
49
36
 
50
- 1. **Init.** `npx @inlang/paraglide-js@latest init` — creates the inlang
51
- project, `messages/{en,de,fr}.json`, the `paraglide/` output, and the Vite
52
- plugin. Add the plugin to `vite.config.ts`.
53
- 2. **Generate messages FROM `uiCopy` — never hand-translate.** The catalog is
54
- the source of truth. Emit `messages/{locale}.json` from
55
- `uiCopy(locale)` so `de`/`fr` come from the reviewed SDK catalog, not a
56
- second copy. (This generator is the `uiCopy → Paraglide` adapter.)
57
- 3. **Route.** Wire the router's `rewrite` with Paraglide's `deLocalizeUrl` /
58
- `localizeUrl` so `/de/jobs` routes internally as `/jobs` and every `Link`
59
- auto-prefixes the active locale. Default-locale-no-prefix: the board
60
- language stays at `/`.
61
- 4. **SSR.** Add `paraglideMiddleware` in the server entry; set `<html lang>`
62
- from `getLocale()` (it replaces the static `board.language` in the root).
63
- 5. **Seam.** Point `boardCopy` at the URL locale (`getLocale()`) instead of
64
- only `board.language`, so `/de/` renders German chrome even on an English
65
- board. This is the opt-in over the single-language model.
66
- 6. **SEO slice.** Per-locale `hreflang`, canonical, and sitemap entries are
67
- part of completing multi-language — do them when you add real locales, not
68
- in the foundation.
37
+ 1. Generate locale messages from `uiCopy(locale)` so reviewed SDK copy remains the translation source. This step is complete when every supported locale's chrome keys are generated from the catalog.
38
+ 2. Point the seam at the URL locale and merge the current board's label overrides. This step is complete when switching locale changes chrome while retaining operator labels.
39
+ 3. Keep API content in `board.context().language`. This step is complete when locale switching leaves job, company, and blog fields unchanged.
40
+ 4. Emit locale-aware `<html lang>`, canonical URLs, `hreflang`, and sitemap entries. This step is complete when each public locale route declares itself and all alternates consistently.
69
41
 
70
- ## Do not
42
+ A compile-time i18n system can replace the seam's backing source. Keep the seam provider-free unless the chosen framework integration itself requires a provider.
71
43
 
72
- - **Do not translate job content** (titles/descriptions). Fully multi-language
73
- listings is a data-model change, out of scope.
74
- - **Do not hand-author translations** in the starter — generate messages from
75
- `uiCopy` so there is one reviewed source.
76
- - **Do not add a runtime i18n provider** (e.g. use-intl's context). Paraglide
77
- is compile-time; keep the no-provider property.
78
- - **Do not scatter catalog calls** through markup — everything imports the one
79
- seam, so the catalog→code migration is a single-file change.
44
+ ## Completion gate
80
45
 
81
- Wiring flavor: `cavuno-board-tanstack-start`.
46
+ - Every authored chrome string enters components through the copy seam.
47
+ - `uiCopy` plus `board.context().labels` resolves the expected operator override.
48
+ - Each supported locale renders translated chrome at its public URL.
49
+ - Switching locale preserves API-served job, company, and blog content.
50
+ - Server HTML uses the active locale in `<html lang>` with no hydration mismatch.
51
+ - Canonical, `hreflang`, and sitemap URLs agree for every locale route.
52
+ - Missing translation keys fail generation or tests rather than appearing as raw keys.