@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
package/dist/theme.mjs ADDED
@@ -0,0 +1,128 @@
1
+ // src/theme/index.ts
2
+ var BOARD_COLOR_KEYS = [
3
+ "brandColor",
4
+ "buttonPrimary",
5
+ "buttonPrimaryText",
6
+ "buttonDanger",
7
+ "background",
8
+ "surfaceBackground",
9
+ "mutedBackground",
10
+ "highlightBackground",
11
+ "text",
12
+ "textSubtle",
13
+ "textMuted",
14
+ "textDisabled",
15
+ "textError",
16
+ "border",
17
+ "contrastBackground",
18
+ "contrastText"
19
+ ];
20
+ function color(colors, key) {
21
+ const value = colors[key];
22
+ return typeof value === "string" && value ? value : void 0;
23
+ }
24
+ function tokenLines(colors) {
25
+ const lines = [];
26
+ const set = (token, value) => {
27
+ if (value) lines.push(` --${token}: ${value};`);
28
+ };
29
+ set("background", color(colors, "background"));
30
+ set("foreground", color(colors, "text"));
31
+ set("card", color(colors, "surfaceBackground"));
32
+ set("card-foreground", color(colors, "text"));
33
+ set("popover", color(colors, "surfaceBackground"));
34
+ set("popover-foreground", color(colors, "text"));
35
+ set("primary", color(colors, "buttonPrimary"));
36
+ set("primary-foreground", color(colors, "buttonPrimaryText"));
37
+ set("secondary", color(colors, "mutedBackground"));
38
+ set("secondary-foreground", color(colors, "text"));
39
+ set("muted", color(colors, "mutedBackground"));
40
+ set("muted-foreground", color(colors, "textMuted"));
41
+ set("accent", color(colors, "highlightBackground"));
42
+ set("accent-foreground", color(colors, "text"));
43
+ set(
44
+ "destructive",
45
+ color(colors, "buttonDanger") ?? color(colors, "textError")
46
+ );
47
+ set("border", color(colors, "border"));
48
+ set("input", color(colors, "border"));
49
+ set("ring", color(colors, "brandColor") ?? color(colors, "buttonPrimary"));
50
+ set("contrast-background", color(colors, "contrastBackground"));
51
+ set("contrast-foreground", color(colors, "contrastText"));
52
+ set("foreground-subtle", color(colors, "textSubtle"));
53
+ set("foreground-disabled", color(colors, "textDisabled"));
54
+ set("foreground-error", color(colors, "textError"));
55
+ return lines;
56
+ }
57
+ function boardThemeToCss(theme) {
58
+ if (!theme) return "";
59
+ const light = tokenLines(theme.colors.light ?? {});
60
+ const dark = tokenLines(theme.colors.dark ?? {});
61
+ const fontSans = theme.typography?.fontSans;
62
+ if (fontSans) {
63
+ light.push(
64
+ ` --font-sans: '${themeFontFamily(fontSans)}', ui-sans-serif, system-ui, sans-serif;`
65
+ );
66
+ }
67
+ const blocks = [];
68
+ if (light.length > 0) blocks.push(`:root {
69
+ ${light.join("\n")}
70
+ }`);
71
+ if (dark.length > 0) blocks.push(`.dark {
72
+ ${dark.join("\n")}
73
+ }`);
74
+ return blocks.join("\n\n");
75
+ }
76
+ function themeMode(theme) {
77
+ if (!theme) return "system";
78
+ return theme.mode === "light" || theme.mode === "dark" ? theme.mode : "system";
79
+ }
80
+ var THEME_FONT_GOOGLE_FAMILIES = {
81
+ "be-vietnam-pro": "Be+Vietnam+Pro",
82
+ inter: "Inter",
83
+ "plus-jakarta-sans": "Plus+Jakarta+Sans",
84
+ "dm-sans": "DM+Sans",
85
+ "public-sans": "Public+Sans",
86
+ figtree: "Figtree",
87
+ "work-sans": "Work+Sans",
88
+ "open-sans": "Open+Sans",
89
+ poppins: "Poppins",
90
+ hind: "Hind",
91
+ lexend: "Lexend",
92
+ "fira-sans": "Fira+Sans",
93
+ manrope: "Manrope",
94
+ "source-sans-pro": "Source+Sans+3",
95
+ outfit: "Outfit",
96
+ "space-grotesk": "Space+Grotesk",
97
+ geist: "Geist",
98
+ "source-serif-4": "Source+Serif+4",
99
+ lora: "Lora",
100
+ "crimson-pro": "Crimson+Pro"
101
+ };
102
+ function googleFamily(fontKey) {
103
+ return THEME_FONT_GOOGLE_FAMILIES[fontKey] ?? encodeURIComponent(fontKey).replaceAll("%20", "+");
104
+ }
105
+ function themeFontFamily(fontKey) {
106
+ return googleFamily(fontKey).replaceAll("+", " ");
107
+ }
108
+ function googleFontsUrl(theme) {
109
+ if (!theme) return null;
110
+ const families = /* @__PURE__ */ new Set();
111
+ if (theme.typography?.fontSans) {
112
+ families.add(googleFamily(theme.typography.fontSans));
113
+ }
114
+ if (theme.typography?.fontHeading) {
115
+ families.add(googleFamily(theme.typography.fontHeading));
116
+ }
117
+ if (families.size === 0) return null;
118
+ const params = [...families].map((f) => `family=${f}:wght@400;500;600;700`).join("&");
119
+ return `https://fonts.googleapis.com/css2?${params}&display=swap`;
120
+ }
121
+ export {
122
+ BOARD_COLOR_KEYS,
123
+ THEME_FONT_GOOGLE_FAMILIES,
124
+ boardThemeToCss,
125
+ googleFontsUrl,
126
+ themeFontFamily,
127
+ themeMode
128
+ };
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@cavuno/board",
3
- "version": "1.25.0",
3
+ "version": "1.27.0",
4
4
  "description": "Typed isomorphic client for the Cavuno Board API",
5
5
  "license": "MIT",
6
+ "type": "commonjs",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/wollemiahq/cavuno.git",
@@ -29,6 +30,56 @@
29
30
  "default": "./dist/index.js"
30
31
  }
31
32
  },
33
+ "./format": {
34
+ "import": {
35
+ "types": "./dist/format.d.mts",
36
+ "default": "./dist/format.mjs"
37
+ },
38
+ "require": {
39
+ "types": "./dist/format.d.ts",
40
+ "default": "./dist/format.js"
41
+ }
42
+ },
43
+ "./filters": {
44
+ "import": {
45
+ "types": "./dist/filters.d.mts",
46
+ "default": "./dist/filters.mjs"
47
+ },
48
+ "require": {
49
+ "types": "./dist/filters.d.ts",
50
+ "default": "./dist/filters.js"
51
+ }
52
+ },
53
+ "./theme": {
54
+ "import": {
55
+ "types": "./dist/theme.d.mts",
56
+ "default": "./dist/theme.mjs"
57
+ },
58
+ "require": {
59
+ "types": "./dist/theme.d.ts",
60
+ "default": "./dist/theme.js"
61
+ }
62
+ },
63
+ "./seo": {
64
+ "import": {
65
+ "types": "./dist/seo.d.mts",
66
+ "default": "./dist/seo.mjs"
67
+ },
68
+ "require": {
69
+ "types": "./dist/seo.d.ts",
70
+ "default": "./dist/seo.js"
71
+ }
72
+ },
73
+ "./sitemap": {
74
+ "import": {
75
+ "types": "./dist/sitemap.d.mts",
76
+ "default": "./dist/sitemap.mjs"
77
+ },
78
+ "require": {
79
+ "types": "./dist/sitemap.d.ts",
80
+ "default": "./dist/sitemap.js"
81
+ }
82
+ },
32
83
  "./skills": {
33
84
  "import": {
34
85
  "types": "./dist/skills.d.mts",
@@ -49,19 +100,23 @@
49
100
  "access": "public"
50
101
  },
51
102
  "scripts": {
52
- "build": "tsup",
103
+ "build": "rm -rf dist && tsup",
53
104
  "clean": "git clean -xdf .turbo dist node_modules src/generated",
54
105
  "typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.node.json",
55
106
  "test": "vitest run",
107
+ "check:publish": "publint --strict && attw --pack . --profile node16",
56
108
  "gen:types": "tsx scripts/gen-types.ts",
109
+ "check:types": "OPENAPI_URL=spec/openapi.snapshot.json tsx scripts/gen-types.ts && git diff --exit-code -- src/generated/openapi-types.ts",
57
110
  "gen:skills-manifest": "tsx scripts/generate-skills-manifest.ts",
58
111
  "assert-publish-target": "node -e \"const p=require('./package.json'); if(p.name!=='@cavuno/board'){throw new Error('Refusing to publish: package.json name is '+p.name+', expected @cavuno/board')}; if(p.private){throw new Error('Refusing to publish: package.json has private:true')}\"",
59
112
  "prepublishOnly": "pnpm run assert-publish-target && pnpm run gen:skills-manifest && pnpm run build"
60
113
  },
61
114
  "devDependencies": {
115
+ "@arethetypeswrong/cli": "^0.18.2",
62
116
  "@kit/tsconfig": "workspace:*",
63
117
  "@types/node": "catalog:",
64
118
  "openapi-typescript": "^7.6.0",
119
+ "publint": "^0.3.12",
65
120
  "tsup": "^8.4.0",
66
121
  "tsx": "^4.19.2",
67
122
  "vitest": "^2.1.9"
@@ -0,0 +1,147 @@
1
+ ---
2
+ name: cavuno-board-account
3
+ description: Candidate account self-service with the @cavuno/board SDK — me.retrieve/delete, the candidate profile (merge-patch update, handle availability), experience/education CRUD, skills/languages full-replace, avatar upload, resume upload with async parse polling, and notification preferences plus the anonymous token unsubscribe. Use when building account, profile-edit, onboarding, or notification-settings pages for a signed-in board user.
4
+ ---
5
+
6
+ # Candidate account self-service
7
+
8
+ Everything the signed-in candidate manages about themselves lives under `board.me`. Every call here requires a board-user bearer token — in the browser the SDK reads it from `auth.storage` after login; on the server pass it per call via `options.headers` (see `cavuno-board-auth` for the httpOnly-cookie pattern). The one exception is the anonymous token unsubscribe at the end.
9
+
10
+ ## When to use
11
+
12
+ - Account page: show the signed-in user, delete the account.
13
+ - Profile editor: bio/headline/handle, experience, education, skills, languages, avatar.
14
+ - Resume-driven onboarding (upload → parse → auto-populated profile).
15
+ - Email notification settings + the unsubscribe link in emails.
16
+
17
+ ## When not to use
18
+
19
+ - Login/registration/tokens — `cavuno-board-auth`.
20
+ - Applications and saved jobs — `cavuno-board-applications`.
21
+ - The employer facet (`me.companies.*`), messaging (`me.conversations.*`), job alerts (`me.alerts.*`).
22
+
23
+ Out of scope — do not invent exports: the SDK ships no form components, file-picker UI, or upload widgets; the host app owns all UI and cookie plumbing.
24
+
25
+ ## Account: retrieve and delete
26
+
27
+ ```ts snippet
28
+ const me = await board.me.retrieve(); // the authenticated BoardUser
29
+ await board.me.delete(); // 204 — synchronous, irreversible cascade
30
+ ```
31
+
32
+ `delete` removes the account and all dependent data (profile, collections, saved jobs, alerts, avatar/resume files). Gate it behind an explicit confirmation.
33
+
34
+ ## Profile: retrieve, update, handle availability
35
+
36
+ The profile is a singleton. `update` is a merge-patch — omitted fields stay unchanged.
37
+
38
+ ```ts snippet
39
+ const profile = await board.me.profile.retrieve();
40
+ profile.handle; profile.headline; profile.jobSearchStatus;
41
+
42
+ await board.me.profile.update({
43
+ headline: 'Staff Engineer',
44
+ jobSearchStatus: 'open_to_offers',
45
+ profileVisibility: 'public',
46
+ });
47
+
48
+ const { available } = await board.me.profile.handleAvailable('jane');
49
+ ```
50
+
51
+ `handleAvailable` is advisory (my current handle counts as available); `update` re-checks on write.
52
+
53
+ ## Experience and education: per-entry CRUD
54
+
55
+ Both collections are id-keyed CRUD; updates are merge-patch. `createExperience` requires `title`, `companyName`, `startDate`; `createEducation` requires `institutionName`.
56
+
57
+ ```ts snippet
58
+ const { data: entries } = await board.me.profile.listExperience();
59
+ const entry = await board.me.profile.createExperience({
60
+ title: 'Staff Engineer',
61
+ companyName: 'Acme',
62
+ startDate: '2022-01',
63
+ });
64
+ await board.me.profile.updateExperience(entry.id, { endDate: '2025-06' });
65
+ await board.me.profile.deleteExperience(entry.id);
66
+ // Education mirrors this: listEducation / createEducation /
67
+ // updateEducation(id, body) / deleteEducation(id).
68
+ ```
69
+
70
+ ## Skills and languages: PUT full-replace
71
+
72
+ `updateSkills` / `updateLanguages` replace the **whole set** — there is no add/remove-one endpoint. Sending only the new item wipes the rest. Round-trip the current list:
73
+
74
+ ```ts snippet
75
+ const current = await board.me.profile.listSkills();
76
+ await board.me.profile.updateSkills({
77
+ skills: [...current.data.map((s) => s.name), 'TypeScript'], // ordered names
78
+ });
79
+
80
+ await board.me.profile.updateLanguages({
81
+ languages: [{ name: 'English', proficiency: 'native' }],
82
+ });
83
+ ```
84
+
85
+ Both return the full updated list envelope, so you can render straight from the response.
86
+
87
+ ## Avatar upload
88
+
89
+ Pass a `Blob`/`File` — the SDK builds the multipart `FormData` (field `file`). JPEG/PNG/WebP, ≤ 5 MB.
90
+
91
+ ```ts snippet
92
+ const { avatarUrl } = await board.me.profile.uploadAvatar(file);
93
+ ```
94
+
95
+ ## Resume: upload, then poll the parse
96
+
97
+ `upload` starts an **async parse** that auto-populates the profile. It returns the resume with `parseStatus: 'parsing'` — poll `retrieve()` until `parsed` or `failed`, then re-read `profile.*` for the populated fields. Options: `keepResumeOnFile`, `importMode: 'append_only' | 'replace_all'`, `confirmReplaceAll`.
98
+
99
+ ```ts snippet
100
+ let resume = await board.me.resume.upload(file, { keepResumeOnFile: true });
101
+ // Bounded poll — a stuck parse job must not hang the UI forever.
102
+ const MAX_POLLS = 30; // ~60s at 2s cadence
103
+ for (let i = 0; resume.parseStatus === 'parsing' && i < MAX_POLLS; i++) {
104
+ await new Promise((r) => setTimeout(r, 2000));
105
+ resume = await board.me.resume.retrieve();
106
+ }
107
+ if (resume.parseStatus === 'parsed') {
108
+ const profile = await board.me.profile.retrieve(); // auto-populated
109
+ } else if (resume.parseStatus === 'failed') {
110
+ resume.parseFailureReason; // surface it
111
+ } else {
112
+ // still 'parsing' after the window: show "taking longer than usual",
113
+ // keep the manual profile editor usable — never block on the parse.
114
+ }
115
+ ```
116
+
117
+ `resume.file` holds the stored file (`url` is a short-lived signed download URL). `parseStatus` is `null` when no parse has run. `board.me.resume.delete()` removes the stored file and withdraws keep-on-file consent — parsed profile fields stay.
118
+
119
+ ## Notification preferences + anonymous unsubscribe
120
+
121
+ Two channels today: `messageEmails` and `applicationEmails`. `update` toggles one and returns the full updated set.
122
+
123
+ ```ts snippet
124
+ const { data: prefs } = await board.me.notificationPreferences.retrieve();
125
+ await board.me.notificationPreferences.update({
126
+ channel: 'messageEmails',
127
+ subscribed: false,
128
+ });
129
+ ```
130
+
131
+ The one-click unsubscribe link in emails is **anonymous** — no session; the HMAC token is the authorization. All three fields come from the link's query string; resolves void (204):
132
+
133
+ ```ts snippet
134
+ await board.me.notificationPreferences.unsubscribeWithToken({
135
+ boardUserId,
136
+ channel: 'applicationEmails',
137
+ token,
138
+ });
139
+ ```
140
+
141
+ ## Verify
142
+
143
+ - [ ] A profile `update` followed by `retrieve` shows the new field values.
144
+ - [ ] Adding a skill preserves the existing ones (the full-replace round-trip is in place).
145
+ - [ ] After `uploadAvatar`, the returned `avatarUrl` renders.
146
+ - [ ] Resume upload reaches `parsed` (or surfaces `failed` + `parseFailureReason`), and the profile shows parsed data.
147
+ - [ ] The email unsubscribe link works in a logged-out browser.
@@ -0,0 +1,110 @@
1
+ ---
2
+ name: cavuno-board-applications
3
+ description: The native apply flow with the @cavuno/board SDK — jobs.apply (signed-in or guest), attaching a resume via jobs.uploadApplicationResume, the jobs.myApplication "have I applied?" check, managing submitted applications (me.applications list/retrieve/updateFacts/withdraw), and saved jobs (me.savedJobs). Use when building an apply form, an application-tracker page, or a save-job button.
4
+ ---
5
+
6
+ # Apply flow and application tracking
7
+
8
+ Apply lives on the job (`board.jobs.apply` — optional-auth); everything after submission lives under `board.me.applications` (bearer token required, see `cavuno-board-auth`). Saved jobs follow the same authenticated pattern.
9
+
10
+ ## When to use
11
+
12
+ - The apply form on a job page (native apply, signed-in or guest).
13
+ - Attaching a resume to an application.
14
+ - "My applications" tracker: list, detail, edit facts, withdraw.
15
+ - Save/unsave-job buttons and the saved-jobs page.
16
+
17
+ ## When not to use
18
+
19
+ - Jobs whose `applicationUrl` points off-board (external apply) — just link out.
20
+ - The employer's pipeline view of the same data (`me.companies.applicants.*`).
21
+ - Candidate profile/resume self-service — `cavuno-board-account`.
22
+
23
+ Out of scope — do not invent exports: the SDK provides no apply-form components, file-picker UI, or auth cookie plumbing — the host app owns those. There is also **no guest-claim method** on the SDK: after a guest signs up, their applications are associated via a server-driven magic-link email flow, not an SDK call.
24
+
25
+ ## Submit an application
26
+
27
+ `jobs.apply` is optional-auth on one URL. Signed in, name/email derive from the candidate profile — send at most a `coverNote`. A guest supplies `name` + `email` (allowed only when the board permits applying without sign-up; otherwise the call fails). Idempotent — a repeat apply returns the existing application.
28
+
29
+ ```ts snippet
30
+ // Signed-in candidate:
31
+ const application = await board.jobs.apply('senior-chef', {
32
+ coverNote: 'Excited to cook here.',
33
+ });
34
+
35
+ // Guest (board must allow applications without sign-up):
36
+ const guestApp = await board.jobs.apply('senior-chef', {
37
+ name: 'Ada Lovelace',
38
+ email: 'ada@example.com',
39
+ });
40
+ ```
41
+
42
+ ## Attach a resume
43
+
44
+ A separate multipart call — pass a `Blob`/`File`; the SDK builds the `FormData`. A signed-in candidate targets their own application for the job; a guest passes the `applicationId` returned by `apply`. Returns the updated application (`resumeFilename` set).
45
+
46
+ ```ts snippet
47
+ // Signed-in:
48
+ await board.jobs.uploadApplicationResume('senior-chef', file);
49
+ // Guest:
50
+ await board.jobs.uploadApplicationResume('senior-chef', file, {
51
+ applicationId: guestApp.id,
52
+ });
53
+ ```
54
+
55
+ ## "Have I applied?" — the apply-button state
56
+
57
+ `jobs.myApplication` returns the authenticated candidate's application for the job, and **throws a 404 `BoardApiError` when there is none** — branch with `isNotFound`:
58
+
59
+ ```ts snippet
60
+ import { isNotFound } from '@cavuno/board';
61
+
62
+ try {
63
+ const mine = await board.jobs.myApplication('senior-chef');
64
+ // already applied — show status instead of the apply button
65
+ } catch (err) {
66
+ if (!isNotFound(err)) throw err;
67
+ // not applied yet — show the apply form
68
+ }
69
+ ```
70
+
71
+ ## Application status — polled, not realtime
72
+
73
+ `Application.status` is a coarse candidate-facing state **derived from the employer's pipeline stage**: `'applied' | 'interviewing' | 'negotiation' | 'hired' | 'archived'`. It changes server-side as the employer moves the applicant — the surface is plain REST, so re-fetch to observe updates; nothing pushes to the client. `job` on the application is nullable (the job may have been removed).
74
+
75
+ ## Manage my applications
76
+
77
+ ```ts snippet
78
+ const { data, nextCursor } = await board.me.applications.list({ limit: 20 }); // newest first
79
+ const app = await board.me.applications.retrieve(applicationId);
80
+
81
+ // Merge-patch the candidate-facing facts — only while still editable:
82
+ await board.me.applications.updateFacts(applicationId, {
83
+ coverNote: 'Updated after our call.',
84
+ });
85
+
86
+ await board.me.applications.withdraw(applicationId); // 204 — permanently deletes
87
+ ```
88
+
89
+ `updateFacts` accepts `candidateName`, `candidateEmail`, `candidateHeadline`, `candidateLocation`, `coverNote` (all optional). Withdraw is permanent deletion, not a status flip — confirm before calling.
90
+
91
+ ## Saved jobs — the adjacent pattern
92
+
93
+ Same authenticated `me.*` shape. Each saved row embeds the full `PublicJob`, so the saved-jobs page renders without extra fetches.
94
+
95
+ ```ts snippet
96
+ const saved = await board.me.savedJobs.list({ limit: 20 });
97
+ saved.data[0]?.job.title; // full PublicJob embedded
98
+
99
+ await board.me.savedJobs.save({ jobId: job.id }); // converges — re-saving returns the same row
100
+ await board.me.savedJobs.unsave(job.id); // idempotent — unknown ids still 204
101
+ ```
102
+
103
+ ## Verify
104
+
105
+ - [ ] Signed-in apply sends no name/email; guest apply sends both.
106
+ - [ ] A second apply to the same job returns the same application id (no duplicate).
107
+ - [ ] After `uploadApplicationResume`, the application's `resumeFilename` is set.
108
+ - [ ] The apply button flips state via `myApplication` + `isNotFound`, not a local flag.
109
+ - [ ] Withdraw removes the row from `me.applications.list` on re-fetch.
110
+ - [ ] Unsave leaves the saved-jobs list consistent even when clicked twice.
@@ -0,0 +1,99 @@
1
+ ---
2
+ name: cavuno-board-blog
3
+ description: Build the blog with the @cavuno/board SDK — post archives (blog.posts.list with tagSlug/authorSlug/featured), the post page (blog.posts.retrieve + adjacent + similar), blog.tags and blog.authors, and free-text blog.search. Covers the summary-vs-detail split (html on the single read only), cursor pagination, slug-change redirects (redirected/newSlug), and the SEO fields the API actually returns.
4
+ ---
5
+
6
+ # Blog: posts, tags, authors, search
7
+
8
+ Every list and search returns `PublicBlogPostSummary`; only `posts.retrieve` returns the full `PublicBlogPost` with the `html` body.
9
+
10
+ ## When to use
11
+
12
+ - The blog index, tag archives, author archives, and featured rails.
13
+ - The post page: body, prev/next nav, related-posts rail.
14
+ - Blog search boxes.
15
+
16
+ ## When not to use
17
+
18
+ - Writing posts/tags/authors — that is the admin API; this SDK is the public read surface.
19
+ - Deciding whether the board has a blog at all — read `features.blog` from the board context.
20
+
21
+ Out of scope — do not invent exports: no RSS feed, sitemap, or OG-image generation — the host app builds those routes from the data here (`publishedAt`, `canonicalUrl`, `ogImageUrl` are all returned).
22
+
23
+ ## List posts and archives
24
+
25
+ `blog.posts.list` returns a `ListEnvelope<PublicBlogPostSummary>`. `BlogPostsListQuery` supports `limit` (1–100), `cursor`, `tagSlug`, `authorSlug`, and `featured: 'true'` (opt-in only — the string literal, not a boolean). Blog lists page by cursor only; there is no `offset` and no total `count`.
26
+
27
+ ```ts snippet
28
+ const page = await board.blog.posts.list({ tagSlug: 'news', limit: 12 });
29
+ for (const post of page.data) {
30
+ post.title;
31
+ post.customExcerpt; // null unless the author wrote one
32
+ post.coverUrl; // null when no cover image
33
+ post.readingTimeMin;
34
+ post.authors; // embedded: id, name, slug, bio, avatarUrl, social URLs
35
+ post.tags; // embedded: id, name, slug, description
36
+ }
37
+ const next = page.nextCursor
38
+ ? await board.blog.posts.list({ tagSlug: 'news', limit: 12, cursor: page.nextCursor })
39
+ : null; // nextCursor is null when hasMore is false
40
+ ```
41
+
42
+ ## Render a post
43
+
44
+ `posts.retrieve` adds the detail-only fields: `html` (the rendered rich-text body, nullable), `ogImageUrl`, `featureImageCaption`, `seoTitle`, `seoDescription`, `redirected`, `newSlug`.
45
+
46
+ ```ts snippet
47
+ const post = await board.blog.posts.retrieve('hello-world');
48
+ if (post.redirected && post.newSlug) {
49
+ // the slug changed — redirect to the post at newSlug instead of rendering here
50
+ }
51
+ post.html; // inject as the article body
52
+ post.seoTitle ?? post.title; // <title>
53
+ post.seoDescription ?? post.customExcerpt; // meta description
54
+ post.canonicalUrl; // author-set canonical override, or null
55
+ post.ogImageUrl ?? post.coverUrl; // social card image
56
+ ```
57
+
58
+ Anti-pattern: don't render a post at a stale URL. When `redirected` is true, issue a redirect to `newSlug` — old slugs keep resolving, but the canonical location moved.
59
+
60
+ Anti-pattern: don't look for `html` on list items. Summaries never carry it; fetch `retrieve` for the post page only.
61
+
62
+ ## Prev/next and related
63
+
64
+ ```ts snippet
65
+ const { previous, next } = await board.blog.posts.adjacent('hello-world');
66
+ // previous = older post, next = newer; each a summary or null
67
+
68
+ const rail = await board.blog.posts.similar('hello-world', { limit: 6 }); // 1–20, default 6
69
+ ```
70
+
71
+ ## Tags and authors
72
+
73
+ Both are list + retrieve-by-slug. `PublicBlogTag`: `id`, `name`, `slug`, `description`. `PublicBlogAuthor`: `id`, `name`, `slug`, `bio`, `avatarUrl`, `websiteUrl`, `twitterUrl`, `linkedinUrl`, `githubUrl`.
74
+
75
+ ```ts snippet
76
+ const { data: tags } = await board.blog.tags.list();
77
+ const tag = await board.blog.tags.retrieve('news');
78
+ const { data: authors } = await board.blog.authors.list();
79
+ const author = await board.blog.authors.retrieve('jane');
80
+ ```
81
+
82
+ Archive pages combine the two: `tags.retrieve` for the header, `posts.list({ tagSlug })` for the posts.
83
+
84
+ ## Search
85
+
86
+ `blog.search` posts a `BlogSearchBody` (`query` up to 200 chars, optional `cursor`, `limit` 1–50 — note the lower cap than lists) and returns a `SearchEnvelope<PublicBlogPostSummary>`:
87
+
88
+ ```ts snippet
89
+ const results = await board.blog.search({ query: 'launch', limit: 10 });
90
+ results.data[0]?.slug;
91
+ ```
92
+
93
+ ## Verify
94
+
95
+ - A list item has no `html` key; `posts.retrieve` of the same slug returns it (string or null).
96
+ - Retrieving a post by an old slug returns `redirected: true` with `newSlug` set, and your route redirects there.
97
+ - `posts.list({ featured: 'true' })` returns only posts with `featured: true`.
98
+ - Cursor paging terminates: `nextCursor` is `null` on the last page.
99
+ - `adjacent` on the newest post returns `next: null`; on the oldest, `previous: null`.
@@ -0,0 +1,99 @@
1
+ ---
2
+ name: cavuno-board-companies
3
+ description: Build company surfaces with the @cavuno/board SDK — the companies index (companies.list / companies.search with the market filter), the company profile (companies.retrieve, listJobs, similar), the markets sub-surface (companies.markets + markets.resolve with its 308 redirectTo), and company salary pages (companies.salaries + salaries.category). Covers PublicCompany vs PublicCompanyDetail and envelope pagination.
4
+ ---
5
+
6
+ # Companies: index, profile, markets, salaries
7
+
8
+ Lists and search return `PublicCompany`; only `retrieve` returns `PublicCompanyDetail` (which adds `markets`). Company salary pages are their own sub-surface under `companies.salaries`.
9
+
10
+ ## When to use
11
+
12
+ - The companies index page and its market (sector) filter.
13
+ - The company profile page: detail, open-jobs list, similar-companies rail.
14
+ - Per-company salary pages (`/companies/:slug/salaries[/:category]`).
15
+
16
+ ## When not to use
17
+
18
+ - Board-wide job browse/search — `jobs.*` (see cavuno-board-jobs).
19
+ - Board-wide salary hubs (titles/skills/locations) — `board.salaries.*`.
20
+ - Employer self-service (claiming/managing a company) — the authed `board.me.companies.*` surface.
21
+
22
+ Out of scope — do not invent exports: no company create/update/logo upload (that is the admin API, not this SDK), and no sitemap or OG-image generation — the host app owns those routes.
23
+
24
+ ## List, search, market filter
25
+
26
+ `companies.list` returns a `CompanyListEnvelope`: `ListEnvelope<PublicCompany>` plus optional `relatedSearches` (market suggestions). `CompaniesListQuery` supports `limit` (1–100), `cursor`, `offset` (takes precedence over `cursor`; pair with the response `count` to page in parallel), and `marketSlug` — unknown market slugs 404.
27
+
28
+ ```ts snippet
29
+ const page = await board.companies.list({ limit: 20, marketSlug: 'cybersecurity' });
30
+ page.hasMore;
31
+ page.nextCursor; // null when hasMore is false
32
+ for (const company of page.data) {
33
+ company.name;
34
+ company.publishedJobCount;
35
+ company.links.public; // canonical URL, or null when the company lacks a slug
36
+ }
37
+ ```
38
+
39
+ `companies.search` posts a `CompaniesSearchBody` (`query` free text matched against the company name, up to 200 chars; optional `marketSlug`, `cursor`, `limit` 1–100) and returns a `SearchEnvelope<PublicCompany>`:
40
+
41
+ ```ts snippet
42
+ const results = await board.companies.search({ query: 'acme', limit: 20 });
43
+ ```
44
+
45
+ ## Markets (sectors)
46
+
47
+ `companies.markets` is callable AND carries `.resolve`. The call lists the board's markets ranked by company count (`CompanyMarket`: `slug`, `name`, `companyCount`; `limit` 1–200, default 100 — a top-N preview; optional `search`). `.resolve(slug)` returns a `TaxonomyResolution` — `sourceSlug`, `canonicalSlug`, `displayName`, and `redirectTo` (the canonical slug to 308 to when the inbound slug differs; `null` otherwise).
48
+
49
+ ```ts snippet
50
+ const { data: markets } = await board.companies.markets({ search: 'robotics' });
51
+ const market = await board.companies.markets.resolve('cybersecurity');
52
+ if (market.redirectTo) {
53
+ // 308 the market-scoped browse to the canonical slug
54
+ }
55
+ ```
56
+
57
+ Resolve first, then pass the resolved slug as `marketSlug` — don't guess slugs (unknown ones 404).
58
+
59
+ ## Profile: retrieve, jobs, similar
60
+
61
+ `PublicCompany` (lists/search) carries: `id`, `name`, `slug`, `website`, `logoUrl`, `description`, `jobCount`, `publishedJobCount`, `links.public`. `PublicCompanyDetail` (retrieve only) adds `markets: CompanyMarketRef[]` (`name` + source `slug`; empty when none).
62
+
63
+ ```ts snippet
64
+ const company = await board.companies.retrieve('acme'); // PublicCompanyDetail
65
+ company.markets; // detail-only
66
+
67
+ const jobs = await board.companies.listJobs('acme', { limit: 10 });
68
+ // JobCardListEnvelope — same PublicJobCard shape as jobs.list; cursor + limit only
69
+
70
+ const rail = await board.companies.similar('acme', { limit: 6 }); // 1–20, default 6
71
+ ```
72
+
73
+ `similar` uses the hosted ranking (most open roles first) and excludes the company itself.
74
+
75
+ Anti-pattern: don't render `markets` from a list row — it only exists on the detail. Fetch `retrieve` for the profile page.
76
+
77
+ ## Company salaries
78
+
79
+ `companies.salaries` is callable AND carries `.category`. The call is the company salary overview (`CompanySalary`): `overallSalary` (nullable), `bySeniority` rows vs the board baseline (`diffPercent`), `competitors`, `topLocations`, `byCategory`, board-wide baselines, and `currency`. `.category(companySlug, categorySlug, { locale })` is one job category at the company (`CompanyCategorySalary`).
80
+
81
+ ```ts snippet
82
+ const overview = await board.companies.salaries('acme');
83
+ overview.bySeniority[0]?.diffPercent; // vs the board baseline, or null
84
+
85
+ const cat = await board.companies.salaries.category('acme', 'software-engineer', {
86
+ locale: 'de',
87
+ });
88
+ cat.categorySourceSlug; // immutable English slug
89
+ cat.categoryCanonicalSlug; // board-language slug — 308 to it when the inbound differs
90
+ ```
91
+
92
+ Pass `{ locale }` for board-language category names; the company slug/name are never localized. The response returns BOTH `categorySourceSlug` and `categoryCanonicalSlug` — the consumer issues the 308 itself.
93
+
94
+ ## Verify
95
+
96
+ - `companies.retrieve('<known-slug>')` returns `object: 'public_company'` with a `markets` array; the same company in `companies.list` has no `markets` key.
97
+ - A slug from `companies.markets()` works as `marketSlug`; a made-up slug returns 404 (`isNotFound`).
98
+ - Paginating with `nextCursor` terminates: `nextCursor` is `null` on the last page.
99
+ - `companies.salaries.category(...)` with a non-canonical category slug returns a differing `categoryCanonicalSlug`, and your route 308s to it.