@cavuno/board 1.26.0 → 1.28.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.
- package/dist/filters.d.mts +1 -1
- package/dist/filters.d.ts +1 -1
- package/dist/format.d.mts +1 -1
- package/dist/format.d.ts +1 -1
- package/dist/index.d.mts +158 -255
- package/dist/index.d.ts +158 -255
- package/dist/index.js +60 -4
- package/dist/index.mjs +60 -4
- package/dist/{jobs-CM67_J6H.d.mts → jobs-DK5mPBgq.d.mts} +1 -1
- package/dist/{jobs-CM67_J6H.d.ts → jobs-DK5mPBgq.d.ts} +1 -1
- package/dist/salaries-CXt6Vkrp.d.ts +130 -0
- package/dist/salaries-CrJsaZe6.d.mts +130 -0
- package/dist/seo.d.mts +288 -0
- package/dist/seo.d.ts +288 -0
- package/dist/seo.js +1102 -0
- package/dist/seo.mjs +1079 -0
- package/dist/server.d.mts +111 -0
- package/dist/server.d.ts +111 -0
- package/dist/server.js +199 -0
- package/dist/server.mjs +176 -0
- package/dist/sitemap.d.mts +63 -0
- package/dist/sitemap.d.ts +63 -0
- package/dist/sitemap.js +353 -0
- package/dist/sitemap.mjs +330 -0
- package/package.json +31 -1
- package/skills/cavuno-board-auth/SKILL.md +6 -5
- package/skills/cavuno-board-seo/SKILL.md +180 -0
- package/skills/cavuno-board-server/SKILL.md +229 -0
- package/skills/cavuno-board-sitemap/SKILL.md +147 -0
- package/skills/manifest.json +22 -1
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cavuno-board-server
|
|
3
|
+
description: SSR session plumbing with @cavuno/board/server — the __Host- httpOnly session-cookie codec (BoardSession serialize/parse/clear + the 5-minute isExpiringSoon window), the board-password grant-cookie codec with the open-redirect guards (safeRedirectPath, currentPathFromReferer), and createSessionRefresher, the single-flight rotation helper for the single-use refresh token. Use when wiring board-user auth or the board-password gate into any server-rendered frontend (TanStack Start, Next.js, Remix, Workers).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Server session plumbing
|
|
7
|
+
|
|
8
|
+
`@cavuno/board/server` ships the pieces every SSR board frontend re-invents
|
|
9
|
+
around auth: cookie codecs and the refresh-rotation helper. Everything is
|
|
10
|
+
pure and platform-neutral — helpers take and return cookie **strings**
|
|
11
|
+
(`Set-Cookie` values, `Cookie` headers), never framework request/response
|
|
12
|
+
objects. The middleware objects themselves stay framework-owned: you write
|
|
13
|
+
~20 lines of glue per framework (the `cavuno-board-tanstack-start` flavor
|
|
14
|
+
skill has the reference wiring), and everything inside the glue comes from
|
|
15
|
+
here.
|
|
16
|
+
|
|
17
|
+
## When to use
|
|
18
|
+
|
|
19
|
+
- Holding a board-user session (bearer pair) in an httpOnly cookie on SSR.
|
|
20
|
+
- Refreshing the single-use refresh token safely under concurrency.
|
|
21
|
+
- Wiring the board-password gate (`board.password.verify()` grant) with a
|
|
22
|
+
safe `?redirect=` round-trip.
|
|
23
|
+
|
|
24
|
+
## When not to use
|
|
25
|
+
|
|
26
|
+
- Browser-only SPAs with no server — use `auth.storage: 'local'` /
|
|
27
|
+
`'session'` / `'memory'` instead (see `cavuno-board-auth`).
|
|
28
|
+
- Framework middleware objects, CSRF protection, cookie encryption — all
|
|
29
|
+
app-owned. The session cookie is httpOnly + JSON; if you want an encrypted
|
|
30
|
+
cookie, wrap the codec output yourself.
|
|
31
|
+
|
|
32
|
+
## The session cookie
|
|
33
|
+
|
|
34
|
+
The SDK never sees server storage (create the client with no `auth.storage`
|
|
35
|
+
→ `nostore`). The bearer pair lives in ONE `__Host-` httpOnly cookie owned
|
|
36
|
+
by your app; `BoardSession` is `{ accessToken, refreshToken, expiresAt }`.
|
|
37
|
+
|
|
38
|
+
```ts snippet
|
|
39
|
+
import {
|
|
40
|
+
clearSessionCookie,
|
|
41
|
+
parseSessionCookie,
|
|
42
|
+
serializeSessionCookie,
|
|
43
|
+
} from '@cavuno/board/server';
|
|
44
|
+
|
|
45
|
+
// After login: persist the pair (BoardAuthSession already carries expiresAt).
|
|
46
|
+
const session = await board.auth.login({ email, password });
|
|
47
|
+
setCookieHeader = serializeSessionCookie({
|
|
48
|
+
accessToken: session.accessToken,
|
|
49
|
+
refreshToken: session.refreshToken,
|
|
50
|
+
expiresAt: session.expiresAt,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// On every request: parse the Cookie header your framework hands you.
|
|
54
|
+
const current = parseSessionCookie(request.headers.get('cookie'));
|
|
55
|
+
|
|
56
|
+
// On logout (after board.auth.logout({ refreshToken })): expire it.
|
|
57
|
+
setCookieHeader = clearSessionCookie();
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`serializeSessionCookie` locks the attributes (`__Host-` prefix, `Path=/`,
|
|
61
|
+
`HttpOnly`, `Secure`, `SameSite=Lax`, 30-day `Max-Age` matching the refresh
|
|
62
|
+
token's server-side lifetime). `parseSessionCookie` returns `null` for
|
|
63
|
+
absent, malformed, or wrong-shape cookies — treat `null` as signed out.
|
|
64
|
+
|
|
65
|
+
## Single-flight refresh in a session middleware
|
|
66
|
+
|
|
67
|
+
Refresh tokens are single-use: two concurrent requests that both refresh
|
|
68
|
+
burn the pair — the loser 401s and the user is signed out mid-session.
|
|
69
|
+
`createSessionRefresher` dedupes concurrent rotations per refreshToken, and
|
|
70
|
+
`isExpiringSoon` gives you the proactive 5-minute window so the old access
|
|
71
|
+
token still works while you rotate.
|
|
72
|
+
|
|
73
|
+
```ts snippet
|
|
74
|
+
import {
|
|
75
|
+
clearSessionCookie,
|
|
76
|
+
createSessionRefresher,
|
|
77
|
+
isExpiringSoon,
|
|
78
|
+
parseSessionCookie,
|
|
79
|
+
serializeSessionCookie,
|
|
80
|
+
} from '@cavuno/board/server';
|
|
81
|
+
|
|
82
|
+
// Module scope: ONE refresher per board client, shared by all requests —
|
|
83
|
+
// that sharing IS the single-flight guarantee — WITHIN one process/isolate. Across instances (serverless/multi-pod) simultaneous refreshes can still race; the proactive isExpiringSoon window keeps that rare, it does not eliminate it.
|
|
84
|
+
const refreshSession = createSessionRefresher(board);
|
|
85
|
+
|
|
86
|
+
async function resolveSession(cookieHeader: string | null) {
|
|
87
|
+
const session = parseSessionCookie(cookieHeader);
|
|
88
|
+
if (!session) return { session: null, setCookie: null };
|
|
89
|
+
if (!isExpiringSoon(session, Date.now())) {
|
|
90
|
+
return { session, setCookie: null };
|
|
91
|
+
}
|
|
92
|
+
const next = await refreshSession(session);
|
|
93
|
+
return next
|
|
94
|
+
? { session: next, setCookie: serializeSessionCookie(next) }
|
|
95
|
+
: { session: null, setCookie: clearSessionCookie() }; // burned/revoked → signed out
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The contract: a rotated `BoardSession` on success (write it back to the
|
|
100
|
+
cookie), `null` on a 401 (the token is burned or the session revoked —
|
|
101
|
+
clear the cookie and continue signed out, **never retry**), and anything
|
|
102
|
+
else (network error, 5xx, 429) rethrows for your error handling. Calls with
|
|
103
|
+
the SAME refreshToken while one rotation is in flight await that same
|
|
104
|
+
rotation; after it settles, the next call starts fresh.
|
|
105
|
+
|
|
106
|
+
Pass the access token per SDK call — the client itself stays stateless:
|
|
107
|
+
|
|
108
|
+
```ts snippet
|
|
109
|
+
const me = await board.me.retrieve(undefined, {
|
|
110
|
+
headers: { authorization: `Bearer ${session.accessToken}` },
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## The board-password grant cookie
|
|
115
|
+
|
|
116
|
+
On password-protected boards, `board.password.verify({ password })` returns
|
|
117
|
+
an HMAC grant token; gated reads take it as the `X-Board-Access` header.
|
|
118
|
+
Same codec pattern, 24-hour cookie (hosted parity):
|
|
119
|
+
|
|
120
|
+
```ts snippet
|
|
121
|
+
import {
|
|
122
|
+
clearGrantCookie,
|
|
123
|
+
parseGrantCookie,
|
|
124
|
+
serializeGrantCookie,
|
|
125
|
+
} from '@cavuno/board/server';
|
|
126
|
+
|
|
127
|
+
const { token } = await board.password.verify({ password });
|
|
128
|
+
setCookieHeader = serializeGrantCookie(token);
|
|
129
|
+
|
|
130
|
+
// Thread it to gated reads:
|
|
131
|
+
const grant = parseGrantCookie(request.headers.get('cookie'));
|
|
132
|
+
const jobs = await board.jobs.list(undefined, {
|
|
133
|
+
headers: grant ? { 'X-Board-Access': grant } : {},
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
When a read still fails with `isBoardPasswordRequired` (grant expired or
|
|
138
|
+
the password rotated), send the visitor to your `/password` page and clear
|
|
139
|
+
the stale cookie with `clearGrantCookie()`.
|
|
140
|
+
|
|
141
|
+
## Open-redirect guards
|
|
142
|
+
|
|
143
|
+
The `/password?redirect=` round-trip is the classic open-redirect hole.
|
|
144
|
+
`safeRedirectPath` transcribes the hosted board's guard (golden-tested
|
|
145
|
+
against it): same-origin absolute paths pass through, everything else —
|
|
146
|
+
`//evil.com`, full URLs, `javascript:`, backslash and control-character
|
|
147
|
+
normalization bypasses — collapses to `'/'`.
|
|
148
|
+
|
|
149
|
+
```ts snippet
|
|
150
|
+
import {
|
|
151
|
+
currentPathFromReferer,
|
|
152
|
+
safeRedirectPath,
|
|
153
|
+
} from '@cavuno/board/server';
|
|
154
|
+
|
|
155
|
+
// Where to send the visitor after a correct password:
|
|
156
|
+
redirect(safeRedirectPath(query.redirect));
|
|
157
|
+
|
|
158
|
+
// Building the challenge link: derive the come-back path from the Referer
|
|
159
|
+
// your framework read for you (already guarded internally).
|
|
160
|
+
const backTo = currentPathFromReferer(request.headers.get('referer'));
|
|
161
|
+
redirect(`/password?redirect=${encodeURIComponent(backTo)}`);
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Thread derivations (core entry, not /server)
|
|
165
|
+
|
|
166
|
+
The messaging thread's pure rules ship on the main entry — they're
|
|
167
|
+
client-safe view logic, not server plumbing:
|
|
168
|
+
|
|
169
|
+
```ts snippet
|
|
170
|
+
import { isColdRule, isOwnMessage, lastOwnMessageId } from '@cavuno/board';
|
|
171
|
+
|
|
172
|
+
const mine = isOwnMessage(message, counterpartyId); // bubble side + actions
|
|
173
|
+
const composerLocked = isColdRule(messages, counterpartyId); // cold-message cap
|
|
174
|
+
const seenTargetId = lastOwnMessageId(messages, counterpartyId); // "Seen" row
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`isColdRule` mirrors the server gate (`messaging_cold_rule` 403) so the
|
|
178
|
+
composer can disable itself instead of surfacing the error — the server
|
|
179
|
+
stays authoritative.
|
|
180
|
+
|
|
181
|
+
## Multi-board origins
|
|
182
|
+
|
|
183
|
+
One origin serving MULTIPLE boards must scope its cookies and storage per
|
|
184
|
+
board — otherwise one board's login clobbers another's (hosted scopes its
|
|
185
|
+
grant cookie per account for exactly this reason). Pass the board
|
|
186
|
+
identifier to the codecs; the browser storage modes scope automatically via
|
|
187
|
+
`createBoardClient`'s own `board`:
|
|
188
|
+
|
|
189
|
+
```ts snippet
|
|
190
|
+
import {
|
|
191
|
+
serializeSessionCookie,
|
|
192
|
+
parseSessionCookie,
|
|
193
|
+
sessionCookieName,
|
|
194
|
+
} from '@cavuno/board/server';
|
|
195
|
+
|
|
196
|
+
const scope = { board: 'pk_boardA' };
|
|
197
|
+
const cookie = serializeSessionCookie(session, scope);
|
|
198
|
+
const restored = parseSessionCookie(cookieHeader, scope);
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Single-board deployments (one board per hostname — the starter model) omit
|
|
202
|
+
the scope; the cookie names stay stable.
|
|
203
|
+
|
|
204
|
+
## Anti-patterns
|
|
205
|
+
|
|
206
|
+
```ts no-check
|
|
207
|
+
// NEVER auto-retry a null refresh — the token is single-use; a retry loop
|
|
208
|
+
// hammers the API with burned tokens:
|
|
209
|
+
while (!(await refreshSession(session))) {} // wrong
|
|
210
|
+
|
|
211
|
+
// NEVER create a refresher per request — per-request instances can't
|
|
212
|
+
// dedupe, which defeats the single-flight design:
|
|
213
|
+
const refreshSession = createSessionRefresher(board); // must be module scope
|
|
214
|
+
|
|
215
|
+
// NEVER redirect to the raw query param:
|
|
216
|
+
redirect(query.redirect); // open redirect — use safeRedirectPath
|
|
217
|
+
|
|
218
|
+
// NEVER put the session in auth.storage on the server — auth.refresh persists the rotated pair into client.storage, so a shared persistent store bleeds one user's tokens into other requests (cross-user session leak). 'nostore' + per-call headers is the contract — a shared instance
|
|
219
|
+
// leaks one user's tokens into another's request. Cookie + per-call header.
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Checklist
|
|
223
|
+
|
|
224
|
+
- [ ] Session cookie parsed on every request; `null` treated as signed out.
|
|
225
|
+
- [ ] `createSessionRefresher` instance is module-scoped (shared across
|
|
226
|
+
requests) and its `null` result clears the cookie without retrying.
|
|
227
|
+
- [ ] Rotated sessions are written back with `serializeSessionCookie`.
|
|
228
|
+
- [ ] Every `?redirect=` consumer goes through `safeRedirectPath`.
|
|
229
|
+
- [ ] Bearer tokens ride per-call headers; the shared client has no storage.
|
|
@@ -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`.
|
package/skills/manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.
|
|
2
|
+
"version": "1.28.0",
|
|
3
3
|
"skills": [
|
|
4
4
|
{
|
|
5
5
|
"name": "cavuno-board-account",
|
|
@@ -106,6 +106,20 @@
|
|
|
106
106
|
"framework": null,
|
|
107
107
|
"category": "core"
|
|
108
108
|
},
|
|
109
|
+
{
|
|
110
|
+
"name": "cavuno-board-seo",
|
|
111
|
+
"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.",
|
|
112
|
+
"path": "skills/cavuno-board-seo/SKILL.md",
|
|
113
|
+
"framework": null,
|
|
114
|
+
"category": "core"
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"name": "cavuno-board-server",
|
|
118
|
+
"description": "SSR session plumbing with @cavuno/board/server — the __Host- httpOnly session-cookie codec (BoardSession serialize/parse/clear + the 5-minute isExpiringSoon window), the board-password grant-cookie codec with the open-redirect guards (safeRedirectPath, currentPathFromReferer), and createSessionRefresher, the single-flight rotation helper for the single-use refresh token. Use when wiring board-user auth or the board-password gate into any server-rendered frontend (TanStack Start, Next.js, Remix, Workers).",
|
|
119
|
+
"path": "skills/cavuno-board-server/SKILL.md",
|
|
120
|
+
"framework": null,
|
|
121
|
+
"category": "core"
|
|
122
|
+
},
|
|
109
123
|
{
|
|
110
124
|
"name": "cavuno-board-setup",
|
|
111
125
|
"description": "End-to-end orchestrator for building a headless Cavuno job board with the @cavuno/board SDK. Start here after `npx @cavuno/board setup` copies the skills — detect the framework, wire the client, render board context, jobs browsing and detail, board-user auth and saved jobs, handle errors and access gating, then verify.",
|
|
@@ -113,6 +127,13 @@
|
|
|
113
127
|
"framework": null,
|
|
114
128
|
"category": "core"
|
|
115
129
|
},
|
|
130
|
+
{
|
|
131
|
+
"name": "cavuno-board-sitemap",
|
|
132
|
+
"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.",
|
|
133
|
+
"path": "skills/cavuno-board-sitemap/SKILL.md",
|
|
134
|
+
"framework": null,
|
|
135
|
+
"category": "core"
|
|
136
|
+
},
|
|
116
137
|
{
|
|
117
138
|
"name": "cavuno-board-smoke-test",
|
|
118
139
|
"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.",
|