@cavuno/board 2.0.5 → 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 (73) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +1 -3
  3. package/dist/{_spec-BKtVFcV9.d.mts → _spec-nkWAraS0.d.mts} +4 -18
  4. package/dist/{_spec-BKtVFcV9.d.ts → _spec-nkWAraS0.d.ts} +4 -18
  5. package/dist/bin.mjs +40 -57
  6. package/dist/{board-3Xj_Y-Tx.d.ts → board-NE98PlwN.d.ts} +2 -3
  7. package/dist/{board-pNN710Zc.d.mts → board-muu8BI8P.d.mts} +2 -3
  8. package/dist/doctor.js +21 -47
  9. package/dist/doctor.mjs +24 -50
  10. package/dist/filters.d.mts +2 -2
  11. package/dist/filters.d.ts +2 -2
  12. package/dist/format.d.mts +3 -3
  13. package/dist/format.d.ts +3 -3
  14. package/dist/index.d.mts +11 -27
  15. package/dist/index.d.ts +11 -27
  16. package/dist/index.js +3 -3
  17. package/dist/index.mjs +3 -3
  18. package/dist/{jobs-C4qRfCdP.d.ts → jobs-BiEvL9d7.d.ts} +1 -1
  19. package/dist/{jobs-k84C0v_8.d.mts → jobs-lVvKCBCU.d.mts} +1 -1
  20. package/dist/{salaries--J5mTylN.d.ts → salaries-BahBpxWT.d.ts} +2 -2
  21. package/dist/{salaries-C9YLO5NG.d.mts → salaries-ChSqDBAM.d.mts} +2 -2
  22. package/dist/{search-CE_qQiM7.d.mts → search-CCyFWPi3.d.mts} +1 -1
  23. package/dist/{search-CLRQ_El9.d.ts → search-CyRvVMUp.d.ts} +1 -1
  24. package/dist/seo.d.mts +4 -4
  25. package/dist/seo.d.ts +4 -4
  26. package/dist/server.d.mts +5 -5
  27. package/dist/server.d.ts +5 -5
  28. package/dist/sitemap.d.mts +5 -5
  29. package/dist/sitemap.d.ts +5 -5
  30. package/dist/skills.d.mts +15 -4
  31. package/dist/skills.d.ts +15 -4
  32. package/dist/skills.js +30 -7
  33. package/dist/skills.mjs +30 -8
  34. package/dist/suggest.d.mts +2 -2
  35. package/dist/suggest.d.ts +2 -2
  36. package/package.json +1 -11
  37. package/skills/cavuno-board-account/SKILL.md +54 -66
  38. package/skills/cavuno-board-api-client/SKILL.md +74 -0
  39. package/skills/cavuno-board-applications/SKILL.md +38 -52
  40. package/skills/cavuno-board-auth/SKILL.md +36 -61
  41. package/skills/cavuno-board-blog/SKILL.md +60 -49
  42. package/skills/cavuno-board-companies/SKILL.md +71 -53
  43. package/skills/cavuno-board-errors/SKILL.md +29 -46
  44. package/skills/cavuno-board-filters/SKILL.md +64 -75
  45. package/skills/cavuno-board-format/SKILL.md +63 -68
  46. package/skills/cavuno-board-i18n/SKILL.md +33 -62
  47. package/skills/cavuno-board-job-alerts/SKILL.md +68 -57
  48. package/skills/cavuno-board-jobs/SKILL.md +63 -55
  49. package/skills/cavuno-board-messaging/SKILL.md +53 -59
  50. package/skills/cavuno-board-paywall/SKILL.md +48 -54
  51. package/skills/cavuno-board-post-a-job/SKILL.md +118 -0
  52. package/skills/cavuno-board-salaries/SKILL.md +73 -50
  53. package/skills/cavuno-board-search-suggestions/SKILL.md +82 -0
  54. package/skills/cavuno-board-seo/BLOG.md +40 -0
  55. package/skills/cavuno-board-seo/JOB_AND_BREADCRUMBS.md +49 -0
  56. package/skills/cavuno-board-seo/LISTING.md +36 -0
  57. package/skills/cavuno-board-seo/SALARY.md +61 -0
  58. package/skills/cavuno-board-seo/SKILL.md +32 -163
  59. package/skills/cavuno-board-server-sessions/SKILL.md +143 -0
  60. package/skills/cavuno-board-setup/SKILL.md +43 -80
  61. package/skills/cavuno-board-sitemap/SKILL.md +76 -93
  62. package/skills/cavuno-board-smoke-test/SKILL.md +34 -69
  63. package/skills/manifest.json +43 -57
  64. package/dist/theme.d.mts +0 -115
  65. package/dist/theme.d.ts +0 -115
  66. package/dist/theme.js +0 -195
  67. package/dist/theme.mjs +0 -174
  68. package/skills/cavuno-board-client/SKILL.md +0 -89
  69. package/skills/cavuno-board-job-posting/SKILL.md +0 -116
  70. package/skills/cavuno-board-server/SKILL.md +0 -229
  71. package/skills/cavuno-board-suggest/SKILL.md +0 -119
  72. package/skills/cavuno-board-theme/SKILL.md +0 -57
  73. package/skills/flavors/tanstack-start/SKILL.md +0 -102
@@ -1,108 +1,102 @@
1
1
  ---
2
2
  name: cavuno-board-paywall
3
- description: Build the candidate job-access paywall with the @cavuno/board SDK — anonymous offer reads (paywall.offers), the gated browse experience (gatedCount on job lists), the embedded Stripe checkout mount kit (me.access.checkout → retrieveCheckout → grant), and the billing portal (me.access.portal for recurring grants).
3
+ description: Grant-is-truth candidate paywall with @cavuno/board. Use for offers, gated job counts, embedded checkout handoff, access confirmation, or subscription portal links.
4
4
  ---
5
5
 
6
- # Candidate paywall: offers, checkout, grant
6
+ # Grant-is-truth candidate paywall
7
7
 
8
- The candidate-facing money flow: anonymous pricing, gated job lists, and an embedded Stripe checkout that ends in an access grant.
8
+ The candidate money flow is anonymous offers → authenticated embedded checkout → webhook-backed grant. The grant, rather than checkout completion, owns access.
9
9
 
10
- Out of scope — do not invent exports: the SDK is Stripe-agnostic. It returns a *mount kit*; loading Stripe.js (`loadStripe`, `initEmbeddedCheckout`) and every redirect is host-app-owned. There is no SDK helper that mounts, redirects, or handles webhooks.
10
+ Board-password access is a separate `X-Board-Access` mechanism. Employer posting payments use `cavuno-board-post-a-job`.
11
11
 
12
- ## When to use
12
+ ## Render offers and gating
13
13
 
14
- - The pricing/upsell page, the gated jobs view, the checkout page, and the "manage subscription" button.
15
-
16
- ## When not to use
17
-
18
- - Board-password gating (`X-Board-Access`) — unrelated; see `cavuno-board-errors`.
19
- - Employer job-posting payments — see `cavuno-board-job-posting`.
20
-
21
- ## Offers (anonymous)
22
-
23
- `board.paywall.offers()` lists the enabled offer tiers — public, no auth. Returns `[]` when the paywall is disabled (the internal Stripe price id is never exposed).
14
+ `board.paywall.offers()` is anonymous and returns an empty list when disabled. Internal Stripe price ids stay server-side.
24
15
 
25
16
  ```ts snippet
26
17
  const { data: offers } = await board.paywall.offers();
27
18
  for (const offer of offers) {
28
- offer.offerKey; // e.g. 'monthly' — what you post to checkout
19
+ offer.offerKey;
29
20
  offer.label;
30
21
  offer.billingLabel;
31
22
  offer.amountCents;
32
23
  offer.currency;
33
- offer.offerType; // 'recurring' | 'lifetime'
34
- offer.isDefault; // pre-select this tier
24
+ offer.offerType;
25
+ offer.isDefault;
35
26
  }
36
27
  ```
37
28
 
38
- ## The gated browse: gatedCount
29
+ `offerType` is `'recurring' | 'lifetime'`; post the selected `offerKey` to checkout.
39
30
 
40
- On gated boards the jobs catalog reads (browse / search / company-jobs) withhold results from unentitled viewers and report how many via `gatedCount` on the envelope. Surface it as the upsell:
31
+ Gated catalog reads expose withheld inventory as `gatedCount`. The same jobs endpoint returns the entitled view when called with the candidate bearer token.
41
32
 
42
33
  ```ts snippet
43
34
  const page = await board.jobs.list({ limit: 20 });
44
- if (page.gatedCount && page.gatedCount > 0) {
45
- // "Unlock N more roles" → link to the offers page
35
+ if ((page.gatedCount ?? 0) > 0) {
36
+ renderUpsell(page.gatedCount);
46
37
  }
47
38
  ```
48
39
 
49
- The same call with an entitled board-user bearer token returns the ungated view — one URL for both.
50
-
51
- ## Checkout: mint the mount kit
40
+ ## Mint and mount checkout
52
41
 
53
- `board.me.access.checkout` (signed-in, candidate profile required) starts an embedded checkout and returns a connected-account mount kit — `{ sessionId, clientSecret, stripeAccountId, publishableKey, offerType }`. `returnPath` is relative-only; the server resolves it against the board's canonical host and appends `?session_id={CHECKOUT_SESSION_ID}`.
42
+ `board.me.access.checkout` requires a signed-in candidate profile and returns a mount kit. `returnPath` is relative; the server makes the canonical absolute return URL and appends Stripe's session placeholder.
54
43
 
55
44
  ```ts snippet
56
- import { loadStripe } from '@stripe/stripe-js'; // host-app dependency
45
+ import { loadStripe } from '@stripe/stripe-js';
57
46
 
58
47
  const kit = await board.me.access.checkout({
59
- offerKey: 'monthly', // the chosen PaywallOffer.offerKey
60
- returnPath: '/account/access', // relative path Stripe returns to
48
+ offerKey: 'monthly',
49
+ returnPath: '/account/access',
61
50
  colorMode: 'light',
62
51
  });
63
52
 
64
- // Host-owned mounting — stripeAccount is required for connected-account embeds:
65
53
  const stripe = await loadStripe(kit.publishableKey, {
66
54
  stripeAccount: kit.stripeAccountId,
67
55
  });
68
56
  ```
69
57
 
70
- Duplicate POSTs are benign (a second session simply expires unpaid) — no idempotency machinery needed.
58
+ The kit contains `sessionId`, `clientSecret`, `stripeAccountId`, `publishableKey`, and `offerType`. The host owns Stripe.js, embedded-checkout mounting, and redirects. A repeated checkout POST safely creates another session that can expire unpaid.
71
59
 
72
- ## Completion: poll the session, confirm the grant
60
+ ## Confirm access from the grant
73
61
 
74
- `retrieveCheckout(sessionId)` reports `open` (re-mountable — `clientSecret` set), `complete`, or `expired`. On completion, the entitlement lands via webhook within seconds — `grant()` is the source of truth, and it always resolves (no access is `hasAccess: false`, not an error):
62
+ `retrieveCheckout` reports `open`, `complete`, or `expired`. Open sessions are remountable with their `clientSecret`; expired sessions require a new checkout. After completion, poll `grant()` within a bounded window because webhook delivery is asynchronous.
75
63
 
76
64
  ```ts snippet
77
- const state = await board.me.access.retrieveCheckout(kit.sessionId);
78
- // 'open' → remount with state.clientSecret
79
- // 'expired' → mint a fresh session
80
- // 'complete' → poll the grant until the webhook lands:
81
-
82
- const grant = await board.me.access.grant();
83
- grant.hasAccess; // gate the ungated jobs view on this
84
- grant.status; // 'active' | 'past_due' | … | null
85
- grant.offerType; // 'recurring' | 'lifetime' | null
86
- grant.currentPeriodEnd;
87
- grant.cancelAtPeriodEnd;
65
+ const checkout = await board.me.access.retrieveCheckout(kit.sessionId);
66
+
67
+ if (checkout.status === 'complete') {
68
+ let grant = await board.me.access.grant();
69
+ for (let poll = 0; !grant.hasAccess && poll < 10; poll++) {
70
+ await new Promise((resolve) => setTimeout(resolve, 1000));
71
+ grant = await board.me.access.grant();
72
+ }
73
+ if (grant.hasAccess) renderUnlockedJobs();
74
+ else renderPendingAccess();
75
+ }
88
76
  ```
89
77
 
90
- Gate failures throw `paywall_*` codes (`paywall_disabled`, `paywall_no_candidate_profile`, `paywall_offer_not_found`, `paywall_already_active`, `paywall_invalid_checkout_session`) — branch with the guards from `cavuno-board-errors`.
78
+ `grant()` always resolves: no access is `{ hasAccess: false }`. Its `status`, `offerType`, `currentPeriodEnd`, and `cancelAtPeriodEnd` drive the account UI. Gate failures use `paywall_disabled`, `paywall_no_candidate_profile`, `paywall_offer_not_found`, `paywall_already_active`, and `paywall_invalid_checkout_session`; handle them with `cavuno-board-errors`.
91
79
 
92
- ## Manage subscription: portal
80
+ ## Open the subscription portal
93
81
 
94
- Only `recurring` grants get a portal (`paywall_no_recurring_subscription` otherwise). Unlike checkout, the portal IS a redirect flow — the host app navigates to the Stripe-hosted URL:
82
+ Only recurring grants have a portal. The host follows the returned Stripe-hosted URL.
95
83
 
96
84
  ```ts snippet
85
+ const grant = await board.me.access.grant();
97
86
  if (grant.offerType === 'recurring') {
98
- const { url } = await board.me.access.portal({ returnPath: '/account' });
99
- location.href = url; // host-owned redirect to Stripe's portal
87
+ const { url } = await board.me.access.portal({
88
+ returnPath: '/account',
89
+ });
90
+ location.href = url;
100
91
  }
101
92
  ```
102
93
 
103
- ## Verify
94
+ A lifetime grant produces `paywall_no_recurring_subscription` for `portal`.
95
+
96
+ ## Completion gate
104
97
 
105
- - [ ] Anonymous fetch of `paywall.offers()` renders tiers with no auth header; a paywall-disabled board renders none (`data: []`).
106
- - [ ] As an unentitled viewer, `board.jobs.list` carries `gatedCount > 0` and the upsell shows; with an active grant the same call returns `gatedCount` absent/0 and the full list.
107
- - [ ] Complete a test payment: `retrieveCheckout` flips to `complete` and `grant().hasAccess` turns true within seconds (webhook latency), not instantly.
108
- - [ ] `portal()` succeeds for a recurring grant and throws for a lifetime one.
98
+ - Anonymous offers render without authorization; disabled paywall renders no tiers.
99
+ - Unentitled jobs show `gatedCount`; the active-grant request exposes the full list.
100
+ - Test checkout reaches `complete`, then bounded grant polling reaches `hasAccess: true` after webhook delivery.
101
+ - UI access decisions read `grant().hasAccess`.
102
+ - Recurring grants open the portal; lifetime grants show a deliberate non-portal state.
@@ -0,0 +1,118 @@
1
+ ---
2
+ name: cavuno-board-post-a-job
3
+ description: Post a job publicly with @cavuno/board. Use for plan selection, logos, checkout handoff, or verified existing credit.
4
+ ---
5
+
6
+ # Status-first job posting
7
+
8
+ The public posting funnel is anonymous and rate-limited. Its invariant is status-first handling: `board.jobPosting.create` returns one of four success states, and the host follows that state. Rejected submissions throw `BoardApiError` instead.
9
+
10
+ Employer dashboard job management belongs to `board.me.companies.jobs.*`. Candidate access payments belong to `cavuno-board-paywall`.
11
+
12
+ ## Select a plan
13
+
14
+ ```ts snippet
15
+ const { data: plans } = await board.jobPosting.plans();
16
+ for (const plan of plans) {
17
+ plan.id;
18
+ plan.prices;
19
+ plan.invoiceOnly;
20
+ plan.isRecommended;
21
+ plan.features;
22
+ }
23
+ ```
24
+
25
+ Pass the chosen `plan.id` as `submission.selectedPlan`. Invoice-only plans collect `invoiceBilling` rather than immediate payment.
26
+
27
+ ## Resolve a logo
28
+
29
+ Both logo paths return a stored `publicUrl` for `create`. The SDK owns multipart encoding.
30
+
31
+ ```ts snippet
32
+ const uploaded = await board.jobPosting.uploadLogo(file);
33
+ const fetched = await board.jobPosting.fetchLogoByDomain('acme.com');
34
+ ```
35
+
36
+ Uploads accept JPEG, PNG, WebP, or GIF up to 2 MB. A missing domain logo throws code `job_posting_logo_not_found`; continue without a logo.
37
+
38
+ ## Submit and exhaust the result
39
+
40
+ ```ts snippet
41
+ const result = await board.jobPosting.create({
42
+ submission: {
43
+ companyName: 'Acme',
44
+ contactName: 'Ada',
45
+ contactEmail: 'ada@acme.com',
46
+ title: 'Staff Engineer',
47
+ description: '<p>…</p>',
48
+ employmentType: 'full_time',
49
+ remoteOption: 'remote',
50
+ officeLocations: [],
51
+ applicationUrl: 'https://acme.com/apply',
52
+ salaryRangeEnabled: false,
53
+ selectedPlan: plan.id,
54
+ },
55
+ logoUrl: uploaded.publicUrl,
56
+ });
57
+
58
+ switch (result.status) {
59
+ case 'checkout':
60
+ location.href = result.checkoutUrl;
61
+ break;
62
+ case 'published':
63
+ linkToJob(result.jobSlug);
64
+ break;
65
+ case 'pending_approval':
66
+ showPending(result.jobId);
67
+ break;
68
+ case 'invoice_sent':
69
+ showInvoiceSent(result.jobId);
70
+ break;
71
+ }
72
+ ```
73
+
74
+ The mapping is:
75
+
76
+ - paid Stripe plan → `checkout`;
77
+ - free or credited posting → `published`, or `pending_approval` on moderated boards;
78
+ - invoice plan → `invoice_sent`.
79
+
80
+ The checkout value is a URL for a host-owned full-page redirect. Payment publication happens by webhook; the SDK exposes no Stripe integration or publish-confirm method.
81
+
82
+ ## Verified existing credit
83
+
84
+ Email verification protects billing ownership. Send verification first, then exchange the token for options; a selected option becomes `selectedBilling`.
85
+
86
+ ```ts snippet
87
+ await board.jobPosting.sendBillingVerification({ email });
88
+
89
+ const { options } = await board.jobPosting.getBillingOptions({
90
+ verificationToken,
91
+ });
92
+
93
+ const option = options[0];
94
+ if (option) {
95
+ option.jobsRemaining;
96
+ option.featuredRemaining;
97
+ option.renewsAt;
98
+
99
+ await board.jobPosting.create({
100
+ submission,
101
+ selectedBilling: {
102
+ type: option.type,
103
+ id: option.id,
104
+ planId: option.planId,
105
+ },
106
+ });
107
+ }
108
+ ```
109
+
110
+ Bare-email billing lookup is intentionally absent because it would expose whether an address holds credit.
111
+
112
+ ## Completion gate
113
+
114
+ - Every `create` call handles all four statuses.
115
+ - Paid checkout redirects through `checkoutUrl`; the job appears only after webhook completion.
116
+ - Free unmoderated submission returns a resolving `jobSlug`.
117
+ - Missing domain logos leave submission usable without `logoUrl`.
118
+ - Existing credit is selectable only after token verification.
@@ -1,87 +1,110 @@
1
1
  ---
2
2
  name: cavuno-board-salaries
3
- description: Build salary pages with the @cavuno/board SDK — salaries.titles/skills/locations (list + retrieve + cross-axis .locations/.location/.titles/.skills), salaries.companies.list. Covers the sourceSlug/canonicalSlug 308 contract, the { locale } overlay, aggregate field shapes (avg/median/percentile bands + jobCount), and why salary math must never be recomputed client-side.
3
+ description: Salary read models with @cavuno/board. Use for title, skill, or location salary indexes and detail, cross-axis pages, or the salary companies hub.
4
4
  ---
5
5
 
6
- # Salary pages
6
+ # Salaries
7
7
 
8
- Pre-aggregated salary read-models, one nested resource per axis (titles, skills, locations) plus a companies hub. Every number is server-computed; the SDK returns render-ready aggregates.
8
+ The salary namespaces return pre-aggregated read models. Render their figures
9
+ directly in the response `currency`; server-side weighted averages, medians,
10
+ percentile bands, and seniority splits are the source of truth.
9
11
 
10
- ## When to use
12
+ Single-company salary pages use `companies.salaries` and
13
+ `companies.salaries.category`. A job's own range comes from its
14
+ `salaryMin`/`salaryMax` fields.
11
15
 
12
- - `/salaries` index pages (by title, skill, or location) and their detail pages.
13
- - Cross-axis pages: "Software Engineer salary in Berlin", "Python salary by location".
14
- - The `/salaries/companies` hub.
16
+ ## Build axis indexes
15
17
 
16
- ## When not to use
17
-
18
- - A single company's salary pages — use `companies.salaries(slug)` / `companies.salaries.category(slug, categorySlug)` (see the companies namespace).
19
- - Per-job salary display — the job itself carries `salaryMin`/`salaryMax` (see `cavuno-board-jobs`).
20
-
21
- ## Out of scope — do not invent exports
22
-
23
- Only the methods and fields shown here exist. If a field is not in these snippets or the exported types, do not reference it.
24
-
25
- ## Index pages
26
-
27
- Each axis has `list` returning a `ListEnvelope` of index items. Index items carry `avgSalaryMin`/`avgSalaryMax` and `jobCount` (sample size); title/skill items add `p25SalaryMin`/`p75SalaryMax` and `currency`.
18
+ Titles, skills, and locations each expose `.list()`. Index items contain
19
+ `avgSalaryMin`, `avgSalaryMax`, and sample-size `jobCount`; title and skill
20
+ items also contain `p25SalaryMin`, `p75SalaryMax`, and `currency`.
28
21
 
29
22
  ```ts snippet
30
23
  const titles = await board.salaries.titles.list();
31
- for (const t of titles.data) {
32
- t.slug; t.name;
33
- t.avgSalaryMin; t.avgSalaryMax; // render as-is, in t.currency
34
- t.jobCount; // "based on N jobs"
24
+ for (const title of titles.data) {
25
+ title.slug;
26
+ title.name;
27
+ title.avgSalaryMin;
28
+ title.avgSalaryMax;
29
+ title.jobCount;
35
30
  }
36
31
  const skills = await board.salaries.skills.list();
37
- const companies = await board.salaries.companies.list(); // ranked by sample size
32
+ const companies = await board.salaries.companies.list();
38
33
  ```
39
34
 
40
- `salaries.locations.list()` returns a flattened place tree — each `SalaryLocation` has `parentSlug` (`null` at the top level); rebuild the country → region → city browse from those edges.
35
+ `salaries.companies.list()` ranks companies by sample size.
36
+ `salaries.locations.list()` returns a flattened place tree: rebuild country,
37
+ region, and city nesting from each `SalaryLocation.parentSlug`; top-level rows
38
+ have `parentSlug: null`.
41
39
 
42
- ## Detail pages and the slug contract
40
+ ## Retrieve detail and canonicalize slugs
43
41
 
44
- `retrieve(slug)` accepts the inbound slug (board-language or English) and returns both `sourceSlug` (immutable English stats key) and `canonicalSlug` (board-language URL). **The API never redirects — your app 308s to `canonicalSlug` when the inbound slug differs.** Pass `{ locale }` for board-language names + canonical slugs (`en` is the identity fast-path).
42
+ Every axis `retrieve(slug)` accepts an inbound English or board-language slug.
43
+ The result includes immutable English `sourceSlug` and board-language
44
+ `canonicalSlug`. Pass `{ locale }` for board-language names and canonical
45
+ slugs, then issue a 308 when the inbound slug differs from `canonicalSlug`.
46
+ The API itself returns data rather than a redirect.
45
47
 
46
48
  ```ts snippet
47
- const title = await board.salaries.titles.retrieve('software-engineer', { locale: 'de' });
48
- title.canonicalSlug; // 308 target if it differs from the requested slug
49
- title.overallSalary; // { avgMin, avgMax, p25Min, p75Max, jobCount } | null
50
- title.bySeniority; // rows with avgSalaryMin/avgSalaryMax/jobCount + board comparison
51
- title.topCompanies; // rails: { avgSalaryMin, avgSalaryMax, jobCount, ... }
52
- title.currency; // the single currency all numbers are quoted in
49
+ const title = await board.salaries.titles.retrieve('software-engineer', {
50
+ locale: 'de',
51
+ });
52
+ title.canonicalSlug;
53
+ title.overallSalary;
54
+ title.bySeniority;
55
+ title.topCompanies;
56
+ title.currency;
53
57
 
54
58
  const skill = await board.salaries.skills.retrieve('python');
55
59
  const place = await board.salaries.locations.retrieve('berlin');
56
60
  ```
57
61
 
58
- `overallSalary` is `null` when the axis has no sample — render an empty state, don't fabricate a band. Skill and location details also carry `medianMin`/`medianMax` inside `overallSalary`; the title detail exposes medians as top-level `boardMedianMin`/`boardMedianMax` instead. `bySeniority` rows on title/skill details include board-wide comparison fields (`boardAvgMin`, `diffPercent`, …) for "X% above board average" copy.
62
+ `overallSalary` is nullable. Render an empty state when it is `null`.
63
+ Title detail contains `{ avgMin, avgMax, p25Min, p75Max, jobCount }` there and
64
+ exposes medians as top-level `boardMedianMin` and `boardMedianMax`. Skill and
65
+ location details instead include `medianMin` and `medianMax` inside
66
+ `overallSalary`. Title and skill `bySeniority` rows include board-comparison
67
+ fields such as `boardAvgMin` and `diffPercent`.
59
68
 
60
- ## Cross-axis pages
69
+ ## Build cross-axis pages
61
70
 
62
- Titles and skills each expose `.locations(slug)` (index of places with data) and `.location(slug, locationSlug)` (one place); locations expose the suffix reads `.titles(slug)` and `.skills(slug)`.
71
+ Titles and skills expose `.locations(slug)` and
72
+ `.location(slug, locationSlug)`. Locations expose `.titles(slug)` and
73
+ `.skills(slug)`.
63
74
 
64
75
  ```ts snippet
65
- const idx = await board.salaries.titles.locations('software-engineer');
66
- idx.locations; // SalaryLocation[] — flattened parentSlug tree
76
+ const index = await board.salaries.titles.locations('software-engineer');
77
+ index.locations;
67
78
 
68
- const berlin = await board.salaries.titles.location('software-engineer', 'berlin');
69
- berlin.categoryCanonicalSlug; // 4 slugs: category/location × source/canonical
79
+ const berlin = await board.salaries.titles.location(
80
+ 'software-engineer',
81
+ 'berlin');
82
+ berlin.categoryCanonicalSlug;
70
83
  berlin.locationCanonicalSlug;
71
- berlin.overallSalary; // p25Min/p75Max nullable here
84
+ berlin.overallSalary;
72
85
 
73
- const inBerlin = await board.salaries.locations.titles('berlin');
86
+ const titlesInBerlin = await board.salaries.locations.titles('berlin');
74
87
  const skillsInBerlin = await board.salaries.locations.skills('berlin');
75
88
  ```
76
89
 
77
- ## Never recompute salary math
90
+ A cross-axis detail carries four slug fields: source and canonical slugs for
91
+ both category and location. Canonicalize both URL segments. Its
92
+ `overallSalary.p25Min` and `p75Max` may be null.
93
+
94
+ ## Preserve aggregate semantics
95
+
96
+ Display response fields as-is. Client arithmetic such as averaging min/max,
97
+ deriving medians from percentiles, summing rails, or converting currencies
98
+ changes the weighted meaning and diverges from the hosted board. Use
99
+ `jobCount`, rather than `data.length`, for sample-size copy.
78
100
 
79
- Render the returned aggregates **as-is**. Do not average mins and maxes, derive medians from percentiles, sum across rails, or convert currency client-side — every response quotes one board `currency` and the weighted math is done server-side against the full sample. Client-side arithmetic will disagree with the hosted board and other consumers.
101
+ ## Completion gate
80
102
 
81
- ## Verify
103
+ Finish only after every applicable check passes:
82
104
 
83
- - [ ] Requesting a title/skill/location by a stale slug renders a 308 to `canonicalSlug`.
84
- - [ ] A non-`en` board passes `{ locale }` and shows board-language names.
85
- - [ ] An axis with `overallSalary: null` shows an empty state, not `NaN`/`0`.
86
- - [ ] All displayed figures match the raw response fields (no client math), formatted in the response `currency`.
87
- - [ ] Sample sizes come from `jobCount`, not `data.length`.
105
+ - Stale axis and cross-axis slugs produce 308s to every returned canonical
106
+ segment.
107
+ - A non-English board passes `{ locale }` and renders board-language names.
108
+ - `overallSalary: null` renders an empty state rather than `0` or `NaN`.
109
+ - Every salary, comparison, and sample size maps directly to a response field
110
+ and uses the response currency.
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: cavuno-board-search-suggestions
3
+ description: Build server-ranked search suggestions with @cavuno/board. Use for company-and-term typeahead, autocomplete dropdowns, or suggestion-controller wiring.
4
+ ---
5
+
6
+ # Server-ranked search suggestions
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.
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`.
11
+
12
+ ## Read suggestions
13
+
14
+ ```ts snippet
15
+ const { items, query } = await board.search.suggest({
16
+ q: 'acme',
17
+ limit: 10,
18
+ });
19
+
20
+ for (const item of items) {
21
+ if (item.type === 'company') {
22
+ item.slug;
23
+ item.name;
24
+ item.jobCount;
25
+ } else {
26
+ item.termType;
27
+ item.displayName;
28
+ item.canonicalSlug;
29
+ item.sourceSlug;
30
+ }
31
+ }
32
+ ```
33
+
34
+ Queries shorter than two characters return no items. `limit` accepts 1–25 and defaults to 25.
35
+
36
+ Term suggestions have `termType: 'category' | 'skill'`; use `canonicalSlug` for links and `sourceSlug` for job filters.
37
+
38
+ ## Drive UI with the controller
39
+
40
+ ```ts snippet
41
+ import { createSuggestController } from '@cavuno/board/suggest';
42
+
43
+ const suggest = createSuggestController(board, { limit: 10 });
44
+ const unsubscribe = suggest.subscribe(() => {
45
+ const state = suggest.getState();
46
+ renderSuggestions(state.items, state.status, state.query);
47
+ });
48
+
49
+ suggest.setQuery(inputValue);
50
+ suggest.setExcludedCompanySlugs(appliedCompanySlugs);
51
+
52
+ unsubscribe();
53
+ suggest.dispose();
54
+ ```
55
+
56
+ Defaults are a 250 ms debounce and two-character minimum. Previous items remain while status is `loading`. The store is compatible with `useSyncExternalStore`. `setExcludedCompanySlugs` is the view-level exception: it removes company items synchronously without requesting again. Preserve the remaining items and order.
57
+
58
+ The SDK provides the data and controller, while the host owns dropdown markup, keyboard navigation, and framework bindings.
59
+
60
+ ## Apply a company suggestion
61
+
62
+ Company slugs are public URL identity. Write the slug into listing filters and pass it as `companySlug`.
63
+
64
+ ```ts snippet
65
+ import { parseListingFilters } from '@cavuno/board/filters';
66
+
67
+ const filters = parseListingFilters(rawSearchParams);
68
+ const page = await board.jobs.list({
69
+ limit: 20,
70
+ companySlug: filters.company,
71
+ });
72
+ ```
73
+
74
+ Unknown slugs are dropped server-side. If none resolve, the listing has `count: 0`. When `companySlug` and `companyId` are both present, the server uses their union.
75
+
76
+ ## Completion gate
77
+
78
+ - A controller query below two characters renders no results and makes no request.
79
+ - Rapid typing renders only the final response.
80
+ - Excluded companies disappear without changing the relative order of other items.
81
+ - Selecting a company writes its slug to the URL and sends `companySlug` directly.
82
+ - Teardown unsubscribes listeners and calls `dispose`.
@@ -0,0 +1,40 @@
1
+ # Blog structured data
2
+
3
+ Read this reference for blog-post `Article` and author `ProfilePage` JSON-LD.
4
+
5
+ ```ts snippet
6
+ import {
7
+ createAuthorProfileJsonLd,
8
+ createBlogArticleJsonLd,
9
+ } from '@cavuno/board/seo';
10
+
11
+ const { name } = await board.context();
12
+ const post = await board.blog.posts.retrieve('hello-world');
13
+ const article = createBlogArticleJsonLd({
14
+ post,
15
+ boardName: name,
16
+ permalink: `https://jobs.example.com/blog/${post.slug}`,
17
+ ogImageUrl: `https://jobs.example.com/blog/${post.slug}/og`,
18
+ });
19
+
20
+ const author = await board.blog.authors.retrieve('jane');
21
+ const { data: authorPosts } = await board.blog.posts.list({
22
+ authorSlug: 'jane',
23
+ });
24
+ const profile = createAuthorProfileJsonLd({
25
+ author,
26
+ canonical: `https://jobs.example.com/blog/author/${author.slug}`,
27
+ description: author.bio ?? `Posts by ${author.name}`,
28
+ origin: 'https://jobs.example.com',
29
+ posts: authorPosts,
30
+ totalPosts: authorPosts.length,
31
+ });
32
+ ```
33
+
34
+ The article `ogImageUrl` is the fallback when the post has no cover. Supply
35
+ author posts newest-first; the profile builder keeps the first five as
36
+ `hasPart`.
37
+
38
+ This branch is complete when the post emits an `Article`, the author emits a
39
+ `ProfilePage`, every URL is absolute and canonical, and a null builder result
40
+ leaves no script tag.
@@ -0,0 +1,49 @@
1
+ # Job detail and breadcrumbs
2
+
3
+ Read this reference for Google for Jobs `JobPosting` or `BreadcrumbList`
4
+ structured data.
5
+
6
+ ## Build JobPosting JSON-LD
7
+
8
+ `PublicJob` carries the inputs, including derived remote permit codes.
9
+ Worldwide-remote jobs automatically enumerate all 249 countries because
10
+ Google requires a location signal alongside `TELECOMMUTE`.
11
+
12
+ ```ts snippet
13
+ import { createJobPostingJsonLd } from '@cavuno/board/seo';
14
+
15
+ const { name, logoUrl } = await board.context();
16
+ const job = await board.jobs.retrieve('senior-chef');
17
+
18
+ const jsonLd = createJobPostingJsonLd({
19
+ job,
20
+ board: { name, logoUrl },
21
+ shareUrl:
22
+ `https://jobs.example.com/companies/${job.company?.slug}/jobs/${job.slug}`,
23
+ });
24
+ ```
25
+
26
+ Use the builder as the schema boundary. It encodes pruning, salary mirroring,
27
+ remote-location behavior, and Google for Jobs shapes.
28
+
29
+ ## Build BreadcrumbList JSON-LD
30
+
31
+ Labels are trimmed, blank crumbs are dropped, and fewer than two surviving
32
+ crumbs return `null`. The current page omits `href`, which makes the builder
33
+ omit its `item`. Supply absolute hrefs, or pass `origin` for path hrefs.
34
+
35
+ ```ts snippet
36
+ import { createBreadcrumbJsonLd } from '@cavuno/board/seo';
37
+
38
+ const trail = createBreadcrumbJsonLd(
39
+ [
40
+ { label: 'Home', href: '/' },
41
+ { label: 'Jobs', href: '/jobs' },
42
+ { label: job.title },
43
+ ],
44
+ { origin: 'https://jobs.example.com' });
45
+ ```
46
+
47
+ This branch is complete when the JobPosting passes Google's Rich Results test,
48
+ the worldwide-remote fixture contains 249 countries, and breadcrumb output is
49
+ absent below two usable crumbs.
@@ -0,0 +1,36 @@
1
+ # Listing metadata and JSON-LD
2
+
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.
6
+
7
+ ```ts snippet
8
+ import { listingHead, listingJsonLd } from '@cavuno/board/seo';
9
+
10
+ const { name } = await board.context();
11
+ const head = listingHead({
12
+ boardName: name,
13
+ origin: 'https://jobs.example.com',
14
+ path: '/jobs/robotics',
15
+ heading: 'Robotics jobs',
16
+ count: 42,
17
+ });
18
+
19
+ const objects = listingJsonLd({
20
+ origin: 'https://jobs.example.com',
21
+ breadcrumbs: [{ name: 'Home', path: '/' }, { name: 'Jobs' }],
22
+ jobs: cards.map((card) => ({
23
+ slug: card.slug,
24
+ company: card.company,
25
+ })),
26
+ });
27
+ ```
28
+
29
+ `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`.
32
+
33
+ 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.