jamdesk 1.1.127 → 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.
Files changed (33) hide show
  1. package/dist/commands/broken-links.d.ts +2 -0
  2. package/dist/commands/broken-links.d.ts.map +1 -1
  3. package/dist/commands/broken-links.js +11 -2
  4. package/dist/commands/broken-links.js.map +1 -1
  5. package/dist/lib/deps.d.ts.map +1 -1
  6. package/dist/lib/deps.js +5 -0
  7. package/dist/lib/deps.js.map +1 -1
  8. package/package.json +2 -1
  9. package/vendored/app/api/markdown-export/[project]/[...slug]/route.ts +71 -1
  10. package/vendored/app/layout.tsx +8 -0
  11. package/vendored/components/AIActionsMenu.tsx +72 -3
  12. package/vendored/components/layout/EmbedLinkInterceptor.tsx +37 -0
  13. package/vendored/components/layout/LayoutWrapper.tsx +29 -1
  14. package/vendored/components/layout/PageColumns.tsx +10 -5
  15. package/vendored/components/navigation/Breadcrumb.tsx +6 -1
  16. package/vendored/components/navigation/SocialFooter.tsx +40 -17
  17. package/vendored/lib/api-spec-menu-gate.ts +49 -0
  18. package/vendored/lib/api-spec-offer.ts +50 -0
  19. package/vendored/lib/api-specs-bundle.ts +255 -0
  20. package/vendored/lib/api-specs-markdown-hint.ts +49 -0
  21. package/vendored/lib/api-specs-route.ts +45 -0
  22. package/vendored/lib/docs-types.ts +1 -1
  23. package/vendored/lib/heading-extractor.ts +34 -30
  24. package/vendored/lib/layout-helpers.tsx +19 -2
  25. package/vendored/lib/middleware-helpers.ts +29 -0
  26. package/vendored/lib/render-doc-page.tsx +93 -76
  27. package/vendored/lib/scanner-blocklist.ts +7 -0
  28. package/vendored/lib/static-artifacts.ts +39 -9
  29. package/vendored/lib/static-file-route.ts +35 -1
  30. package/vendored/scripts/github-slugger-regex.cjs +13 -0
  31. package/vendored/scripts/validate-links.cjs +136 -22
  32. package/vendored/themes/jam/variables.css +8 -0
  33. package/vendored/workspace-package-lock.json +128 -120
@@ -365,6 +365,8 @@ interface DocsChromeProps {
365
365
  customCss: string | null;
366
366
  customJs: string | null;
367
367
  children: React.ReactNode;
368
+ embed?: boolean;
369
+ embedTheme?: 'light' | 'dark' | 'auto';
368
370
  }
369
371
 
370
372
  /**
@@ -383,6 +385,8 @@ export async function DocsChrome({
383
385
  customCss,
384
386
  customJs,
385
387
  children,
388
+ embed,
389
+ embedTheme,
386
390
  }: DocsChromeProps): Promise<React.ReactElement> {
387
391
  // Lowercase to match docs-config canonical case — `data-theme="nebula"` CSS
388
392
  // selectors are case-sensitive, so `theme: "NEBULA"` from disk would silently
@@ -410,6 +414,11 @@ export async function DocsChrome({
410
414
  const appearanceDefault = config.appearance?.default || 'system';
411
415
  const appearanceStrict = config.appearance?.strict || false;
412
416
 
417
+ // In embed mode, an explicit ?theme=dark|light forces that theme so the
418
+ // panel can match the host app; theme=auto (or omitted) keeps system.
419
+ const embedForcedTheme =
420
+ embed && (embedTheme === 'light' || embedTheme === 'dark') ? embedTheme : undefined;
421
+
413
422
  const linkPrefix = config.hostAtDocs ? '/docs' : '';
414
423
 
415
424
  // Skip building the IIFE string in dev — both the script render below and
@@ -464,6 +473,11 @@ export async function DocsChrome({
464
473
  }}
465
474
  />
466
475
  <meta name="viewport" content="width=device-width, initial-scale=1" />
476
+ {/* Embed render (widget modal): default every in-frame link to a new tab.
477
+ Clicking a doc link should open the full docs site in a new tab, not
478
+ navigate the modal iframe into a chrome'd (non-embed) page. Scoped to
479
+ embed — this <head> only renders when x-jd-layout=embed. */}
480
+ {embed && <base target="_blank" />}
467
481
  {config.fonts && (
468
482
  <>
469
483
  <link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -646,11 +660,14 @@ export async function DocsChrome({
646
660
  )}
647
661
  <ThemeProvider
648
662
  defaultTheme={appearanceDefault}
649
- forcedTheme={appearanceStrict ? (appearanceDefault === 'system' ? undefined : appearanceDefault as 'light' | 'dark') : undefined}
663
+ forcedTheme={
664
+ embedForcedTheme ??
665
+ (appearanceStrict ? (appearanceDefault === 'system' ? undefined : appearanceDefault as 'light' | 'dark') : undefined)
666
+ }
650
667
  >
651
668
  <LinkPrefixProvider prefix={linkPrefix}>
652
669
  <ProjectSlugProvider slug={resolvedProjectSlug || ''}>
653
- <LayoutWrapper config={config}>
670
+ <LayoutWrapper config={config} embed={embed}>
654
671
  {children}
655
672
  </LayoutWrapper>
656
673
  </ProjectSlugProvider>
@@ -504,6 +504,31 @@ export function getMcpApiPath(projectSlug: string): string {
504
504
  return `/api/mcp/${projectSlug}`;
505
505
  }
506
506
 
507
+ export type EmbedTheme = 'light' | 'dark' | 'auto';
508
+
509
+ /**
510
+ * True when the request opts into the chrome-less embed render.
511
+ *
512
+ * Supports `?embed=1` (canonical) and `?layout=embed` (convenience alias).
513
+ *
514
+ * @param searchParams - URL search params from the incoming request
515
+ * @returns true if this request should render without site chrome
516
+ */
517
+ export function isEmbedRequest(searchParams: URLSearchParams): boolean {
518
+ return searchParams.get('embed') === '1' || searchParams.get('layout') === 'embed';
519
+ }
520
+
521
+ /**
522
+ * Forced theme for an embed render; defaults to 'auto' (prefers-color-scheme).
523
+ *
524
+ * @param searchParams - URL search params from the incoming request
525
+ * @returns 'dark' | 'light' when explicitly set, otherwise 'auto'
526
+ */
527
+ export function getEmbedTheme(searchParams: URLSearchParams): EmbedTheme {
528
+ const t = searchParams.get('theme');
529
+ return t === 'dark' || t === 'light' ? t : 'auto';
530
+ }
531
+
507
532
  /**
508
533
  * Check if this is a chat request that needs routing.
509
534
  *
@@ -629,6 +654,7 @@ const TRUSTED_PROXY_HEADERS = [
629
654
  // attacker on a subdomain ALSO toggle the "owner can sign in"
630
655
  // copy. Strip them all from the inbound request.
631
656
  'x-jd-layout',
657
+ 'x-jd-embed-theme', // forced-theme for embed render — never client-supplied
632
658
  'x-jd-custom-domain',
633
659
  'x-jd-project-name',
634
660
  'x-jd-project-logo',
@@ -678,6 +704,9 @@ export interface BuildProjectHeadersOptions {
678
704
  * Strips any client-supplied copies of those headers from the inbound
679
705
  * request to prevent header smuggling.
680
706
  *
707
+ * Strips (but does NOT set) x-jd-layout and x-jd-embed-theme — callers own
708
+ * those after this call; see TRUSTED_PROXY_HEADERS for the full strip list.
709
+ *
681
710
  * @param projectSlug - Resolved project slug
682
711
  * @param existingHeaders - Existing request headers to clone
683
712
  * @param hostAtDocs - Whether docs are hosted at /docs
@@ -13,11 +13,12 @@
13
13
  import { notFound } from 'next/navigation';
14
14
  import { MDXRemote } from 'next-mdx-remote/rsc';
15
15
  import type { Metadata } from 'next';
16
- import type { AnchorHTMLAttributes, ReactElement } from 'react';
16
+ import type { AnchorHTMLAttributes, ReactElement, ReactNode } from 'react';
17
17
  import { MDXComponents } from '@/components/mdx/MDXComponents';
18
18
  import { Breadcrumb } from '@/components/navigation/Breadcrumb';
19
19
  import { TableOfContents } from '@/components/navigation/TableOfContents';
20
20
  import { PageColumns } from '@/components/layout/PageColumns';
21
+ import { EmbedLinkInterceptor } from '@/components/layout/EmbedLinkInterceptor';
21
22
  import { PageNavigation } from '@/components/navigation/PageNavigation';
22
23
  import { SocialFooter } from '@/components/navigation/SocialFooter';
23
24
  import { ApiPageWrapper } from '@/components/mdx/ApiPage';
@@ -82,6 +83,7 @@ import { candidateSpecPaths } from '@/lib/openapi/lang-spec-path';
82
83
  import { ApiEndpoint } from '@/components/mdx/ApiEndpoint';
83
84
  import { AIActionsMenu } from '@/components/AIActionsMenu';
84
85
  import { getContextualOptions } from '@/lib/contextual-defaults';
86
+ import { withApiSpecDownload } from '@/lib/api-spec-menu-gate';
85
87
  import { SnippetComponents } from '@/components/snippets/ProjectSnippets';
86
88
 
87
89
  type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'TRACE';
@@ -274,6 +276,13 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
274
276
  const { slug: slugInput, projectSlug, hostAtDocs, requestHeaders } = input;
275
277
  const isIsr = isIsrMode();
276
278
 
279
+ // Chrome-less embed render (widget modal iframe). `x-jd-layout=embed` is set
280
+ // by proxy.ts. In embed mode we drop the footer + TOC and break cross-page
281
+ // links out of the iframe. `wrap` is a no-op on normal pages so the link
282
+ // interceptor only mounts when actually embedded.
283
+ const embed = requestHeaders?.get('x-jd-layout') === 'embed';
284
+ const wrap = (n: ReactNode) => embed ? <EmbedLinkInterceptor>{n}</EmbedLinkInterceptor> : n;
285
+
277
286
  if (isIsr) {
278
287
  if (!projectSlug) notFound();
279
288
  const exists = await projectExists(projectSlug);
@@ -499,6 +508,12 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
499
508
 
500
509
  const contextualOptions = getContextualOptions(config);
501
510
  const hasAiActions = contextualOptions.length > 0;
511
+ const apiMenuOptions = withApiSpecDownload(contextualOptions, {
512
+ isIsr,
513
+ isApiPage,
514
+ pageHasOpenApi: !!data.openapi,
515
+ config,
516
+ });
502
517
 
503
518
  const proseClasses = 'prose max-w-none';
504
519
 
@@ -531,86 +546,88 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
531
546
  if (isApiPage) {
532
547
  return (
533
548
  <>{jsonLdScript}<ApiPageWrapper hasCodePanels={hasCodePanels}>
534
- <article className="px-4 sm:px-6 lg:px-8 py-6 sm:py-10 flex-1 min-w-0">
535
- <Breadcrumb slug={slug} config={config} />
536
-
537
- {data.title && (
538
- <header className="mb-4 sm:mb-6">
539
- <div className="flex items-center gap-3">
540
- <h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-theme-text-primary tracking-tight">
541
- {data.title}
542
- </h1>
543
- {rssIcon}
544
- {hasAiActions && <div className="ml-auto flex-shrink-0 hidden sm:block"><AIActionsMenu options={contextualOptions} projectName={config.name} /></div>}
545
- </div>
546
- {data.description && (
547
- <p className="text-base sm:text-lg text-theme-text-secondary leading-relaxed mt-2 sm:mt-3">
548
- {data.description}
549
- </p>
549
+ {wrap(
550
+ <article className="px-4 sm:px-6 lg:px-8 py-6 sm:py-10 flex-1 min-w-0">
551
+ <Breadcrumb slug={slug} config={config} hidden={embed} />
552
+
553
+ {data.title && (
554
+ <header className="mb-4 sm:mb-6">
555
+ <div className="flex items-center gap-3">
556
+ <h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-theme-text-primary tracking-tight">
557
+ {data.title}
558
+ </h1>
559
+ {rssIcon}
560
+ {hasAiActions && <div className="ml-auto flex-shrink-0 hidden sm:block"><AIActionsMenu options={apiMenuOptions} projectName={config.name} /></div>}
561
+ </div>
562
+ {data.description && (
563
+ <p className="text-base sm:text-lg text-theme-text-secondary leading-relaxed mt-2 sm:mt-3">
564
+ {data.description}
565
+ </p>
566
+ )}
567
+ {hasAiActions && <div className="mt-3 sm:hidden" style={{ paddingLeft: 0 }}><AIActionsMenu options={apiMenuOptions} projectName={config.name} /></div>}
568
+ </header>
569
+ )}
570
+
571
+ <div className={proseClasses}>
572
+ {mdxApiMethod && mdxApiPath && (
573
+ mdxEndpointData && playgroundDisplay !== 'none' ? (
574
+ <OpenApiEndpoint
575
+ endpoint={mdxEndpointData}
576
+ playgroundOnly
577
+ playgroundDisplay={playgroundDisplay}
578
+ authMethod={resolvedMdxAuth.method}
579
+ authHeaderName={resolvedMdxAuth.headerName}
580
+ serverUrl={fallbackServerUrl}
581
+ proxyEnabled={proxyEnabled}
582
+ languages={config.api?.examples?.languages}
583
+ />
584
+ ) : (
585
+ <ApiEndpoint
586
+ method={mdxApiMethod}
587
+ path={mdxApiPath}
588
+ baseUrl={fallbackServerUrl}
589
+ />
590
+ )
550
591
  )}
551
- {hasAiActions && <div className="mt-3 sm:hidden" style={{ paddingLeft: 0 }}><AIActionsMenu options={contextualOptions} projectName={config.name} /></div>}
552
- </header>
553
- )}
554
592
 
555
- <div className={proseClasses}>
556
- {mdxApiMethod && mdxApiPath && (
557
- mdxEndpointData && playgroundDisplay !== 'none' ? (
593
+ {openApiEndpointData && (
558
594
  <OpenApiEndpoint
559
- endpoint={mdxEndpointData}
560
- playgroundOnly
595
+ endpoint={openApiEndpointData}
596
+ codeExamples={openApiCodeExamples || undefined}
561
597
  playgroundDisplay={playgroundDisplay}
562
- authMethod={resolvedMdxAuth.method}
563
- authHeaderName={resolvedMdxAuth.headerName}
598
+ authMethod={resolvedOpenApiAuth.method}
599
+ authHeaderName={resolvedOpenApiAuth.headerName}
564
600
  serverUrl={fallbackServerUrl}
565
601
  proxyEnabled={proxyEnabled}
566
602
  languages={config.api?.examples?.languages}
567
603
  />
568
- ) : (
569
- <ApiEndpoint
570
- method={mdxApiMethod}
571
- path={mdxApiPath}
572
- baseUrl={fallbackServerUrl}
573
- />
574
- )
575
- )}
576
-
577
- {openApiEndpointData && (
578
- <OpenApiEndpoint
579
- endpoint={openApiEndpointData}
580
- codeExamples={openApiCodeExamples || undefined}
581
- playgroundDisplay={playgroundDisplay}
582
- authMethod={resolvedOpenApiAuth.method}
583
- authHeaderName={resolvedOpenApiAuth.headerName}
584
- serverUrl={fallbackServerUrl}
585
- proxyEnabled={proxyEnabled}
586
- languages={config.api?.examples?.languages}
587
- />
588
- )}
604
+ )}
589
605
 
590
- {!openApiEndpointData && openApiError && (
591
- <OpenApiError message={openApiError} slug={slug.join('/')} />
592
- )}
606
+ {!openApiEndpointData && openApiError && (
607
+ <OpenApiError message={openApiError} slug={slug.join('/')} />
608
+ )}
593
609
 
594
- <StepSlugProvider entries={stepEntries}>
595
- <MDXRemote
596
- source={openApiEndpointData
597
- ? content.replace(/<ResponseExample>[\s\S]*?<\/ResponseExample>/g, '')
598
- : content}
599
- components={AllComponentsWithInline}
600
- options={{
601
- ...mdxSecurityOptions,
602
- mdxOptions: {
603
- ...getCommonMdxOptions(config, highlighter),
604
- recmaPlugins: [recmaCompoundComponents],
605
- },
606
- }}
607
- />
608
- </StepSlugProvider>
609
- </div>
610
+ <StepSlugProvider entries={stepEntries}>
611
+ <MDXRemote
612
+ source={openApiEndpointData
613
+ ? content.replace(/<ResponseExample>[\s\S]*?<\/ResponseExample>/g, '')
614
+ : content}
615
+ components={AllComponentsWithInline}
616
+ options={{
617
+ ...mdxSecurityOptions,
618
+ mdxOptions: {
619
+ ...getCommonMdxOptions(config, highlighter),
620
+ recmaPlugins: [recmaCompoundComponents],
621
+ },
622
+ }}
623
+ />
624
+ </StepSlugProvider>
625
+ </div>
610
626
 
611
- <PageNavigation currentSlug={slug.join('/')} config={config} />
612
- <SocialFooter config={config} hidden={data.hideFooter} projectSlug={projectSlug ?? undefined} />
613
- </article>
627
+ {!embed && <PageNavigation currentSlug={slug.join('/')} config={config} />}
628
+ <SocialFooter config={config} hidden={data.hideFooter} embed={embed} projectSlug={projectSlug ?? undefined} />
629
+ </article>,
630
+ )}
614
631
  </ApiPageWrapper></>
615
632
  );
616
633
  }
@@ -631,9 +648,9 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
631
648
  </StepSlugProvider>
632
649
  );
633
650
 
634
- const articleContent = (
651
+ const articleContent = wrap(
635
652
  <article className="px-4 sm:px-6 lg:px-8 py-6 sm:py-10">
636
- <Breadcrumb slug={slug} config={config} />
653
+ <Breadcrumb slug={slug} config={config} hidden={embed} />
637
654
 
638
655
  {data.title && (
639
656
  <header className="mb-6 sm:mb-10">
@@ -656,10 +673,10 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
656
673
  <div className={proseClasses}>
657
674
  {hasView ? <ViewWrapper>{mdxContent}</ViewWrapper> : mdxContent}
658
675
 
659
- <PageNavigation currentSlug={slug.join('/')} config={config} isWideMode={isWideMode || hasPanel} />
676
+ {!embed && <PageNavigation currentSlug={slug.join('/')} config={config} isWideMode={isWideMode || hasPanel} />}
660
677
  </div>
661
- <SocialFooter config={config} hidden={data.hideFooter} projectSlug={projectSlug ?? undefined} />
662
- </article>
678
+ <SocialFooter config={config} hidden={data.hideFooter} embed={embed} projectSlug={projectSlug ?? undefined} />
679
+ </article>,
663
680
  );
664
681
 
665
682
  if (hasPanel) {
@@ -669,7 +686,7 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
669
686
  return (
670
687
  <>
671
688
  {jsonLdScript}
672
- <PageColumns toc={<TableOfContents content={content} />} isWideMode={isWideMode}>
689
+ <PageColumns toc={<TableOfContents content={content} />} isWideMode={isWideMode} embed={embed}>
673
690
  {articleContent}
674
691
  </PageColumns>
675
692
  </>
@@ -197,6 +197,13 @@ export function classifyScannerProbe(pathname: string): ScannerCategory | null {
197
197
  const wellKnownMatch = /^\/(?:[a-z0-9][a-z0-9-]{0,15}\/)?\.well-known\/(.*)$/.exec(lower);
198
198
  if (wellKnownMatch && !/(?:^|\/)\.[^/]/.test(wellKnownMatch[1])) return null;
199
199
 
200
+ // Carve-out: api-specs.zip is the one .zip this platform legitimately serves —
201
+ // the request-time bundle of every OpenAPI spec a docs site references, streamed
202
+ // by app/api-specs.zip/route.ts (and the /docs hostAtDocs variant). Without this,
203
+ // the backup-archive blocklist below fast-404s it as a `*.zip` probe. Exact match
204
+ // only — no other .zip path is exempt.
205
+ if (lower === '/api-specs.zip' || lower === '/docs/api-specs.zip') return null;
206
+
200
207
  // Dotfile or dot-dir, at the root or at any segment boundary.
201
208
  // Matches: /.git, /.env, /.aws/credentials, /admin/.env, /backend/.git/config
202
209
  // Does NOT match: /blog/page.with.dots, /api/v1.2/endpoint (dot is
@@ -454,6 +454,8 @@ export interface GeneratedArtifacts {
454
454
  llmsFullTxt: string;
455
455
  robotsTxt: string;
456
456
  rssFeed: string | null;
457
+ /** changelog.json for the embeddable widget — built from the same updates as rssFeed. */
458
+ changelog: string;
457
459
  }
458
460
 
459
461
  /**
@@ -482,18 +484,21 @@ export function generateAllArtifacts(options: GenerateAllOptions): GeneratedArti
482
484
  : '';
483
485
  const robotsTxt = generateRobotsTxt({ baseUrl, hostAtDocs, noindex });
484
486
 
485
- // Generate RSS feed if any pages have rss: true
487
+ // Extract <Update> entries once they power BOTH the RSS feed and
488
+ // changelog.json, so sharing the single pass keeps the widget's "latest" and
489
+ // feed.xml structurally in sync (and avoids scanning every rss page twice).
490
+ const updates = rssPages && rssPages.length > 0 ? extractUpdatesFromPages(rssPages) : [];
491
+
486
492
  let rssFeed: string | null = null;
487
- if (rssPages && rssPages.length > 0) {
488
- const updates = extractUpdatesFromPages(rssPages);
489
- if (updates.length > 0) {
490
- rssFeed = generateRssFeed({
491
- name, description, baseUrl, hostAtDocs, updates,
492
- });
493
- }
493
+ if (updates.length > 0) {
494
+ rssFeed = generateRssFeed({
495
+ name, description, baseUrl, hostAtDocs, updates,
496
+ });
494
497
  }
495
498
 
496
- return { sitemap, llmsTxt, llmsFullTxt, robotsTxt, rssFeed };
499
+ const changelog = generateChangelog(updates);
500
+
501
+ return { sitemap, llmsTxt, llmsFullTxt, robotsTxt, rssFeed, changelog };
497
502
  }
498
503
 
499
504
  // =============================================================================
@@ -614,6 +619,31 @@ export function extractUpdatesFromPages(pages: RssPageInfo[]): UpdateEntry[] {
614
619
  return updates;
615
620
  }
616
621
 
622
+ /**
623
+ * Emit changelog metadata (newest <Update> first) for the embeddable
624
+ * "What's New" widget's unread indicator. Consumes the SAME UpdateEntry[]
625
+ * that powers feed.xml (extractUpdatesFromPages), so the widget's "latest"
626
+ * always agrees with the RSS feed and inherits its rss:true gating (docs
627
+ * pages that merely demonstrate <Update> are excluded). Entries without a
628
+ * parseable date are dropped (can't be ordered). Stable id = date+slug so a
629
+ * label typo doesn't reset every visitor's seen-state. Ties on the same date
630
+ * are broken by label slug (not source order) so the chosen `latest` — and
631
+ * thus the unread id — stays identical across builds even if page iteration
632
+ * order shifts; otherwise a reorder would silently reset every visitor's dot.
633
+ */
634
+ export function generateChangelog(updates: UpdateEntry[]): string {
635
+ const dated = updates
636
+ .filter((u) => u.date && !Number.isNaN(Date.parse(u.date)))
637
+ .map((u) => ({ u, ts: Date.parse(u.date as string) }))
638
+ .sort((a, b) => b.ts - a.ts
639
+ || labelToSlug(a.u.label).localeCompare(labelToSlug(b.u.label)));
640
+ const top = dated[0]?.u ?? null;
641
+ const latest = top
642
+ ? { id: `${top.date}-${labelToSlug(top.label)}`, label: top.label, date: top.date }
643
+ : null;
644
+ return JSON.stringify({ version: 1, latest, count: dated.length });
645
+ }
646
+
617
647
  /**
618
648
  * Generate RSS 2.0 XML feed from update entries.
619
649
  */
@@ -15,9 +15,10 @@ import { log } from '@/lib/logger';
15
15
  import { isIsrMode } from '@/lib/page-isr-helpers';
16
16
  import { fetchStaticFile } from '@/lib/r2-content';
17
17
 
18
- /** Filenames served via createStaticFileHandler (6 at root + 6 at /docs). */
18
+ /** Filenames served via createStaticFileHandler (7 at root + 7 at /docs). */
19
19
  export const STATIC_FILE_NAMES = [
20
20
  'sitemap.xml', 'robots.txt', 'llms.txt', 'llms-full.txt', 'feed.xml', 'search-data.json',
21
+ 'changelog.json',
21
22
  ] as const;
22
23
 
23
24
  /** All CDN paths for static file routes — used by revalidation to purge CDN cache. */
@@ -41,6 +42,14 @@ const CONTENT_TYPES: Record<string, string> = {
41
42
  *
42
43
  * `search-data.json` is intentionally NOT in this list: a real user landing
43
44
  * on the upstream subdomain would still hit it from the in-page search UI.
45
+ *
46
+ * `changelog.json` is intentionally NOT in this list either, and must stay out:
47
+ * the embeddable widget always fetches it from `<slug>.jamdesk.app` (custom
48
+ * domains can't serve the `?embed=1` render), and that subdomain carries
49
+ * `x-jd-noindex: true` whenever the project has a custom domain. Suppressing it
50
+ * here would 404 the metadata fetch and permanently break the widget's unread
51
+ * dot for every custom-domain customer. It's a deliberately public artifact
52
+ * (served with `Access-Control-Allow-Origin: *`), not a URL-inventory file.
44
53
  */
45
54
  const NOINDEX_SUPPRESSED_FILES = new Set([
46
55
  'sitemap.xml', 'llms.txt', 'llms-full.txt', 'feed.xml',
@@ -84,6 +93,31 @@ function getContentType(filename: string): string {
84
93
  return CONTENT_TYPES[ext] || 'application/octet-stream';
85
94
  }
86
95
 
96
+ /**
97
+ * Like createStaticFileHandler, but adds Access-Control-Allow-Origin: * so the
98
+ * file can be fetched cross-origin (the embeddable widget runs on the
99
+ * customer's site and reads changelog.json from the project's jamdesk.app
100
+ * origin). Simple GET — no preflight needed.
101
+ *
102
+ * PUBLIC ARTIFACTS ONLY. `*` makes every byte this serves readable cross-origin
103
+ * by any website. Only ever wrap genuinely public files (changelog.json). Do
104
+ * NOT reuse this for project-private or auth-scoped routes — that would widen
105
+ * them to the entire web.
106
+ */
107
+ export function createCorsStaticFileHandler(
108
+ filename: string,
109
+ label: string,
110
+ contentTypeOverride?: string,
111
+ cacheControlOverride?: string,
112
+ ): (request: NextRequest) => Promise<NextResponse> {
113
+ const handler = createStaticFileHandler(filename, label, contentTypeOverride, cacheControlOverride);
114
+ return async function GET(request: NextRequest): Promise<NextResponse> {
115
+ const response = await handler(request);
116
+ response.headers.set('Access-Control-Allow-Origin', '*');
117
+ return response;
118
+ };
119
+ }
120
+
87
121
  /**
88
122
  * Create a GET handler that serves a static file from R2.
89
123
  *
@@ -0,0 +1,13 @@
1
+ // AUTO-GENERATED — do not edit by hand.
2
+ // Byte-for-byte copy of github-slugger@2.0.0's removal regex, transformed ESM->CJS so the CJS link validator
3
+ // (validate-links.cjs) can require it. github-slugger is what rehype-slug
4
+ // uses to assign real heading ids; replicating it keeps #fragment validation
5
+ // aligned with the true DOM anchors. Regenerate after upgrading github-slugger:
6
+ // node scripts/gen-github-slugger-regex.cjs
7
+ // A drift test (validate-links-slug.test.ts) fails if this diverges.
8
+
9
+ // This module is generated by `script/`.
10
+ /* eslint-disable no-control-regex, no-misleading-character-class, no-useless-escape */
11
+ const regex = /[\0-\x1F!-,\.\/:-@\[-\^`\{-\xA9\xAB-\xB4\xB6-\xB9\xBB-\xBF\xD7\xF7\u02C2-\u02C5\u02D2-\u02DF\u02E5-\u02EB\u02ED\u02EF-\u02FF\u0375\u0378\u0379\u037E\u0380-\u0385\u0387\u038B\u038D\u03A2\u03F6\u0482\u0530\u0557\u0558\u055A-\u055F\u0589-\u0590\u05BE\u05C0\u05C3\u05C6\u05C8-\u05CF\u05EB-\u05EE\u05F3-\u060F\u061B-\u061F\u066A-\u066D\u06D4\u06DD\u06DE\u06E9\u06FD\u06FE\u0700-\u070F\u074B\u074C\u07B2-\u07BF\u07F6-\u07F9\u07FB\u07FC\u07FE\u07FF\u082E-\u083F\u085C-\u085F\u086B-\u089F\u08B5\u08C8-\u08D2\u08E2\u0964\u0965\u0970\u0984\u098D\u098E\u0991\u0992\u09A9\u09B1\u09B3-\u09B5\u09BA\u09BB\u09C5\u09C6\u09C9\u09CA\u09CF-\u09D6\u09D8-\u09DB\u09DE\u09E4\u09E5\u09F2-\u09FB\u09FD\u09FF\u0A00\u0A04\u0A0B-\u0A0E\u0A11\u0A12\u0A29\u0A31\u0A34\u0A37\u0A3A\u0A3B\u0A3D\u0A43-\u0A46\u0A49\u0A4A\u0A4E-\u0A50\u0A52-\u0A58\u0A5D\u0A5F-\u0A65\u0A76-\u0A80\u0A84\u0A8E\u0A92\u0AA9\u0AB1\u0AB4\u0ABA\u0ABB\u0AC6\u0ACA\u0ACE\u0ACF\u0AD1-\u0ADF\u0AE4\u0AE5\u0AF0-\u0AF8\u0B00\u0B04\u0B0D\u0B0E\u0B11\u0B12\u0B29\u0B31\u0B34\u0B3A\u0B3B\u0B45\u0B46\u0B49\u0B4A\u0B4E-\u0B54\u0B58-\u0B5B\u0B5E\u0B64\u0B65\u0B70\u0B72-\u0B81\u0B84\u0B8B-\u0B8D\u0B91\u0B96-\u0B98\u0B9B\u0B9D\u0BA0-\u0BA2\u0BA5-\u0BA7\u0BAB-\u0BAD\u0BBA-\u0BBD\u0BC3-\u0BC5\u0BC9\u0BCE\u0BCF\u0BD1-\u0BD6\u0BD8-\u0BE5\u0BF0-\u0BFF\u0C0D\u0C11\u0C29\u0C3A-\u0C3C\u0C45\u0C49\u0C4E-\u0C54\u0C57\u0C5B-\u0C5F\u0C64\u0C65\u0C70-\u0C7F\u0C84\u0C8D\u0C91\u0CA9\u0CB4\u0CBA\u0CBB\u0CC5\u0CC9\u0CCE-\u0CD4\u0CD7-\u0CDD\u0CDF\u0CE4\u0CE5\u0CF0\u0CF3-\u0CFF\u0D0D\u0D11\u0D45\u0D49\u0D4F-\u0D53\u0D58-\u0D5E\u0D64\u0D65\u0D70-\u0D79\u0D80\u0D84\u0D97-\u0D99\u0DB2\u0DBC\u0DBE\u0DBF\u0DC7-\u0DC9\u0DCB-\u0DCE\u0DD5\u0DD7\u0DE0-\u0DE5\u0DF0\u0DF1\u0DF4-\u0E00\u0E3B-\u0E3F\u0E4F\u0E5A-\u0E80\u0E83\u0E85\u0E8B\u0EA4\u0EA6\u0EBE\u0EBF\u0EC5\u0EC7\u0ECE\u0ECF\u0EDA\u0EDB\u0EE0-\u0EFF\u0F01-\u0F17\u0F1A-\u0F1F\u0F2A-\u0F34\u0F36\u0F38\u0F3A-\u0F3D\u0F48\u0F6D-\u0F70\u0F85\u0F98\u0FBD-\u0FC5\u0FC7-\u0FFF\u104A-\u104F\u109E\u109F\u10C6\u10C8-\u10CC\u10CE\u10CF\u10FB\u1249\u124E\u124F\u1257\u1259\u125E\u125F\u1289\u128E\u128F\u12B1\u12B6\u12B7\u12BF\u12C1\u12C6\u12C7\u12D7\u1311\u1316\u1317\u135B\u135C\u1360-\u137F\u1390-\u139F\u13F6\u13F7\u13FE-\u1400\u166D\u166E\u1680\u169B-\u169F\u16EB-\u16ED\u16F9-\u16FF\u170D\u1715-\u171F\u1735-\u173F\u1754-\u175F\u176D\u1771\u1774-\u177F\u17D4-\u17D6\u17D8-\u17DB\u17DE\u17DF\u17EA-\u180A\u180E\u180F\u181A-\u181F\u1879-\u187F\u18AB-\u18AF\u18F6-\u18FF\u191F\u192C-\u192F\u193C-\u1945\u196E\u196F\u1975-\u197F\u19AC-\u19AF\u19CA-\u19CF\u19DA-\u19FF\u1A1C-\u1A1F\u1A5F\u1A7D\u1A7E\u1A8A-\u1A8F\u1A9A-\u1AA6\u1AA8-\u1AAF\u1AC1-\u1AFF\u1B4C-\u1B4F\u1B5A-\u1B6A\u1B74-\u1B7F\u1BF4-\u1BFF\u1C38-\u1C3F\u1C4A-\u1C4C\u1C7E\u1C7F\u1C89-\u1C8F\u1CBB\u1CBC\u1CC0-\u1CCF\u1CD3\u1CFB-\u1CFF\u1DFA\u1F16\u1F17\u1F1E\u1F1F\u1F46\u1F47\u1F4E\u1F4F\u1F58\u1F5A\u1F5C\u1F5E\u1F7E\u1F7F\u1FB5\u1FBD\u1FBF-\u1FC1\u1FC5\u1FCD-\u1FCF\u1FD4\u1FD5\u1FDC-\u1FDF\u1FED-\u1FF1\u1FF5\u1FFD-\u203E\u2041-\u2053\u2055-\u2070\u2072-\u207E\u2080-\u208F\u209D-\u20CF\u20F1-\u2101\u2103-\u2106\u2108\u2109\u2114\u2116-\u2118\u211E-\u2123\u2125\u2127\u2129\u212E\u213A\u213B\u2140-\u2144\u214A-\u214D\u214F-\u215F\u2189-\u24B5\u24EA-\u2BFF\u2C2F\u2C5F\u2CE5-\u2CEA\u2CF4-\u2CFF\u2D26\u2D28-\u2D2C\u2D2E\u2D2F\u2D68-\u2D6E\u2D70-\u2D7E\u2D97-\u2D9F\u2DA7\u2DAF\u2DB7\u2DBF\u2DC7\u2DCF\u2DD7\u2DDF\u2E00-\u2E2E\u2E30-\u3004\u3008-\u3020\u3030\u3036\u3037\u303D-\u3040\u3097\u3098\u309B\u309C\u30A0\u30FB\u3100-\u3104\u3130\u318F-\u319F\u31C0-\u31EF\u3200-\u33FF\u4DC0-\u4DFF\u9FFD-\u9FFF\uA48D-\uA4CF\uA4FE\uA4FF\uA60D-\uA60F\uA62C-\uA63F\uA673\uA67E\uA6F2-\uA716\uA720\uA721\uA789\uA78A\uA7C0\uA7C1\uA7CB-\uA7F4\uA828-\uA82B\uA82D-\uA83F\uA874-\uA87F\uA8C6-\uA8CF\uA8DA-\uA8DF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA954-\uA95F\uA97D-\uA97F\uA9C1-\uA9CE\uA9DA-\uA9DF\uA9FF\uAA37-\uAA3F\uAA4E\uAA4F\uAA5A-\uAA5F\uAA77-\uAA79\uAAC3-\uAADA\uAADE\uAADF\uAAF0\uAAF1\uAAF7-\uAB00\uAB07\uAB08\uAB0F\uAB10\uAB17-\uAB1F\uAB27\uAB2F\uAB5B\uAB6A-\uAB6F\uABEB\uABEE\uABEF\uABFA-\uABFF\uD7A4-\uD7AF\uD7C7-\uD7CA\uD7FC-\uD7FF\uE000-\uF8FF\uFA6E\uFA6F\uFADA-\uFAFF\uFB07-\uFB12\uFB18-\uFB1C\uFB29\uFB37\uFB3D\uFB3F\uFB42\uFB45\uFBB2-\uFBD2\uFD3E-\uFD4F\uFD90\uFD91\uFDC8-\uFDEF\uFDFC-\uFDFF\uFE10-\uFE1F\uFE30-\uFE32\uFE35-\uFE4C\uFE50-\uFE6F\uFE75\uFEFD-\uFF0F\uFF1A-\uFF20\uFF3B-\uFF3E\uFF40\uFF5B-\uFF65\uFFBF-\uFFC1\uFFC8\uFFC9\uFFD0\uFFD1\uFFD8\uFFD9\uFFDD-\uFFFF]|\uD800[\uDC0C\uDC27\uDC3B\uDC3E\uDC4E\uDC4F\uDC5E-\uDC7F\uDCFB-\uDD3F\uDD75-\uDDFC\uDDFE-\uDE7F\uDE9D-\uDE9F\uDED1-\uDEDF\uDEE1-\uDEFF\uDF20-\uDF2C\uDF4B-\uDF4F\uDF7B-\uDF7F\uDF9E\uDF9F\uDFC4-\uDFC7\uDFD0\uDFD6-\uDFFF]|\uD801[\uDC9E\uDC9F\uDCAA-\uDCAF\uDCD4-\uDCD7\uDCFC-\uDCFF\uDD28-\uDD2F\uDD64-\uDDFF\uDF37-\uDF3F\uDF56-\uDF5F\uDF68-\uDFFF]|\uD802[\uDC06\uDC07\uDC09\uDC36\uDC39-\uDC3B\uDC3D\uDC3E\uDC56-\uDC5F\uDC77-\uDC7F\uDC9F-\uDCDF\uDCF3\uDCF6-\uDCFF\uDD16-\uDD1F\uDD3A-\uDD7F\uDDB8-\uDDBD\uDDC0-\uDDFF\uDE04\uDE07-\uDE0B\uDE14\uDE18\uDE36\uDE37\uDE3B-\uDE3E\uDE40-\uDE5F\uDE7D-\uDE7F\uDE9D-\uDEBF\uDEC8\uDEE7-\uDEFF\uDF36-\uDF3F\uDF56-\uDF5F\uDF73-\uDF7F\uDF92-\uDFFF]|\uD803[\uDC49-\uDC7F\uDCB3-\uDCBF\uDCF3-\uDCFF\uDD28-\uDD2F\uDD3A-\uDE7F\uDEAA\uDEAD-\uDEAF\uDEB2-\uDEFF\uDF1D-\uDF26\uDF28-\uDF2F\uDF51-\uDFAF\uDFC5-\uDFDF\uDFF7-\uDFFF]|\uD804[\uDC47-\uDC65\uDC70-\uDC7E\uDCBB-\uDCCF\uDCE9-\uDCEF\uDCFA-\uDCFF\uDD35\uDD40-\uDD43\uDD48-\uDD4F\uDD74\uDD75\uDD77-\uDD7F\uDDC5-\uDDC8\uDDCD\uDDDB\uDDDD-\uDDFF\uDE12\uDE38-\uDE3D\uDE3F-\uDE7F\uDE87\uDE89\uDE8E\uDE9E\uDEA9-\uDEAF\uDEEB-\uDEEF\uDEFA-\uDEFF\uDF04\uDF0D\uDF0E\uDF11\uDF12\uDF29\uDF31\uDF34\uDF3A\uDF45\uDF46\uDF49\uDF4A\uDF4E\uDF4F\uDF51-\uDF56\uDF58-\uDF5C\uDF64\uDF65\uDF6D-\uDF6F\uDF75-\uDFFF]|\uD805[\uDC4B-\uDC4F\uDC5A-\uDC5D\uDC62-\uDC7F\uDCC6\uDCC8-\uDCCF\uDCDA-\uDD7F\uDDB6\uDDB7\uDDC1-\uDDD7\uDDDE-\uDDFF\uDE41-\uDE43\uDE45-\uDE4F\uDE5A-\uDE7F\uDEB9-\uDEBF\uDECA-\uDEFF\uDF1B\uDF1C\uDF2C-\uDF2F\uDF3A-\uDFFF]|\uD806[\uDC3B-\uDC9F\uDCEA-\uDCFE\uDD07\uDD08\uDD0A\uDD0B\uDD14\uDD17\uDD36\uDD39\uDD3A\uDD44-\uDD4F\uDD5A-\uDD9F\uDDA8\uDDA9\uDDD8\uDDD9\uDDE2\uDDE5-\uDDFF\uDE3F-\uDE46\uDE48-\uDE4F\uDE9A-\uDE9C\uDE9E-\uDEBF\uDEF9-\uDFFF]|\uD807[\uDC09\uDC37\uDC41-\uDC4F\uDC5A-\uDC71\uDC90\uDC91\uDCA8\uDCB7-\uDCFF\uDD07\uDD0A\uDD37-\uDD39\uDD3B\uDD3E\uDD48-\uDD4F\uDD5A-\uDD5F\uDD66\uDD69\uDD8F\uDD92\uDD99-\uDD9F\uDDAA-\uDEDF\uDEF7-\uDFAF\uDFB1-\uDFFF]|\uD808[\uDF9A-\uDFFF]|\uD809[\uDC6F-\uDC7F\uDD44-\uDFFF]|[\uD80A\uD80B\uD80E-\uD810\uD812-\uD819\uD824-\uD82B\uD82D\uD82E\uD830-\uD833\uD837\uD839\uD83D\uD83F\uD87B-\uD87D\uD87F\uD885-\uDB3F\uDB41-\uDBFF][\uDC00-\uDFFF]|\uD80D[\uDC2F-\uDFFF]|\uD811[\uDE47-\uDFFF]|\uD81A[\uDE39-\uDE3F\uDE5F\uDE6A-\uDECF\uDEEE\uDEEF\uDEF5-\uDEFF\uDF37-\uDF3F\uDF44-\uDF4F\uDF5A-\uDF62\uDF78-\uDF7C\uDF90-\uDFFF]|\uD81B[\uDC00-\uDE3F\uDE80-\uDEFF\uDF4B-\uDF4E\uDF88-\uDF8E\uDFA0-\uDFDF\uDFE2\uDFE5-\uDFEF\uDFF2-\uDFFF]|\uD821[\uDFF8-\uDFFF]|\uD823[\uDCD6-\uDCFF\uDD09-\uDFFF]|\uD82C[\uDD1F-\uDD4F\uDD53-\uDD63\uDD68-\uDD6F\uDEFC-\uDFFF]|\uD82F[\uDC6B-\uDC6F\uDC7D-\uDC7F\uDC89-\uDC8F\uDC9A-\uDC9C\uDC9F-\uDFFF]|\uD834[\uDC00-\uDD64\uDD6A-\uDD6C\uDD73-\uDD7A\uDD83\uDD84\uDD8C-\uDDA9\uDDAE-\uDE41\uDE45-\uDFFF]|\uD835[\uDC55\uDC9D\uDCA0\uDCA1\uDCA3\uDCA4\uDCA7\uDCA8\uDCAD\uDCBA\uDCBC\uDCC4\uDD06\uDD0B\uDD0C\uDD15\uDD1D\uDD3A\uDD3F\uDD45\uDD47-\uDD49\uDD51\uDEA6\uDEA7\uDEC1\uDEDB\uDEFB\uDF15\uDF35\uDF4F\uDF6F\uDF89\uDFA9\uDFC3\uDFCC\uDFCD]|\uD836[\uDC00-\uDDFF\uDE37-\uDE3A\uDE6D-\uDE74\uDE76-\uDE83\uDE85-\uDE9A\uDEA0\uDEB0-\uDFFF]|\uD838[\uDC07\uDC19\uDC1A\uDC22\uDC25\uDC2B-\uDCFF\uDD2D-\uDD2F\uDD3E\uDD3F\uDD4A-\uDD4D\uDD4F-\uDEBF\uDEFA-\uDFFF]|\uD83A[\uDCC5-\uDCCF\uDCD7-\uDCFF\uDD4C-\uDD4F\uDD5A-\uDFFF]|\uD83B[\uDC00-\uDDFF\uDE04\uDE20\uDE23\uDE25\uDE26\uDE28\uDE33\uDE38\uDE3A\uDE3C-\uDE41\uDE43-\uDE46\uDE48\uDE4A\uDE4C\uDE50\uDE53\uDE55\uDE56\uDE58\uDE5A\uDE5C\uDE5E\uDE60\uDE63\uDE65\uDE66\uDE6B\uDE73\uDE78\uDE7D\uDE7F\uDE8A\uDE9C-\uDEA0\uDEA4\uDEAA\uDEBC-\uDFFF]|\uD83C[\uDC00-\uDD2F\uDD4A-\uDD4F\uDD6A-\uDD6F\uDD8A-\uDFFF]|\uD83E[\uDC00-\uDFEF\uDFFA-\uDFFF]|\uD869[\uDEDE-\uDEFF]|\uD86D[\uDF35-\uDF3F]|\uD86E[\uDC1E\uDC1F]|\uD873[\uDEA2-\uDEAF]|\uD87A[\uDFE1-\uDFFF]|\uD87E[\uDE1E-\uDFFF]|\uD884[\uDF4B-\uDFFF]|\uDB40[\uDC00-\uDCFF\uDDF0-\uDFFF]/g
12
+
13
+ module.exports = { regex };