jamdesk 1.1.189 → 1.1.190

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.189",
3
+ "version": "1.1.190",
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",
@@ -15,13 +15,12 @@ import {
15
15
  import { parseFrontmatterLenient } from '@/lib/frontmatter-utils';
16
16
  import { siteHasOpenApiSpecs } from '@/lib/api-specs-bundle';
17
17
  import { getContextualOptions } from '@/lib/contextual-defaults';
18
- import { isIsrMode, getBaseUrlFromConfig, getDocsSubpath } from '@/lib/page-isr-helpers';
18
+ import { isIsrMode, getBaseUrl, getDocsSubpath } from '@/lib/page-isr-helpers';
19
19
  import { apiSpecsMarkdownFooter } from '@/lib/api-specs-markdown-hint';
20
20
  import { collectLocalSpecPaths } from '@/lib/openapi/operation-description';
21
21
  import { operationMarkdownForRef } from '@/lib/openapi/enrich-page-infos';
22
22
  import { injectAgentDirective } from '@/lib/agent-directive';
23
23
  import { logger } from '@/shared/logger';
24
- import { DEFAULT_DOCS_SUBPATH } from '@/shared/docs-subpath';
25
24
  import type { DocsConfig } from '@/lib/docs-types';
26
25
  import type { OpenAPI } from 'openapi-types';
27
26
 
@@ -113,7 +112,14 @@ export async function GET(
113
112
  // Arg order is load-bearing: `withDirective` is the agent-visible body the
114
113
  // footer is appended to; `raw` is the unfiltered source the helper reads
115
114
  // frontmatter from (visibility filtering never touches the leading YAML block).
116
- const body = await withApiSpecsHint(withDirective, raw, project, config, docsPrefix);
115
+ const body = await withApiSpecsHint(
116
+ withDirective,
117
+ raw,
118
+ project,
119
+ config,
120
+ request.headers,
121
+ hostAtDocs,
122
+ );
117
123
 
118
124
  // Two paths reach this handler via proxy rewrite:
119
125
  // (a) `.md` URL: unique CDN cache key, safe to share.
@@ -143,17 +149,24 @@ export async function GET(
143
149
  * in ISR. Non-fatal: any failure returns the markdown unchanged — it never throws
144
150
  * and never 500s an export.
145
151
  *
146
- * The URL is built from the server-known project slug (getBaseUrlFromConfig), not
147
- * request headers — so it's unspoofable, deterministic, and cache-safe. The
148
- * api-specs.zip route is registered at both `/` and `/docs`, so
149
- * `https://<slug>.jamdesk.app[/docs]/api-specs.zip` always resolves.
152
+ * The URL is built with `getBaseUrl` — the same helper the canonical and OG
153
+ * tags use — so the link points at the host the reader actually asked for. It
154
+ * reads only TRUSTED_PROXY_HEADERS (canonical host, forwarded host), which
155
+ * buildProjectHeaders strips from the inbound request, so it stays unspoofable.
156
+ *
157
+ * It used to hard-code `https://<slug>.jamdesk.app[/docs]` off the slug. Both
158
+ * hosts serve the zip today, so that read as a cosmetic choice — but a tenant
159
+ * with `customDomainOnly: true` 404s its own bare subdomain for anything that
160
+ * isn't a presentation asset, and `.zip` is not on that exemption list. The
161
+ * footer would have handed agents a dead link the moment a customer ticked it.
150
162
  */
151
163
  async function withApiSpecsHint(
152
164
  markdown: string,
153
165
  raw: string,
154
166
  project: string,
155
167
  config: DocsConfig | null,
156
- docsPrefix?: string,
168
+ headers: Headers,
169
+ hostAtDocs: boolean,
157
170
  ): Promise<string> {
158
171
  try {
159
172
  if (!isIsrMode()) return markdown;
@@ -196,23 +209,16 @@ async function withApiSpecsHint(
196
209
  }
197
210
  }
198
211
 
199
- // Config (fetched once in GET) drives the site-wide spec check, the
200
- // menu-enabled check, and the hostAtDocs/docsPrefix link prefix. A
201
- // failed/missing config must not block a page-level `openapi:` page
202
- // (which qualifies on its own): treat it as "no site specs" but keep
203
- // the default-enabled menu.
212
+ // Config (fetched once in GET) drives the site-wide spec check and the
213
+ // menu-enabled check. A failed/missing config must not block a page-level
214
+ // `openapi:` page (which qualifies on its own): treat it as "no site
215
+ // specs" but keep the default-enabled menu.
204
216
  //
205
- // Only a CUSTOM prefix is passed through. '' and '/docs' keep deriving the
206
- // base URL from `config.hostAtDocs` alone, exactly as before this route
207
- // learned about subpaths — and since hostAtDocs is a runtime-only field
208
- // that the stored docs.json never carries, that is the historical
209
- // `https://<slug>.jamdesk.app/api-specs.zip`. Passing the middleware's
210
- // '/docs' through instead would silently move every default tenant's zip
211
- // link. Both shapes resolve (dotted paths skip the prefix redirects), so
212
- // this is byte-identity, not correctness.
213
- const customPrefix =
214
- docsPrefix && docsPrefix !== DEFAULT_DOCS_SUBPATH ? docsPrefix : undefined;
215
- const zipUrl = `${getBaseUrlFromConfig(project, !!config?.hostAtDocs, customPrefix)}/api-specs.zip`;
217
+ // getBaseUrl resolves the docs prefix from the same headers it resolves
218
+ // the host from, so a tenant at example.com/help gets
219
+ // `https://example.com/help/api-specs.zip` and a bare subdomain still gets
220
+ // the root. The zip route is registered at both `/` and `/<prefix>`.
221
+ const zipUrl = `${getBaseUrl(headers, project, hostAtDocs)}/api-specs.zip`;
216
222
 
217
223
  const footer = apiSpecsMarkdownFooter({
218
224
  isIsr: true,
@@ -492,6 +492,18 @@ export async function DocsChrome({
492
492
  const bannerContent = config.banner?.content?.trim() || '';
493
493
  const bannerNoFlash = !embed && !!bannerContent && config.banner?.dismissible === true && !!resolvedProjectSlug;
494
494
 
495
+ // SSR scroll lock, off under `next dev`. React 19 logs "Encountered a script
496
+ // tag while rendering React component" for any inline <script> in a
497
+ // component tree, and Next surfaces it as a full-screen console error on
498
+ // every docs page in `jamdesk dev` — the same reason the subdomain-error
499
+ // suppressor below is prod-gated. The lock only defends against Chrome's
500
+ // same-tab CROSS-ORIGIN scroll-restore heuristic, which needs a real prior
501
+ // origin, so dev loses nothing. The attribute and the bootstrap that
502
+ // releases it must move together: shipping the attribute without the script
503
+ // leaves #content-scroll-container permanently unscrollable.
504
+ // Read at render time (not module scope) so tests can exercise both arms.
505
+ const scrollLockEnabled = process.env.NODE_ENV !== 'development';
506
+
495
507
  // Font Awesome CSS uses preinit (not preload) so React 19 emits the
496
508
  // stylesheet link in <head> at SSR time. preinit (rather than a runtime
497
509
  // beforeInteractive <Script>/<link> injection) is what gets the @font-face
@@ -510,7 +522,7 @@ export async function DocsChrome({
510
522
  preinit(FA_CSS_HREF, { as: 'style' });
511
523
 
512
524
  return (
513
- <html lang={toHreflang(lang)} dir={dir} suppressHydrationWarning data-scroll-behavior="smooth" data-scroll-locked="true">
525
+ <html lang={toHreflang(lang)} dir={dir} suppressHydrationWarning data-scroll-behavior="smooth" data-scroll-locked={scrollLockEnabled ? 'true' : undefined}>
514
526
  <head>
515
527
  {/* CMP scripts must be first + synchronous: auto-blocking CMPs
516
528
  (Osano/Termly) need to intercept later scripts. Never gated.
@@ -534,18 +546,22 @@ export async function DocsChrome({
534
546
  restore attempts hit a structurally non-scrollable element
535
547
  (scrollTop pinned to 0).
536
548
  */}
537
- <script
538
- dangerouslySetInnerHTML={{
539
- __html: scrollLockBootstrap,
540
- }}
541
- />
542
- {/* JS-off fallback — bootstrap can't unlock, so override the rule. */}
543
- <noscript
544
- dangerouslySetInnerHTML={{
545
- __html:
546
- '<style>html[data-scroll-locked] #content-scroll-container{overflow-y:auto !important;}</style>',
547
- }}
548
- />
549
+ {scrollLockEnabled && (
550
+ <>
551
+ <script
552
+ dangerouslySetInnerHTML={{
553
+ __html: scrollLockBootstrap,
554
+ }}
555
+ />
556
+ {/* JS-off fallback — bootstrap can't unlock, so override the rule. */}
557
+ <noscript
558
+ dangerouslySetInnerHTML={{
559
+ __html:
560
+ '<style>html[data-scroll-locked] #content-scroll-container{overflow-y:auto !important;}</style>',
561
+ }}
562
+ />
563
+ </>
564
+ )}
549
565
  {/* Banner no-flash: hide a previously-dismissed (dismissible) banner
550
566
  before first paint. The <style> is inert until the script sets the
551
567
  attribute; the script is prod-gated like the analytics inline script. */}