@cavuno/board 4.42.0 → 4.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/{_spec-B2aizgxr.d.mts → _spec-CdNZDaaR.d.mts} +39 -0
  3. package/dist/{_spec-B2aizgxr.d.ts → _spec-CdNZDaaR.d.ts} +39 -0
  4. package/dist/bin.mjs +570 -10
  5. package/dist/{blog-BkACbXY0.d.ts → blog-ZvLJ6w1Q.d.ts} +1 -1
  6. package/dist/{blog-CYHSnNTZ.d.mts → blog-uV51b4Cg.d.mts} +1 -1
  7. package/dist/{board-DWeT9Jbl.d.ts → board-3JU14eF3.d.ts} +2 -2
  8. package/dist/{board-DK4HmSty.d.mts → board-CJw-MJpf.d.mts} +2 -2
  9. package/dist/{breadcrumbs-nTfIWzRh.d.mts → breadcrumbs-BY1UMCbL.d.mts} +2 -2
  10. package/dist/{breadcrumbs-CtKa2zPe.d.ts → breadcrumbs-CXvdv6Ry.d.ts} +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.d.mts +1 -0
  14. package/dist/doctor.d.ts +1 -0
  15. package/dist/doctor.js +570 -10
  16. package/dist/doctor.mjs +570 -10
  17. package/dist/filters.d.mts +2 -2
  18. package/dist/filters.d.ts +2 -2
  19. package/dist/format.d.mts +4 -4
  20. package/dist/format.d.ts +4 -4
  21. package/dist/index.d.mts +107 -14
  22. package/dist/index.d.ts +107 -14
  23. package/dist/index.js +129 -8
  24. package/dist/index.mjs +128 -9
  25. package/dist/{jobs-SAkNWgAB.d.ts → jobs-C7UvN_dW.d.ts} +1 -1
  26. package/dist/{jobs-C_eO-WfM.d.mts → jobs-D9ERWWda.d.mts} +1 -1
  27. package/dist/{search-Cb3Aoa4c.d.mts → search-BMSNK85o.d.mts} +1 -1
  28. package/dist/{search-CVcMFOuJ.d.ts → search-D0TFnn1x.d.ts} +1 -1
  29. package/dist/{seo-DPVud7SH.d.mts → seo-C3WmgifR.d.mts} +1 -1
  30. package/dist/{seo-DgFBQTIK.d.ts → seo-CYmVKHsu.d.ts} +1 -1
  31. package/dist/seo-files.d.mts +25 -3
  32. package/dist/seo-files.d.ts +25 -3
  33. package/dist/seo-files.js +44 -10
  34. package/dist/seo-files.mjs +44 -10
  35. package/dist/seo.d.mts +6 -6
  36. package/dist/seo.d.ts +6 -6
  37. package/dist/server.d.mts +6 -6
  38. package/dist/server.d.ts +6 -6
  39. package/dist/server.mjs +1 -1
  40. package/dist/sitemap.d.mts +6 -6
  41. package/dist/sitemap.d.ts +6 -6
  42. package/dist/sitemap.mjs +1 -1
  43. package/dist/suggest.d.mts +2 -2
  44. package/dist/suggest.d.ts +2 -2
  45. package/package.json +1 -1
  46. package/skills/cavuno-board-filters/SKILL.md +13 -0
  47. package/skills/cavuno-board-i18n/SKILL.md +10 -0
  48. package/skills/cavuno-board-post-a-job/SKILL.md +2 -1
  49. package/skills/manifest.json +1 -1
package/dist/doctor.mjs CHANGED
@@ -37,6 +37,358 @@ function loadSkillCorpus(baseDir) {
37
37
  };
38
38
  }
39
39
 
40
+ // src/paths/index.ts
41
+ function encodePathSegment(segment) {
42
+ try {
43
+ return encodeURIComponent(decodeURIComponent(segment)).replace(
44
+ /%3A/gi,
45
+ ":"
46
+ );
47
+ } catch {
48
+ return encodeURIComponent(segment).replace(/%3A/gi, ":");
49
+ }
50
+ }
51
+ function jobDetailPath(companySlug, jobSlug) {
52
+ return `/companies/${encodePathSegment(companySlug)}/jobs/${encodePathSegment(jobSlug)}`;
53
+ }
54
+ function jobsCategoryPath(categorySlug) {
55
+ return `/jobs/${encodePathSegment(categorySlug)}`;
56
+ }
57
+ function jobsSkillPath(skillSlug) {
58
+ return `/jobs/skills/${encodePathSegment(skillSlug)}`;
59
+ }
60
+ function jobsLocationPath(placeSlug) {
61
+ return `/jobs/locations/${encodePathSegment(placeSlug)}`;
62
+ }
63
+ function companyPath(companySlug) {
64
+ return `/companies/${encodePathSegment(companySlug)}`;
65
+ }
66
+ function companyMarketPath(marketSlug) {
67
+ return `/companies/markets/${encodePathSegment(marketSlug)}`;
68
+ }
69
+ function companySalaryPath(companySlug) {
70
+ return `/companies/${encodePathSegment(companySlug)}/salaries`;
71
+ }
72
+ function salaryTitlePath(titleSlug) {
73
+ return `/salaries/titles/${encodePathSegment(titleSlug)}`;
74
+ }
75
+ function salarySkillPath(skillSlug) {
76
+ return `/salaries/skills/${encodePathSegment(skillSlug)}`;
77
+ }
78
+ function salaryLocationPath(placeSlug) {
79
+ return `/salaries/locations/${encodePathSegment(placeSlug)}`;
80
+ }
81
+ function blogPostPath(postSlug) {
82
+ return `/blog/${encodePathSegment(postSlug)}`;
83
+ }
84
+ function blogTagPath(tagSlug) {
85
+ return `/blog/tag/${encodePathSegment(tagSlug)}`;
86
+ }
87
+ function blogAuthorPath(authorSlug) {
88
+ return `/blog/author/${encodePathSegment(authorSlug)}`;
89
+ }
90
+ function talentProfilePath(handle) {
91
+ return `/p/${encodePathSegment(handle)}`;
92
+ }
93
+ var BOARD_PATHS = {
94
+ home: "/",
95
+ jobs: "/jobs",
96
+ companies: "/companies",
97
+ salaries: "/salaries",
98
+ salaryCompanies: "/salaries/companies",
99
+ salaryTitles: "/salaries/titles",
100
+ salarySkills: "/salaries/skills",
101
+ salaryLocations: "/salaries/locations",
102
+ blog: "/blog",
103
+ about: "/about",
104
+ privacyPolicy: "/privacy-policy",
105
+ termsOfService: "/terms-of-service",
106
+ cookiePolicy: "/cookie-policy",
107
+ impressum: "/impressum",
108
+ talent: "/talent",
109
+ employers: "/employers",
110
+ /**
111
+ * Alert-email surfaces. Promoted out of raw literals in
112
+ * the job-alerts composer so /go indirection and email composition share
113
+ * one definition.
114
+ */
115
+ alertsManage: "/alerts/manage",
116
+ alertsConfirm: "/alerts/confirm"
117
+ };
118
+
119
+ // src/route-contract/roles.ts
120
+ var ROLE_PARAM_REGISTRY = {
121
+ jobDetail: ["companySlug", "jobSlug"],
122
+ jobsCategory: ["categorySlug"],
123
+ jobsSkill: ["skillSlug"],
124
+ jobsLocation: ["placeSlug"],
125
+ company: ["companySlug"],
126
+ companyMarket: ["marketSlug"],
127
+ companySalary: ["companySlug"],
128
+ salaryTitle: ["titleSlug"],
129
+ salarySkill: ["skillSlug"],
130
+ salaryLocation: ["placeSlug"],
131
+ blogPost: ["postSlug"],
132
+ blogTag: ["tagSlug"],
133
+ blogAuthor: ["authorSlug"],
134
+ talentProfile: ["handle"],
135
+ home: [],
136
+ jobs: [],
137
+ companies: [],
138
+ salaries: [],
139
+ salaryCompanies: [],
140
+ salaryTitles: [],
141
+ salarySkills: [],
142
+ salaryLocations: [],
143
+ blog: [],
144
+ about: [],
145
+ privacyPolicy: [],
146
+ termsOfService: [],
147
+ cookiePolicy: [],
148
+ impressum: [],
149
+ talent: [],
150
+ employers: [],
151
+ alertsManage: [],
152
+ alertsConfirm: []
153
+ };
154
+ var ALL_ROLES = Object.keys(
155
+ ROLE_PARAM_REGISTRY
156
+ );
157
+ var ROLE_SET = new Set(ALL_ROLES);
158
+ var CANONICAL_MANIFEST = {
159
+ version: 1,
160
+ roles: {
161
+ jobDetail: jobDetailPath(":companySlug", ":jobSlug"),
162
+ jobsCategory: jobsCategoryPath(":categorySlug"),
163
+ jobsSkill: jobsSkillPath(":skillSlug"),
164
+ jobsLocation: jobsLocationPath(":placeSlug"),
165
+ company: companyPath(":companySlug"),
166
+ companyMarket: companyMarketPath(":marketSlug"),
167
+ companySalary: companySalaryPath(":companySlug"),
168
+ salaryTitle: salaryTitlePath(":titleSlug"),
169
+ salarySkill: salarySkillPath(":skillSlug"),
170
+ salaryLocation: salaryLocationPath(":placeSlug"),
171
+ blogPost: blogPostPath(":postSlug"),
172
+ blogTag: blogTagPath(":tagSlug"),
173
+ blogAuthor: blogAuthorPath(":authorSlug"),
174
+ talentProfile: talentProfilePath(":handle"),
175
+ home: BOARD_PATHS.home,
176
+ jobs: BOARD_PATHS.jobs,
177
+ companies: BOARD_PATHS.companies,
178
+ salaries: BOARD_PATHS.salaries,
179
+ salaryCompanies: BOARD_PATHS.salaryCompanies,
180
+ salaryTitles: BOARD_PATHS.salaryTitles,
181
+ salarySkills: BOARD_PATHS.salarySkills,
182
+ salaryLocations: BOARD_PATHS.salaryLocations,
183
+ blog: BOARD_PATHS.blog,
184
+ about: BOARD_PATHS.about,
185
+ privacyPolicy: BOARD_PATHS.privacyPolicy,
186
+ termsOfService: BOARD_PATHS.termsOfService,
187
+ cookiePolicy: BOARD_PATHS.cookiePolicy,
188
+ impressum: BOARD_PATHS.impressum,
189
+ talent: BOARD_PATHS.talent,
190
+ employers: BOARD_PATHS.employers,
191
+ alertsManage: "/alerts/manage",
192
+ alertsConfirm: "/alerts/confirm"
193
+ }
194
+ };
195
+
196
+ // src/route-contract/match-history.ts
197
+ var ROLE_INDEX_PATHS = {
198
+ jobDetail: BOARD_PATHS.jobs,
199
+ jobsCategory: BOARD_PATHS.jobs,
200
+ jobsSkill: BOARD_PATHS.jobs,
201
+ jobsLocation: BOARD_PATHS.jobs,
202
+ company: BOARD_PATHS.companies,
203
+ companyMarket: BOARD_PATHS.companies,
204
+ companySalary: BOARD_PATHS.companies,
205
+ salaryTitle: BOARD_PATHS.salaries,
206
+ salarySkill: BOARD_PATHS.salaries,
207
+ salaryLocation: BOARD_PATHS.salaries,
208
+ salaryCompanies: BOARD_PATHS.salaries,
209
+ salaryTitles: BOARD_PATHS.salaries,
210
+ salarySkills: BOARD_PATHS.salaries,
211
+ salaryLocations: BOARD_PATHS.salaries,
212
+ salaries: BOARD_PATHS.salaries,
213
+ blogPost: BOARD_PATHS.blog,
214
+ blogTag: BOARD_PATHS.blog,
215
+ blogAuthor: BOARD_PATHS.blog,
216
+ blog: BOARD_PATHS.blog,
217
+ talentProfile: BOARD_PATHS.talent,
218
+ home: BOARD_PATHS.home,
219
+ jobs: BOARD_PATHS.jobs,
220
+ companies: BOARD_PATHS.companies,
221
+ about: BOARD_PATHS.about,
222
+ privacyPolicy: BOARD_PATHS.privacyPolicy,
223
+ termsOfService: BOARD_PATHS.termsOfService,
224
+ cookiePolicy: BOARD_PATHS.cookiePolicy,
225
+ impressum: BOARD_PATHS.impressum,
226
+ talent: BOARD_PATHS.talent,
227
+ employers: BOARD_PATHS.employers,
228
+ alertsManage: BOARD_PATHS.home,
229
+ alertsConfirm: BOARD_PATHS.home
230
+ };
231
+ function splitSegments(path) {
232
+ const withoutLeading = path.startsWith("/") ? path.slice(1) : path;
233
+ const withoutTrailing = withoutLeading.length > 1 && withoutLeading.endsWith("/") ? withoutLeading.slice(0, -1) : withoutLeading;
234
+ if (withoutTrailing === "") return [];
235
+ return withoutTrailing.split("/");
236
+ }
237
+ var PREFIXED_PARAM_RE = /^([^:]+):([A-Za-z_][A-Za-z0-9_]*)(.*)$/;
238
+ function matchPathToTemplate(template, pathname) {
239
+ const templateSegs = splitSegments(template);
240
+ const pathSegs = splitSegments(pathname);
241
+ if (templateSegs.length !== pathSegs.length) {
242
+ return null;
243
+ }
244
+ const params = {};
245
+ for (let i = 0; i < templateSegs.length; i++) {
246
+ const tSeg = templateSegs[i];
247
+ const pSeg = pathSegs[i];
248
+ let name = null;
249
+ let value = pSeg;
250
+ if (tSeg.startsWith(":") && tSeg.length > 1) {
251
+ name = tSeg.slice(1);
252
+ } else {
253
+ const prefixed = PREFIXED_PARAM_RE.exec(tSeg);
254
+ if (prefixed) {
255
+ const [, prefix = "", param = "", suffix = ""] = prefixed;
256
+ if (!pSeg.startsWith(prefix) || !pSeg.endsWith(suffix)) return null;
257
+ name = param;
258
+ value = pSeg.slice(prefix.length, pSeg.length - suffix.length);
259
+ }
260
+ }
261
+ if (name !== null) {
262
+ if (value.length === 0) return null;
263
+ if (value.includes("/") || value.includes("\\")) return null;
264
+ params[name] = value;
265
+ continue;
266
+ }
267
+ if (tSeg !== pSeg) return null;
268
+ }
269
+ return params;
270
+ }
271
+
272
+ // src/route-contract/params.ts
273
+ function extractParamNames(template) {
274
+ const names = [];
275
+ const re = /:([A-Za-z_][A-Za-z0-9_]*)/g;
276
+ let match;
277
+ while ((match = re.exec(template)) !== null) {
278
+ const name = match[1];
279
+ if (name !== void 0) names.push(name);
280
+ }
281
+ return names;
282
+ }
283
+
284
+ // src/route-contract/validate.ts
285
+ var TEMPLATE_CHARSET_RE = /^[A-Za-z0-9/_.:@-]+$/;
286
+ var FIXED_TEXT_ROLES = /* @__PURE__ */ new Set(["talentProfile"]);
287
+ function checkRoleTemplate(role, template) {
288
+ const errors = [];
289
+ if (typeof template !== "string" || template.length === 0) {
290
+ errors.push({
291
+ code: "invalid_template",
292
+ message: `role '${role}' template must be a non-empty string`,
293
+ role
294
+ });
295
+ return errors;
296
+ }
297
+ if (!template.startsWith("/")) {
298
+ errors.push({
299
+ code: "invalid_template",
300
+ message: `role '${role}' template must start with '/', got ${JSON.stringify(template)}`,
301
+ role
302
+ });
303
+ return errors;
304
+ }
305
+ if (/\s/.test(template)) {
306
+ errors.push({
307
+ code: "invalid_template",
308
+ message: `role '${role}' template must not contain whitespace, got ${JSON.stringify(template)}`,
309
+ role
310
+ });
311
+ return errors;
312
+ }
313
+ if (template.includes("?") || template.includes("#")) {
314
+ errors.push({
315
+ code: "template_query_or_fragment",
316
+ message: `role '${role}' template must not contain '?' or '#', got ${JSON.stringify(template)}`,
317
+ role
318
+ });
319
+ return errors;
320
+ }
321
+ if (template.startsWith("//") || template.includes("//")) {
322
+ errors.push({
323
+ code: "template_double_slash",
324
+ message: `role '${role}' template must not contain '//', got ${JSON.stringify(template)}`,
325
+ role
326
+ });
327
+ return errors;
328
+ }
329
+ for (const seg of template.split("/")) {
330
+ if (seg === "." || seg === "..") {
331
+ errors.push({
332
+ code: "template_dot_segment",
333
+ message: `role '${role}' template must not contain '.' or '..' segments, got ${JSON.stringify(template)}`,
334
+ role
335
+ });
336
+ return errors;
337
+ }
338
+ }
339
+ if (!TEMPLATE_CHARSET_RE.test(template)) {
340
+ errors.push({
341
+ code: "template_illegal_charset",
342
+ message: `role '${role}' template contains illegal characters, got ${JSON.stringify(template)}`,
343
+ role
344
+ });
345
+ return errors;
346
+ }
347
+ const registry = ROLE_PARAM_REGISTRY[role];
348
+ const tokens = extractParamNames(template);
349
+ if (registry.length === 0) {
350
+ if (tokens.length > 0) {
351
+ errors.push({
352
+ code: "static_has_params",
353
+ message: `static role '${role}' template must not contain ':' tokens, got ${template}`,
354
+ role,
355
+ token: tokens[0]
356
+ });
357
+ }
358
+ } else {
359
+ const registrySet = new Set(registry);
360
+ for (const token of tokens) {
361
+ if (!registrySet.has(token)) {
362
+ errors.push({
363
+ code: "unregistered_param",
364
+ message: `role '${role}' template references unregistered param ':${token}'`,
365
+ role,
366
+ token
367
+ });
368
+ }
369
+ }
370
+ const retained = tokens.filter((t) => registrySet.has(t));
371
+ if (retained.length === 0) {
372
+ errors.push({
373
+ code: "missing_entity_param",
374
+ message: `role '${role}' template must contain at least one registry param (${registry.join(", ")})`,
375
+ role
376
+ });
377
+ }
378
+ if (FIXED_TEXT_ROLES.has(role) && isParamsOnly(template)) {
379
+ errors.push({
380
+ code: "invalid_template",
381
+ message: `role '${role}' template must carry fixed text around its params (e.g. '/p/:handle' or '/@:handle'), got ${JSON.stringify(template)}`,
382
+ role
383
+ });
384
+ }
385
+ }
386
+ return errors;
387
+ }
388
+ function isParamsOnly(template) {
389
+ return template.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, "").replace(/\//g, "").length === 0;
390
+ }
391
+
40
392
  // src/doctor/checks.ts
41
393
  function record(id, tier) {
42
394
  return (status, detail) => ({
@@ -83,8 +435,42 @@ function checkEnv(env) {
83
435
  return results;
84
436
  }
85
437
  var JOB_DETAIL_LINK_RE = /href="(\/companies\/[a-z0-9-]+\/jobs\/[a-z0-9-]+)"/i;
86
- function extractJobDetailLink(html) {
87
- return html.match(JOB_DETAIL_LINK_RE)?.[1] ?? null;
438
+ function extractJobDetailLink(html, templates) {
439
+ if (!templates) return html.match(JOB_DETAIL_LINK_RE)?.[1] ?? null;
440
+ for (const match of html.matchAll(HREF_PATH_RE)) {
441
+ const path = match[1];
442
+ if (path.endsWith("/")) continue;
443
+ for (const template of templates) {
444
+ const params = matchPathToTemplate(template, path);
445
+ if (params && Object.values(params).every((v) => SLUG_RE.test(v))) {
446
+ return path;
447
+ }
448
+ }
449
+ }
450
+ return null;
451
+ }
452
+ var HREF_PATH_RE = /href="(\/[^"?#\s]+)"/gi;
453
+ var SLUG_RE = /^[a-z0-9-]+$/i;
454
+ var CANONICAL_JOB_DETAIL_TEMPLATE = CANONICAL_MANIFEST.roles.jobDetail;
455
+ function manifestRoleTemplate(manifest, name) {
456
+ const roles = manifest !== null && typeof manifest === "object" && manifest.version === 1 ? manifest.roles : null;
457
+ const template = roles !== null && typeof roles === "object" ? roles[name] : void 0;
458
+ return typeof template === "string" && checkRoleTemplate(name, template).length === 0 ? template : null;
459
+ }
460
+ function jobRoutesFromManifest(manifest) {
461
+ const role = (name) => manifestRoleTemplate(manifest, name);
462
+ const jobs = role("jobs");
463
+ const company = role("company");
464
+ const jobsWord = jobs?.split("/").filter((seg) => seg !== "" && !seg.includes(":")).at(-1);
465
+ const derived = company && jobsWord ? `${company}/${jobsWord}/:jobSlug` : null;
466
+ const detail = role("jobDetail") ?? (derived && checkRoleTemplate("jobDetail", derived).length === 0 ? derived : null);
467
+ return {
468
+ jobsPath: jobs ?? "/jobs",
469
+ jobDetailTemplates: detail && detail !== CANONICAL_JOB_DETAIL_TEMPLATE ? [detail, CANONICAL_JOB_DETAIL_TEMPLATE] : [CANONICAL_JOB_DETAIL_TEMPLATE]
470
+ };
471
+ }
472
+ function describeJobDetailTemplate(template) {
473
+ return template.replace(":companySlug", "{c}").replace(":jobSlug", "{s}").replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, "{$1}");
88
474
  }
89
475
  var JSON_LD_RE = /<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi;
90
476
  function extractJobPostingJsonLd(html) {
@@ -415,6 +801,25 @@ async function probe(fetchImpl, url) {
415
801
  };
416
802
  }
417
803
  }
804
+ async function probeHop(fetchImpl, url) {
805
+ try {
806
+ const response = await fetchImpl(url, {
807
+ redirect: "manual",
808
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
809
+ });
810
+ await response.body?.cancel().catch(() => void 0);
811
+ return {
812
+ status: response.status,
813
+ location: response.headers.get("location")
814
+ };
815
+ } catch (error) {
816
+ return {
817
+ status: 0,
818
+ location: null,
819
+ error: error instanceof Error ? error.message : String(error)
820
+ };
821
+ }
822
+ }
418
823
 
419
824
  // node_modules/.pnpm/parse5@7.3.0/node_modules/parse5/dist/common/unicode.js
420
825
  var UNDEFINED_CODE_POINTS = /* @__PURE__ */ new Set([
@@ -8351,6 +8756,142 @@ function hasCrawlableCavunoBacklink(html) {
8351
8756
  return visit(root);
8352
8757
  }
8353
8758
 
8759
+ // src/doctor/localized-urls.ts
8760
+ var LOCALIZED_URLS = record("read.localizedUrls", 2);
8761
+ var REDIRECT_OK = /* @__PURE__ */ new Set([
8762
+ "employers",
8763
+ "alertsManage",
8764
+ "alertsConfirm"
8765
+ ]);
8766
+ var MAY_BE_UNPUBLISHED = /* @__PURE__ */ new Set([
8767
+ "blog",
8768
+ "about",
8769
+ "talent",
8770
+ "privacyPolicy",
8771
+ "termsOfService",
8772
+ "cookiePolicy",
8773
+ "impressum"
8774
+ ]);
8775
+ var GATE_SEGMENTS = /* @__PURE__ */ new Set([
8776
+ "password",
8777
+ "coming-soon-gate"
8778
+ ]);
8779
+ var CONCURRENCY = 1;
8780
+ var segments = (path) => path.split("/").filter(Boolean);
8781
+ function translatedStaticRoles(manifest) {
8782
+ const translated = [];
8783
+ for (const role of ALL_ROLES) {
8784
+ if (ROLE_PARAM_REGISTRY[role].length > 0 || role === "home") continue;
8785
+ const english = CANONICAL_MANIFEST.roles[role];
8786
+ const localized = manifestRoleTemplate(manifest, role);
8787
+ if (!english || !localized || localized === english) continue;
8788
+ if (segments(localized).length !== segments(english).length) continue;
8789
+ translated.push({ role, english, localized });
8790
+ }
8791
+ return translated;
8792
+ }
8793
+ function resolveLocation(location, requestUrl) {
8794
+ try {
8795
+ return new URL(location, requestUrl);
8796
+ } catch {
8797
+ return null;
8798
+ }
8799
+ }
8800
+ function pathUnder(base, url) {
8801
+ const prefix = base.pathname.replace(/\/$/, "");
8802
+ const path = prefix && url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) || "/" : url.pathname;
8803
+ return `${path}${url.search}`;
8804
+ }
8805
+ function isGate(base, url, requested) {
8806
+ const parts = segments(pathUnder(base, url).split("?")[0]);
8807
+ const last = parts.at(-1);
8808
+ if (last !== void 0 && GATE_SEGMENTS.has(last)) return true;
8809
+ return parts.length === 1 && url.searchParams.get("redirect") === requested;
8810
+ }
8811
+ async function checkRole(fetchImpl, baseUrl, { role, english, localized }) {
8812
+ const base = new URL(baseUrl);
8813
+ const problems = [];
8814
+ const englishUrl = `${baseUrl}${english}`;
8815
+ const hop = await probeHop(fetchImpl, englishUrl);
8816
+ const target = hop.location === null ? null : resolveLocation(hop.location, englishUrl);
8817
+ if (hop.status !== 308) {
8818
+ problems.push(
8819
+ `${english} \u2192 HTTP ${hop.status}${hop.error ? ` (${hop.error})` : ""}${target ? ` to ${target.href}` : ""}, expected 308 to ${localized}`
8820
+ );
8821
+ } else if (target === null) {
8822
+ problems.push(
8823
+ `${english} \u2192 308 without a usable Location, expected ${localized}`
8824
+ );
8825
+ } else if (target.origin !== base.origin) {
8826
+ problems.push(
8827
+ `${english} \u2192 308 off the board's origin to ${target.href}, expected ${localized}`
8828
+ );
8829
+ } else if (pathUnder(base, target) !== localized) {
8830
+ problems.push(
8831
+ `${english} \u2192 308 to ${pathUnder(base, target)}, expected ${localized}`
8832
+ );
8833
+ }
8834
+ const pageUrl = `${baseUrl}${localized}`;
8835
+ const page = await probeHop(fetchImpl, pageUrl);
8836
+ const pageTarget = page.location === null ? null : resolveLocation(page.location, pageUrl);
8837
+ const ok2xx = page.status >= 200 && page.status < 300;
8838
+ const redirected = page.status >= 300 && page.status < 400;
8839
+ if (ok2xx) {
8840
+ } else if (page.status === 404 && MAY_BE_UNPUBLISHED.has(role)) {
8841
+ } else if (redirected && pageTarget === null) {
8842
+ problems.push(
8843
+ `${localized} \u2192 HTTP ${page.status} without a usable Location, expected 2xx`
8844
+ );
8845
+ } else if (redirected && pageTarget.origin !== base.origin) {
8846
+ problems.push(
8847
+ `${localized} \u2192 HTTP ${page.status} off the board's origin to ${pageTarget.href}, expected 2xx`
8848
+ );
8849
+ } else if (redirected && isGate(base, pageTarget, localized)) {
8850
+ problems.push(
8851
+ `${localized} \u2192 HTTP ${page.status} to the board's access gate ${pathUnder(base, pageTarget)}, expected 2xx`
8852
+ );
8853
+ } else if (!(redirected && REDIRECT_OK.has(role))) {
8854
+ problems.push(
8855
+ `${localized} \u2192 HTTP ${page.status}${page.error ? ` (${page.error})` : ""}${pageTarget ? ` to ${pathUnder(base, pageTarget)}` : ""}, expected 2xx`
8856
+ );
8857
+ }
8858
+ return problems.length > 0 ? `${role}: ${problems.join("; ")}` : null;
8859
+ }
8860
+ async function probeLocalizedUrls(fetchImpl, base, manifest) {
8861
+ if (manifest === null) {
8862
+ return LOCALIZED_URLS(
8863
+ "skip",
8864
+ "not applicable \u2014 no readable route manifest at /.well-known/cavuno.json"
8865
+ );
8866
+ }
8867
+ const roles = translatedStaticRoles(manifest);
8868
+ if (roles.length === 0) {
8869
+ return LOCALIZED_URLS(
8870
+ "skip",
8871
+ "not applicable \u2014 the route manifest publishes no translated URL words"
8872
+ );
8873
+ }
8874
+ const failures = new Array(roles.length).fill(null);
8875
+ let next = 0;
8876
+ const worker = async () => {
8877
+ while (next < roles.length) {
8878
+ const index = next++;
8879
+ failures[index] = await checkRole(fetchImpl, base, roles[index]);
8880
+ }
8881
+ };
8882
+ await Promise.all(
8883
+ Array.from({ length: Math.min(CONCURRENCY, roles.length) }, worker)
8884
+ );
8885
+ const failed = failures.filter((f) => f !== null);
8886
+ if (failed.length > 0) {
8887
+ return LOCALIZED_URLS("fail", failed.join(" | "));
8888
+ }
8889
+ return LOCALIZED_URLS(
8890
+ "pass",
8891
+ `${roles.length} translated pages: each English path 308s to the board's word and the page answers (${roles.map((r) => `${r.english} \u2192 ${r.localized}`).join(", ")})`
8892
+ );
8893
+ }
8894
+
8354
8895
  // src/doctor/read.ts
8355
8896
  var READ = {
8356
8897
  home: record("read.home", 2),
@@ -8363,7 +8904,8 @@ var READ = {
8363
8904
  indexNow: record("read.indexNow", 2),
8364
8905
  googleVerification: record("read.googleVerification", 2),
8365
8906
  attribution: record("read.attribution", 2),
8366
- oauthCallback: record("read.oauthCallback", 2)
8907
+ oauthCallback: record("read.oauthCallback", 2),
8908
+ localizedUrls: LOCALIZED_URLS
8367
8909
  };
8368
8910
  function skipReadProbes(reason) {
8369
8911
  return Object.values(READ).map((make) => make("skip", reason));
@@ -8384,9 +8926,19 @@ async function confirmsNoPublishedJobs(fetchImpl, env) {
8384
8926
  return false;
8385
8927
  }
8386
8928
  }
8387
- async function probeJobsAndJsonLd(fetchImpl, base, env) {
8388
- const jobs = await probe(fetchImpl, `${base}/jobs`);
8389
- const jobLink = jobs.ok ? extractJobDetailLink(jobs.body) : null;
8929
+ async function readManifest(fetchImpl, base) {
8930
+ const manifest = await probe(fetchImpl, `${base}/.well-known/cavuno.json`);
8931
+ if (!manifest.ok) return null;
8932
+ try {
8933
+ return JSON.parse(manifest.body);
8934
+ } catch {
8935
+ return null;
8936
+ }
8937
+ }
8938
+ async function probeJobsAndJsonLd(fetchImpl, base, manifest, env) {
8939
+ const { jobsPath, jobDetailTemplates } = jobRoutesFromManifest(manifest);
8940
+ const jobs = await probe(fetchImpl, `${base}${jobsPath}`);
8941
+ const jobLink = jobs.ok ? extractJobDetailLink(jobs.body, jobDetailTemplates) : null;
8390
8942
  if (jobs.status === 200 && !jobLink && /<(html|body|main)[\s>]/i.test(jobs.body) && await confirmsNoPublishedJobs(fetchImpl, env)) {
8391
8943
  return [
8392
8944
  READ.jobs("pass", "listing renders; API confirms zero published jobs"),
@@ -8395,14 +8947,14 @@ async function probeJobsAndJsonLd(fetchImpl, base, env) {
8395
8947
  }
8396
8948
  const jobsResult = jobs.ok && jobLink ? READ.jobs("pass", `listing renders with job detail links (${jobLink})`) : READ.jobs(
8397
8949
  "fail",
8398
- jobs.ok ? "listing renders but contains no /companies/{c}/jobs/{s} detail links" : `listing HTTP ${jobs.status}`
8950
+ jobs.ok ? `listing renders but contains no ${describeJobDetailTemplate(jobDetailTemplates[0])} detail links` : `listing HTTP ${jobs.status}`
8399
8951
  );
8400
8952
  if (!jobLink) {
8401
8953
  return [
8402
8954
  jobsResult,
8403
8955
  READ.jsonld(
8404
8956
  "skip",
8405
- 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)"
8957
+ 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)`
8406
8958
  )
8407
8959
  ];
8408
8960
  }
@@ -8633,7 +9185,13 @@ function scoreOAuthCallback(probed) {
8633
9185
  async function runReadProbes(fetchImpl, frontendUrl, seo, env) {
8634
9186
  const base = frontendUrl.replace(/\/$/, "");
8635
9187
  const home = await probe(fetchImpl, base);
8636
- const jobsAndJsonLd = await probeJobsAndJsonLd(fetchImpl, base, env);
9188
+ const manifest = await readManifest(fetchImpl, base);
9189
+ const jobsAndJsonLd = await probeJobsAndJsonLd(
9190
+ fetchImpl,
9191
+ base,
9192
+ manifest,
9193
+ env
9194
+ );
8637
9195
  const sitemap = await probeSitemap(fetchImpl, base);
8638
9196
  const robots = await probe(fetchImpl, `${base}/robots.txt`);
8639
9197
  const adsTxt = await probe(fetchImpl, `${base}/ads.txt`);
@@ -8648,6 +9206,7 @@ async function runReadProbes(fetchImpl, frontendUrl, seo, env) {
8648
9206
  `${base}/api/board-auth/oauth/linkedin/callback`
8649
9207
  )
8650
9208
  };
9209
+ const localizedUrls = await probeLocalizedUrls(fetchImpl, base, manifest);
8651
9210
  const board = env?.apiUrl && env.boardKey ? await probe(
8652
9211
  fetchImpl,
8653
9212
  `${apiBase(env.apiUrl)}/v1/boards/${encodeURIComponent(env.boardKey)}`
@@ -8686,7 +9245,8 @@ async function runReadProbes(fetchImpl, frontendUrl, seo, env) {
8686
9245
  }),
8687
9246
  scoreGoogleVerification(home.body, seo),
8688
9247
  attribution,
8689
- scoreOAuthCallback(oauthCallback)
9248
+ scoreOAuthCallback(oauthCallback),
9249
+ localizedUrls
8690
9250
  ];
8691
9251
  }
8692
9252
 
@@ -1,5 +1,5 @@
1
- import { J as JobSort, E as EmploymentType, R as RemoteOption, S as Seniority } from './jobs-C_eO-WfM.mjs';
2
- import './_spec-B2aizgxr.mjs';
1
+ import { J as JobSort, E as EmploymentType, R as RemoteOption, S as Seniority } from './jobs-D9ERWWda.mjs';
2
+ import './_spec-CdNZDaaR.mjs';
3
3
 
4
4
  /**
5
5
  * `@cavuno/board/filters` — the canonical listing-filter vocabulary and URL
package/dist/filters.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { J as JobSort, E as EmploymentType, R as RemoteOption, S as Seniority } from './jobs-SAkNWgAB.js';
2
- import './_spec-B2aizgxr.js';
1
+ import { J as JobSort, E as EmploymentType, R as RemoteOption, S as Seniority } from './jobs-C7UvN_dW.js';
2
+ import './_spec-CdNZDaaR.js';
3
3
 
4
4
  /**
5
5
  * `@cavuno/board/filters` — the canonical listing-filter vocabulary and URL
package/dist/format.d.mts CHANGED
@@ -1,7 +1,7 @@
1
- export { C as CurrencyDisplay, F as FormattedSalaryRange, J as JobBreadcrumb, N as NumberNotation, S as SalaryTimeframeInput, a as SalaryTimeframeValue, b as buildJobBreadcrumbs, f as formatSalaryRange, c as formatSalaryStat, d as formatSalaryStatRange, n as normalizeWebsiteUrl } from './breadcrumbs-nTfIWzRh.mjs';
2
- import { C as CustomFieldDefinition } from './board-DK4HmSty.mjs';
3
- import { C as CustomFieldValues } from './jobs-C_eO-WfM.mjs';
4
- import './_spec-B2aizgxr.mjs';
1
+ export { C as CurrencyDisplay, F as FormattedSalaryRange, J as JobBreadcrumb, N as NumberNotation, S as SalaryTimeframeInput, a as SalaryTimeframeValue, b as buildJobBreadcrumbs, f as formatSalaryRange, c as formatSalaryStat, d as formatSalaryStatRange, n as normalizeWebsiteUrl } from './breadcrumbs-BY1UMCbL.mjs';
2
+ import { C as CustomFieldDefinition } from './board-CJw-MJpf.mjs';
3
+ import { C as CustomFieldValues } from './jobs-D9ERWWda.mjs';
4
+ import './_spec-CdNZDaaR.mjs';
5
5
 
6
6
  /**
7
7
  * Date display helpers in the board language (required leading parameter,
package/dist/format.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- export { C as CurrencyDisplay, F as FormattedSalaryRange, J as JobBreadcrumb, N as NumberNotation, S as SalaryTimeframeInput, a as SalaryTimeframeValue, b as buildJobBreadcrumbs, f as formatSalaryRange, c as formatSalaryStat, d as formatSalaryStatRange, n as normalizeWebsiteUrl } from './breadcrumbs-CtKa2zPe.js';
2
- import { C as CustomFieldDefinition } from './board-DWeT9Jbl.js';
3
- import { C as CustomFieldValues } from './jobs-SAkNWgAB.js';
4
- import './_spec-B2aizgxr.js';
1
+ export { C as CurrencyDisplay, F as FormattedSalaryRange, J as JobBreadcrumb, N as NumberNotation, S as SalaryTimeframeInput, a as SalaryTimeframeValue, b as buildJobBreadcrumbs, f as formatSalaryRange, c as formatSalaryStat, d as formatSalaryStatRange, n as normalizeWebsiteUrl } from './breadcrumbs-CXvdv6Ry.js';
2
+ import { C as CustomFieldDefinition } from './board-3JU14eF3.js';
3
+ import { C as CustomFieldValues } from './jobs-C7UvN_dW.js';
4
+ import './_spec-CdNZDaaR.js';
5
5
 
6
6
  /**
7
7
  * Date display helpers in the board language (required leading parameter,