@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.
@@ -0,0 +1,330 @@
1
+ // src/sitemap/xml.ts
2
+ var SITEMAP_BUCKETS = [
3
+ "marketing",
4
+ "jobs-categories",
5
+ "jobs-skills",
6
+ "jobs-locations",
7
+ "jobs-details",
8
+ "companies",
9
+ "salaries",
10
+ "blog"
11
+ ];
12
+ var SITEMAP_CHUNK_SIZE = 45e3;
13
+ function xmlEscape(value) {
14
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
15
+ }
16
+ function bucketFilename(bucket, chunkIndex = 0) {
17
+ const suffix = chunkIndex > 0 ? `-${chunkIndex + 1}` : "";
18
+ return `${bucket}${suffix}.xml`;
19
+ }
20
+ function parseBucketFilename(filename) {
21
+ if (!filename.endsWith(".xml")) return null;
22
+ const stem = filename.slice(0, -".xml".length);
23
+ const chunkMatch = stem.match(/^(.+?)-(\d+)$/);
24
+ if (chunkMatch) {
25
+ const candidate = chunkMatch[1];
26
+ const chunkNumber = Number.parseInt(chunkMatch[2] ?? "", 10);
27
+ if (SITEMAP_BUCKETS.includes(candidate) && Number.isFinite(chunkNumber) && chunkNumber >= 2) {
28
+ return { bucket: candidate, chunkIndex: chunkNumber - 1 };
29
+ }
30
+ }
31
+ if (SITEMAP_BUCKETS.includes(stem)) {
32
+ return { bucket: stem, chunkIndex: 0 };
33
+ }
34
+ return null;
35
+ }
36
+ function chunk(items, size) {
37
+ if (items.length === 0) return [];
38
+ if (items.length <= size) return [[...items]];
39
+ const out = [];
40
+ for (let i = 0; i < items.length; i += size) {
41
+ out.push(items.slice(i, i + size));
42
+ }
43
+ return out;
44
+ }
45
+ var XML_DECLARATION = '<?xml version="1.0" encoding="UTF-8"?>\n';
46
+ var SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9";
47
+ var IMAGE_NS = "http://www.google.com/schemas/sitemap-image/1.1";
48
+ function lastmod(value) {
49
+ const serialized = value instanceof Date ? value.toISOString() : value;
50
+ return `<lastmod>${xmlEscape(serialized)}</lastmod>
51
+ `;
52
+ }
53
+ function renderUrlset(entries) {
54
+ const normalized = entries.map(
55
+ (entry) => typeof entry === "string" ? { url: entry } : entry
56
+ );
57
+ const hasImages = normalized.some((entry) => (entry.images?.length ?? 0) > 0);
58
+ const namespaces = [`xmlns="${SITEMAP_NS}"`];
59
+ if (hasImages) {
60
+ namespaces.push(`xmlns:image="${IMAGE_NS}"`);
61
+ }
62
+ let xml = XML_DECLARATION + `<urlset ${namespaces.join(" ")}>
63
+ `;
64
+ for (const entry of normalized) {
65
+ xml += "<url>\n";
66
+ xml += `<loc>${xmlEscape(entry.url)}</loc>
67
+ `;
68
+ if (entry.images?.length) {
69
+ for (const image of entry.images) {
70
+ xml += `<image:image>
71
+ <image:loc>${xmlEscape(image)}</image:loc>
72
+ </image:image>
73
+ `;
74
+ }
75
+ }
76
+ if (entry.lastModified) {
77
+ xml += lastmod(entry.lastModified);
78
+ }
79
+ xml += "</url>\n";
80
+ }
81
+ xml += "</urlset>\n";
82
+ return xml;
83
+ }
84
+ function renderSitemapIndex(entries) {
85
+ let xml = XML_DECLARATION + `<sitemapindex xmlns="${SITEMAP_NS}">
86
+ `;
87
+ for (const raw of entries) {
88
+ const entry = typeof raw === "string" ? { url: raw } : raw;
89
+ xml += "<sitemap>\n";
90
+ xml += `<loc>${xmlEscape(entry.url)}</loc>
91
+ `;
92
+ if (entry.lastModified) {
93
+ xml += lastmod(entry.lastModified);
94
+ }
95
+ xml += "</sitemap>\n";
96
+ }
97
+ xml += "</sitemapindex>\n";
98
+ return xml;
99
+ }
100
+
101
+ // src/pagination.ts
102
+ function paginate(listFn, query, options) {
103
+ async function* pages() {
104
+ let current = query;
105
+ for (; ; ) {
106
+ const page = await listFn(current, options);
107
+ yield page;
108
+ if (!page.hasMore || page.nextCursor === null) return;
109
+ const { offset: _offset, ...rest } = current ?? {};
110
+ current = { ...rest, cursor: page.nextCursor };
111
+ }
112
+ }
113
+ return {
114
+ pages,
115
+ async *[Symbol.asyncIterator]() {
116
+ for await (const page of pages()) {
117
+ yield* page.data;
118
+ }
119
+ },
120
+ async toArray({ limit }) {
121
+ const items = [];
122
+ for await (const page of pages()) {
123
+ items.push(...page.data);
124
+ if (items.length >= limit) break;
125
+ }
126
+ return items.slice(0, limit);
127
+ }
128
+ };
129
+ }
130
+
131
+ // src/sitemap/walker.ts
132
+ var MIN_JOBS_PER_INDEXED_PAGE = 5;
133
+ var MAX_PAGES = 200;
134
+ async function drainPages(pages) {
135
+ const acc = [];
136
+ let pageCount = 0;
137
+ for await (const page of pages) {
138
+ acc.push(...page.data);
139
+ pageCount += 1;
140
+ if (pageCount >= MAX_PAGES) {
141
+ if (page.hasMore) {
142
+ console.warn(
143
+ `[sitemap] hit the ${MAX_PAGES}-page pagination cap \u2014 output may be truncated`
144
+ );
145
+ }
146
+ break;
147
+ }
148
+ }
149
+ return acc;
150
+ }
151
+ var PAGE = 100;
152
+ var OFFSET_CONCURRENCY = 8;
153
+ var OFFSET_CEILING = 1e4;
154
+ async function enumerateByOffset(fetchAt, fallback) {
155
+ const first = await fetchAt(0);
156
+ if (first.count === void 0) return fallback();
157
+ const all = [...first.data];
158
+ const ceiling = Math.min(first.count, OFFSET_CEILING);
159
+ const offsets = [];
160
+ for (let o = PAGE; o < ceiling; o += PAGE) offsets.push(o);
161
+ for (let i = 0; i < offsets.length; i += OFFSET_CONCURRENCY) {
162
+ const pages = await Promise.all(
163
+ offsets.slice(i, i + OFFSET_CONCURRENCY).map(fetchAt)
164
+ );
165
+ for (const page of pages) all.push(...page.data);
166
+ }
167
+ if (first.count > OFFSET_CEILING) {
168
+ console.warn(
169
+ `[sitemap] count ${first.count} exceeds the ${OFFSET_CEILING} offset ceiling \u2014 tail not enumerated (needs cursor/bulk support).`
170
+ );
171
+ }
172
+ return all;
173
+ }
174
+ function enumerateJobs(board) {
175
+ return enumerateByOffset(
176
+ (offset) => board.jobs.list({ limit: PAGE, offset }),
177
+ () => drainPages(paginate(board.jobs.list, { limit: PAGE }).pages())
178
+ );
179
+ }
180
+ function enumerateCompanies(board) {
181
+ return enumerateByOffset(
182
+ (offset) => board.companies.list({ limit: PAGE, offset }),
183
+ () => drainPages(paginate(board.companies.list, { limit: PAGE }).pages())
184
+ );
185
+ }
186
+ async function listedBuckets(board) {
187
+ const { features } = await board.context();
188
+ return SITEMAP_BUCKETS.filter((b) => b !== "blog" || features.blog);
189
+ }
190
+ async function buildBucketUrls(board, origin, bucket) {
191
+ switch (bucket) {
192
+ case "marketing":
193
+ return marketing(board, origin);
194
+ case "jobs-categories":
195
+ return jobsTaxonomy(board, origin, "categories");
196
+ case "jobs-skills":
197
+ return jobsTaxonomy(board, origin, "skills");
198
+ case "jobs-locations":
199
+ return jobsLocations(board, origin);
200
+ case "jobs-details":
201
+ return jobDetails(board, origin);
202
+ case "companies":
203
+ return companies(board, origin);
204
+ case "salaries":
205
+ return salaries(board, origin);
206
+ case "blog":
207
+ return blog(board, origin);
208
+ }
209
+ }
210
+ async function marketing(board, origin) {
211
+ const { features } = await board.context();
212
+ const urls = [
213
+ `${origin}/`,
214
+ `${origin}/jobs`,
215
+ `${origin}/about`,
216
+ `${origin}/privacy-policy`,
217
+ `${origin}/terms-of-service`,
218
+ `${origin}/cookie-policy`
219
+ ];
220
+ if (features.impressum) urls.push(`${origin}/impressum`);
221
+ if (features.talentDirectory) urls.push(`${origin}/talent`);
222
+ if (features.employers) urls.push(`${origin}/employers`);
223
+ return urls;
224
+ }
225
+ async function jobsTaxonomy(board, origin, kind) {
226
+ const jobs = await enumerateJobs(board);
227
+ const counts = /* @__PURE__ */ new Map();
228
+ for (const job of jobs) {
229
+ const slugs = new Set(
230
+ (kind === "categories" ? job.categories : job.skills).map((t) => t.slug)
231
+ );
232
+ for (const slug of slugs) counts.set(slug, (counts.get(slug) ?? 0) + 1);
233
+ }
234
+ const prefix = kind === "categories" ? `${origin}/jobs` : `${origin}/jobs/skills`;
235
+ return [...counts.entries()].filter(([, n]) => n >= MIN_JOBS_PER_INDEXED_PAGE).map(([slug]) => slug).sort().map((slug) => `${prefix}/${slug}`);
236
+ }
237
+ async function jobsLocations(board, origin) {
238
+ const { language } = await board.context();
239
+ const { data } = await board.salaries.locations.list({ locale: language });
240
+ return data.filter((l) => l.jobCount >= MIN_JOBS_PER_INDEXED_PAGE).map((l) => l.placeSlug).sort().map((slug) => `${origin}/jobs/locations/${slug}`);
241
+ }
242
+ async function jobDetails(board, origin) {
243
+ const jobs = await enumerateJobs(board);
244
+ const seen = /* @__PURE__ */ new Set();
245
+ for (const job of jobs) {
246
+ if (!job.company?.slug || !job.slug) continue;
247
+ seen.add(`${origin}/companies/${job.company.slug}/jobs/${job.slug}`);
248
+ }
249
+ return [...seen].sort();
250
+ }
251
+ async function companies(board, origin) {
252
+ const [list, markets] = await Promise.all([
253
+ enumerateCompanies(board),
254
+ board.companies.markets()
255
+ ]);
256
+ const urls = [`${origin}/companies`];
257
+ for (const company of list) urls.push(`${origin}/companies/${company.slug}`);
258
+ for (const market of markets.data) {
259
+ urls.push(`${origin}/companies/markets/${market.slug}`);
260
+ }
261
+ return urls;
262
+ }
263
+ async function salaries(board, origin) {
264
+ const { language } = await board.context();
265
+ const [salaryCompanies, titles, skills, locations] = await Promise.all([
266
+ board.salaries.companies.list(),
267
+ board.salaries.titles.list({ locale: language }),
268
+ board.salaries.skills.list({ locale: language }),
269
+ board.salaries.locations.list({ locale: language })
270
+ ]);
271
+ if (!salaryCompanies.data.length && !titles.data.length && !skills.data.length && !locations.data.length) {
272
+ return [];
273
+ }
274
+ const urls = [`${origin}/salaries`];
275
+ if (salaryCompanies.data.length) {
276
+ urls.push(`${origin}/salaries/companies`);
277
+ for (const c of salaryCompanies.data) {
278
+ urls.push(`${origin}/companies/${c.companySlug}/salaries`);
279
+ }
280
+ }
281
+ if (titles.data.length) {
282
+ urls.push(`${origin}/salaries/titles`);
283
+ for (const t of titles.data)
284
+ urls.push(`${origin}/salaries/titles/${t.slug}`);
285
+ }
286
+ if (skills.data.length) {
287
+ urls.push(`${origin}/salaries/skills`);
288
+ for (const s of skills.data)
289
+ urls.push(`${origin}/salaries/skills/${s.slug}`);
290
+ }
291
+ if (locations.data.length) {
292
+ urls.push(`${origin}/salaries/locations`);
293
+ for (const l of locations.data) {
294
+ urls.push(`${origin}/salaries/locations/${l.placeSlug}`);
295
+ }
296
+ }
297
+ return urls;
298
+ }
299
+ async function blog(board, origin) {
300
+ const posts = await drainPages(
301
+ paginate(board.blog.posts.list, { limit: 100 }).pages()
302
+ );
303
+ const urls = [`${origin}/blog`];
304
+ const tagSlugs = /* @__PURE__ */ new Set();
305
+ const authorSlugs = /* @__PURE__ */ new Set();
306
+ for (const post of posts) {
307
+ urls.push(`${origin}/blog/${post.slug}`);
308
+ for (const tag of post.tags) tagSlugs.add(tag.slug);
309
+ for (const author of post.authors) authorSlugs.add(author.slug);
310
+ }
311
+ for (const slug of [...tagSlugs].sort())
312
+ urls.push(`${origin}/blog/tag/${slug}`);
313
+ for (const slug of [...authorSlugs].sort()) {
314
+ urls.push(`${origin}/blog/author/${slug}`);
315
+ }
316
+ return urls;
317
+ }
318
+ export {
319
+ MIN_JOBS_PER_INDEXED_PAGE,
320
+ SITEMAP_BUCKETS,
321
+ SITEMAP_CHUNK_SIZE,
322
+ bucketFilename,
323
+ buildBucketUrls,
324
+ chunk,
325
+ listedBuckets,
326
+ parseBucketFilename,
327
+ renderSitemapIndex,
328
+ renderUrlset,
329
+ xmlEscape
330
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cavuno/board",
3
- "version": "1.26.0",
3
+ "version": "1.28.0",
4
4
  "description": "Typed isomorphic client for the Cavuno Board API",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -60,6 +60,36 @@
60
60
  "default": "./dist/theme.js"
61
61
  }
62
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
+ },
83
+ "./server": {
84
+ "import": {
85
+ "types": "./dist/server.d.mts",
86
+ "default": "./dist/server.mjs"
87
+ },
88
+ "require": {
89
+ "types": "./dist/server.d.ts",
90
+ "default": "./dist/server.js"
91
+ }
92
+ },
63
93
  "./skills": {
64
94
  "import": {
65
95
  "types": "./dist/skills.d.mts",
@@ -41,20 +41,21 @@ session.accessToken; // bearer; never expose to the browser bundle on SSR
41
41
 
42
42
  ## Storage modes
43
43
 
44
- `auth.storage` is `'memory'` | `'nostore'` | a `CustomStorage`. Defaults: **`memory` in the browser, `nostore` on the server**. Browser login works out of the box; shared SSR instances stay stateless.
44
+ `auth.storage` is `'memory'` | `'nostore'` | `'local'` | `'session'` | a `CustomStorage`. Defaults: **`memory` in the browser, `nostore` on the server**. Browser login works out of the box; shared SSR instances stay stateless.
45
45
 
46
46
  ```ts
47
47
  import { createBoardClient } from '@cavuno/board';
48
48
 
49
- // Browser/SPA: token held in memory on the instance.
49
+ // Browser/SPA: 'local' persists the pair across tabs + reloads via
50
+ // localStorage; 'session' scopes it to the tab; 'memory' drops it on reload.
50
51
  const board = createBoardClient({
51
52
  baseUrl: 'https://api.cavuno.com',
52
53
  board: 'pk_a8f3...',
53
- auth: { storage: 'memory' },
54
+ auth: { storage: 'local' },
54
55
  });
55
56
  ```
56
57
 
57
- A `CustomStorage` implements async `getItem`/`setItem`/`removeItem` — back it with `localStorage`, IndexedDB, Redis, or your own store.
58
+ `'local'`/`'session'` are **browser-only** — off-browser they throw loudly at client creation (`storage mode 'local' is browser-only — use 'nostore' + per-call headers on the server`). A `CustomStorage` implements async `getItem`/`setItem`/`removeItem` — back it with IndexedDB, Redis, or your own store.
58
59
 
59
60
  ## No auto-refresh on 401 — handle it explicitly
60
61
 
@@ -87,7 +88,7 @@ await board.auth.logout({ refreshToken }); // revokes server-side, clears sto
87
88
 
88
89
  ## Server-side pattern (keep tokens out of the browser)
89
90
 
90
- On SSR, do not hold the session on a shared instance. Keep the token pair in an httpOnly cookie owned by your app and pass it per call:
91
+ On SSR, do not hold the session on a shared instance. Keep the token pair in an httpOnly cookie owned by your app and pass it per call. `@cavuno/board/server` ships the cookie codec + the single-flight refresh helper for exactly this (see `cavuno-board-server`):
91
92
 
92
93
  ```ts snippet
93
94
  // `accessToken` comes from your httpOnly cookie, read in server code.
@@ -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.