@cavuno/board 3.2.0 → 4.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 (77) hide show
  1. package/CHANGELOG.md +406 -0
  2. package/README.md +6 -2
  3. package/dist/{_spec-nkWAraS0.d.mts → _spec-GVe6WAfT.d.mts} +68 -59
  4. package/dist/{_spec-nkWAraS0.d.ts → _spec-GVe6WAfT.d.ts} +68 -59
  5. package/dist/bin.mjs +7 -2
  6. package/dist/{blog-iV9XEIGf.d.ts → blog-BsmKizpd.d.ts} +1 -1
  7. package/dist/{blog-DGBHmExV.d.mts → blog-Cf64mI8A.d.mts} +1 -1
  8. package/dist/{board-BJbXd_9_.d.mts → board-DUhgiacm.d.ts} +7 -6
  9. package/dist/{board-JhdUWIoq.d.ts → board-GGWJ4coe.d.mts} +7 -6
  10. package/dist/breadcrumbs-DFKr0HzU.d.mts +372 -0
  11. package/dist/breadcrumbs-DiccJhk4.d.ts +372 -0
  12. package/dist/chunk-BGKNXG4H.mjs +146 -0
  13. package/dist/{chunk-NXY3OWGF.mjs → chunk-T2KC6XGV.mjs} +1 -1
  14. package/dist/{chunk-STE77MAT.mjs → chunk-YEAQ3JEI.mjs} +143 -134
  15. package/dist/doctor.js +1 -1
  16. package/dist/doctor.mjs +1 -1
  17. package/dist/filters.d.mts +25 -20
  18. package/dist/filters.d.ts +25 -20
  19. package/dist/filters.js +3 -692
  20. package/dist/filters.mjs +3 -29
  21. package/dist/format.d.mts +57 -231
  22. package/dist/format.d.ts +57 -231
  23. package/dist/format.js +327 -968
  24. package/dist/format.mjs +207 -264
  25. package/dist/go.js +11 -1
  26. package/dist/go.mjs +1 -1
  27. package/dist/index.d.mts +23 -76
  28. package/dist/index.d.ts +23 -76
  29. package/dist/index.js +11 -36
  30. package/dist/index.mjs +11 -36
  31. package/dist/{jobs-BiEvL9d7.d.ts → jobs-BuLpV8iy.d.mts} +4 -4
  32. package/dist/{jobs-lVvKCBCU.d.mts → jobs-QrbXOYB9.d.ts} +4 -4
  33. package/dist/paths.d.mts +89 -1
  34. package/dist/paths.d.ts +89 -1
  35. package/dist/paths.js +62 -14
  36. package/dist/paths.mjs +11 -3
  37. package/dist/route-contract.js +23 -13
  38. package/dist/route-contract.mjs +2 -2
  39. package/dist/{search-CyRvVMUp.d.ts → search-Cr4noJJZ.d.mts} +14 -3
  40. package/dist/{search-CCyFWPi3.d.mts → search-DdsniXQv.d.ts} +14 -3
  41. package/dist/seo.d.mts +30 -29
  42. package/dist/seo.d.ts +30 -29
  43. package/dist/seo.js +197 -718
  44. package/dist/seo.mjs +26 -24
  45. package/dist/server.d.mts +5 -5
  46. package/dist/server.d.ts +5 -5
  47. package/dist/sitemap.d.mts +5 -5
  48. package/dist/sitemap.d.ts +5 -5
  49. package/dist/sitemap.js +25 -14
  50. package/dist/sitemap.mjs +3 -2
  51. package/dist/skills.js +1 -1
  52. package/dist/skills.mjs +1 -1
  53. package/dist/suggest.d.mts +2 -2
  54. package/dist/suggest.d.ts +2 -2
  55. package/dist/suggest.js +17 -3
  56. package/dist/suggest.mjs +17 -3
  57. package/dist/well-known.js +23 -13
  58. package/dist/well-known.mjs +2 -2
  59. package/package.json +2 -4
  60. package/skills/cavuno-board-applications/SKILL.md +4 -1
  61. package/skills/cavuno-board-filters/SKILL.md +11 -12
  62. package/skills/cavuno-board-format/SKILL.md +68 -56
  63. package/skills/cavuno-board-i18n/SKILL.md +102 -21
  64. package/skills/cavuno-board-search-suggestions/SKILL.md +8 -3
  65. package/skills/cavuno-board-seo/BLOG.md +4 -1
  66. package/skills/cavuno-board-seo/JOB_AND_BREADCRUMBS.md +5 -2
  67. package/skills/cavuno-board-seo/LISTING.md +35 -12
  68. package/skills/cavuno-board-seo/SALARY.md +83 -18
  69. package/skills/cavuno-board-seo/SKILL.md +3 -2
  70. package/skills/manifest.json +3 -3
  71. package/dist/breadcrumbs-1hMjcsqA.d.mts +0 -171
  72. package/dist/breadcrumbs-DexU07TT.d.ts +0 -171
  73. package/dist/chunk-IJCJ26SQ.mjs +0 -596
  74. package/dist/chunk-L7ZOYWHW.mjs +0 -98
  75. package/dist/chunk-LUGJWOT7.mjs +0 -96
  76. package/dist/ui-copy-CCkz6CVF.d.mts +0 -230
  77. package/dist/ui-copy-CCkz6CVF.d.ts +0 -230
@@ -1,13 +1,16 @@
1
1
  ---
2
2
  name: cavuno-board-format
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.
3
+ description: Board-language display formatting with @cavuno/board/format. Use for salary ranges, dates, custom-field display, or salary-stat numbers.
4
4
  ---
5
5
 
6
6
  # Display formatting
7
7
 
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.
8
+ `@cavuno/board/format` matches the hosted board's display rules for
9
+ **contract and data-shaping** helpers. Chrome words (enum→label, uiCopy,
10
+ salary lexicon) are application-owned and are not exported here.
11
+
12
+ Read the board language once and pass it as the required first argument to
13
+ every label-producing helper.
11
14
 
12
15
  Filter controls use `cavuno-board-filters`. Salary values remain in their wire
13
16
  currency and retain their server-computed amounts.
@@ -16,87 +19,96 @@ currency and retain their server-computed amounts.
16
19
 
17
20
  ```ts snippet
18
21
  import {
19
- fieldLabel,
20
22
  formatDate,
21
23
  formatPublishedRelativeDate,
22
24
  formatSalaryRange,
25
+ formatSalaryStat,
26
+ formatSalaryStatRange,
27
+ resolveCustomFieldDisplay,
23
28
  } from '@cavuno/board/format';
24
29
 
25
30
  const { language } = await board.context();
26
31
 
27
- formatSalaryRange(
32
+ const salary = formatSalaryRange(
28
33
  language,
29
34
  job.salaryMin,
30
35
  job.salaryMax,
31
36
  job.salaryTimeframe,
32
37
  job.salaryCurrency);
38
+ // salary: { text, timeframe, bound: 'range' | 'from' | 'upTo' } | null
39
+ // Pieces — application composes order (do not paste a join template):
40
+ // text = Intl amount/range (e.g. "$90–120K", "90.000–120.000 €")
41
+ // timeframe = the WIRE ENUM ("per_year" … "per_hour") or null —
42
+ // never a word. Map it through your own catalog.
43
+ // bound = which open-range chrome word to attach, if any
44
+ // When joining text + timeframe (or any mixed-direction operands), isolate
45
+ // each side: U+2066 FSI … U+2069 PDI, or HTML <bdi> / dir="auto".
46
+ // Small rates default to standard by magnitude (|value| < 1000) so
47
+ // cents survive: formatSalaryRange(lang, 22.5, null, 'per_hour', 'USD')
48
+ // → { text: "$22.50", timeframe: "per_hour", bound: "from" }
49
+ // Pass notation 'compact' only when you want forced compact glue.
33
50
  formatPublishedRelativeDate(language, job.publishedAt);
34
51
  formatDate(language, job.publishedAt);
35
- fieldLabel(language, job.seniority);
52
+
53
+ // Salary-page stats (currency required — not USD-hardcoded).
54
+ // notation matches formatSalaryRange: omit for magnitude default
55
+ // (|value| ≥ 1000 → compact; smaller → standard). Pass 'standard' for
56
+ // full figures ($90,000) or 'compact' to force short form.
57
+ formatSalaryStat(language, 90000, detail.currency);
58
+ formatSalaryStatRange(language, 90000, 120000, detail.currency);
59
+ formatSalaryStatRange(language, 90000, 120000, detail.currency, 'standard');
60
+
61
+ // Custom fields — language first. number → kind: 'number' (raw);
62
+ // multi_select → kind: 'multi_select' with values: string[] (labels).
63
+ // Do not String(n). Do not join multi-select in the SDK — app owns list
64
+ // style (conjunction / short / chips):
65
+ // new Intl.ListFormat(language, { style: 'long', type: 'conjunction' })
66
+ // .format(entry.values)
67
+ const fields = resolveCustomFieldDisplay(
68
+ language,
69
+ context.customFields.job,
70
+ job.customFieldValues);
36
71
  ```
37
72
 
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.
73
+ `formatPublishedRelativeDate` produces the short relative value used on job
74
+ cards and the job-detail header (locale-owned RTF `style: 'short'`). `formatDate`
75
+ and `formatMonthYear` produce UTC-pinned absolute forms for blog metadata
76
+ and detail facts.
43
77
 
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.
78
+ The salary timeframe ships as the wire enum on `timeframe` alone —
79
+ not glued to `text` with a space or `/`. Open-range chrome is also
80
+ application-owned via `bound`. Compose amount, unit, and chrome in
81
+ board-language order (prefix, postfix, particles, no-space gluing for ja/zh),
82
+ with bidi isolation when directions may differ.
48
83
 
49
- ## Render locations and saved-job cards
84
+ Missing/`null` currency is not treated as USD: helpers return `null`. Pass the
85
+ job's real `salaryCurrency`. Invalid or unsupported locales return `null`
86
+ rather than English or host-default fallbacks; underscore tags like `ja_JP`
87
+ are normalized to BCP-47 and checked with `Intl.NumberFormat.supportedLocalesOf`.
50
88
 
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.
89
+ ## Saved-job cards
54
90
 
55
- ```ts snippet
56
- import {
57
- cardLocationLabel,
58
- fullJobToCard,
59
- locationLabel,
60
- } from '@cavuno/board/format';
61
-
62
- cardLocationLabel(language, card);
63
- locationLabel(language, job);
64
- const savedCard = fullJobToCard(language, job);
65
- ```
66
-
67
- ## Build salary metadata copy
68
-
69
- `getSalaryLexicon` exposes hosted salary terminology and sentence frames.
91
+ `me/saved-jobs` embeds the same slim `job_card` as the jobs list. Map it with
92
+ the same card view-model as listings — do not convert a full job client-side.
70
93
 
71
94
  ```ts snippet
72
- import { getSalaryLexicon } from '@cavuno/board/format';
73
-
74
- const lexicon = getSalaryLexicon(language);
75
- lexicon.frames.entitySalariesTitle({
76
- entity: 'JavaScript',
77
- range: '$70K – $90K',
78
- });
79
- lexicon.seniority.senior;
95
+ // saved.job is already PublicJobCard
96
+ const card = toJobCardVM(saved.job, { language, /* … */ });
80
97
  ```
81
98
 
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`.
86
-
87
99
  ## Completion gate
88
100
 
89
101
  Finish only after every applicable check passes:
90
102
 
91
103
  - 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.
104
+ substitutes a hardcoded locale or relies on a silent `en` default.
105
+ - Amount and timeframe stay separate until the application joins them; the SDK
106
+ does not emit a pre-joined `"$90K / year"`.
107
+ - Open ranges return `bound: 'from' | 'upTo'`; the application adds open-range
108
+ chrome and composes order itself.
109
+ - Saved jobs arrive as cards; no `fullJobToCard` conversion.
110
+ - Salary-stat formatters receive the detail's `currency` (never invent USD).
111
+ - Formatting changes neither salary amounts nor invents chrome words.
100
112
 
101
113
  ## Cavuno SDK reference
102
114
 
@@ -1,55 +1,136 @@
1
1
  ---
2
2
  name: cavuno-board-i18n
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.
3
+ description: One-seam board localization with @cavuno/board. Use when localizing UI chrome, adding locale routing, or preserving board-language content.
4
4
  ---
5
5
 
6
6
  # One-seam board localization
7
7
 
8
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.
9
9
 
10
- ## Build the copy seam
11
-
12
- All UI strings resolve through one application module. Layer copy in this order:
10
+ ## Own the chrome catalog
13
11
 
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.
12
+ Applications own chrome words. The SDK does not export a chrome-copy runtime
13
+ or seed `messages/`. Keep a typed copy seam in application code
14
+ (the starter already commits `messages/*.json` for en/de/fr).
17
15
 
18
16
  ```ts snippet
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);
28
- }
17
+ // Application-owned: load messages/ for the active locale and expose a
18
+ // seam components import. Shape is a fixed set of string groups (jobCard,
19
+ // jobSearch, jobDetail, apply, alerts, …).
20
+ export type BoardCopy = {
21
+ jobCard: { featuredLabel: string; /* … */ };
22
+ // …
23
+ };
29
24
  ```
30
25
 
31
- Components import this seam rather than the catalog. That keeps the catalog-to-generated-code transition to one module.
26
+ ## Build the copy seam
27
+
28
+ All UI strings resolve through one application module. Prefer compile-time messages (Paraglide / inlang) fed by application-owned `messages/` files:
29
+
30
+ 1. Keep `messages/` in the application (or starter).
31
+ 2. Compile messages with your framework's i18n tool.
32
+ 3. Expose one seam modules components import (`src/copy.ts` or equivalent).
33
+
34
+ Components import this seam rather than reading JSON ad hoc. That keeps the catalog-to-generated-code transition to one module.
32
35
 
33
36
  ## Add locales
34
37
 
35
38
  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:
36
39
 
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.
40
+ 1. Start from your application message catalogs so chrome wording is under version control with the app.
41
+ 2. Point the seam at the URL locale. This step is complete when switching locale changes chrome.
39
42
  3. Keep API content in `board.context().language`. This step is complete when locale switching leaves job, company, and blog fields unchanged.
40
43
  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.
41
44
 
42
45
  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.
43
46
 
47
+ ## Own the five obligations
48
+
49
+ The SDK formats amounts, units, and relative dates with `Intl` and returns
50
+ structure. It does **not** ship plural selection, gender agreement, bidi
51
+ isolation, grapheme-safe truncation, or locale-aware case mapping. Those
52
+ five are application-owned. Satisfying the chrome / routing
53
+ gates alone is not enough — a board that ships without plural rules or
54
+ bidi isolation still fails this skill.
55
+
56
+ ### Plural selection
57
+
58
+ Never branch on `count === 1`. Use `Intl.PluralRules` for the active locale
59
+ and pick the catalog form for the returned category:
60
+
61
+ ```ts snippet
62
+ const pr = new Intl.PluralRules(locale);
63
+ // categories: zero | one | two | few | many | other (locale-dependent)
64
+ const form = messages.jobCount[pr.select(count)];
65
+ // en: { one: "{n} job", other: "{n} jobs" }
66
+ // ar: { zero, one, two, few, many, other } — all six can be required
67
+ const label = form.replace('{n}', new Intl.NumberFormat(locale).format(count));
68
+ ```
69
+
70
+ Use this for every count-bearing chrome string (search results, FAQ sample
71
+ sizes, alert copy). Do not hardcode English `-s`.
72
+
73
+ ### Gender agreement
74
+
75
+ When a catalog string agrees with a person or role (adjectives, participles
76
+ in fr/de/ru/…), store gendered variants in the catalog and select by the
77
+ entity's gender metadata — do not invent agreement in code from English
78
+ stems.
79
+
80
+ ### Bidi isolation
81
+
82
+ When composing SDK display strings with chrome or with mixed-direction
83
+ data (Latin company name inside an RTL sentence is the standard case),
84
+ isolate each operand whose direction may differ:
85
+
86
+ ```ts snippet
87
+ const FSI = '\u2066'; // FIRST STRONG ISOLATE
88
+ const PDI = '\u2069'; // POP DIRECTIONAL ISOLATE
89
+ // Prefer HTML <bdi> / dir="auto" in markup; FSI/PDI in plain-text joins.
90
+ // `salary.timeframe` is the wire enum (`per_year`) — resolve it through
91
+ // your catalog first, then isolate both operands before joining.
92
+ const unit = salary.timeframe ? m[`salary_${salary.timeframe}`]() : '';
93
+ const line = `${FSI}${salary.text}${PDI} ${FSI}${unit}${PDI}`;
94
+ ```
95
+
96
+ Never paste a bare `` `${amount} ${label}` `` into RTL chrome without
97
+ isolation. The SDK returns bare `string` values on purpose — isolation is
98
+ app-owned at the join site.
99
+
100
+ ### Grapheme-safe truncation
101
+
102
+ Do not slice with `string.length` / `substring` on user-visible text.
103
+ Count grapheme clusters (`Intl.Segmenter` with `granularity: 'grapheme'`,
104
+ or a well-tested grapheme library) so emoji ZWJ sequences and combining
105
+ marks are not split.
106
+
107
+ ### Locale-aware case mapping
108
+
109
+ Use `toLocaleLowerCase(locale)` / `toLocaleUpperCase(locale)` for display
110
+ case (Turkish `i`/`İ`, Greek final sigma). Keep identifier folding
111
+ (`toLowerCase` without locale) only for URL slugs and Set keys — the SDK
112
+ does that on purpose.
113
+
114
+ ### Native digits
115
+
116
+ When formatting numbers the app owns (custom-field `kind: 'number'`,
117
+ counts, pagination), use `Intl.NumberFormat(locale)` so native-digit
118
+ locales (`ar`, `bn`, `hi-u-nu-deva`, …) render correctly. Never
119
+ `String(n)` for display.
120
+
44
121
  ## Completion gate
45
122
 
46
123
  - Every authored chrome string enters components through the copy seam.
47
- - `uiCopy` plus `board.context().labels` resolves the expected operator override.
124
+ - Chrome comes from application messages, not from `board.context()` (the API no longer serves a labels bag).
48
125
  - Each supported locale renders translated chrome at its public URL.
49
126
  - Switching locale preserves API-served job, company, and blog content.
50
127
  - Server HTML uses the active locale in `<html lang>` with no hydration mismatch.
51
128
  - Canonical, `hreflang`, and sitemap URLs agree for every locale route.
52
129
  - Missing translation keys fail generation or tests rather than appearing as raw keys.
130
+ - Count-bearing chrome uses `Intl.PluralRules` (no `count === 1` English branches).
131
+ - Composed display strings that mix directions isolate operands (FSI/PDI or `<bdi>`).
132
+ - Visible truncation is grapheme-safe; display case mapping uses the locale.
133
+ - App-owned numbers format via `Intl.NumberFormat`, not `String(n)`.
53
134
 
54
135
  ## Cavuno SDK reference
55
136
 
@@ -5,9 +5,9 @@ description: Build server-ranked search suggestions with @cavuno/board. Use for
5
5
 
6
6
  # Server-ranked search suggestions
7
7
 
8
- `board.search.suggest` returns one interleaved list of companies and taxonomy terms. Server rank is the contract: render its order unchanged. For UI, the headless controller adds debounce, abort, and stale-result dropping.
8
+ `board.search.suggest` returns one interleaved list of companies, markets, taxonomy terms, blog posts, and tags. Pass `types` to restrict kinds; `limit` applies after that filter. Server rank is the contract: render its order unchanged. For UI, the headless controller adds debounce, abort, and stale-result dropping.
9
9
 
10
- Location autocomplete uses `board.taxonomy.places.list({ q })`; filter option lists use `board.taxonomy.categories.list`, `board.taxonomy.skills.list`, or `board.taxonomy.suggestions.list`; job results use `board.jobs.list` or `board.jobs.search`.
10
+ Location autocomplete uses `board.taxonomy.places.list({ q })`; filter option lists use `board.taxonomy.categories.list` or `board.taxonomy.skills.list`; job results use `board.jobs.list` or `board.jobs.search`. Route a selected suggestion with `suggestionPath` from `@cavuno/board/paths`.
11
11
 
12
12
  ## Read suggestions
13
13
 
@@ -15,6 +15,7 @@ Location autocomplete uses `board.taxonomy.places.list({ q })`; filter option li
15
15
  const { items, query } = await board.search.suggest({
16
16
  q: 'acme',
17
17
  limit: 10,
18
+ types: ['company', 'skill', 'category'],
18
19
  });
19
20
 
20
21
  for (const item of items) {
@@ -22,6 +23,10 @@ for (const item of items) {
22
23
  item.slug;
23
24
  item.name;
24
25
  item.jobCount;
26
+ } else if (item.type === 'market') {
27
+ item.slug;
28
+ item.name;
29
+ item.companyCount;
25
30
  } else {
26
31
  item.termType;
27
32
  item.displayName;
@@ -31,7 +36,7 @@ for (const item of items) {
31
36
  }
32
37
  ```
33
38
 
34
- Queries shorter than two characters return no items. `limit` accepts 1–25 and defaults to 25.
39
+ Queries shorter than two characters return no items. `limit` accepts 1–25 and defaults to 25. `{ types: ['skill'], limit: 10 }` returns up to ten skills.
35
40
 
36
41
  Term suggestions have `termType: 'category' | 'skill'`; use `canonicalSlug` for links and `sourceSlug` for job filters.
37
42
 
@@ -21,10 +21,13 @@ const author = await board.blog.authors.retrieve('jane');
21
21
  const { data: authorPosts } = await board.blog.posts.list({
22
22
  authorSlug: 'jane',
23
23
  });
24
+ // Profile description is application-owned. Prefer author.bio; otherwise
25
+ // compose from the board catalog (shape only — not an English sentence).
24
26
  const profile = createAuthorProfileJsonLd({
25
27
  author,
26
28
  canonical: `https://jobs.example.com/blog/author/${author.slug}`,
27
- description: author.bio ?? `Posts by ${author.name}`,
29
+ description:
30
+ author.bio ?? messages.authorProfileDescription({ name: author.name }),
28
31
  origin: 'https://jobs.example.com',
29
32
  posts: authorPosts,
30
33
  totalPosts: authorPosts.length,
@@ -32,13 +32,16 @@ Labels are trimmed, blank crumbs are dropped, and fewer than two surviving
32
32
  crumbs return `null`. The current page omits `href`, which makes the builder
33
33
  omit its `item`. Supply absolute hrefs, or pass `origin` for path hrefs.
34
34
 
35
+ Breadcrumb labels are application-owned — pull them from the board's message
36
+ catalog. The snippet shows structure only, not English copy.
37
+
35
38
  ```ts snippet
36
39
  import { createBreadcrumbJsonLd } from '@cavuno/board/seo';
37
40
 
38
41
  const trail = createBreadcrumbJsonLd(
39
42
  [
40
- { label: 'Home', href: '/' },
41
- { label: 'Jobs', href: '/jobs' },
43
+ { label: messages.breadcrumb('home'), href: '/' },
44
+ { label: messages.breadcrumb('jobs'), href: '/jobs' },
42
45
  { label: job.title },
43
46
  ],
44
47
  { origin: 'https://jobs.example.com' });
@@ -1,24 +1,45 @@
1
1
  # Listing metadata and JSON-LD
2
2
 
3
3
  Read this reference for a job-listing page's head descriptors and structural
4
- JSON-LD. These builders are locale-neutral pass-throughs; supply the page's
5
- resolved heading copy.
4
+ JSON-LD. These builders assemble structure only; supply the page's resolved
5
+ title and meta description from the board's copy source.
6
6
 
7
7
  ```ts snippet
8
8
  import { listingHead, listingJsonLd } from '@cavuno/board/seo';
9
9
 
10
- const { name } = await board.context();
11
- const head = listingHead({
10
+ const { name, language } = await board.context();
11
+ const count = 42;
12
+ const heading = /* resolved page heading from your catalog */ '…';
13
+ // Application owns every display join — count, counter/particle, heading,
14
+ // separator, board name. Pieces (not a pasteable template):
15
+ // countLabel = Intl.NumberFormat(language).format(count)
16
+ // heading = catalog heading for this page
17
+ // boardName = name
18
+ // Compose title and description in the board language (ja counters, RTL order,
19
+ // …). Do not hardcode number-then-noun or " | " order in shared helpers.
20
+ const countLabel = new Intl.NumberFormat(language).format(count);
21
+ const title = messages.listingTitle({ count, countLabel, heading, boardName: name });
22
+ const description = messages.listingDescription({
23
+ count,
24
+ countLabel,
25
+ heading,
12
26
  boardName: name,
27
+ });
28
+
29
+ const head = listingHead({
30
+ title,
13
31
  origin: 'https://jobs.example.com',
14
32
  path: '/jobs/robotics',
15
- heading: 'Robotics jobs',
16
- count: 42,
33
+ description,
17
34
  });
18
35
 
36
+ // Breadcrumb labels are application-owned (catalog), not English literals.
19
37
  const objects = listingJsonLd({
20
38
  origin: 'https://jobs.example.com',
21
- breadcrumbs: [{ name: 'Home', path: '/' }, { name: 'Jobs' }],
39
+ breadcrumbs: [
40
+ { name: messages.breadcrumb('home'), path: '/' },
41
+ { name: messages.breadcrumb('jobs') },
42
+ ],
22
43
  jobs: cards.map((card) => ({
23
44
  slug: card.slug,
24
45
  company: card.company,
@@ -27,10 +48,12 @@ const objects = listingJsonLd({
27
48
  ```
28
49
 
29
50
  `head.meta` contains title, description, and Open Graph descriptors;
30
- `head.links` contains the canonical link. For the example, the count-aware
31
- title is `42 Robotics jobs | Acme Jobs`.
51
+ `head.links` contains the canonical link. Title and description are whatever
52
+ you passed — the SDK does not join count, heading, or board name. Use a
53
+ locale-aware lowercasing API (not `.toLowerCase()`) when your copy needs case
54
+ folding.
32
55
 
33
56
  This branch is complete when head descriptors reflect the resolved page
34
- heading and count, the canonical URL combines the intended origin and path,
35
- and `listingJsonLd` receives card slugs plus company objects from the listing
36
- response.
57
+ title and description, the canonical URL combines the intended origin and
58
+ path, and `listingJsonLd` receives card slugs plus company objects from the
59
+ listing response.
@@ -6,15 +6,17 @@ seniority ordering.
6
6
  ## Build a title page
7
7
 
8
8
  Display-producing helpers require the board language as their leading
9
- argument. The salary money formatters intentionally retain the hosted page's
10
- USD assumption; locale changes number formatting, not currency. The current
11
- FAQ uses templated English sentences until board-custom FAQ copy is exposed.
9
+ argument. Salary money formatters take the detail's `currency` as well —
10
+ locale drives digits/grouping; currency is never USD-hardcoded. FAQ helpers
11
+ return entry kinds and values; compose question/answer prose from the board's
12
+ message catalog (with proper plural rules via `Intl.PluralRules` — never a
13
+ two-way `one`/else branch).
12
14
 
13
15
  ```ts snippet
14
16
  import {
15
17
  buildSalaryFaq,
16
18
  faqJsonLd,
17
- formatRange,
19
+ formatSalaryStatRange,
18
20
  itemListJsonLd,
19
21
  sortBySeniority,
20
22
  titleSalaryJsonLd,
@@ -23,14 +25,57 @@ import {
23
25
  const { language } = await board.context();
24
26
  const detail = await board.salaries.titles.retrieve('software-engineer');
25
27
 
26
- const occupation = titleSalaryJsonLd(language, detail);
28
+ // Application owns the finished distribution name (label + word order).
29
+ // French: `${entity} ${catalog.seniority[seniority]}`; English often
30
+ // `${catalog.seniority[seniority]} ${entity}`; Japanese may omit the space.
31
+ const occupation = titleSalaryJsonLd(detail, {
32
+ seniorityName: ({ seniority, entity }) =>
33
+ copy.seniorityName({ seniority, entity }),
34
+ });
35
+
36
+ // Structure only — map kinds to localized FAQ sentences in the app.
37
+ // The application owns every word; the snippet shows shape, not copy.
38
+ // average carries avgMin/avgMax/currency (raw) + range (compact convenience).
39
+ // Prefer the raw figures when FAQ prose wants full notation ($100,000).
40
+ const entries = buildSalaryFaq(
41
+ language,
42
+ detail.categoryName,
43
+ detail.overallSalary,
44
+ detail.currency);
27
45
  const faq = faqJsonLd(
28
- buildSalaryFaq(language, detail.categoryName, detail.overallSalary));
46
+ entries.map((entry) => {
47
+ if (entry.kind === 'average') {
48
+ const range =
49
+ formatSalaryStatRange(
50
+ language,
51
+ entry.avgMin,
52
+ entry.avgMax,
53
+ entry.currency,
54
+ 'standard') ?? entry.range;
55
+ return {
56
+ // Application catalog — use Intl.PluralRules for jobCount, never a
57
+ // one/else English plural.
58
+ q: copy.faqQuestion({ kind: 'average', label: entry.label }),
59
+ a: copy.faqAnswer({
60
+ kind: 'average',
61
+ label: entry.label,
62
+ range,
63
+ jobCount: entry.jobCount,
64
+ }),
65
+ };
66
+ }
67
+ return {
68
+ q: copy.faqQuestion({ kind: 'methodology', label: entry.label }),
69
+ a: copy.faqAnswer({ kind: 'methodology', label: entry.label }),
70
+ };
71
+ }));
72
+
29
73
  const range = detail.overallSalary
30
- ? formatRange(
74
+ ? formatSalaryStatRange(
31
75
  language,
32
76
  detail.overallSalary.avgMin,
33
- detail.overallSalary.avgMax)
77
+ detail.overallSalary.avgMax,
78
+ detail.currency)
34
79
  : null;
35
80
  const ladder = sortBySeniority(detail.bySeniority);
36
81
  const index = itemListJsonLd([
@@ -44,18 +89,38 @@ const index = itemListJsonLd([
44
89
  ## Select other salary builders
45
90
 
46
91
  - `skillSalaryJsonLd(detail)` for a skill detail.
47
- - `locationSalaryJsonLd(detail)` for a city-level location detail.
48
- - `crossAxisSalaryJsonLd(locale, args)` for a title/skill and location pair.
49
- - `companySalaryJsonLd(locale, detail)` for a company overview.
50
- - `companyCategorySalaryJsonLd(locale, detail)` for a company-category page.
51
- - `formatUsd(locale, value)` for the hosted USD amount format.
52
- - `formatSeniority(locale, key)` and `SENIORITY_ORDER` for seniority display.
92
+ - `locationSalaryJsonLd(detail, { occupationUrl })` for a city-level location
93
+ detail — emits an `ItemList` of top categories (not an `Occupation` named
94
+ after the city). Supply absolute URLs to each title salary page.
95
+ - `crossAxisSalaryJsonLd(args, { seniorityName? })` for a title/skill and
96
+ location pair.
97
+ - `companySalaryJsonLd(detail, { occupationUrl })` for a company overview —
98
+ `ItemList` of categories at that employer (not an occupation named after
99
+ the company).
100
+ - `companyCategorySalaryJsonLd(detail, { seniorityName? })` for a
101
+ company-category page (keeps `Occupation`).
102
+ - `formatSalaryStat(locale, value, currency, notation?)` /
103
+ `formatSalaryStatRange(locale, min, max, currency, notation?)` for
104
+ salary-page amounts (currency from the detail, not hardcoded USD;
105
+ `formatRange` owns the join). `notation` matches `formatSalaryRange`
106
+ (magnitude default when omitted; pass `'standard'` for full figures).
107
+ Returns `null` when currency is empty or `Intl` rejects inputs.
108
+ - `SENIORITY_ORDER` / `sortBySeniority` for seniority ladder ordering
109
+ (display names are application-owned via `seniorityName`).
110
+
111
+ JSON-LD `name` fields are data labels (entity and category names).
112
+ Per-seniority distribution names come only from `seniorityName`; when that
113
+ callback is omitted, those distributions ship without a `name`. Do not expect
114
+ English framing such as "salary (all levels)" or "Average Salary in …" from
115
+ the SDK — put those words in the app if a board still wants them.
53
116
 
54
- The structured-data families are `Occupation`,
55
- `MonetaryAmountDistribution`, `ItemList`, and `FAQPage`. Feed them the SDK
56
- salary read model; preserve nullable aggregates and server-computed values.
117
+ The structured-data families are `Occupation` (title, skill, cross-axis,
118
+ company-category), `ItemList` (location overview, company overview, indexes),
119
+ `MonetaryAmountDistribution`, and `FAQPage`. Feed them the SDK salary read
120
+ model; preserve nullable aggregates and server-computed values.
57
121
 
58
122
  This branch is complete when every applicable schema object has the expected
59
123
  schema.org type, null aggregates produce no fabricated figure, the board
60
- language comes from context, and every displayed value traces to a response
124
+ language comes from context, FAQ prose comes from the application catalog
125
+ with proper plural rules, and every displayed value traces to a response
61
126
  field.
@@ -20,8 +20,9 @@ Read the matching reference completely before implementing that page:
20
20
  For a page that combines branches, read every matching reference.
21
21
 
22
22
  Sitemaps and robots.txt use `cavuno-board-sitemap`. The host app owns OG-image
23
- generation and its route map. `listingHead` receives app-owned heading copy;
24
- the SDK does not reproduce the board's configured meta copy.
23
+ generation and its route map. `listingHead` receives app-owned title and
24
+ meta description copy; the SDK never composes those sentences or joins
25
+ count/heading/board name.
25
26
 
26
27
  ## Apply the shared rendering contract
27
28
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.2.0",
2
+ "version": "4.0.0",
3
3
  "skills": [
4
4
  {
5
5
  "name": "cavuno-board-account",
@@ -59,14 +59,14 @@
59
59
  },
60
60
  {
61
61
  "name": "cavuno-board-format",
62
- "description": "Board-language display formatting with @cavuno/board/format. Use for salary ranges, dates, job labels, location labels, card conversion, or salary metadata copy.",
62
+ "description": "Board-language display formatting with @cavuno/board/format. Use for salary ranges, dates, custom-field display, or salary-stat numbers.",
63
63
  "path": "skills/cavuno-board-format/SKILL.md",
64
64
  "framework": null,
65
65
  "category": "core"
66
66
  },
67
67
  {
68
68
  "name": "cavuno-board-i18n",
69
- "description": "One-seam board localization with @cavuno/board. Use when localizing UI chrome, adding locale routing, merging operator labels, or preserving board-language content.",
69
+ "description": "One-seam board localization with @cavuno/board. Use when localizing UI chrome, adding locale routing, or preserving board-language content.",
70
70
  "path": "skills/cavuno-board-i18n/SKILL.md",
71
71
  "framework": null,
72
72
  "category": "core"