@cavuno/board 1.26.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.
- 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 +6 -131
- package/dist/index.d.ts +6 -131
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- 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/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 +21 -1
- package/skills/cavuno-board-seo/SKILL.md +180 -0
- package/skills/cavuno-board-sitemap/SKILL.md +147 -0
- package/skills/manifest.json +15 -1
package/dist/sitemap.mjs
ADDED
|
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
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.
|
|
3
|
+
"version": "1.27.0",
|
|
4
4
|
"description": "Typed isomorphic client for the Cavuno Board API",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -60,6 +60,26 @@
|
|
|
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
|
+
},
|
|
63
83
|
"./skills": {
|
|
64
84
|
"import": {
|
|
65
85
|
"types": "./dist/skills.d.mts",
|
|
@@ -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.
|
|
@@ -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`.
|