jamdesk 1.1.199 → 1.1.201

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.1.199",
3
+ "version": "1.1.201",
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",
@@ -17,32 +17,58 @@ interface OpenApiSpec {
17
17
  [key: string]: unknown;
18
18
  }
19
19
 
20
- // Cache for OpenAPI specs (longer TTL since specs change infrequently)
21
- const specCache = new Map<string, { spec: OpenApiSpec; timestamp: number }>();
20
+ // Cache for OpenAPI specs (longer TTL since specs change infrequently).
21
+ //
22
+ // Stores the in-flight PROMISE rather than the resolved value, and stores it
23
+ // BEFORE awaiting. Next.js resolves `generateMetadata` and renders the page
24
+ // component for the same request, and both paths resolve the same spec
25
+ // (render-doc-page.tsx buildDocMetadata + renderDocPage). With a value cache
26
+ // populated only after the await, both missed and both paid a full R2 fetch
27
+ // AND a full SwaggerParser.dereference — the dereference being the expensive
28
+ // half. Caching the promise makes the second caller join the first.
29
+ const specCache = new Map<string, { spec: Promise<OpenApiSpec>; timestamp: number }>();
22
30
  const CACHE_TTL = 600_000; // 10 minutes
23
31
 
24
- /** Fetch OpenAPI spec from external URL. */
25
- export async function fetchOpenApiSpec(url: string): Promise<OpenApiSpec> {
26
- // Check cache
27
- const cached = specCache.get(url);
32
+ /**
33
+ * Memoize one spec load on `cacheKey` for CACHE_TTL, de-duplicating concurrent
34
+ * callers. A rejected load is evicted instead of being served for the rest of
35
+ * the TTL, so one transient R2 failure cannot pin a project's specs broken for
36
+ * ten minutes.
37
+ */
38
+ function cachedSpec(
39
+ cacheKey: string,
40
+ produce: () => Promise<OpenApiSpec>,
41
+ ): Promise<OpenApiSpec> {
42
+ const cached = specCache.get(cacheKey);
28
43
  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
29
44
  return cached.spec;
30
45
  }
31
46
 
32
- const response = await fetch(url, {
33
- headers: { Accept: 'application/json' },
47
+ const spec = produce();
48
+ specCache.set(cacheKey, { spec, timestamp: Date.now() });
49
+ // Evict on failure. The identity check means a later successful entry is
50
+ // never deleted by an earlier failure resolving after it. Attaching a
51
+ // handler here also keeps this bookkeeping from surfacing as an unhandled
52
+ // rejection; the caller still sees the throw from its own await.
53
+ spec.catch(() => {
54
+ if (specCache.get(cacheKey)?.spec === spec) specCache.delete(cacheKey);
34
55
  });
56
+ return spec;
57
+ }
35
58
 
36
- if (!response.ok) {
37
- throw new Error(`Failed to fetch OpenAPI spec: ${response.status}`);
38
- }
39
-
40
- const spec = (await response.json()) as OpenApiSpec;
41
-
42
- // Cache it
43
- specCache.set(url, { spec, timestamp: Date.now() });
59
+ /** Fetch OpenAPI spec from external URL. */
60
+ export function fetchOpenApiSpec(url: string): Promise<OpenApiSpec> {
61
+ return cachedSpec(url, async () => {
62
+ const response = await fetch(url, {
63
+ headers: { Accept: 'application/json' },
64
+ });
65
+
66
+ if (!response.ok) {
67
+ throw new Error(`Failed to fetch OpenAPI spec: ${response.status}`);
68
+ }
44
69
 
45
- return spec;
70
+ return (await response.json()) as OpenApiSpec;
71
+ });
46
72
  }
47
73
 
48
74
  /**
@@ -51,34 +77,23 @@ export async function fetchOpenApiSpec(url: string): Promise<OpenApiSpec> {
51
77
  * Handles both YAML and JSON formats, and resolves all internal $ref
52
78
  * references (matching the static mode behavior of SwaggerParser.validate).
53
79
  */
54
- export async function fetchOpenApiSpecFromR2(
80
+ export function fetchOpenApiSpecFromR2(
55
81
  projectSlug: string,
56
82
  specPath: string
57
83
  ): Promise<OpenApiSpec> {
58
- const cacheKey = `r2:${projectSlug}:${specPath}`;
59
-
60
- // Check cache
61
- const cached = specCache.get(cacheKey);
62
- if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
63
- return cached.spec;
64
- }
65
-
66
- const content = await fetchOpenApiFile(projectSlug, specPath);
67
-
68
- // Parse YAML or JSON into a plain object
69
- const isYaml = /\.ya?ml$/i.test(specPath);
70
- const raw = isYaml ? yamlLoad(content) : JSON.parse(content);
71
-
72
- // Dereference all $ref pointers (matching static mode's SwaggerParser.validate behavior).
73
- // SwaggerParser.dereference accepts a loose Document type that does not line up with
74
- // our internal OpenApiSpec; intermediate `any` cast is required at this library boundary.
75
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SwaggerParser type interop (see CLAUDE.md memory)
76
- const spec = await SwaggerParser.dereference(raw as any) as unknown as OpenApiSpec;
77
-
78
- // Cache it
79
- specCache.set(cacheKey, { spec, timestamp: Date.now() });
80
-
81
- return spec;
84
+ return cachedSpec(`r2:${projectSlug}:${specPath}`, async () => {
85
+ const content = await fetchOpenApiFile(projectSlug, specPath);
86
+
87
+ // Parse YAML or JSON into a plain object
88
+ const isYaml = /\.ya?ml$/i.test(specPath);
89
+ const raw = isYaml ? yamlLoad(content) : JSON.parse(content);
90
+
91
+ // Dereference all $ref pointers (matching static mode's SwaggerParser.validate behavior).
92
+ // SwaggerParser.dereference accepts a loose Document type that does not line up with
93
+ // our internal OpenApiSpec; intermediate `any` cast is required at this library boundary.
94
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SwaggerParser type interop (see CLAUDE.md memory)
95
+ return await SwaggerParser.dereference(raw as any) as unknown as OpenApiSpec;
96
+ });
82
97
  }
83
98
 
84
99
  /**
@@ -348,6 +348,82 @@ export async function resolveOpenApiMetaDescription(
348
348
  return null;
349
349
  }
350
350
 
351
+ /**
352
+ * The description used for `<meta>`, the OG/Twitter tags and the OG card
353
+ * image: authored frontmatter first, then the OpenAPI operation, then the
354
+ * page's first prose paragraph.
355
+ *
356
+ * Shared by `buildDocMetadata` and `renderDocPage` so the meta tags and the
357
+ * JSON-LD `image` resolve the SAME text. They used to disagree: only the
358
+ * metadata path resolved a description, so on pages with none authored the
359
+ * JSON-LD card URL omitted `description=` while og:image carried it, and the
360
+ * two URLs rendered two genuinely different cards — two OG renders and two CDN
361
+ * entries per page.
362
+ *
363
+ * The gate stays on the RAW value, not frontmatterText(): YAML hands back a
364
+ * Date for `description: 2026-01-01` and a number for `description: 404`.
365
+ * Both are truthy and must keep suppressing the fallbacks exactly as before.
366
+ *
367
+ * NEVER write the result back onto `data`. `parseFrontmatter` is gray-matter,
368
+ * whose cache returns a shallow copy that SHARES one `data` object per content
369
+ * string (gray-matter/index.js:39, `Object.assign({}, cached)`), so an
370
+ * assignment reached `renderDocPage`'s own parse of the same page and rendered
371
+ * as the page SUBTITLE: the operation description twice on every spec-backed
372
+ * page, and the first paragraph twice on every description-less prose page.
373
+ * The cache is module-global, so it persisted across requests rather than
374
+ * being a within-request ordering race. (#7 follow-up.)
375
+ */
376
+ export async function resolveDerivedDescription(
377
+ config: DocsConfig,
378
+ data: FrontmatterData,
379
+ content: string,
380
+ projectSlug: string | null | undefined,
381
+ ): Promise<string | undefined> {
382
+ let metaDescription: string | undefined = data.description;
383
+ if (metaDescription) return metaDescription;
384
+
385
+ if (typeof data.openapi === 'string' && data.openapi && config.api?.openapi) {
386
+ // Source the description from the OpenAPI operation before falling back to
387
+ // first-paragraph extraction (which is empty for prose-less API pages).
388
+ // The spec loader REUSES the render path's cached loaders — the ISR module
389
+ // cache (`r2:${slug}:${specPath}`, 10-min TTL) that `renderDocPage`
390
+ // populates when it renders the page's <ApiEndpoint>, or the static
391
+ // `getCachedSpec`. That cache stores the in-flight PROMISE, so this lookup
392
+ // joins the render's load rather than starting a second one — Next.js runs
393
+ // `generateMetadata` and the page component for the same request, and a
394
+ // value-cache populated only after the await let BOTH miss and both pay a
395
+ // full R2 fetch plus a full SwaggerParser.dereference. See openapi-isr.ts
396
+ // `cachedSpec`. That matters because docs routes are force-dynamic, so this
397
+ // metadata runs on EVERY page view (every human + crawler). Mirrors the
398
+ // `useIsr`/`projectDir` derivation in `renderDocPage`'s OpenAPI branch.
399
+ const specPaths = collectLocalSpecPaths(config.api.openapi);
400
+ const useIsr = isIsrMode() && !!projectSlug;
401
+ const projectDir = useIsr ? null : getContentDir();
402
+ const loadSpecForMeta = async (sp: string): Promise<unknown | null> => {
403
+ try {
404
+ if (useIsr && projectSlug) {
405
+ const { resolveOpenApiSpec } = await import('@/lib/openapi-isr');
406
+ return await resolveOpenApiSpec(projectSlug, sp);
407
+ }
408
+ if (projectDir) {
409
+ const { api } = await getCachedSpec(sp, projectDir);
410
+ return api;
411
+ }
412
+ return null;
413
+ } catch {
414
+ // Any load/parse error degrades to generateAutoDescription below.
415
+ return null;
416
+ }
417
+ };
418
+ const sourced = await resolveOpenApiMetaDescription(
419
+ data.openapi, specPaths, loadSpecForMeta,
420
+ );
421
+ if (sourced) metaDescription = sourced;
422
+ }
423
+
424
+ return metaDescription || generateAutoDescription(content);
425
+ }
426
+
351
427
  export async function buildDocMetadata(input: RenderInput): Promise<Metadata> {
352
428
  const { slug: slugInput, projectSlug, hostAtDocs, docsPrefix, requestHeaders } = input;
353
429
  const linkPrefix = docsPrefix ?? (hostAtDocs ? '/docs' : '');
@@ -413,59 +489,9 @@ export async function buildDocMetadata(input: RenderInput): Promise<Metadata> {
413
489
  const parsed = parseFrontmatter(fileContents);
414
490
  const data = parsed.data as FrontmatterData;
415
491
 
416
- // DERIVED for <meta>/OG/Twitter/OG-card only — never written back onto
417
- // `data`. `parseFrontmatter` is gray-matter, whose cache returns a shallow
418
- // copy that SHARES one `data` object per content string, so an assignment
419
- // here reached `renderDocPage`'s own parse of the same page and rendered as
420
- // the page SUBTITLE: the operation description twice on every spec-backed
421
- // page, and the first paragraph twice on every description-less prose page.
422
- // The cache is module-global, so it persisted across requests rather than
423
- // being a within-request ordering race. (#7 follow-up.)
424
- //
425
- // The gate stays on the RAW value, not frontmatterText(): YAML hands back a
426
- // Date for `description: 2026-01-01` and a number for `description: 404`.
427
- // Both are truthy and must keep suppressing the fallbacks exactly as before.
428
- let metaDescription: string | undefined = data.description;
429
- if (!metaDescription) {
430
- if (typeof data.openapi === 'string' && data.openapi && config.api?.openapi) {
431
- // Source the description from the OpenAPI operation before falling back to
432
- // first-paragraph extraction (which is empty for prose-less API pages).
433
- // The spec loader REUSES the render path's cached loaders — the ISR module
434
- // cache (`r2:${slug}:${specPath}`, 10-min TTL) that `renderDocPage`
435
- // populates when it renders the page's <ApiEndpoint>, or the static
436
- // `getCachedSpec`. So the spec is the same warm parse the render already
437
- // did: the marginal cost of this lookup is a cache hit, never an uncached
438
- // fetch+parse. That matters because docs routes are force-dynamic, so this
439
- // metadata runs on EVERY page view (every human + crawler). Mirrors the
440
- // `useIsr`/`projectDir` derivation in `renderDocPage`'s OpenAPI branch.
441
- const specPaths = collectLocalSpecPaths(config.api.openapi);
442
- const useIsr = isIsrMode() && !!projectSlug;
443
- const projectDir = useIsr ? null : getContentDir();
444
- const loadSpecForMeta = async (sp: string): Promise<unknown | null> => {
445
- try {
446
- if (useIsr && projectSlug) {
447
- const { resolveOpenApiSpec } = await import('@/lib/openapi-isr');
448
- return await resolveOpenApiSpec(projectSlug, sp);
449
- }
450
- if (projectDir) {
451
- const { api } = await getCachedSpec(sp, projectDir);
452
- return api;
453
- }
454
- return null;
455
- } catch {
456
- // Any load/parse error degrades to generateAutoDescription below.
457
- return null;
458
- }
459
- };
460
- const sourced = await resolveOpenApiMetaDescription(
461
- data.openapi, specPaths, loadSpecForMeta,
462
- );
463
- if (sourced) metaDescription = sourced;
464
- }
465
- if (!metaDescription) {
466
- metaDescription = generateAutoDescription(parsed.content);
467
- }
468
- }
492
+ const metaDescription = await resolveDerivedDescription(
493
+ config, data, parsed.content, projectSlug,
494
+ );
469
495
 
470
496
  // buildSeoMetadata reads `frontmatter.description` for og:, twitter: and the
471
497
  // OG card, so it must see the RESOLVED text — via a copy, never by mutating
@@ -611,7 +637,15 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
611
637
 
612
638
  const baseUrl = resolveBaseUrl(requestHeaders, projectSlug, hostAtDocs);
613
639
  const faqPairs = extractFaqPairs(rawContent);
614
- const ogImageUrl = buildPageOgImageUrl(config, data, baseUrl, linkPrefix);
640
+ // The same resolution buildDocMetadata performs, so og:image and the JSON-LD
641
+ // `image` point at ONE card. Passed as a copy — never a write onto the shared
642
+ // `data`, which is what caused #7 in the first place.
643
+ const ogDescription = await resolveDerivedDescription(
644
+ config, data, rawContent, projectSlug,
645
+ );
646
+ const ogImageUrl = buildPageOgImageUrl(
647
+ config, { ...data, description: ogDescription }, baseUrl, linkPrefix,
648
+ );
615
649
  // See frontmatterText: a YAML Date title throws when React renders it as a
616
650
  // child below, so every consumer in this function reads the coerced value.
617
651
  const titleText = frontmatterText(data.title);
@@ -879,7 +879,7 @@ const MAX_DESCRIPTION_LENGTH = 155;
879
879
  /** Patterns that indicate a paragraph is not prose (headings, components, images, comments, etc). */
880
880
  const NON_PROSE_PATTERNS = [
881
881
  /^#{1,6}\s/, // headings
882
- /^<[A-Z]/, // MDX components
882
+ /^<\/?[A-Z]/, // MDX components, opening AND closing (`</ResponseExample>`)
883
883
  /^!\[/, // images
884
884
  /^<!--/, // HTML comments
885
885
  /^>/, // blockquotes
@@ -68,9 +68,9 @@
68
68
  }
69
69
  },
70
70
  "node_modules/@alloc/quick-lru": {
71
- "version": "5.2.0",
72
- "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
73
- "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
71
+ "version": "5.3.0",
72
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz",
73
+ "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==",
74
74
  "license": "MIT",
75
75
  "engines": {
76
76
  "node": ">=10"
@@ -2290,12 +2290,12 @@
2290
2290
  }
2291
2291
  },
2292
2292
  "node_modules/commander": {
2293
- "version": "8.3.0",
2294
- "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
2295
- "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
2293
+ "version": "15.0.0",
2294
+ "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
2295
+ "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
2296
2296
  "license": "MIT",
2297
2297
  "engines": {
2298
- "node": ">= 12"
2298
+ "node": ">=22.12.0"
2299
2299
  }
2300
2300
  },
2301
2301
  "node_modules/cose-base": {
@@ -3763,16 +3763,16 @@
3763
3763
  }
3764
3764
  },
3765
3765
  "node_modules/katex": {
3766
- "version": "0.18.4",
3767
- "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.4.tgz",
3768
- "integrity": "sha512-IMPntbRLOU+eu88XDiFKqQ8Akhr9Tv7jDMXqPhjG9SI1JMA4DIgXk4x9k4skJz2NZJXBRbC+2pYBLj9olqcZow==",
3766
+ "version": "0.18.5",
3767
+ "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.5.tgz",
3768
+ "integrity": "sha512-1FU5H3RjGJVj6GT9fMjf2uMIXmPQp232aXS3UEeQzf0dxefHg/CKMvNB3DuOC46LFG/E5PNxrE8dOwB782FDGg==",
3769
3769
  "funding": [
3770
3770
  "https://opencollective.com/katex",
3771
3771
  "https://github.com/sponsors/katex"
3772
3772
  ],
3773
3773
  "license": "MIT",
3774
3774
  "dependencies": {
3775
- "commander": "^8.3.0"
3775
+ "commander": "^15.0.0"
3776
3776
  },
3777
3777
  "bin": {
3778
3778
  "katex": "cli.js"
@@ -4475,6 +4475,15 @@
4475
4475
  "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
4476
4476
  }
4477
4477
  },
4478
+ "node_modules/mermaid/node_modules/commander": {
4479
+ "version": "8.3.0",
4480
+ "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
4481
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
4482
+ "license": "MIT",
4483
+ "engines": {
4484
+ "node": ">= 12"
4485
+ }
4486
+ },
4478
4487
  "node_modules/mermaid/node_modules/katex": {
4479
4488
  "version": "0.16.47",
4480
4489
  "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
@@ -4700,6 +4709,15 @@
4700
4709
  "url": "https://opencollective.com/unified"
4701
4710
  }
4702
4711
  },
4712
+ "node_modules/micromark-extension-math/node_modules/commander": {
4713
+ "version": "8.3.0",
4714
+ "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
4715
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
4716
+ "license": "MIT",
4717
+ "engines": {
4718
+ "node": ">= 12"
4719
+ }
4720
+ },
4703
4721
  "node_modules/micromark-extension-math/node_modules/katex": {
4704
4722
  "version": "0.16.47",
4705
4723
  "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
@@ -5786,6 +5804,15 @@
5786
5804
  "url": "https://opencollective.com/unified"
5787
5805
  }
5788
5806
  },
5807
+ "node_modules/rehype-katex/node_modules/commander": {
5808
+ "version": "8.3.0",
5809
+ "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
5810
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
5811
+ "license": "MIT",
5812
+ "engines": {
5813
+ "node": ">= 12"
5814
+ }
5815
+ },
5789
5816
  "node_modules/rehype-katex/node_modules/katex": {
5790
5817
  "version": "0.16.47",
5791
5818
  "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",