@cavuno/board 4.42.0 → 4.44.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 (43) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/{_spec-B2aizgxr.d.mts → _spec-CLxrfYJb.d.mts} +23 -0
  3. package/dist/{_spec-B2aizgxr.d.ts → _spec-CLxrfYJb.d.ts} +23 -0
  4. package/dist/bin.mjs +401 -6
  5. package/dist/{blog-CYHSnNTZ.d.mts → blog-CQ40QX7l.d.mts} +1 -1
  6. package/dist/{blog-BkACbXY0.d.ts → blog-mlhJ6UcD.d.ts} +1 -1
  7. package/dist/{board-DWeT9Jbl.d.ts → board-DWFDeICw.d.ts} +2 -2
  8. package/dist/{board-DK4HmSty.d.mts → board-TOzb2D8c.d.mts} +2 -2
  9. package/dist/{breadcrumbs-CtKa2zPe.d.ts → breadcrumbs-DJHyzBji.d.ts} +2 -2
  10. package/dist/{breadcrumbs-nTfIWzRh.d.mts → breadcrumbs-DauM7O7_.d.mts} +2 -2
  11. package/dist/browser/cavuno-board.global.min.js +1 -1
  12. package/dist/{chunk-WSO6JN5C.mjs → chunk-DZD3DAEY.mjs} +2 -0
  13. package/dist/doctor.js +401 -6
  14. package/dist/doctor.mjs +401 -6
  15. package/dist/filters.d.mts +2 -2
  16. package/dist/filters.d.ts +2 -2
  17. package/dist/format.d.mts +4 -4
  18. package/dist/format.d.ts +4 -4
  19. package/dist/index.d.mts +88 -14
  20. package/dist/index.d.ts +88 -14
  21. package/dist/index.js +105 -8
  22. package/dist/index.mjs +104 -9
  23. package/dist/{jobs-C_eO-WfM.d.mts → jobs-CjQM8EPT.d.mts} +1 -1
  24. package/dist/{jobs-SAkNWgAB.d.ts → jobs-DZVMzBOQ.d.ts} +1 -1
  25. package/dist/{search-Cb3Aoa4c.d.mts → search-BtBE6iJO.d.mts} +1 -1
  26. package/dist/{search-CVcMFOuJ.d.ts → search-CF_wa20h.d.ts} +1 -1
  27. package/dist/{seo-DPVud7SH.d.mts → seo-0bfk63qi.d.mts} +1 -1
  28. package/dist/{seo-DgFBQTIK.d.ts → seo-CU9LpWH7.d.ts} +1 -1
  29. package/dist/seo-files.d.mts +2 -2
  30. package/dist/seo-files.d.ts +2 -2
  31. package/dist/seo.d.mts +6 -6
  32. package/dist/seo.d.ts +6 -6
  33. package/dist/server.d.mts +6 -6
  34. package/dist/server.d.ts +6 -6
  35. package/dist/server.mjs +1 -1
  36. package/dist/sitemap.d.mts +6 -6
  37. package/dist/sitemap.d.ts +6 -6
  38. package/dist/sitemap.mjs +1 -1
  39. package/dist/suggest.d.mts +2 -2
  40. package/dist/suggest.d.ts +2 -2
  41. package/package.json +1 -1
  42. package/skills/cavuno-board-post-a-job/SKILL.md +2 -1
  43. package/skills/manifest.json +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,41 @@
3
3
  This changelog records changes that affect Board API compatibility, exported
4
4
  types, runtime behavior, or supported integration patterns.
5
5
 
6
+ ## 4.44.0 — 2026-10-12
7
+
8
+ - Saved searches: `me.companies.talentLists.members.list()` takes `stage`, a
9
+ comma-separated list of pipeline stages, on a saved search linked to a job.
10
+ It keeps the saved people whose application on that job is at one of those
11
+ stages, so a stage view can be paged like any other list. Values match
12
+ stored stages exactly: to match a board column, pass every stage the column
13
+ shows (the Applied column is `applied,review`). `count` reflects the stage
14
+ and filters; `total` is still every saved person you can see. Passing
15
+ `stage` for a saved search with no linked job is a 400.
16
+
17
+ ## 4.43.0 — 2026-10-12
18
+
19
+ - Saved searches can hold people. `me.companies.talentLists.members.list()`,
20
+ `.add()` and `.remove()` manage the people saved to a saved search, and
21
+ `me.companies.talentLists.memberships()` lists the saved searches one
22
+ candidate is on. `members.list()` takes the same filters as `talent.list()`,
23
+ including `radius`, and returns `total` (every saved person you can see)
24
+ next to `count` (those matching the filters). Each saved search reports
25
+ `memberCount`. A saved search holds up to 500 people; adding more returns
26
+ 409 `talent_list_full`.
27
+ - Saving someone to a saved search that is linked to a job also shortlists
28
+ them on that job, and linking a job later shortlists everyone already
29
+ saved. Removing someone from a saved search never changes their place in a
30
+ job pipeline.
31
+ - Messaging: on boards that sell talent access, sending a message about a
32
+ shortlisted candidate who has not applied needs talent access, as a message
33
+ started from the talent directory does. Messages about people who applied
34
+ are unchanged.
35
+ - Talent directory: signed-in viewers also see profiles that are visible to
36
+ signed-in users only.
37
+ - Job postings: spending an existing credit or subscription through
38
+ `job-postings` requires `billingVerificationToken`, the token you pass to
39
+ `getBillingOptions`. Posts that buy a plan or are free are unaffected.
40
+
6
41
  ## 4.42.0 — 2026-10-12
7
42
 
8
43
  - Talent search: `talent.list()` takes `radius`, in kilometres (1–250,
@@ -64,6 +64,9 @@ interface components {
64
64
  job: string;
65
65
  candidateBoardUserId: string;
66
66
  };
67
+ AddTalentListMemberBody: {
68
+ candidateBoardUserId: string;
69
+ };
67
70
  Alert: {
68
71
  /** @description Job-alert preference ID. Also the path key. */
69
72
  id: string;
@@ -1082,6 +1085,8 @@ interface components {
1082
1085
  id?: string;
1083
1086
  planId: string;
1084
1087
  };
1088
+ /** @description The `verificationToken` the billing options were listed with. Required when `selectedBilling.type` is `order` or `subscription`; the token's email must match `submission.contactEmail`. */
1089
+ billingVerificationToken?: string;
1085
1090
  invoiceBilling?: {
1086
1091
  billingName?: string;
1087
1092
  address: {
@@ -3602,6 +3607,8 @@ interface components {
3602
3607
  createdBy: string;
3603
3608
  createdAt: number;
3604
3609
  updatedAt: number;
3610
+ /** @description People saved to this list (at most 500). Includes anyone the members list omits for the viewer (blocked, hidden or deactivated). */
3611
+ memberCount: number;
3605
3612
  };
3606
3613
  TalentListFilters: {
3607
3614
  q?: string;
@@ -3617,6 +3624,22 @@ interface components {
3617
3624
  permitCountry?: string;
3618
3625
  interestedRole?: string;
3619
3626
  };
3627
+ TalentListMember: {
3628
+ id: string;
3629
+ /** @enum {string} */
3630
+ object: "talent_list_member";
3631
+ candidate: components["schemas"]["TalentDirectoryEntry"];
3632
+ addedAt: number;
3633
+ /** @description Board-user id of the employer who saved them. */
3634
+ addedBy: string;
3635
+ };
3636
+ TalentListMembership: {
3637
+ /** @enum {string} */
3638
+ object: "talent_list_membership";
3639
+ listId: string;
3640
+ candidateBoardUserId: string;
3641
+ addedAt: number;
3642
+ };
3620
3643
  TalentProfile: {
3621
3644
  /** @enum {string} */
3622
3645
  object: "talent_profile";
@@ -64,6 +64,9 @@ interface components {
64
64
  job: string;
65
65
  candidateBoardUserId: string;
66
66
  };
67
+ AddTalentListMemberBody: {
68
+ candidateBoardUserId: string;
69
+ };
67
70
  Alert: {
68
71
  /** @description Job-alert preference ID. Also the path key. */
69
72
  id: string;
@@ -1082,6 +1085,8 @@ interface components {
1082
1085
  id?: string;
1083
1086
  planId: string;
1084
1087
  };
1088
+ /** @description The `verificationToken` the billing options were listed with. Required when `selectedBilling.type` is `order` or `subscription`; the token's email must match `submission.contactEmail`. */
1089
+ billingVerificationToken?: string;
1085
1090
  invoiceBilling?: {
1086
1091
  billingName?: string;
1087
1092
  address: {
@@ -3602,6 +3607,8 @@ interface components {
3602
3607
  createdBy: string;
3603
3608
  createdAt: number;
3604
3609
  updatedAt: number;
3610
+ /** @description People saved to this list (at most 500). Includes anyone the members list omits for the viewer (blocked, hidden or deactivated). */
3611
+ memberCount: number;
3605
3612
  };
3606
3613
  TalentListFilters: {
3607
3614
  q?: string;
@@ -3617,6 +3624,22 @@ interface components {
3617
3624
  permitCountry?: string;
3618
3625
  interestedRole?: string;
3619
3626
  };
3627
+ TalentListMember: {
3628
+ id: string;
3629
+ /** @enum {string} */
3630
+ object: "talent_list_member";
3631
+ candidate: components["schemas"]["TalentDirectoryEntry"];
3632
+ addedAt: number;
3633
+ /** @description Board-user id of the employer who saved them. */
3634
+ addedBy: string;
3635
+ };
3636
+ TalentListMembership: {
3637
+ /** @enum {string} */
3638
+ object: "talent_list_membership";
3639
+ listId: string;
3640
+ candidateBoardUserId: string;
3641
+ addedAt: number;
3642
+ };
3620
3643
  TalentProfile: {
3621
3644
  /** @enum {string} */
3622
3645
  object: "talent_profile";
package/dist/bin.mjs CHANGED
@@ -39,6 +39,358 @@ function loadSkillCorpus(baseDir) {
39
39
  };
40
40
  }
41
41
 
42
+ // src/paths/index.ts
43
+ function encodePathSegment(segment) {
44
+ try {
45
+ return encodeURIComponent(decodeURIComponent(segment)).replace(
46
+ /%3A/gi,
47
+ ":"
48
+ );
49
+ } catch {
50
+ return encodeURIComponent(segment).replace(/%3A/gi, ":");
51
+ }
52
+ }
53
+ function jobDetailPath(companySlug, jobSlug) {
54
+ return `/companies/${encodePathSegment(companySlug)}/jobs/${encodePathSegment(jobSlug)}`;
55
+ }
56
+ function jobsCategoryPath(categorySlug) {
57
+ return `/jobs/${encodePathSegment(categorySlug)}`;
58
+ }
59
+ function jobsSkillPath(skillSlug) {
60
+ return `/jobs/skills/${encodePathSegment(skillSlug)}`;
61
+ }
62
+ function jobsLocationPath(placeSlug) {
63
+ return `/jobs/locations/${encodePathSegment(placeSlug)}`;
64
+ }
65
+ function companyPath(companySlug) {
66
+ return `/companies/${encodePathSegment(companySlug)}`;
67
+ }
68
+ function companyMarketPath(marketSlug) {
69
+ return `/companies/markets/${encodePathSegment(marketSlug)}`;
70
+ }
71
+ function companySalaryPath(companySlug) {
72
+ return `/companies/${encodePathSegment(companySlug)}/salaries`;
73
+ }
74
+ function salaryTitlePath(titleSlug) {
75
+ return `/salaries/titles/${encodePathSegment(titleSlug)}`;
76
+ }
77
+ function salarySkillPath(skillSlug) {
78
+ return `/salaries/skills/${encodePathSegment(skillSlug)}`;
79
+ }
80
+ function salaryLocationPath(placeSlug) {
81
+ return `/salaries/locations/${encodePathSegment(placeSlug)}`;
82
+ }
83
+ function blogPostPath(postSlug) {
84
+ return `/blog/${encodePathSegment(postSlug)}`;
85
+ }
86
+ function blogTagPath(tagSlug) {
87
+ return `/blog/tag/${encodePathSegment(tagSlug)}`;
88
+ }
89
+ function blogAuthorPath(authorSlug) {
90
+ return `/blog/author/${encodePathSegment(authorSlug)}`;
91
+ }
92
+ function talentProfilePath(handle) {
93
+ return `/p/${encodePathSegment(handle)}`;
94
+ }
95
+ var BOARD_PATHS = {
96
+ home: "/",
97
+ jobs: "/jobs",
98
+ companies: "/companies",
99
+ salaries: "/salaries",
100
+ salaryCompanies: "/salaries/companies",
101
+ salaryTitles: "/salaries/titles",
102
+ salarySkills: "/salaries/skills",
103
+ salaryLocations: "/salaries/locations",
104
+ blog: "/blog",
105
+ about: "/about",
106
+ privacyPolicy: "/privacy-policy",
107
+ termsOfService: "/terms-of-service",
108
+ cookiePolicy: "/cookie-policy",
109
+ impressum: "/impressum",
110
+ talent: "/talent",
111
+ employers: "/employers",
112
+ /**
113
+ * Alert-email surfaces. Promoted out of raw literals in
114
+ * the job-alerts composer so /go indirection and email composition share
115
+ * one definition.
116
+ */
117
+ alertsManage: "/alerts/manage",
118
+ alertsConfirm: "/alerts/confirm"
119
+ };
120
+
121
+ // src/route-contract/roles.ts
122
+ var ROLE_PARAM_REGISTRY = {
123
+ jobDetail: ["companySlug", "jobSlug"],
124
+ jobsCategory: ["categorySlug"],
125
+ jobsSkill: ["skillSlug"],
126
+ jobsLocation: ["placeSlug"],
127
+ company: ["companySlug"],
128
+ companyMarket: ["marketSlug"],
129
+ companySalary: ["companySlug"],
130
+ salaryTitle: ["titleSlug"],
131
+ salarySkill: ["skillSlug"],
132
+ salaryLocation: ["placeSlug"],
133
+ blogPost: ["postSlug"],
134
+ blogTag: ["tagSlug"],
135
+ blogAuthor: ["authorSlug"],
136
+ talentProfile: ["handle"],
137
+ home: [],
138
+ jobs: [],
139
+ companies: [],
140
+ salaries: [],
141
+ salaryCompanies: [],
142
+ salaryTitles: [],
143
+ salarySkills: [],
144
+ salaryLocations: [],
145
+ blog: [],
146
+ about: [],
147
+ privacyPolicy: [],
148
+ termsOfService: [],
149
+ cookiePolicy: [],
150
+ impressum: [],
151
+ talent: [],
152
+ employers: [],
153
+ alertsManage: [],
154
+ alertsConfirm: []
155
+ };
156
+ var ALL_ROLES = Object.keys(
157
+ ROLE_PARAM_REGISTRY
158
+ );
159
+ var ROLE_SET = new Set(ALL_ROLES);
160
+ var CANONICAL_MANIFEST = {
161
+ version: 1,
162
+ roles: {
163
+ jobDetail: jobDetailPath(":companySlug", ":jobSlug"),
164
+ jobsCategory: jobsCategoryPath(":categorySlug"),
165
+ jobsSkill: jobsSkillPath(":skillSlug"),
166
+ jobsLocation: jobsLocationPath(":placeSlug"),
167
+ company: companyPath(":companySlug"),
168
+ companyMarket: companyMarketPath(":marketSlug"),
169
+ companySalary: companySalaryPath(":companySlug"),
170
+ salaryTitle: salaryTitlePath(":titleSlug"),
171
+ salarySkill: salarySkillPath(":skillSlug"),
172
+ salaryLocation: salaryLocationPath(":placeSlug"),
173
+ blogPost: blogPostPath(":postSlug"),
174
+ blogTag: blogTagPath(":tagSlug"),
175
+ blogAuthor: blogAuthorPath(":authorSlug"),
176
+ talentProfile: talentProfilePath(":handle"),
177
+ home: BOARD_PATHS.home,
178
+ jobs: BOARD_PATHS.jobs,
179
+ companies: BOARD_PATHS.companies,
180
+ salaries: BOARD_PATHS.salaries,
181
+ salaryCompanies: BOARD_PATHS.salaryCompanies,
182
+ salaryTitles: BOARD_PATHS.salaryTitles,
183
+ salarySkills: BOARD_PATHS.salarySkills,
184
+ salaryLocations: BOARD_PATHS.salaryLocations,
185
+ blog: BOARD_PATHS.blog,
186
+ about: BOARD_PATHS.about,
187
+ privacyPolicy: BOARD_PATHS.privacyPolicy,
188
+ termsOfService: BOARD_PATHS.termsOfService,
189
+ cookiePolicy: BOARD_PATHS.cookiePolicy,
190
+ impressum: BOARD_PATHS.impressum,
191
+ talent: BOARD_PATHS.talent,
192
+ employers: BOARD_PATHS.employers,
193
+ alertsManage: "/alerts/manage",
194
+ alertsConfirm: "/alerts/confirm"
195
+ }
196
+ };
197
+
198
+ // src/route-contract/match-history.ts
199
+ var ROLE_INDEX_PATHS = {
200
+ jobDetail: BOARD_PATHS.jobs,
201
+ jobsCategory: BOARD_PATHS.jobs,
202
+ jobsSkill: BOARD_PATHS.jobs,
203
+ jobsLocation: BOARD_PATHS.jobs,
204
+ company: BOARD_PATHS.companies,
205
+ companyMarket: BOARD_PATHS.companies,
206
+ companySalary: BOARD_PATHS.companies,
207
+ salaryTitle: BOARD_PATHS.salaries,
208
+ salarySkill: BOARD_PATHS.salaries,
209
+ salaryLocation: BOARD_PATHS.salaries,
210
+ salaryCompanies: BOARD_PATHS.salaries,
211
+ salaryTitles: BOARD_PATHS.salaries,
212
+ salarySkills: BOARD_PATHS.salaries,
213
+ salaryLocations: BOARD_PATHS.salaries,
214
+ salaries: BOARD_PATHS.salaries,
215
+ blogPost: BOARD_PATHS.blog,
216
+ blogTag: BOARD_PATHS.blog,
217
+ blogAuthor: BOARD_PATHS.blog,
218
+ blog: BOARD_PATHS.blog,
219
+ talentProfile: BOARD_PATHS.talent,
220
+ home: BOARD_PATHS.home,
221
+ jobs: BOARD_PATHS.jobs,
222
+ companies: BOARD_PATHS.companies,
223
+ about: BOARD_PATHS.about,
224
+ privacyPolicy: BOARD_PATHS.privacyPolicy,
225
+ termsOfService: BOARD_PATHS.termsOfService,
226
+ cookiePolicy: BOARD_PATHS.cookiePolicy,
227
+ impressum: BOARD_PATHS.impressum,
228
+ talent: BOARD_PATHS.talent,
229
+ employers: BOARD_PATHS.employers,
230
+ alertsManage: BOARD_PATHS.home,
231
+ alertsConfirm: BOARD_PATHS.home
232
+ };
233
+ function splitSegments(path) {
234
+ const withoutLeading = path.startsWith("/") ? path.slice(1) : path;
235
+ const withoutTrailing = withoutLeading.length > 1 && withoutLeading.endsWith("/") ? withoutLeading.slice(0, -1) : withoutLeading;
236
+ if (withoutTrailing === "") return [];
237
+ return withoutTrailing.split("/");
238
+ }
239
+ var PREFIXED_PARAM_RE = /^([^:]+):([A-Za-z_][A-Za-z0-9_]*)(.*)$/;
240
+ function matchPathToTemplate(template, pathname) {
241
+ const templateSegs = splitSegments(template);
242
+ const pathSegs = splitSegments(pathname);
243
+ if (templateSegs.length !== pathSegs.length) {
244
+ return null;
245
+ }
246
+ const params = {};
247
+ for (let i = 0; i < templateSegs.length; i++) {
248
+ const tSeg = templateSegs[i];
249
+ const pSeg = pathSegs[i];
250
+ let name = null;
251
+ let value = pSeg;
252
+ if (tSeg.startsWith(":") && tSeg.length > 1) {
253
+ name = tSeg.slice(1);
254
+ } else {
255
+ const prefixed = PREFIXED_PARAM_RE.exec(tSeg);
256
+ if (prefixed) {
257
+ const [, prefix = "", param = "", suffix = ""] = prefixed;
258
+ if (!pSeg.startsWith(prefix) || !pSeg.endsWith(suffix)) return null;
259
+ name = param;
260
+ value = pSeg.slice(prefix.length, pSeg.length - suffix.length);
261
+ }
262
+ }
263
+ if (name !== null) {
264
+ if (value.length === 0) return null;
265
+ if (value.includes("/") || value.includes("\\")) return null;
266
+ params[name] = value;
267
+ continue;
268
+ }
269
+ if (tSeg !== pSeg) return null;
270
+ }
271
+ return params;
272
+ }
273
+
274
+ // src/route-contract/params.ts
275
+ function extractParamNames(template) {
276
+ const names = [];
277
+ const re = /:([A-Za-z_][A-Za-z0-9_]*)/g;
278
+ let match;
279
+ while ((match = re.exec(template)) !== null) {
280
+ const name = match[1];
281
+ if (name !== void 0) names.push(name);
282
+ }
283
+ return names;
284
+ }
285
+
286
+ // src/route-contract/validate.ts
287
+ var TEMPLATE_CHARSET_RE = /^[A-Za-z0-9/_.:@-]+$/;
288
+ var FIXED_TEXT_ROLES = /* @__PURE__ */ new Set(["talentProfile"]);
289
+ function checkRoleTemplate(role, template) {
290
+ const errors = [];
291
+ if (typeof template !== "string" || template.length === 0) {
292
+ errors.push({
293
+ code: "invalid_template",
294
+ message: `role '${role}' template must be a non-empty string`,
295
+ role
296
+ });
297
+ return errors;
298
+ }
299
+ if (!template.startsWith("/")) {
300
+ errors.push({
301
+ code: "invalid_template",
302
+ message: `role '${role}' template must start with '/', got ${JSON.stringify(template)}`,
303
+ role
304
+ });
305
+ return errors;
306
+ }
307
+ if (/\s/.test(template)) {
308
+ errors.push({
309
+ code: "invalid_template",
310
+ message: `role '${role}' template must not contain whitespace, got ${JSON.stringify(template)}`,
311
+ role
312
+ });
313
+ return errors;
314
+ }
315
+ if (template.includes("?") || template.includes("#")) {
316
+ errors.push({
317
+ code: "template_query_or_fragment",
318
+ message: `role '${role}' template must not contain '?' or '#', got ${JSON.stringify(template)}`,
319
+ role
320
+ });
321
+ return errors;
322
+ }
323
+ if (template.startsWith("//") || template.includes("//")) {
324
+ errors.push({
325
+ code: "template_double_slash",
326
+ message: `role '${role}' template must not contain '//', got ${JSON.stringify(template)}`,
327
+ role
328
+ });
329
+ return errors;
330
+ }
331
+ for (const seg of template.split("/")) {
332
+ if (seg === "." || seg === "..") {
333
+ errors.push({
334
+ code: "template_dot_segment",
335
+ message: `role '${role}' template must not contain '.' or '..' segments, got ${JSON.stringify(template)}`,
336
+ role
337
+ });
338
+ return errors;
339
+ }
340
+ }
341
+ if (!TEMPLATE_CHARSET_RE.test(template)) {
342
+ errors.push({
343
+ code: "template_illegal_charset",
344
+ message: `role '${role}' template contains illegal characters, got ${JSON.stringify(template)}`,
345
+ role
346
+ });
347
+ return errors;
348
+ }
349
+ const registry = ROLE_PARAM_REGISTRY[role];
350
+ const tokens = extractParamNames(template);
351
+ if (registry.length === 0) {
352
+ if (tokens.length > 0) {
353
+ errors.push({
354
+ code: "static_has_params",
355
+ message: `static role '${role}' template must not contain ':' tokens, got ${template}`,
356
+ role,
357
+ token: tokens[0]
358
+ });
359
+ }
360
+ } else {
361
+ const registrySet = new Set(registry);
362
+ for (const token of tokens) {
363
+ if (!registrySet.has(token)) {
364
+ errors.push({
365
+ code: "unregistered_param",
366
+ message: `role '${role}' template references unregistered param ':${token}'`,
367
+ role,
368
+ token
369
+ });
370
+ }
371
+ }
372
+ const retained = tokens.filter((t) => registrySet.has(t));
373
+ if (retained.length === 0) {
374
+ errors.push({
375
+ code: "missing_entity_param",
376
+ message: `role '${role}' template must contain at least one registry param (${registry.join(", ")})`,
377
+ role
378
+ });
379
+ }
380
+ if (FIXED_TEXT_ROLES.has(role) && isParamsOnly(template)) {
381
+ errors.push({
382
+ code: "invalid_template",
383
+ message: `role '${role}' template must carry fixed text around its params (e.g. '/p/:handle' or '/@:handle'), got ${JSON.stringify(template)}`,
384
+ role
385
+ });
386
+ }
387
+ }
388
+ return errors;
389
+ }
390
+ function isParamsOnly(template) {
391
+ return template.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, "").replace(/\//g, "").length === 0;
392
+ }
393
+
42
394
  // src/doctor/checks.ts
43
395
  function record(id, tier) {
44
396
  return (status, detail) => ({
@@ -85,8 +437,41 @@ function checkEnv(env) {
85
437
  return results;
86
438
  }
87
439
  var JOB_DETAIL_LINK_RE = /href="(\/companies\/[a-z0-9-]+\/jobs\/[a-z0-9-]+)"/i;
88
- function extractJobDetailLink(html) {
89
- return html.match(JOB_DETAIL_LINK_RE)?.[1] ?? null;
440
+ function extractJobDetailLink(html, templates) {
441
+ if (!templates) return html.match(JOB_DETAIL_LINK_RE)?.[1] ?? null;
442
+ for (const match of html.matchAll(HREF_PATH_RE)) {
443
+ const path = match[1];
444
+ if (path.endsWith("/")) continue;
445
+ for (const template of templates) {
446
+ const params = matchPathToTemplate(template, path);
447
+ if (params && Object.values(params).every((v) => SLUG_RE.test(v))) {
448
+ return path;
449
+ }
450
+ }
451
+ }
452
+ return null;
453
+ }
454
+ var HREF_PATH_RE = /href="(\/[^"?#\s]+)"/gi;
455
+ var SLUG_RE = /^[a-z0-9-]+$/i;
456
+ var CANONICAL_JOB_DETAIL_TEMPLATE = CANONICAL_MANIFEST.roles.jobDetail;
457
+ function jobRoutesFromManifest(manifest) {
458
+ const roles = manifest !== null && typeof manifest === "object" && manifest.version === 1 ? manifest.roles : null;
459
+ const role = (name) => {
460
+ const template = roles !== null && typeof roles === "object" ? roles[name] : void 0;
461
+ return typeof template === "string" && checkRoleTemplate(name, template).length === 0 ? template : null;
462
+ };
463
+ const jobs = role("jobs");
464
+ const company = role("company");
465
+ const jobsWord = jobs?.split("/").filter((seg) => seg !== "" && !seg.includes(":")).at(-1);
466
+ const derived = company && jobsWord ? `${company}/${jobsWord}/:jobSlug` : null;
467
+ const detail = role("jobDetail") ?? (derived && checkRoleTemplate("jobDetail", derived).length === 0 ? derived : null);
468
+ return {
469
+ jobsPath: jobs ?? "/jobs",
470
+ jobDetailTemplates: detail && detail !== CANONICAL_JOB_DETAIL_TEMPLATE ? [detail, CANONICAL_JOB_DETAIL_TEMPLATE] : [CANONICAL_JOB_DETAIL_TEMPLATE]
471
+ };
472
+ }
473
+ function describeJobDetailTemplate(template) {
474
+ return template.replace(":companySlug", "{c}").replace(":jobSlug", "{s}").replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, "{$1}");
90
475
  }
91
476
  var JSON_LD_RE = /<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi;
92
477
  function extractJobPostingJsonLd(html) {
@@ -8361,9 +8746,19 @@ async function confirmsNoPublishedJobs(fetchImpl, env) {
8361
8746
  return false;
8362
8747
  }
8363
8748
  }
8749
+ async function readJobRoutes(fetchImpl, base) {
8750
+ const manifest = await probe(fetchImpl, `${base}/.well-known/cavuno.json`);
8751
+ if (!manifest.ok) return jobRoutesFromManifest(null);
8752
+ try {
8753
+ return jobRoutesFromManifest(JSON.parse(manifest.body));
8754
+ } catch {
8755
+ return jobRoutesFromManifest(null);
8756
+ }
8757
+ }
8364
8758
  async function probeJobsAndJsonLd(fetchImpl, base, env) {
8365
- const jobs = await probe(fetchImpl, `${base}/jobs`);
8366
- const jobLink = jobs.ok ? extractJobDetailLink(jobs.body) : null;
8759
+ const { jobsPath, jobDetailTemplates } = await readJobRoutes(fetchImpl, base);
8760
+ const jobs = await probe(fetchImpl, `${base}${jobsPath}`);
8761
+ const jobLink = jobs.ok ? extractJobDetailLink(jobs.body, jobDetailTemplates) : null;
8367
8762
  if (jobs.status === 200 && !jobLink && /<(html|body|main)[\s>]/i.test(jobs.body) && await confirmsNoPublishedJobs(fetchImpl, env)) {
8368
8763
  return [
8369
8764
  READ.jobs("pass", "listing renders; API confirms zero published jobs"),
@@ -8372,14 +8767,14 @@ async function probeJobsAndJsonLd(fetchImpl, base, env) {
8372
8767
  }
8373
8768
  const jobsResult = jobs.ok && jobLink ? READ.jobs("pass", `listing renders with job detail links (${jobLink})`) : READ.jobs(
8374
8769
  "fail",
8375
- jobs.ok ? "listing renders but contains no /companies/{c}/jobs/{s} detail links" : `listing HTTP ${jobs.status}`
8770
+ jobs.ok ? `listing renders but contains no ${describeJobDetailTemplate(jobDetailTemplates[0])} detail links` : `listing HTTP ${jobs.status}`
8376
8771
  );
8377
8772
  if (!jobLink) {
8378
8773
  return [
8379
8774
  jobsResult,
8380
8775
  READ.jsonld(
8381
8776
  "skip",
8382
- jobs.ok ? "not probed \u2014 the listing rendered no job detail link (see read.jobs failure)" : "not probed \u2014 the /jobs listing itself failed (see read.jobs failure)"
8777
+ jobs.ok ? "not probed \u2014 the listing rendered no job detail link (see read.jobs failure)" : `not probed \u2014 the ${jobsPath} listing itself failed (see read.jobs failure)`
8383
8778
  )
8384
8779
  ];
8385
8780
  }
@@ -1,4 +1,4 @@
1
- import { S as Schemas } from './_spec-B2aizgxr.mjs';
1
+ import { S as Schemas } from './_spec-CLxrfYJb.mjs';
2
2
 
3
3
  /** Author shape embedded on posts (no `object` discriminator). */
4
4
  type BlogAuthorEmbed = Schemas['PublicBlogAuthorEmbed'];
@@ -1,4 +1,4 @@
1
- import { S as Schemas } from './_spec-B2aizgxr.js';
1
+ import { S as Schemas } from './_spec-CLxrfYJb.js';
2
2
 
3
3
  /** Author shape embedded on posts (no `object` discriminator). */
4
4
  type BlogAuthorEmbed = Schemas['PublicBlogAuthorEmbed'];
@@ -1,5 +1,5 @@
1
- import { S as Schemas } from './_spec-B2aizgxr.js';
2
- import { L as ListEnvelope, t as RelatedSearch, a as CustomFieldFilter, O as ObjectReferenceFilter } from './jobs-SAkNWgAB.js';
1
+ import { S as Schemas } from './_spec-CLxrfYJb.js';
2
+ import { L as ListEnvelope, t as RelatedSearch, a as CustomFieldFilter, O as ObjectReferenceFilter } from './jobs-DZVMzBOQ.js';
3
3
 
4
4
  type PublicCompany = Schemas['CompanyPublic'];
5
5
  /** A market (sector) a company operates in — name + its source slug. */
@@ -1,5 +1,5 @@
1
- import { S as Schemas } from './_spec-B2aizgxr.mjs';
2
- import { L as ListEnvelope, t as RelatedSearch, a as CustomFieldFilter, O as ObjectReferenceFilter } from './jobs-C_eO-WfM.mjs';
1
+ import { S as Schemas } from './_spec-CLxrfYJb.mjs';
2
+ import { L as ListEnvelope, t as RelatedSearch, a as CustomFieldFilter, O as ObjectReferenceFilter } from './jobs-CjQM8EPT.mjs';
3
3
 
4
4
  type PublicCompany = Schemas['CompanyPublic'];
5
5
  /** A market (sector) a company operates in — name + its source slug. */
@@ -1,5 +1,5 @@
1
- import { P as PublicBoard, a as CompanyCategorySalary, b as CompanySalary, L as LocationSalaryDetail, S as SkillSalaryDetail, T as TitleSalaryDetail } from './board-DWeT9Jbl.js';
2
- import { P as PublicJob } from './jobs-SAkNWgAB.js';
1
+ import { P as PublicBoard, a as CompanyCategorySalary, b as CompanySalary, L as LocationSalaryDetail, S as SkillSalaryDetail, T as TitleSalaryDetail } from './board-DWFDeICw.js';
2
+ import { P as PublicJob } from './jobs-DZVMzBOQ.js';
3
3
 
4
4
  /**
5
5
  * Wire enum for `salaryTimeframe`. Defined here (not re-exported from any
@@ -1,5 +1,5 @@
1
- import { P as PublicBoard, a as CompanyCategorySalary, b as CompanySalary, L as LocationSalaryDetail, S as SkillSalaryDetail, T as TitleSalaryDetail } from './board-DK4HmSty.mjs';
2
- import { P as PublicJob } from './jobs-C_eO-WfM.mjs';
1
+ import { P as PublicBoard, a as CompanyCategorySalary, b as CompanySalary, L as LocationSalaryDetail, S as SkillSalaryDetail, T as TitleSalaryDetail } from './board-TOzb2D8c.mjs';
2
+ import { P as PublicJob } from './jobs-CjQM8EPT.mjs';
3
3
 
4
4
  /**
5
5
  * Wire enum for `salaryTimeframe`. Defined here (not re-exported from any