@cavuno/board 1.25.0 → 1.27.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 (49) hide show
  1. package/README.md +88 -0
  2. package/dist/bin.mjs +67 -34
  3. package/dist/filters.d.mts +55 -0
  4. package/dist/filters.d.ts +55 -0
  5. package/dist/filters.js +193 -0
  6. package/dist/filters.mjs +170 -0
  7. package/dist/format.d.mts +240 -0
  8. package/dist/format.d.ts +240 -0
  9. package/dist/format.js +453 -0
  10. package/dist/format.mjs +430 -0
  11. package/dist/index.d.mts +81 -3965
  12. package/dist/index.d.ts +81 -3965
  13. package/dist/index.js +154 -7
  14. package/dist/index.mjs +151 -4
  15. package/dist/jobs-DK5mPBgq.d.mts +3836 -0
  16. package/dist/jobs-DK5mPBgq.d.ts +3836 -0
  17. package/dist/salaries-CXt6Vkrp.d.ts +130 -0
  18. package/dist/salaries-CrJsaZe6.d.mts +130 -0
  19. package/dist/seo.d.mts +288 -0
  20. package/dist/seo.d.ts +288 -0
  21. package/dist/seo.js +1102 -0
  22. package/dist/seo.mjs +1079 -0
  23. package/dist/sitemap.d.mts +63 -0
  24. package/dist/sitemap.d.ts +63 -0
  25. package/dist/sitemap.js +353 -0
  26. package/dist/sitemap.mjs +330 -0
  27. package/dist/theme.d.mts +63 -0
  28. package/dist/theme.d.ts +63 -0
  29. package/dist/theme.js +149 -0
  30. package/dist/theme.mjs +128 -0
  31. package/package.json +57 -2
  32. package/skills/cavuno-board-account/SKILL.md +147 -0
  33. package/skills/cavuno-board-applications/SKILL.md +110 -0
  34. package/skills/cavuno-board-blog/SKILL.md +99 -0
  35. package/skills/cavuno-board-companies/SKILL.md +99 -0
  36. package/skills/cavuno-board-filters/SKILL.md +89 -0
  37. package/skills/cavuno-board-format/SKILL.md +104 -0
  38. package/skills/cavuno-board-job-alerts/SKILL.md +115 -0
  39. package/skills/cavuno-board-job-posting/SKILL.md +130 -0
  40. package/skills/cavuno-board-jobs/SKILL.md +15 -0
  41. package/skills/cavuno-board-messaging/SKILL.md +121 -0
  42. package/skills/cavuno-board-paywall/SKILL.md +108 -0
  43. package/skills/cavuno-board-salaries/SKILL.md +87 -0
  44. package/skills/cavuno-board-seo/SKILL.md +180 -0
  45. package/skills/cavuno-board-setup/SKILL.md +26 -2
  46. package/skills/cavuno-board-sitemap/SKILL.md +147 -0
  47. package/skills/cavuno-board-smoke-test/SKILL.md +84 -0
  48. package/skills/cavuno-board-theme/SKILL.md +69 -0
  49. package/skills/manifest.json +106 -1
@@ -0,0 +1,108 @@
1
+ ---
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).
4
+ ---
5
+
6
+ # Candidate paywall: offers, checkout, grant
7
+
8
+ The candidate-facing money flow (doc 36 / ADR-0056): anonymous pricing, gated job lists, and an embedded Stripe checkout that ends in an access grant.
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.
11
+
12
+ ## When to use
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).
24
+
25
+ ```ts snippet
26
+ const { data: offers } = await board.paywall.offers();
27
+ for (const offer of offers) {
28
+ offer.offerKey; // e.g. 'monthly' — what you post to checkout
29
+ offer.label;
30
+ offer.billingLabel;
31
+ offer.amountCents;
32
+ offer.currency;
33
+ offer.offerType; // 'recurring' | 'lifetime'
34
+ offer.isDefault; // pre-select this tier
35
+ }
36
+ ```
37
+
38
+ ## The gated browse: gatedCount
39
+
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:
41
+
42
+ ```ts snippet
43
+ 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
46
+ }
47
+ ```
48
+
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
52
+
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}`.
54
+
55
+ ```ts snippet
56
+ import { loadStripe } from '@stripe/stripe-js'; // host-app dependency
57
+
58
+ const kit = await board.me.access.checkout({
59
+ offerKey: 'monthly', // the chosen PaywallOffer.offerKey
60
+ returnPath: '/account/access', // relative path Stripe returns to
61
+ colorMode: 'light',
62
+ });
63
+
64
+ // Host-owned mounting — stripeAccount is required for connected-account embeds:
65
+ const stripe = await loadStripe(kit.publishableKey, {
66
+ stripeAccount: kit.stripeAccountId,
67
+ });
68
+ ```
69
+
70
+ Duplicate POSTs are benign (a second session simply expires unpaid) — no idempotency machinery needed.
71
+
72
+ ## Completion: poll the session, confirm the grant
73
+
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):
75
+
76
+ ```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;
88
+ ```
89
+
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`.
91
+
92
+ ## Manage subscription: portal
93
+
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:
95
+
96
+ ```ts snippet
97
+ 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
100
+ }
101
+ ```
102
+
103
+ ## Verify
104
+
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.
@@ -0,0 +1,87 @@
1
+ ---
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.
4
+ ---
5
+
6
+ # Salary pages
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.
9
+
10
+ ## When to use
11
+
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.
15
+
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`.
28
+
29
+ ```ts snippet
30
+ 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"
35
+ }
36
+ const skills = await board.salaries.skills.list();
37
+ const companies = await board.salaries.companies.list(); // ranked by sample size
38
+ ```
39
+
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.
41
+
42
+ ## Detail pages and the slug contract
43
+
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).
45
+
46
+ ```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
53
+
54
+ const skill = await board.salaries.skills.retrieve('python');
55
+ const place = await board.salaries.locations.retrieve('berlin');
56
+ ```
57
+
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.
59
+
60
+ ## Cross-axis pages
61
+
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)`.
63
+
64
+ ```ts snippet
65
+ const idx = await board.salaries.titles.locations('software-engineer');
66
+ idx.locations; // SalaryLocation[] — flattened parentSlug tree
67
+
68
+ const berlin = await board.salaries.titles.location('software-engineer', 'berlin');
69
+ berlin.categoryCanonicalSlug; // 4 slugs: category/location × source/canonical
70
+ berlin.locationCanonicalSlug;
71
+ berlin.overallSalary; // p25Min/p75Max nullable here
72
+
73
+ const inBerlin = await board.salaries.locations.titles('berlin');
74
+ const skillsInBerlin = await board.salaries.locations.skills('berlin');
75
+ ```
76
+
77
+ ## Never recompute salary math
78
+
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 (averages, medians, p25/p75 bands, per-seniority splits) is done server-side against the full sample. Client-side arithmetic will disagree with the hosted board and other consumers.
80
+
81
+ ## Verify
82
+
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`.
@@ -0,0 +1,180 @@
1
+ ---
2
+ name: cavuno-board-seo
3
+ description: Structured data + head builders for board frontends with @cavuno/board/seo — Google for Jobs JobPosting JSON-LD, breadcrumbs, blog Article/ProfilePage, salary-page Occupation/FAQ structured data, and listing <head> descriptors. Use when building a job-detail, listing, blog, or salary page and it needs JSON-LD or SEO meta tags, or whenever "structured data", "rich results", or "Google for Jobs" comes up.
4
+ ---
5
+
6
+ # SEO: structured data + head builders
7
+
8
+ `@cavuno/board/seo` emits the exact structured data the hosted board emits —
9
+ golden-tested against the hosted builders, so a tenant frontend gets Google
10
+ for Jobs, breadcrumb, blog, and salary rich results without reinventing (or
11
+ drifting from) the platform's rules. Render every builder's output into a
12
+ `<script type="application/ld+json">` tag; builders return `null` when there
13
+ is nothing worth emitting — skip the tag then.
14
+
15
+ ## When to use
16
+
17
+ - A job detail page needs Google for Jobs `JobPosting` JSON-LD.
18
+ - Any page needs a `BreadcrumbList`.
19
+ - Blog post / author pages need `Article` / `ProfilePage` JSON-LD.
20
+ - Salary pages need `Occupation`/`MonetaryAmountDistribution`/`ItemList`/
21
+ `FAQPage` structured data or the templated salary FAQ.
22
+ - A jobs-listing page needs its `<head>` (title with count, canonical, OG).
23
+
24
+ ## When not to use
25
+
26
+ - Display text on the page itself — `cavuno-board-format`.
27
+ - Sitemaps and robots.txt — the sitemap module/skill.
28
+ - OG image generation and route maps — app-owned (see out of scope).
29
+
30
+ ## Job-posting JSON-LD (the detail page)
31
+
32
+ `PublicJob` carries every field this needs — including the derived remote
33
+ permit codes. Worldwide-remote jobs automatically enumerate all 249
34
+ countries (Google requires a location signal next to TELECOMMUTE).
35
+
36
+ ```ts snippet
37
+ import { createJobPostingJsonLd } from '@cavuno/board/seo';
38
+
39
+ const { name, logoUrl } = await board.context();
40
+ const job = await board.jobs.retrieve('senior-chef');
41
+
42
+ const jsonLd = createJobPostingJsonLd({
43
+ job,
44
+ board: { name, logoUrl },
45
+ shareUrl: `https://jobs.example.com/companies/${job.company?.slug}/jobs/${job.slug}`,
46
+ });
47
+ // null when nothing renderable remains — omit the <script> tag then.
48
+ ```
49
+
50
+ ## Breadcrumbs
51
+
52
+ Hosted semantics: labels are trimmed, blank crumbs dropped, fewer than two
53
+ crumbs → `null`, and the current page (no `href`) omits `item`. Pass
54
+ absolute hrefs, or give `origin` and use paths:
55
+
56
+ ```ts snippet
57
+ import { createBreadcrumbJsonLd } from '@cavuno/board/seo';
58
+
59
+ const trail = createBreadcrumbJsonLd(
60
+ [
61
+ { label: 'Home', href: '/' },
62
+ { label: 'Jobs', href: '/jobs' },
63
+ { label: job.title }, // current page — no href
64
+ ],
65
+ { origin: 'https://jobs.example.com' },
66
+ );
67
+ ```
68
+
69
+ ## Blog Article + author ProfilePage
70
+
71
+ ```ts snippet
72
+ import {
73
+ createBlogArticleJsonLd,
74
+ createAuthorProfileJsonLd,
75
+ } from '@cavuno/board/seo';
76
+
77
+ const post = await board.blog.posts.retrieve('hello-world');
78
+ const article = createBlogArticleJsonLd({
79
+ post,
80
+ boardName: name,
81
+ permalink: `https://jobs.example.com/blog/${post.slug}`,
82
+ ogImageUrl: `https://jobs.example.com/blog/${post.slug}/og`, // fallback when no cover
83
+ });
84
+
85
+ const author = await board.blog.authors.retrieve('jane');
86
+ const { data: authorPosts } = await board.blog.posts.list({ authorSlug: 'jane' });
87
+ const profile = createAuthorProfileJsonLd({
88
+ author,
89
+ canonical: `https://jobs.example.com/blog/author/${author.slug}`,
90
+ description: author.bio ?? `Posts by ${author.name}`,
91
+ origin: 'https://jobs.example.com',
92
+ posts: authorPosts, // newest first; the builder keeps 5 as hasPart
93
+ totalPosts: authorPosts.length,
94
+ });
95
+ ```
96
+
97
+ ## Salary-page structured data + FAQ
98
+
99
+ The board language is a REQUIRED leading parameter wherever display strings
100
+ are produced (no `en` default). The money formatters are USD-hardcoded —
101
+ the hosted salary-page quirk, kept deliberately; the locale drives number
102
+ formatting only. The FAQ ships TEMPLATED English sentences (board-custom
103
+ FAQ copy is a tracked API gap and will supersede it).
104
+
105
+ ```ts snippet
106
+ import {
107
+ titleSalaryJsonLd,
108
+ buildSalaryFaq,
109
+ faqJsonLd,
110
+ itemListJsonLd,
111
+ formatRange,
112
+ sortBySeniority,
113
+ } from '@cavuno/board/seo';
114
+
115
+ const { language } = await board.context();
116
+ const detail = await board.salaries.titles.retrieve('software-engineer');
117
+
118
+ const occupation = titleSalaryJsonLd(language, detail);
119
+ const faq = faqJsonLd(
120
+ buildSalaryFaq(language, detail.categoryName, detail.overallSalary),
121
+ );
122
+ // Index pages: itemListJsonLd(items); on-page figures: formatRange(language, min, max)
123
+ // and sortBySeniority(detail.bySeniority) for the ladder table.
124
+ ```
125
+
126
+ Also available: `skillSalaryJsonLd(detail)`, `locationSalaryJsonLd(detail)`
127
+ (city-level pages only), `crossAxisSalaryJsonLd(locale, args)`,
128
+ `companySalaryJsonLd(locale, detail)`,
129
+ `companyCategorySalaryJsonLd(locale, detail)`, `formatUsd(locale, value)`,
130
+ `formatSeniority(locale, key)`, `SENIORITY_ORDER`.
131
+
132
+ ## Listing pages: head + structural JSON-LD
133
+
134
+ Locale-neutral pass-through — you supply the heading copy:
135
+
136
+ ```ts snippet
137
+ import { listingHead, listingJsonLd } from '@cavuno/board/seo';
138
+
139
+ const head = listingHead({
140
+ boardName: name,
141
+ origin: 'https://jobs.example.com',
142
+ path: '/jobs/robotics',
143
+ heading: 'Robotics jobs',
144
+ count: 42, // → "42 Robotics jobs | Acme Jobs"
145
+ });
146
+ // head.meta → title/description/OG descriptors; head.links → canonical.
147
+
148
+ const objects = listingJsonLd({
149
+ origin: 'https://jobs.example.com',
150
+ breadcrumbs: [{ name: 'Home', path: '/' }, { name: 'Jobs' }],
151
+ jobs: cards.map((c) => ({ slug: c.slug, company: c.company })),
152
+ });
153
+ ```
154
+
155
+ ## Anti-patterns
156
+
157
+ ```ts no-check
158
+ // NEVER hand-roll JobPosting JSON-LD — the pruning, salary mirroring, and
159
+ // worldwide country enumeration are Google-spec rules the SDK encodes:
160
+ const ld = { '@type': 'JobPosting', salary: job.salaryMin }; // wrong shape
161
+ // NEVER default the locale on salary builders:
162
+ titleSalaryJsonLd('en', detail); // hardcoded 'en' on a de board
163
+ // NEVER emit a JSON-LD script for a null builder result.
164
+ ```
165
+
166
+ ## Out of scope — do not invent exports
167
+
168
+ OG **image generation** (the `/og` card renderer), the legal/route **map**,
169
+ and the meta title/description **copy** (board SEO config + localized copy
170
+ is not replicated — `listingHead` takes your strings) are app-owned. No
171
+ sitemap builders here — that is `@cavuno/board/sitemap`'s job.
172
+
173
+ ## Verify
174
+
175
+ - [ ] The job detail page's JSON-LD validates in Google's Rich Results test
176
+ as a JobPosting, and a worldwide-remote job lists 249 countries.
177
+ - [ ] Pages with a null builder result render NO empty ld+json script.
178
+ - [ ] A de board passes `language` from `board.context()` — the salary
179
+ FAQ figures render de-formatted, and no call site hardcodes 'en'.
180
+ - [ ] Breadcrumb JSON-LD only appears on pages with 2+ crumbs.
@@ -68,7 +68,7 @@ export const board = createBoardClient({
68
68
 
69
69
  ## Build jobs browsing + detail
70
70
 
71
- The core surface. `jobs.list` / `jobs.search` for listing pages, `jobs.retrieve` for the detail page, `jobs.similar` for the related rail. Honor storefront pagination and the candidate-paywall `gatedCount`. See `cavuno-board-jobs`.
71
+ The core surface. `jobs.list` / `jobs.search` for listing pages, `jobs.retrieve` for the detail page, `jobs.similar` for the related rail. Honor storefront pagination and the candidate-paywall `gatedCount`; use `paginate()` for full-catalog walks (sitemaps, feeds). See `cavuno-board-jobs`.
72
72
 
73
73
  ## Add board users + saved jobs
74
74
 
@@ -78,6 +78,30 @@ Register/login/refresh/logout, then `me.retrieve` and `me.savedJobs.*`. There is
78
78
 
79
79
  Every method throws `BoardApiError` on a non-2xx; branch with the typed guards. Password-protected boards need a `password.verify()` grant. See `cavuno-board-errors`.
80
80
 
81
+ ## Style, format, and filter with the helper subpaths
82
+
83
+ Three subpath modules carry the display layer — use them instead of
84
+ hand-rolling (each has a skill):
85
+
86
+ - Salary/date/label formatting in the board language → `cavuno-board-format`
87
+ - Listing-filter vocabulary + URL param parsing → `cavuno-board-filters`
88
+ - Board theme → shadcn CSS variables + fonts → `cavuno-board-theme`
89
+
90
+ ## Build the remaining surfaces per feature flag
91
+
92
+ One focused skill per surface — delegate by name, and build a surface only
93
+ when its `features` flag (from `board.context()`) is enabled:
94
+
95
+ - Companies directory, markets, per-company jobs → `cavuno-board-companies`
96
+ - Blog (posts, tags, authors, search) → `cavuno-board-blog`
97
+ - Programmatic salary pages → `cavuno-board-salaries`
98
+ - Job alerts (anonymous double-opt-in + account CRUD) → `cavuno-board-job-alerts`
99
+ - Candidate account self-service (profile, resume, avatar, prefs) → `cavuno-board-account`
100
+ - Apply flow + my applications + saved jobs → `cavuno-board-applications`
101
+ - Candidate↔employer messaging (polled REST) → `cavuno-board-messaging`
102
+ - Candidate job-access paywall (offers, checkout, grant) → `cavuno-board-paywall`
103
+ - Public job-posting funnel (plans, submit, billing) → `cavuno-board-job-posting`
104
+
81
105
  ## App-owned concerns (out of scope)
82
106
 
83
107
  The SDK serves data only. Your app owns: page layout and chrome copy, marketing/legal prose, and the authoring of SEO artifacts (sitemap, RSS, OG images) — built from API data, not served as documents. The Board API never returns layouts or page-builder JSON.
@@ -89,7 +113,7 @@ The SDK serves data only. Your app owns: page layout and chrome copy, marketing/
89
113
  - A detail page renders from `board.jobs.retrieve(slug)`.
90
114
  - An invalid slug surfaces a handled `isNotFound(err)` path, not a crash.
91
115
 
92
- When the `cavuno-board-smoke-test` skill is present, run it against your `pk_…` to verify end to end.
116
+ Then run the `cavuno-board-smoke-test` skill against your `pk_…` type checks are not enough for board wiring.
93
117
 
94
118
  ## Stop conditions
95
119
 
@@ -0,0 +1,147 @@
1
+ ---
2
+ name: cavuno-board-sitemap
3
+ description: Build the board's sitemap with @cavuno/board/sitemap — the hosted 8-bucket model (marketing, jobs taxonomies, job details, companies, salaries, blog), 45k chunking, XML rendering, and the buildBucketUrls walker that enumerates a board's content through the BoardSdk with the hosted SEO rules built in (feature gating, ≥5-job thin-content floor, pagination backstops). Use when adding /sitemap.xml and /sitemap/:file routes to a custom board frontend.
4
+ ---
5
+
6
+ # Sitemap: the 8-bucket model
7
+
8
+ `@cavuno/board/sitemap` ships the hosted board's sitemap architecture: a
9
+ sitemap INDEX at `/sitemap.xml` pointing at one file per content bucket,
10
+ each an ordinary `<urlset>`. The XML byte layout and the bucket rules are
11
+ golden-tested against the hosted implementation, so a custom frontend's
12
+ sitemap corpus lines up bucket-for-bucket with what cavuno.com would emit.
13
+
14
+ Two tiers, use both:
15
+
16
+ - **XML primitives** (pure, no I/O): `SITEMAP_BUCKETS`,
17
+ `SITEMAP_CHUNK_SIZE` (45,000), `chunk`, `bucketFilename` /
18
+ `parseBucketFilename`, `renderUrlset`, `renderSitemapIndex`.
19
+ - **The walker** (opinionated, injected I/O): `listedBuckets(board)` and
20
+ `buildBucketUrls(board, origin, bucket)` take your `BoardSdk` instance
21
+ and return plain URL strings with the hosted rules applied — you never
22
+ re-derive the SEO policy.
23
+
24
+ ## When to use
25
+
26
+ - Adding `/sitemap.xml` + `/sitemap/:file` routes to a board frontend.
27
+ - Deciding which listing pages deserve indexing (the walker already does).
28
+
29
+ ## When not to use
30
+
31
+ - Structured data / meta tags — `cavuno-board-seo`.
32
+ - Feeds (RSS/Atom) — app-owned.
33
+
34
+ ## The two routes
35
+
36
+ The index lists one entry per bucket; each bucket route parses its filename
37
+ back to a bucket + chunk and renders a urlset. Empty buckets render a valid
38
+ empty urlset — only `blog` is dropped from the index when the feature is
39
+ off (`listedBuckets` handles that).
40
+
41
+ ```ts snippet
42
+ import {
43
+ SITEMAP_CHUNK_SIZE,
44
+ buildBucketUrls,
45
+ bucketFilename,
46
+ chunk,
47
+ listedBuckets,
48
+ parseBucketFilename,
49
+ renderSitemapIndex,
50
+ renderUrlset,
51
+ } from '@cavuno/board/sitemap';
52
+
53
+ // GET /sitemap.xml — one <sitemap> per listed bucket (plus -2, -3… chunks).
54
+ const origin = 'https://jobs.example.com';
55
+ const buckets = await listedBuckets(board);
56
+ const locs: string[] = [];
57
+ for (const bucket of buckets) {
58
+ const urls = await buildBucketUrls(board, origin, bucket);
59
+ const chunks = chunk(urls, SITEMAP_CHUNK_SIZE);
60
+ for (let i = 0; i < Math.max(chunks.length, 1); i += 1) {
61
+ locs.push(`${origin}/sitemap/${bucketFilename(bucket, i)}`);
62
+ }
63
+ }
64
+ return xmlResponse(renderSitemapIndex(locs));
65
+ ```
66
+
67
+ ```ts snippet
68
+ // GET /sitemap/:file — parse, enumerate, slice, render. Unknown file → 404.
69
+ const parsed = parseBucketFilename(params.file);
70
+ if (!parsed) return notFound();
71
+ const urls = await buildBucketUrls(board, origin, parsed.bucket);
72
+ const page = chunk(urls, SITEMAP_CHUNK_SIZE)[parsed.chunkIndex] ?? [];
73
+ return xmlResponse(renderUrlset(page));
74
+ ```
75
+
76
+ `renderUrlset` also accepts `{ url, lastModified?, images? }` entries when
77
+ you have per-URL dates (a `Date` serializes to ISO 8601). `changefreq` and
78
+ `priority` are deliberately unsupported — the hosted board never emits them.
79
+
80
+ ## robots.txt
81
+
82
+ Point crawlers at the index — one line, at the site root:
83
+
84
+ ```txt
85
+ Sitemap: https://jobs.example.com/sitemap.xml
86
+ ```
87
+
88
+ ## The rules the walker enforces (don't re-implement)
89
+
90
+ - **Thin-content floor**: a category/skill/location listing page is emitted
91
+ only with ≥5 distinct jobs (`MIN_JOBS_PER_INDEXED_PAGE`) — below that,
92
+ the page is thin content that wastes crawl budget.
93
+ - **Feature gating**: `/impressum`, `/talent`, `/employers` appear in the
94
+ marketing bucket only when `context().features` enables them; the blog
95
+ bucket exists only when `features.blog` is on.
96
+ - **Board language**: salary-index reads pass `context().language`, so a
97
+ non-English board emits board-language canonical salary slugs (jobs,
98
+ companies, and blog slugs arrive canonical on the wire already). No
99
+ locale parameter to thread.
100
+ - **Pagination backstops**: offset enumeration runs in parallel when the
101
+ envelope carries `count` (capped at the API's 10,000-offset window);
102
+ cursor walks cap at 200 pages. Both warn on truncation instead of
103
+ hanging a build.
104
+
105
+ ## Stable order caveat (cursor walks)
106
+
107
+ Cursor and offset enumeration order can shift between requests on a large,
108
+ churning board — two chunk requests may see slightly different orderings.
109
+ That is harmless for a sitemap (URLs dedupe and the ≥5 counts are a
110
+ heuristic), but do NOT reuse the walker as a general "export all jobs in
111
+ order" tool; for stable ordering, pass an explicit sort/query to
112
+ `board.jobs.list` yourself.
113
+
114
+ ## Named exclusions (v1 API gap — not bugs)
115
+
116
+ Two URL families the HOSTED sitemap emits are deliberately absent, because
117
+ v1 exposes them only per-slug and a bulk-pairs endpoint doesn't exist yet
118
+ (per-slug N+1 is ~1k+ calls per build):
119
+
120
+ - Cross-axis salary pages (title×location, skill×location,
121
+ company×category, and the per-entity `/locations` · `/titles` ·
122
+ `/skills` salary index pages).
123
+ - Jobs place×category / place×skill combination listings.
124
+
125
+ Both stay reachable through internal links. When the bulk endpoint lands,
126
+ the walker picks them up additively — don't hand-enumerate them.
127
+
128
+ ## Anti-patterns
129
+
130
+ ```ts snippet
131
+ // NEVER emit every taxonomy page unconditionally — the ≥5-job floor exists
132
+ // to keep thin pages out of the index:
133
+ for (const c of allCategories) urls.push(`${origin}/jobs/${c.slug}`); // wrong
134
+ // NEVER hand-build the XML with a template string per route — use
135
+ // renderUrlset/renderSitemapIndex (escaping + byte-parity with hosted).
136
+ // NEVER fetch every salary detail page to discover cross-axis pairs (N+1).
137
+ ```
138
+
139
+ ## Verify
140
+
141
+ - [ ] `/sitemap.xml` lists one file per bucket (no `blog` entry when the
142
+ feature is off) and every listed file returns valid XML.
143
+ - [ ] A category with 4 jobs is absent from `jobs-categories.xml`; one with
144
+ 5 is present.
145
+ - [ ] `jobs-details-2.xml` style chunk names round-trip through
146
+ `parseBucketFilename` and unknown filenames 404.
147
+ - [ ] robots.txt points at `/sitemap.xml`.
@@ -0,0 +1,84 @@
1
+ ---
2
+ name: cavuno-board-smoke-test
3
+ description: Runtime verification for a wired Cavuno board frontend — literal curl probes against the Board API plus per-surface behavioral checks and a mandatory production-build pass. Run after cavuno-board-setup finishes, after upgrading @cavuno/board, or whenever board wiring is suspect. Type checks are not enough for board wiring.
4
+ ---
5
+
6
+ # Smoke-testing a Cavuno board frontend
7
+
8
+ Type checks prove the code compiles against the SDK; they do not prove the
9
+ app reaches the right board with the right credentials, or that gating,
10
+ auth, and SEO surfaces behave. Verify at runtime, in this order.
11
+
12
+ ## When to use
13
+
14
+ - Right after `cavuno-board-setup` completes.
15
+ - After upgrading `@cavuno/board` or rotating the `pk_…` key.
16
+ - When any surface renders empty and you don't know which layer is wrong.
17
+
18
+ ## 1 — Probe the API directly (before blaming app code)
19
+
20
+ Use the real env values the app reads (`PUBLIC_CAVUNO_API_URL`,
21
+ `PUBLIC_CAVUNO_BOARD`). Expected outputs are exact.
22
+
23
+ ```bash
24
+ # Board context: MUST return JSON with "object": "public_board" and your
25
+ # board's name — not an HTML error page, not {"error":{"code":"boards_not_found"}}.
26
+ curl -s "$PUBLIC_CAVUNO_API_URL/v1/boards/$PUBLIC_CAVUNO_BOARD" | head -c 300
27
+
28
+ # Jobs list: MUST return {"object":"list", ... "data":[...]}. An empty data
29
+ # array on a board you know has jobs means the wrong board identifier.
30
+ curl -s "$PUBLIC_CAVUNO_API_URL/v1/boards/$PUBLIC_CAVUNO_BOARD/jobs?limit=2" | head -c 300
31
+
32
+ # Error envelope: a bogus job slug MUST return the v1 error shape with
33
+ # "code":"jobs_not_found" — anything else means a proxy is rewriting responses.
34
+ curl -s "$PUBLIC_CAVUNO_API_URL/v1/boards/$PUBLIC_CAVUNO_BOARD/jobs/definitely-not-a-job"
35
+ ```
36
+
37
+ On a password-protected board, every content read above returns 401 with
38
+ `"code":"board_password_required"` — that is correct behavior, not a
39
+ failure; verify the grant flow in step 3 instead.
40
+
41
+ ## 2 — Verify the app serves board data
42
+
43
+ Start the app (dev is fine for this step) and probe its own routes:
44
+
45
+ - The home/shell renders the board name and logo from `board.context()`.
46
+ - A listing page shows real job cards; a card links to
47
+ `/companies/:companySlug/jobs/:jobSlug` (never `/jobs/:slug`).
48
+ - A fabricated job URL renders the app's not-found state — a handled
49
+ `isNotFound` branch, not a crash or blank page.
50
+
51
+ ## 3 — Per-surface behavioral checks (only for surfaces you built)
52
+
53
+ - **Auth**: register → the verification gate appears; login → an authed
54
+ read (`me.retrieve`) succeeds; after `auth.logout()` the same read fails
55
+ with a 401 that the app handles by signing out, not looping.
56
+ - **Password gate**: with protection enabled, an anonymous content read
57
+ redirects to the password page; `password.verify()` + retry renders
58
+ content; a wrong password shows `board_password_invalid` messaging.
59
+ - **Saved jobs / apply**: save → reload → still saved (server state, not
60
+ local); a second apply to the same job returns the same application
61
+ (idempotent), not a duplicate.
62
+ - **Paywall** (gated boards): anonymous list shows the `gatedCount` upsell;
63
+ an entitled login makes the same URL return the ungated view.
64
+ - **Alerts**: subscribe → `status: "created"`; repeat → `"duplicate"`.
65
+
66
+ ## 4 — Production build (mandatory)
67
+
68
+ Dev servers mask wiring bugs (looser env loading, dev-only middleware, no
69
+ tree-shake). Run the app's real build, boot the production output, and
70
+ repeat step 2 against it. `import 'server-only'`-style boundaries and env
71
+ prefixes (`VITE_`, `NEXT_PUBLIC_`) frequently pass dev and fail prod.
72
+
73
+ ## Out of scope — do not invent checks
74
+
75
+ No load testing, no Lighthouse/SEO scoring, no visual regression — those
76
+ are app-owned. This skill verifies board wiring only.
77
+
78
+ ## Stop conditions
79
+
80
+ Stop and report to the human when: step 1 fails (the problem is
81
+ credentials/network, not code — do not "fix" app code to compensate); the
82
+ API returns codes the app has no branch for; or a check needs a credential
83
+ you don't have (board password, test account). Never commit real
84
+ credentials while fixing findings.