@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
package/CHANGELOG.md CHANGED
@@ -3,6 +3,412 @@
3
3
  This changelog records changes that affect Board API compatibility, exported
4
4
  types, runtime behavior, or supported integration patterns.
5
5
 
6
+ ## 4.0.0 — 2026-08-03
7
+
8
+ - **Lossy seams:** Resolution is fine;
9
+ discarding the input is not. After every call the application must
10
+ still be able to produce a different legitimate rendering.
11
+ - `resolveCustomFieldDisplay` multi-select returns
12
+ `{ kind: 'multi_select', values: string[] }` instead of a
13
+ pre-joined `kind: 'text'` sentence. `Intl.ListFormat` (or chips /
14
+ short style / disjunction) moves to the app — same reason numbers
15
+ stay raw. Leading `language` stays for signature stability; the SDK no longer reads it for multi-select.
16
+ - `buildSalaryFaq` average entries carry `avgMin`, `avgMax`, and
17
+ `currency` alongside the compact `range` convenience. FAQ prose can
18
+ reformat with `notation: 'standard'` without re-threading the detail
19
+ object. `label` is still echoed so FAQ mapping is a pure map over
20
+ entries (the SDK never reads it for formatting).
21
+ - `formatSalaryStat` / `formatSalaryStatRange` accept optional
22
+ `notation` with the same magnitude default as `formatSalaryRange`
23
+ (`|value| ≥ 1000` → compact; smaller → standard). `$90K` vs
24
+ `$90,000` is register, not locale.
25
+ - : remove `ListFormat` from "what the SDK keeps"; add the
26
+ mechanical recoverability test and worked table; document Google
27
+ controlled vocabularies (`EDUCATION_TO_CREDENTIAL`,
28
+ `EMPLOYMENT_TYPE_TO_GOOGLE`) as wire values, not copy; correct that
29
+ timeframe units ship as the wire enum, not unit words from `Intl`.
30
+
31
+ - **Pre-publish blockers (salary format + SEO paths):** Closes the final
32
+ review that blocked publish.
33
+ - `formatSalaryStat` / `formatSalaryStatRange` null-guard currency
34
+ (`currency?.trim()`) so a runtime `null` returns `null` instead of
35
+ `TypeError` — matches `formatSalaryRange` and the skill/changelog
36
+ contract (`string | null`).
37
+ - Non-finite amounts (`NaN`, `±Infinity`) return `null` across
38
+ `formatSalaryStat`, `formatSalaryStatRange`, and `formatSalaryRange`
39
+ (they are `number` at the type level and slip past `== null`).
40
+ - Identical range ends (`min === max`) format as a fixed salary
41
+ (single amount), not ICU's `approximatelySign` form (`~$140K`). A
42
+ posted pin is not a degenerate range.
43
+ - `formatSalaryRange` defaults `notation` by **magnitude**: compact
44
+ only where ICU shortens (`|value| ≥ 1000`); smaller values stay
45
+ `standard` so hourly/day rates keep CLDR minor units (`$22.50`,
46
+ `KWD 22.567`). Explicit `notation` still wins.
47
+ - `listingJsonLd` builds job URLs via `jobDetailPath` / `boardUrl`
48
+ (drops company-less jobs rather than linking `/jobs/{slug}`); omits
49
+ `BreadcrumbList` when the trail has fewer than 2 crumbs.
50
+ `buildJobBreadcrumbs` uses `paths/` helpers instead of hand-built
51
+ strings.
52
+ - `companySalaryJsonLd` / `locationSalaryJsonLd` accept omitted
53
+ `options` and return `null` (siblings already did; these threw).
54
+
55
+ - **i18n audit (format / paths / skills):** Closes the specialist audit
56
+ against ICU/CLDR/W3C practice. Architecture (discriminant seams, no
57
+ composition, `Intl`-over-catalogs) stands; defects were concentrated in
58
+ timezone pinning, CLDR width choice, and structure-vs-support locale
59
+ validation.
60
+ - `formatMonthYear` pins `timeZone: 'UTC'` (and date-only inputs as UTC
61
+ midnight) so full ISO timestamps agree with `formatDate` under every
62
+ host zone — no more `"Mai 2023"` / `"Dec 2022"` for June / Jan UTC.
63
+ - `formatPublishedRelativeDate` uses RTF `style: 'short'` (not
64
+ `narrow`). English reads `"5 days ago"`; French / Russian / Romanian
65
+ no longer degrade to signed numbers (`-5 j`).
66
+ - `normalizeLocale` requires `Intl.NumberFormat.supportedLocalesOf` —
67
+ well-formed but unsupported tags (`xx`, `qqq`, `und`, …) return
68
+ `null` instead of silently using the host default. Contract in
69
+ `locale.ts`, `salary-range.ts`, and `skills/cavuno-board-format` is
70
+ now enforced.
71
+ - Currency fraction digits: compact notation keeps a one-digit cap
72
+ (documented); `notation: 'standard'` (optional override on
73
+ `formatSalaryRange`) lets each currency's CLDR minor units win.
74
+ Default notation is magnitude-selected (see pre-publish blockers
75
+ above), not unconditional compact.
76
+ - `resolveCustomFieldDisplay` emits `kind: 'number'` with the raw
77
+ value (not pre-stringified `kind: 'text'`), so the app formats with
78
+ `Intl.NumberFormat`.
79
+ - Path helpers percent-encode dynamic segments (non-ASCII board-language
80
+ slugs become URIs for sitemap `<loc>`, canonical, email). `:` is kept
81
+ for route-contract templates; already-encoded input is not
82
+ double-encoded.
83
+ - Filter / suggest Set keys NFC-normalize; suggest `minChars` counts
84
+ graphemes (`Intl.Segmenter`), so a single CJK character qualifies.
85
+ - `skills/cavuno-board-i18n` carries the five obligations
86
+ (plural selection, gender, bidi isolation with FSI/PDI, grapheme
87
+ truncation, locale-aware case) and extends the completion gate.
88
+ Salary-range / format skills document bidi isolation at the join
89
+ site rather than returning `{ value, dir }`.
90
+
91
+ - **Rule (word order):** The SDK never decides what goes next to what.
92
+ "No hardcoded strings" is the easy half. Word order is the half that
93
+ survives a grep, because every operand is data and only the
94
+ *arrangement* is English. A template with two data holes and a space
95
+ (or `|`, or `/`) between them is an English sentence with the words
96
+ removed. The SDK formats with `Intl`, returns structure, and leaves
97
+ composition — counters, particles, separators, prefix vs postfix — to
98
+ the application. Invalid locales prefer `null` over English-arranged
99
+ fallbacks; underscore tags (`ja_JP`) normalize to BCP-47 (`ja-JP`) so
100
+ one typo does not drop every formatter into English at once.
101
+
102
+ - **Breaking:** `listingHead` requires a caller-supplied `title` (same
103
+ ownership as `description`). Removed `heading`, `boardName`, `count`,
104
+ and `language` from `ListingHeadOptions` — the SDK no longer composes
105
+ `` `${count} ${heading} | ${boardName}` `` (Japanese wants
106
+ `1,225件の求人`, not `1,225 求人 | …`). Pass the finished title from
107
+ the board's message catalog. Also removes the only unguarded
108
+ `Intl.NumberFormat` construction in the SDK (a malformed board
109
+ language no longer crashes the listing page).
110
+
111
+ - **Breaking:** `formatSalaryRange` returns
112
+ `{ text, timeframe, bound } | null`. `text` is the `Intl`-formatted
113
+ amount/range only; `timeframe` is the **wire enum** (`per_year`,
114
+ `per_hour`, …) — never a unit word from `Intl` and never an owner
115
+ override. Applications map the enum through their own catalog and
116
+ compose amount↔timeframe order (`$90K / year` vs `年収900万〜1200万円`).
117
+ Missing currency, invalid locale, or rejected currency format →
118
+ `null` (no bare number, no English `M`/`k`/`$`).
119
+
120
+ - **Breaking:** Removed `SalaryTimeframeOverrides` from
121
+ `@cavuno/board/format`. Owner-edited unit labels are application-side
122
+ copy keyed off the wire `timeframe` enum the helper returns; the SDK
123
+ no longer accepts an override map.
124
+
125
+ - **Breaking:** `GET/POST …/me/saved-jobs` embeds a slim `job_card` (the
126
+ same shape as jobs list/search), not a full `public_job` with HTML
127
+ `description`. Fetch the job detail by slug when the full body is
128
+ needed. `remoteLocationLabel` is resolved server-side from
129
+ `remoteRules`, so region-limited remote jobs keep their real region
130
+ label instead of a client-side lossy guess.
131
+
132
+ - **Breaking:** Removed `fullJobToCard` from `@cavuno/board/format`. The
133
+ saved-jobs endpoint now serves cards; convert with the same card VM as
134
+ listings (`toJobCardVM(saved.job, …)`). That helper was the last
135
+ user-facing hardcoded English in the SDK (`Remote`, `Worldwide`,
136
+ `(hybrid)`).
137
+
138
+ - **Breaking:** `formatSalaryRange`'s board language is required — no
139
+ silent `en` default. `formatPublishedRelativeDate` under one minute uses
140
+ `Intl.RelativeTimeFormat` (`"now"` / `"jetzt"` / `"maintenant"`), not a
141
+ hardcoded English string. `resolveCustomFieldDisplay` takes the board
142
+ language as its leading parameter (signature stability). Multi-select
143
+ originally joined via `Intl.ListFormat`; that was lossy and now returns
144
+ `kind: 'multi_select'` with `values: string[]` (see Lossy seams above).
145
+
146
+ - **Breaking:** Renamed and currency-aware salary-stat formatters:
147
+ `formatUsd` / `formatRange` → `formatSalaryStat(locale, value, currency)` /
148
+ `formatSalaryStatRange(locale, min, max, currency)`. The `/format`
149
+ aliases `formatSalaryStatUsd` / `formatSalaryStatRange` are replaced by
150
+ the same names (no USD-only alias). `buildSalaryFaq` takes `currency`
151
+ as a fourth argument so FAQ ranges match the board's money. Both formatters
152
+ return `string | null` (`null` when currency is empty or `Intl` rejects
153
+ the locale/currency) instead of inventing English `M`/`k` or a bare `$`.
154
+
155
+ - **Breaking:** `formatSalaryRange` first moved from `string | null` to
156
+ `{ text; bound } | null` so open floors/ceilings are named by the app;
157
+ the timeframe is now a third field (see top of this heading) rather than
158
+ a suffix glued onto `text`.
159
+
160
+ - **Breaking:** Unit, currency, and date *words* derive from `Intl`;
161
+ `skills/` snippets show shape (catalog placeholders), not pasteable
162
+ English copy or join templates. The SDK formats with `Intl` and returns
163
+ structure; it does not supply open-range chrome, nav/button copy, filter
164
+ labels, SEO sentence templates, or amount↔unit arrangement. Dropped the
165
+ private chrome-copy package dependency entirely. Closed ranges use
166
+ `Intl.NumberFormat.prototype.formatRange` (locale owns separator,
167
+ repeated currency/magnitude, and bidi marks). Open-range chrome and
168
+ amount↔timeframe order are application-owned via `bound` + `timeframe`.
169
+ `buildJobBreadcrumbs` no longer takes `language` or `labels` parameters —
170
+ it returns structural `kind`s for chrome crumbs (`home` / `jobs`) instead
171
+ of display names; applications supply the words when mapping to
172
+ `BreadcrumbList`. `seniorityLabels` / `sortLabels` / `formatSeniority`
173
+ are removed. Type exports removed with the chrome surface:
174
+ `AlertsCopy`, `ApplyCopy`, `BlogCopy`, `BoardLabelOverrides`,
175
+ `BreadcrumbsCopy`, `CopyLinkCopy`, `EntityCopy`, `FooterCopy`,
176
+ `JobCardCopy`, `JobDetailCopy`, `JobSearchCopy`, `NavCopy`,
177
+ `PaginationCopy`, `SalaryCopy`, `SalaryFrames`, `SalaryLexicon`,
178
+ `SeniorityKey`, `UiCopy`.
179
+
180
+ - **Breaking:** `listingHead` requires a caller-supplied `description` (and
181
+ now `title` — see top of this heading). It no longer composes
182
+ `Browse … on …` (English on every locale) or case-folds the heading with
183
+ locale-blind `.toLowerCase()`.
184
+
185
+ - **Breaking:** `buildSalaryFaq` returns structured
186
+ `SalaryFaqEntry` values (`kind: 'average' | 'methodology'` plus label,
187
+ `formatRange` range, and `jobCount`) instead of English FAQ prose.
188
+ Map entries to `{ q, a }` in the application (with proper plural rules),
189
+ then pass them to `faqJsonLd`.
190
+
191
+ - **Breaking:** Salary JSON-LD builders
192
+ (`titleSalaryJsonLd`, `skillSalaryJsonLd`, `locationSalaryJsonLd`,
193
+ `crossAxisSalaryJsonLd`, `companySalaryJsonLd`,
194
+ `companyCategorySalaryJsonLd`) use data-only `name` fields (entity
195
+ names). Removed English framing
196
+ (`… salary (all levels)`, `Average Salary in …`, `Jobs at …`,
197
+ `… salary in …`, trailing ` salary`).
198
+
199
+ - **Breaking:** Dropped the dead `locale` parameter from
200
+ `titleSalaryJsonLd`, `crossAxisSalaryJsonLd`, `companySalaryJsonLd`,
201
+ and `companyCategorySalaryJsonLd` — it was discarded (`void locale`)
202
+ and did not change output. Per-seniority distribution names are no
203
+ longer title-cased wire enums (`entry_level` → `Entry level`) and are
204
+ no longer joined as `` `${seniority} ${entity}` `` (English word order).
205
+ Pass optional `seniorityName({ seniority, entity })` that returns the
206
+ finished string in board-language order, or omit it so those
207
+ distributions ship without a `name` (valid structured data;
208
+ wrong-language / wrong-order names are not).
209
+
210
+ - **Breaking:** `formatSalaryRange` never invents USD when
211
+ `salaryCurrency` is null/empty — returns `null`, matching
212
+ `createJobPostingJsonLd` which omits `baseSalary` without a currency.
213
+ Invalid locales and rejected currency formats also return `null` rather
214
+ than English `M`/`k`/`$` or a bare number.
215
+
216
+ - **Breaking:** `formatMonthYear` returns `string | null` — `null` on
217
+ missing/unparseable input (never the English literal `"Invalid Date"`),
218
+ and accepts full ISO timestamps in addition to date-only forms.
219
+
220
+ - **Breaking:** `locationSalaryJsonLd` and `companySalaryJsonLd` no
221
+ longer emit an `Occupation` / `OccupationAggregationByEmployer`
222
+ envelope with the place or employer as `name` (neither is an
223
+ occupation). They emit an `ItemList` of the occupations present on
224
+ that page (`topCategories` / `byCategory`), each linking to its own
225
+ occupation page via required `occupationUrl`. Return `null` when
226
+ there are no per-occupation rows (or when the location is not
227
+ city/locality). The other four builders keep `Occupation`.
228
+
229
+ - **Breaking:** `board.taxonomy.suggestions.list` and
230
+ `GET /v1/boards/{identifier}/suggestions` are removed. Use
231
+ `board.search.suggest({ types: ['category', 'skill'], … })` instead. The
232
+ `taxonomy` namespace itself stays (`categories`, `skills`, `places`,
233
+ `remotePermits`, resolve methods).
234
+
235
+ - `board.search.suggest` accepts
236
+ `types?: ('company' | 'category' | 'skill' | 'market' | 'post' | 'tag')[]`.
237
+ Omit for every kind. `limit` applies **after** type filtering, so
238
+ `{ types: ['skill'], limit: 10 }` returns up to ten skills.
239
+
240
+ - `SuggestionItem` gains a `market` variant (`type: 'market'`, `slug`, `name`,
241
+ `companyCount`) for company-scope typeahead alongside companies.
242
+
243
+ - `SuggestionItem` gains `post` (`type: 'post'`, `id`, `slug`, `title`) and
244
+ `tag` (`type: 'tag'`, `id`, `slug`, `name`) for blog typeahead. Blog posts
245
+ and tags are public, so no extra access control.
246
+
247
+ - `@cavuno/board/paths` adds `suggestionPath(suggestion, { scope, location? })`
248
+ plus `jobsLocationCategoryPath` / `jobsLocationSkillPath`. A jobs-scope
249
+ company suggestion returns `null` — apply a `companySlug` filter; do not
250
+ invent `/jobs/companies/<slug>`. Blog scope routes posts via
251
+ `blogPostPath` and tags via `blogTagPath`.
252
+
253
+ - Added `sdk/reference/search.mdoc` and a docs-contract gate that every
254
+ `BoardSdk` namespace has a matching reference page.
255
+
256
+ - The sitemap no longer advertises `/talent` on boards whose talent directory
257
+ is `employers_only`. That page is gated behind an approved employer session,
258
+ so listing it pointed crawlers at the gate rather than the directory. Only a
259
+ `public` directory is listed.
260
+
261
+ - **Breaking:** `board.context().customFields` is now keyed by model instead of
262
+ a flat job-only array. Today only `job` is present; `company` / `talent` keys
263
+ land when those models ship (absent keys are not empty arrays).
264
+
265
+ Before:
266
+
267
+ ```json
268
+ "customFields": [
269
+ { "key": "security_clearance", "label": "Security clearance", "type": "single_select", "required": false }
270
+ ]
271
+ ```
272
+
273
+ After:
274
+
275
+ ```json
276
+ "customFields": {
277
+ "job": [
278
+ { "key": "security_clearance", "label": "Security clearance", "type": "single_select", "required": false }
279
+ ]
280
+ }
281
+ ```
282
+
283
+ Resolve job values with `resolveCustomFieldDisplay(context.customFields.job, job.customFieldValues)`
284
+ — the helper still takes a definitions array and did not change.
285
+
286
+ - **Breaking:** `features.talentDirectory` is now the tri-state access mode
287
+ (`off` | `public` | `employers_only`). The top-level
288
+ `talentDirectoryVisibility` field is removed — it duplicated the same gate.
289
+
290
+ Before:
291
+
292
+ ```json
293
+ "features": { "talentDirectory": true },
294
+ "talentDirectoryVisibility": "employers_only"
295
+ ```
296
+
297
+ After:
298
+
299
+ ```json
300
+ "features": { "talentDirectory": "employers_only" }
301
+ ```
302
+
303
+ Link `/talent` whenever `features.talentDirectory !== 'off'`. Do not treat
304
+ the field as a boolean — the string `'off'` is truthy in JavaScript.
305
+
306
+ - **Breaking:** `board.seo()` no longer returns presentation. Removed
307
+ `icons` (ico/svg/appleTouch/icon192/icon512/iconMaskable512) and
308
+ `manifest.themeColor`. Kept `canonicalBase`, `adsTxt`, `indexNowKey`,
309
+ `googleSiteVerification`, and `manifest.name`. Applications own brand
310
+ assets and theme tokens.
311
+
312
+ Before:
313
+
314
+ ```json
315
+ {
316
+ "canonicalBase": "https://jobs.example.com",
317
+ "adsTxt": null,
318
+ "indexNowKey": null,
319
+ "googleSiteVerification": null,
320
+ "icons": { "ico": "…", "svg": null, "appleTouch": null, "icon192": null, "icon512": null, "iconMaskable512": null },
321
+ "manifest": { "name": "Acme Jobs", "themeColor": "#1d4ed8" }
322
+ }
323
+ ```
324
+
325
+ After:
326
+
327
+ ```json
328
+ {
329
+ "canonicalBase": "https://jobs.example.com",
330
+ "adsTxt": null,
331
+ "indexNowKey": null,
332
+ "googleSiteVerification": null,
333
+ "manifest": { "name": "Acme Jobs" }
334
+ }
335
+ ```
336
+
337
+ - **Breaking:** `board.context().footer` is gone. Operator-authored contact
338
+ and social identity move to a top-level `contact` group; editor-authored
339
+ presentation (`description`, `navigationOrder`, `customLinks`) is removed
340
+ — applications own footer layout.
341
+
342
+ Before:
343
+
344
+ ```json
345
+ "footer": {
346
+ "description": "Roles from {{board_name}}.",
347
+ "contactEmail": "hello@acme.com",
348
+ "websiteUrl": "https://acme.com/",
349
+ "xUrl": "https://x.com/acmejobs",
350
+ "facebookUrl": null,
351
+ "linkedinUrl": "https://linkedin.com/company/acme",
352
+ "navigationOrder": ["home", "blog"],
353
+ "customLinks": [{ "id": "abc", "label": "Hub", "url": "/hub" }]
354
+ }
355
+ ```
356
+
357
+ After:
358
+
359
+ ```json
360
+ "contact": {
361
+ "email": "hello@acme.com",
362
+ "websiteUrl": "https://acme.com/",
363
+ "xUrl": "https://x.com/acmejobs",
364
+ "facebookUrl": null,
365
+ "linkedinUrl": "https://linkedin.com/company/acme"
366
+ }
367
+ ```
368
+
369
+ Brand/social URL sanitization is unchanged (absolute http(s) only).
370
+ `companyLegalName` / `companyAddress` were never on this public context
371
+ payload (they ride the legal/impressum surface); no `company` group was
372
+ invented here.
373
+
374
+ - **Breaking:** Chrome copy leaves the public `@cavuno/board` surface. Removed from `@cavuno/board/format`: `uiCopy`,
375
+ `PUBLIC_LABEL_GROUPS`, `fieldLabel`, `getSalaryLexicon`, `locationLabel`,
376
+ `cardLocationLabel`, `companyIntro`, and the `UiCopy` type re-export.
377
+ Removed from `@cavuno/board/seo`: `formatSeniority`. Removed from
378
+ `@cavuno/board/filters`: `seniorityLabels`, `sortLabels`.
379
+
380
+ Hosted boards import chrome words from a private package. Third-party applications own their copy in code (or their
381
+ message catalogs). The published SDK no longer imports or bundles that
382
+ package.
383
+
384
+ What stays on `@cavuno/board/format`: `formatSalaryRange` (`Intl`-backed
385
+ amounts, wire `timeframe` enum, and a `bound` discriminant for open ranges),
386
+ date helpers, `resolveCustomFieldDisplay`, `normalizeWebsiteUrl`,
387
+ `buildJobBreadcrumbs` (kinds + data names; no `language`/`labels` args),
388
+ `COUNTRY_CODES` / `countryOptions`, and the salary-stat formatters. Filter
389
+ vocabularies (wire enums + parsers only) stay on `@cavuno/board/filters`.
390
+
391
+ - **Breaking:** `board.legal.retrieve`, the legal types (`PublicLegalPage`,
392
+ `LegalPageType`, `LegalEntity`), and `GET /v1/boards/{identifier}/legal/{type}`
393
+ (`getBoardLegal`) are removed. Legal prose is editor-authored
394
+ application content, not a Board API concern.
395
+
396
+ - **Breaking:** `board.context().labels` is removed. Operator label overrides
397
+ no longer ride the public Board API — applications own chrome words.
398
+ Hosted storage for operator overrides is unchanged (hosted chrome still
399
+ reads the bag).
400
+
401
+ Before:
402
+
403
+ ```json
404
+ "labels": {
405
+ "jobCardLabels": { "featuredLabel": "Top Job" },
406
+ "navLabels": { "home": "Jobs" }
407
+ }
408
+ ```
409
+
410
+ After: field absent.
411
+
6
412
  ## 3.2.0 — 2026-08-03
7
413
 
8
414
  - Added `formatSalaryStatUsd`, `formatSalaryStatRange`, `normalizeWebsiteUrl`,
package/README.md CHANGED
@@ -53,8 +53,12 @@ const { name, language, features } = await board.context();
53
53
  const page = await board.jobs.list({ limit: 20 });
54
54
  const job = await board.jobs.retrieve('senior-chef');
55
55
 
56
- // Federated search-dropdown suggestions (companies + taxonomy terms).
57
- const { items } = await board.search.suggest({ q: 'acme', limit: 10 });
56
+ // Federated search-dropdown suggestions (companies, markets, taxonomy terms).
57
+ const { items } = await board.search.suggest({
58
+ q: 'acme',
59
+ limit: 10,
60
+ types: ['company', 'skill'],
61
+ });
58
62
  // Or the headless controller (debounce / abort / stale-drop built in):
59
63
  // import { createSuggestController } from '@cavuno/board/suggest';
60
64
  // const suggest = createSuggestController(board);
@@ -245,19 +245,9 @@ interface components {
245
245
  * @description The board's canonical base URL (honours a configured custom domain). Build `robots.txt`'s `Sitemap:` line (`${canonicalBase}/sitemap.xml`) and canonical links from it.
246
246
  */
247
247
  canonicalBase: string;
248
- /** @description Absolute URLs for the board's configured favicons / app icons (null when unset). The variant key implies the role + size; build `<link rel="icon">` tags + the web-manifest icon list from these. */
249
- icons: {
250
- ico: string | null;
251
- svg: string | null;
252
- appleTouch: string | null;
253
- icon192: string | null;
254
- icon512: string | null;
255
- iconMaskable512: string | null;
256
- };
257
- /** @description Web-manifest metadata; assemble `site.webmanifest` from this + `icons`. */
248
+ /** @description Web-manifest name only. Theme color and icons left the surface in 4.0.0: applications own brand assets and presentation tokens. */
258
249
  manifest: {
259
250
  name: string;
260
- themeColor: string;
261
251
  };
262
252
  };
263
253
  BoardUser: {
@@ -1339,23 +1329,6 @@ interface components {
1339
1329
  /** Format: email */
1340
1330
  email: string;
1341
1331
  };
1342
- LegalPage: {
1343
- /** @enum {string} */
1344
- object: "legal_page";
1345
- /** @description The resolved legal page type. */
1346
- type: string;
1347
- /** @description Page heading (H1), with `{{board_name}}` resolved. */
1348
- title: string;
1349
- /** @description Owner-authored prose as portable HTML. Empty string when the page has no text body. */
1350
- content: string;
1351
- /** @enum {string} */
1352
- contentFormat: "html";
1353
- /** @description Structured impressum legal-entity facts; `null` for non-impressum pages. */
1354
- legalEntity: {
1355
- legalName: string | null;
1356
- address: string | null;
1357
- } | null;
1358
- };
1359
1332
  LocationSalaryDetail: {
1360
1333
  /** @enum {string} */
1361
1334
  object: "location_salary_detail";
@@ -1465,6 +1438,23 @@ interface components {
1465
1438
  jobCount: number;
1466
1439
  }[];
1467
1440
  };
1441
+ MarketSuggestion: {
1442
+ /** @enum {string} */
1443
+ object: "suggestion";
1444
+ /**
1445
+ * @description discriminator enum property added by openapi-typescript
1446
+ * @enum {string}
1447
+ */
1448
+ type: "market";
1449
+ /** @description Stable market identity (source slug; markets are slug-keyed). */
1450
+ id: string;
1451
+ /** @description Public market URL slug. */
1452
+ slug: string;
1453
+ /** @description Market display name. */
1454
+ name: string;
1455
+ /** @description Number of companies on the board in this market. */
1456
+ companyCount: number;
1457
+ };
1468
1458
  Message: {
1469
1459
  id: string;
1470
1460
  /** @enum {string} */
@@ -1561,6 +1551,21 @@ interface components {
1561
1551
  messagesPerPeriod: string;
1562
1552
  };
1563
1553
  };
1554
+ PostSuggestion: {
1555
+ /** @enum {string} */
1556
+ object: "suggestion";
1557
+ /**
1558
+ * @description discriminator enum property added by openapi-typescript
1559
+ * @enum {string}
1560
+ */
1561
+ type: "post";
1562
+ /** @description Stable published blog post identity. */
1563
+ id: string;
1564
+ /** @description Public blog post URL slug. */
1565
+ slug: string;
1566
+ /** @description Post title. */
1567
+ title: string;
1568
+ };
1564
1569
  PublicBlogAdjacentPosts: {
1565
1570
  /** @enum {string} */
1566
1571
  object: "blog_adjacent_posts";
@@ -1651,7 +1656,11 @@ interface components {
1651
1656
  candidates: boolean;
1652
1657
  employers: boolean;
1653
1658
  blog: boolean;
1654
- talentDirectory: boolean;
1659
+ /**
1660
+ * @description Talent directory access mode. Link /talent whenever this is not `'off'`. An employers-only directory renders a sign-in upsell for anonymous visitors, matching the hosted chrome. Replaces the former top-level `talentDirectoryVisibility` and the boolean `features.talentDirectory`.
1661
+ * @enum {string}
1662
+ */
1663
+ talentDirectory: "off" | "public" | "employers_only";
1655
1664
  registrationWall: boolean;
1656
1665
  passwordProtected: boolean;
1657
1666
  publicJobSubmission: boolean;
@@ -1662,11 +1671,6 @@ interface components {
1662
1671
  /** @description Whether applicant↔employer messaging is enabled on the board. `false` means the `me/conversations` route family rejects with `messaging_disabled` (403). Hide inbox/dock/Message CTAs. */
1663
1672
  messaging: boolean;
1664
1673
  };
1665
- /**
1666
- * @description The tri-state behind `features.talentDirectory` (which is `visibility === 'public'`). Link /talent whenever this is not `'off'`. An employers-only directory renders a sign-in upsell for anonymous visitors, matching the hosted chrome.
1667
- * @enum {string}
1668
- */
1669
- talentDirectoryVisibility: "off" | "public" | "employers_only";
1670
1674
  analytics: {
1671
1675
  ga4MeasurementId: string | null;
1672
1676
  gtmId: string | null;
@@ -1674,33 +1678,23 @@ interface components {
1674
1678
  linkedInPartnerId: string | null;
1675
1679
  cookieConsentRequired: boolean;
1676
1680
  };
1677
- /** @description Operator-defined custom job-field definitions, in display order. Board-wide; the frontend uses these to render and localize each job's opaque `customFieldValues`. Empty when the board defines none. Display-only: not filterable or searchable in v1. */
1678
- customFields: components["schemas"]["CustomFieldDefinition"][];
1679
- /** @description Stored operator label overrides by config group (`jobCardLabels`, `navLabels`, `breadcrumbsLabels`, …): plain-text chrome copy to merge over the `@cavuno/board` `uiCopy(language)` catalog via `uiCopy(language, labels)`. Empty object when the board stores none. */
1680
- labels: {
1681
- [key: string]: {
1682
- [key: string]: string;
1683
- };
1681
+ /** @description Operator-defined custom field definitions keyed by model (currently only `job`). Each key holds that model's definitions in display order. Company and talent keys are added when those models ship. The frontend uses these to render and localize each record's opaque `customFieldValues`. Display-only: not filterable or searchable in v1. */
1682
+ customFields: {
1683
+ /** @description This model's custom-field definitions in display order. Empty when the board defines none for the model. */
1684
+ job: components["schemas"]["CustomFieldDefinition"][];
1684
1685
  };
1685
- /** @description Footer/brand data the hosted board footer renders (description, contact, website + social links, navigation order). The brand/social URLs (`websiteUrl`, `facebookUrl`, `linkedinUrl`, `xUrl`) are sanitized to absolute http(s); `customLinks[].url` is served verbatim (see its own note). */
1686
- footer: {
1687
- /** @description Operator-written footer brand description. May carry a `{{board_name}}` placeholder. Resolve it before rendering. When null, fall back to the label catalog's `footer.defaultDescription` template. */
1688
- description: string | null;
1689
- /** @description Public contact email for the footer About column (render as `mailto:`). */
1690
- contactEmail: string | null;
1686
+ /** @description Operator-authored contact and social identity (settings › Contact & company details). Extracted from the former `footer` group in 4.0.0: contact is identity data, not layout. Brand/social URLs are sanitized to absolute http(s). */
1687
+ contact: {
1688
+ /** @description Public contact email (render as `mailto:`). Operator-set in settings › Contact & company details. */
1689
+ email: string | null;
1690
+ /** @description Company website URL, sanitized to absolute http(s). Null when unset or non-http(s). */
1691
1691
  websiteUrl: string | null;
1692
1692
  /** @description X (Twitter) profile URL, normalized from the stored handle. */
1693
1693
  xUrl: string | null;
1694
+ /** @description Facebook profile URL, sanitized to absolute http(s). Null when unset or non-http(s). */
1694
1695
  facebookUrl: string | null;
1696
+ /** @description LinkedIn profile URL, sanitized to absolute http(s). Null when unset or non-http(s). */
1695
1697
  linkedinUrl: string | null;
1696
- /** @description Operator-configured navigation order: system item ids (`home`, `companies`, `pricing`, `blog`) and `custom:<id>` refs into `customLinks`. Empty when the operator never reordered. Render the system defaults. System items still gate on their feature flags (e.g. `blog` only when `features.blog`). */
1697
- navigationOrder: string[];
1698
- /** @description Operator-defined navigation links referenced from `navigationOrder` as `custom:<id>`; links not referenced there append after the system items (hosted-footer behavior). Each `url` is served verbatim (trimmed only) and may be a relative path (e.g. `/hub`): unlike the brand/social URLs it is NOT run through http(s) sanitization (matching the hosted footer, which supports relative links), so a consumer MUST sanitize it before binding to an anchor href. */
1699
- customLinks: {
1700
- id: string;
1701
- label: string;
1702
- url: string;
1703
- }[];
1704
1698
  };
1705
1699
  };
1706
1700
  PublicCompaniesSearchBody: {
@@ -1841,7 +1835,7 @@ interface components {
1841
1835
  slug: string;
1842
1836
  name: string;
1843
1837
  }[];
1844
- /** @description Opaque, display-only custom-field values, keyed by each field's `key`. Values are the option `key`(s) for select fields, or the raw boolean/number/text otherwise. Resolve labels via the board's `customFields` definitions (see `GET /v1/boards/:identifier`). `{}` when the board defines no custom fields. Not filterable or searchable in v1. */
1838
+ /** @description Opaque, display-only custom-field values, keyed by each field's `key`. Values are the option `key`(s) for select fields, or the raw boolean/number/text otherwise. Resolve labels via the board's `customFields.job` definitions (see `GET /v1/boards/:identifier`). `{}` when the board defines no custom fields. Not filterable or searchable in v1. */
1845
1839
  customFieldValues: {
1846
1840
  [key: string]: string | string[] | boolean | number;
1847
1841
  };
@@ -2186,7 +2180,7 @@ interface components {
2186
2180
  jobId: string;
2187
2181
  /** Format: date-time */
2188
2182
  savedAt: string;
2189
- job: components["schemas"]["PublicJob"];
2183
+ job: components["schemas"]["PublicJobCard"];
2190
2184
  };
2191
2185
  SendWorkEmailBody: {
2192
2186
  /** Format: email */
@@ -2347,10 +2341,25 @@ interface components {
2347
2341
  object: "suggest_result";
2348
2342
  /** @description The trimmed query string that produced these suggestions. */
2349
2343
  query: string;
2350
- /** @description Interleaved company + taxonomy-term suggestions. Order is server-ranked; clients must not re-sort. */
2344
+ /** @description Interleaved company, market, taxonomy-term, blog-post, and tag suggestions. Order is server-ranked; clients must not re-sort. When `types` is set, only the requested kinds appear and `limit` applies after that filter. */
2351
2345
  items: components["schemas"]["SuggestionItem"][];
2352
2346
  };
2353
- SuggestionItem: components["schemas"]["CompanySuggestion"] | components["schemas"]["TermSuggestion"];
2347
+ SuggestionItem: components["schemas"]["CompanySuggestion"] | components["schemas"]["MarketSuggestion"] | components["schemas"]["TermSuggestion"] | components["schemas"]["PostSuggestion"] | components["schemas"]["TagSuggestion"];
2348
+ TagSuggestion: {
2349
+ /** @enum {string} */
2350
+ object: "suggestion";
2351
+ /**
2352
+ * @description discriminator enum property added by openapi-typescript
2353
+ * @enum {string}
2354
+ */
2355
+ type: "tag";
2356
+ /** @description Stable tag identity (source slug; tags are slug-keyed). */
2357
+ id: string;
2358
+ /** @description Public blog tag URL slug. */
2359
+ slug: string;
2360
+ /** @description Tag display name. */
2361
+ name: string;
2362
+ };
2354
2363
  TalentAccess: {
2355
2364
  /** @enum {string} */
2356
2365
  object: "talent_access";