jamdesk 1.1.128 → 1.1.129

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.
@@ -9,6 +9,8 @@ export interface BrokenLink {
9
9
  file: string;
10
10
  line: number | null;
11
11
  link: string;
12
+ /** Present for fragment-not-found warnings; carries any "Did you mean #x?" hint. */
13
+ message?: string;
12
14
  suggestion?: string;
13
15
  }
14
16
  export interface BrokenLinksOptions {
@@ -1 +1 @@
1
- {"version":3,"file":"broken-links.d.ts","sourceRoot":"","sources":["../../src/commands/broken-links.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAaH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,aAAa,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,wBAAsB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAgG5E"}
1
+ {"version":3,"file":"broken-links.d.ts","sourceRoot":"","sources":["../../src/commands/broken-links.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAaH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,aAAa,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,oFAAoF;IACpF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,wBAAsB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAwG5E"}
@@ -66,6 +66,10 @@ export async function brokenLinks(options) {
66
66
  spin.stop();
67
67
  // Add suggestions using Levenshtein distance
68
68
  const brokenWithSuggestions = warnings.map((w) => {
69
+ // Fragment-not-found warnings carry their own message (incl. any
70
+ // "Did you mean #anchor?" hint); a page-path Levenshtein guess would be noise.
71
+ if (w.message)
72
+ return { ...w };
69
73
  let suggestion;
70
74
  let minDistance = Infinity;
71
75
  // Normalize the link for comparison
@@ -86,10 +90,15 @@ export async function brokenLinks(options) {
86
90
  process.exit(0);
87
91
  }
88
92
  console.log('\nBroken links found:\n');
89
- for (const { file, line, link, suggestion } of brokenWithSuggestions) {
93
+ for (const { file, line, link, message, suggestion } of brokenWithSuggestions) {
90
94
  const location = line ? `${file}:${line}` : file;
91
95
  console.log(`${location} - ${link}`);
92
- if (suggestion) {
96
+ if (message) {
97
+ // Fragment-not-found: show the validator's message verbatim (it already
98
+ // includes "Did you mean #anchor?" when a near-miss heading exists).
99
+ console.log(` └─ ${message}`);
100
+ }
101
+ else if (suggestion) {
93
102
  console.log(` └─ Did you mean: ${suggestion}`);
94
103
  }
95
104
  }
@@ -1 +1 @@
1
- {"version":3,"file":"broken-links.js","sourceRoot":"","sources":["../../src/commands/broken-links.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"broken-links.js","sourceRoot":"","sources":["../../src/commands/broken-links.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.1.128",
3
+ "version": "1.1.129",
4
4
  "description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
5
5
  "keywords": [
6
6
  "jamdesk",
@@ -1,5 +1,6 @@
1
1
  import type { ContextualOption, DocsConfig } from './docs-types';
2
2
  import { siteHasOpenApiSpecs } from './api-specs-bundle';
3
+ import { shouldOfferApiSpecDownload } from './api-spec-offer';
3
4
 
4
5
  export interface ApiSpecGateInput {
5
6
  isIsr: boolean;
@@ -10,23 +11,35 @@ export interface ApiSpecGateInput {
10
11
  }
11
12
 
12
13
  /**
13
- * Insert `download-api-spec` iff: ISR mode (route + R2 only exist in prod) AND
14
- * an API page AND the menu is non-empty (respect a disabled menu) AND the site
15
- * has ≥1 spec (page-level OR anywhere in docs.json). Never mutates input or dups.
16
- * The route is the source of truth (404 if assembly is empty); this is a hint.
14
+ * Insert `download-api-spec` into the AI-actions menu iff the page offers the
15
+ * api-specs download (shared gate: shouldOfferApiSpecDownload). The offer
16
+ * decision is shared with the markdown footer so the two cannot drift; this
17
+ * function owns only the menu-specific concerns: idempotency and placement.
17
18
  *
18
- * Placement: directly below "View as Markdown", so the download sits with the
19
- * page-level actions near the top rather than trailing the MCP/editor
20
- * integrations. Falls back to just after "Copy page", then the front, when
21
- * "view" is absent (a customized `contextual.options` list).
19
+ * Placement: directly below "View as Markdown"; falls back to just after "Copy
20
+ * page", then the front, when "view" is absent (a customized options list).
21
+ *
22
+ * `siteHasOpenApiSpecs` walks the whole docs.json navigation, so it is passed as
23
+ * a thunk — the shared predicate evaluates it only after the cheap gates pass
24
+ * and only when the page has no page-level `openapi:`, preserving the original
25
+ * no-walk-on-guide-pages behavior.
22
26
  */
23
27
  export function withApiSpecDownload(
24
28
  options: ContextualOption[],
25
29
  { isIsr, isApiPage, pageHasOpenApi, config }: ApiSpecGateInput,
26
30
  ): ContextualOption[] {
27
- if (!isIsr || !isApiPage || options.length === 0) return options;
28
31
  if (options.includes('download-api-spec')) return options;
29
- if (!(pageHasOpenApi || siteHasOpenApiSpecs(config))) return options;
32
+ if (
33
+ !shouldOfferApiSpecDownload({
34
+ isIsr,
35
+ menuEnabled: options.length > 0,
36
+ isApiPage,
37
+ pageHasOpenApi,
38
+ siteHasSpecs: () => siteHasOpenApiSpecs(config),
39
+ })
40
+ ) {
41
+ return options;
42
+ }
30
43
  const result = [...options];
31
44
  const viewIdx = result.indexOf('view');
32
45
  const copyIdx = result.indexOf('copy');
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Single source of truth for "should this page offer the api-specs download?".
3
+ * Consumed by BOTH the visible menu-button gate (withApiSpecDownload,
4
+ * lib/api-spec-menu-gate.ts) and the agent-facing markdown footer gate
5
+ * (apiSpecsMarkdownFooter, lib/api-specs-markdown-hint.ts), so the two gates
6
+ * cannot drift on the offer condition.
7
+ *
8
+ * Pure: no I/O, no config access. Callers resolve the inputs (ISR, frontmatter,
9
+ * menu, spec existence) and pass primitives.
10
+ *
11
+ * Rule: ISR + the AI-actions menu is enabled + the page is an API page (`api:`
12
+ * or `openapi:` frontmatter) + there is a spec to bundle — a page-level
13
+ * `openapi:` short-circuits the site-wide spec check, exactly as the menu has
14
+ * always done.
15
+ *
16
+ * `siteHasSpecs` may be a thunk. The menu gate's site-spec check
17
+ * (siteHasOpenApiSpecs) walks the whole docs.json navigation; the original gate
18
+ * deliberately avoided that walk on the common non-API-page render path.
19
+ * Passing `() => …` preserves the laziness — the thunk runs only after the cheap
20
+ * gates pass and only when the page has no page-level `openapi:`.
21
+ */
22
+ export interface ApiSpecOfferInput {
23
+ /** ISR runtime. Both gates are inert in local `jamdesk dev` (isIsr=false). */
24
+ isIsr: boolean;
25
+ /**
26
+ * The AI-actions menu is present with ≥1 option — i.e. NOT disabled via
27
+ * `contextual.enabled:false` / `options:[]`. This is a derived fact, not a
28
+ * config flag: callers compute it from the rendered options list
29
+ * (`options.length > 0` / `getContextualOptions(config).length > 0`), which is
30
+ * where menu-enablement actually lives (`lib/contextual-defaults.ts`).
31
+ */
32
+ menuEnabled: boolean;
33
+ /** Page frontmatter declares `api:` or `openapi:`. */
34
+ isApiPage: boolean;
35
+ /** Page frontmatter declares its own `openapi:` — short-circuits the site-wide check. */
36
+ pageHasOpenApi: boolean;
37
+ /**
38
+ * Whether the site references ≥1 OpenAPI spec anywhere. May be a thunk so a
39
+ * caller can defer an expensive docs.json navigation walk until the cheap
40
+ * gates pass; see the menu-gate call site.
41
+ */
42
+ siteHasSpecs: boolean | (() => boolean);
43
+ }
44
+
45
+ export function shouldOfferApiSpecDownload(input: ApiSpecOfferInput): boolean {
46
+ const { isIsr, menuEnabled, isApiPage, pageHasOpenApi, siteHasSpecs } = input;
47
+ if (!(isIsr && menuEnabled && isApiPage)) return false;
48
+ if (pageHasOpenApi) return true;
49
+ return typeof siteHasSpecs === 'function' ? siteHasSpecs() : siteHasSpecs;
50
+ }
@@ -5,6 +5,8 @@
5
5
  * getBaseUrlFromConfig), then calls this.
6
6
  */
7
7
 
8
+ import { shouldOfferApiSpecDownload } from './api-spec-offer';
9
+
8
10
  export interface ApiSpecsHintInput {
9
11
  /** ISR mode only — the route + the R2 bundle only exist in prod. */
10
12
  isIsr: boolean;
@@ -30,11 +32,18 @@ export interface ApiSpecsHintInput {
30
32
  * zip route is the source of truth (it 404s if assembly is empty); this is a hint.
31
33
  */
32
34
  export function apiSpecsMarkdownFooter(input: ApiSpecsHintInput): string | null {
33
- const { isIsr, menuEnabled, pageHasOpenApi, pageHasApi, siteHasSpecs, zipUrl } = input;
34
- if (!isIsr) return null;
35
- if (!menuEnabled) return null; // contextual.enabled:false / options:[]
36
- if (!(pageHasOpenApi || pageHasApi)) return null; // not an API page
37
- if (!(pageHasOpenApi || siteHasSpecs)) return null; // no spec to bundle
38
- if (!zipUrl) return null; // defensive
35
+ const { pageHasOpenApi, pageHasApi, zipUrl } = input;
36
+ if (
37
+ !shouldOfferApiSpecDownload({
38
+ isIsr: input.isIsr,
39
+ menuEnabled: input.menuEnabled,
40
+ isApiPage: pageHasOpenApi || pageHasApi,
41
+ pageHasOpenApi,
42
+ siteHasSpecs: input.siteHasSpecs,
43
+ })
44
+ ) {
45
+ return null;
46
+ }
47
+ if (!zipUrl) return null; // defensive: getBaseUrlFromConfig is total, so this is contract insurance
39
48
  return `\n\n---\n\n📦 **OpenAPI specs:** Every OpenAPI specification referenced by this documentation is available as a single download — ${zipUrl}`;
40
49
  }
@@ -251,6 +251,89 @@ function createSlugger() {
251
251
  };
252
252
  }
253
253
 
254
+ /**
255
+ * Levenshtein edit distance (iterative two-row DP). Inlined rather than pulling
256
+ * a dependency: validate-links.cjs is vendored into the CLI as a self-contained
257
+ * script (only ./github-slugger-regex.cjs ships alongside it), so it must not
258
+ * add runtime requires.
259
+ */
260
+ function levenshtein(a, b) {
261
+ if (a === b) return 0;
262
+ if (a.length === 0) return b.length;
263
+ if (b.length === 0) return a.length;
264
+ let prev = new Array(b.length + 1);
265
+ let curr = new Array(b.length + 1);
266
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
267
+ for (let i = 0; i < a.length; i++) {
268
+ curr[0] = i + 1;
269
+ for (let j = 0; j < b.length; j++) {
270
+ const cost = a[i] === b[j] ? 0 : 1;
271
+ curr[j + 1] = Math.min(curr[j] + 1, prev[j + 1] + 1, prev[j] + cost);
272
+ }
273
+ const tmp = prev;
274
+ prev = curr;
275
+ curr = tmp;
276
+ }
277
+ return prev[b.length];
278
+ }
279
+
280
+ /**
281
+ * Maximum edit distance for a "Did you mean #X?" suggestion. Deliberately small
282
+ * (precision over recall): the dominant real cases — apostrophe slips
283
+ * (reset-a-user-s-password → reset-a-users-password) and accent slips
284
+ * (pagina → página) — are distance 1. A larger cap starts suggesting
285
+ * real-but-wrong anchors, which is worse than no suggestion.
286
+ */
287
+ const MAX_SUGGESTION_DISTANCE = 2;
288
+
289
+ /**
290
+ * Find the closest valid heading slug to a fragment that wasn't found, for a
291
+ * "Did you mean #X?" hint. Precision over recall — only returns a slug for an
292
+ * UNAMBIGUOUS near-miss: within MAX_SUGGESTION_DISTANCE AND strictly closer than
293
+ * every other candidate. So apostrophe/accent/typo slips get a suggestion, but a
294
+ * fragment equidistant from several similar headings (step-1/step-2/…,
295
+ * setup/setup-1, changelog versions) yields NO suggestion rather than an
296
+ * arbitrary, confidently-wrong one. Returns null when nothing qualifies.
297
+ */
298
+ function closestSlug(fragment, slugSet) {
299
+ if (!fragment || !slugSet || slugSet.size === 0) return null;
300
+ let best = null;
301
+ let bestDist = Infinity;
302
+ let secondDist = Infinity;
303
+ for (const slug of slugSet) {
304
+ // Edit distance can't be below the length gap; skip candidates that can't
305
+ // beat the cap. Also bounds the Levenshtein work on very large pages, and
306
+ // can't hide a true tie (a distance ≤2 tie implies a length gap ≤2).
307
+ if (Math.abs(fragment.length - slug.length) > MAX_SUGGESTION_DISTANCE) continue;
308
+ const d = levenshtein(fragment, slug);
309
+ if (d < bestDist) {
310
+ secondDist = bestDist;
311
+ bestDist = d;
312
+ best = slug;
313
+ } else if (d < secondDist) {
314
+ secondDist = d;
315
+ }
316
+ }
317
+ // Unambiguous near-miss: within the cap, not an exact match (≥1), and strictly
318
+ // closer than the runner-up (ties → no suggestion).
319
+ return best !== null &&
320
+ bestDist >= 1 &&
321
+ bestDist <= MAX_SUGGESTION_DISTANCE &&
322
+ bestDist < secondDist
323
+ ? best
324
+ : null;
325
+ }
326
+
327
+ /**
328
+ * Build the "Fragment #X not found in headings" warning message, appending a
329
+ * "Did you mean #Y?" hint when closestSlug found an unambiguous near-miss.
330
+ * Shared by both fragment-not-found sites in validateFile().
331
+ */
332
+ function fragmentNotFoundMessage(fragment, suggestion) {
333
+ return 'Fragment #' + fragment + ' not found in headings' +
334
+ (suggestion ? '. Did you mean #' + suggestion + '?' : '');
335
+ }
336
+
254
337
  /**
255
338
  * Extract heading slugs from MDX content.
256
339
  * Includes markdown headings, <Update label="..."> anchors, and
@@ -411,12 +494,13 @@ function validateFile(filePath, contentDir, results, headingMap) {
411
494
  if (!linkPath && fragment) {
412
495
  const currentSlugs = headingMap.get(currentPagePath);
413
496
  if (currentSlugs && !currentSlugs.has(fragment)) {
497
+ const suggestion = closestSlug(fragment, currentSlugs);
414
498
  results.push({
415
499
  type: 'broken_link',
416
500
  file: relativePath,
417
501
  line: lineNumber,
418
502
  link: rawHref,
419
- message: 'Fragment #' + fragment + ' not found in headings',
503
+ message: fragmentNotFoundMessage(fragment, suggestion),
420
504
  });
421
505
  }
422
506
  continue;
@@ -445,12 +529,13 @@ function validateFile(filePath, contentDir, results, headingMap) {
445
529
  if (fragment) {
446
530
  const targetSlugs = getTargetHeadingSlugs(targetPath, contentDir, headingMap);
447
531
  if (targetSlugs && !targetSlugs.has(fragment)) {
532
+ const suggestion = closestSlug(fragment, targetSlugs);
448
533
  results.push({
449
534
  type: 'broken_link',
450
535
  file: relativePath,
451
536
  line: lineNumber,
452
537
  link: rawHref,
453
- message: 'Fragment #' + fragment + ' not found in headings',
538
+ message: fragmentNotFoundMessage(fragment, suggestion),
454
539
  });
455
540
  }
456
541
  }
@@ -576,7 +661,10 @@ if (require.main === module) {
576
661
  const location = w.line ? `${w.file}:${w.line}` : w.file;
577
662
  console.log(` ${location}`);
578
663
  if (w.message) {
579
- console.log(` ${w.message}: ${w.link}`);
664
+ // message is a full sentence (may end in "…?"); keep the link on its
665
+ // own line so the output reads cleanly rather than "…mean #y?: #x".
666
+ console.log(` ${w.message}`);
667
+ console.log(` Link: ${w.link}`);
580
668
  } else {
581
669
  console.log(` Missing page: ${w.link}`);
582
670
  }
@@ -592,4 +680,4 @@ if (require.main === module) {
592
680
  // Export for programmatic use
593
681
  // generateSlug + createSlugger are exported for the drift test
594
682
  // (validate-links-slug.test.ts), which asserts they match the live github-slugger.
595
- module.exports = { validateProject, generateSlug, createSlugger };
683
+ module.exports = { validateProject, generateSlug, createSlugger, closestSlug, levenshtein };
@@ -2923,9 +2923,9 @@
2923
2923
  }
2924
2924
  },
2925
2925
  "node_modules/dompurify": {
2926
- "version": "3.4.7",
2927
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
2928
- "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
2926
+ "version": "3.4.8",
2927
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
2928
+ "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
2929
2929
  "license": "(MPL-2.0 OR Apache-2.0)",
2930
2930
  "optionalDependencies": {
2931
2931
  "@types/trusted-types": "^2.0.7"