jamdesk 1.1.49 → 1.1.51

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.
@@ -1,195 +1,48 @@
1
- import { notFound } from 'next/navigation';
1
+ // Catch-all route for docs pages. Reads `headers()` for per-project
2
+ // resolution (`x-project-slug`/`x-host-at-docs` set by middleware), which
3
+ // forces force-dynamic. The `params.project` branch handles direct
4
+ // `/[project]/...` URLs without middleware involvement (used by tests and
5
+ // non-ISR local dev). The render body lives in lib/render-doc-page.tsx.
2
6
  import { headers } from 'next/headers';
3
- import { MDXRemote } from 'next-mdx-remote/rsc';
4
-
5
- /**
6
- * Route Segment Configuration
7
- *
8
- * force-dynamic: Always render on each request. Required because:
9
- * 1. In ISR mode, we read project slug from headers (set by middleware)
10
- * 2. headers() is a dynamic API that opts out of static generation
11
- *
12
- * In static mode (local dev, customer builds), this is ignored because
13
- * those builds don't deploy to Vercel - they use the static export.
14
- *
15
- * Caching strategy:
16
- * - MDX content cached in R2 (fast fetch)
17
- * - Vercel edge CDN caches responses
18
- * - On-demand revalidation via /api/revalidate endpoint
19
- */
20
- export const dynamic = 'force-dynamic';
21
- export const dynamicParams = true; // Allow paths not in generateStaticParams
22
- import { MDXComponents } from '@/components/mdx/MDXComponents';
23
- import { Breadcrumb } from '@/components/navigation/Breadcrumb';
24
- import { TableOfContents } from '@/components/navigation/TableOfContents';
25
- import { PageColumns } from '@/components/layout/PageColumns';
26
- import { PageNavigation } from '@/components/navigation/PageNavigation';
27
- import { SocialFooter } from '@/components/navigation/SocialFooter';
28
- import { ApiPageWrapper } from '@/components/mdx/ApiPage';
29
- import { OpenApiEndpoint } from '@/components/mdx/OpenApiEndpoint';
30
- import { OpenApiError } from '@/components/openapi/OpenApiError';
31
- import { getHighlighter } from '@/lib/shiki-highlighter';
32
- import { createShikiRehypePlugin } from '@/lib/shiki-config';
33
- import rehypeSlug from 'rehype-slug';
34
- import remarkGfm from 'remark-gfm';
35
- import { rehypeCodeMeta, rehypeRestoreDataTitle } from '@/lib/rehype-code-meta';
36
- import { rehypeClassToClassName } from '@/lib/rehype-class-to-classname';
37
- import { rehypeNoZoomToData } from '@/lib/rehype-nozoom-to-data';
38
- import { preprocessMdx, containsPanel, containsView, buildSnippetAliasMap } from '@/lib/preprocess-mdx';
39
- import { loadSnippetsForIsr } from '@/lib/snippet-loader-isr';
40
- import { PanelWrapper } from '@/components/mdx/PanelWrapper';
41
- import { ViewWrapper } from '@/components/mdx/View';
42
- import { getLatexRemarkPlugins, getLatexRehypePlugins } from '@/lib/latex-config';
43
- import { getTypographyRemarkPlugins } from '@/lib/typography-config';
44
- import { remarkVisibility } from '@/lib/remark-visibility';
45
- import { recmaCompoundComponents } from '@/lib/recma-compound-components';
46
- import { extractInlineComponents } from '@/lib/process-mdx-with-exports';
47
- import { extractHeadings } from '@/lib/heading-extractor';
48
- import { StepSlugProvider, type StepSlugEntry } from '@/components/mdx/StepSlugContext';
49
- import { buildEndpointFromMdx } from '@/lib/build-endpoint-from-mdx';
50
- import { mdxSecurityOptions } from '@/lib/mdx-security-options';
51
7
  import fs from 'fs';
52
8
  import path from 'path';
9
+ import type { Metadata } from 'next';
53
10
  import { getContentDir } from '@/lib/docs';
54
- import type { DocsConfig } from '@/lib/docs-types';
55
- import { buildSeoMetadata, generateAutoDescription, buildSiteTitle } from '@/lib/seo';
56
- import { buildJsonLd } from '@/lib/json-ld';
57
11
  import {
58
- getContentLoader,
59
12
  isIsrMode,
60
13
  getProjectFromRequest,
61
14
  getHostAtDocs,
62
- normalizeSlugForContent,
63
- parseFrontmatter,
64
- projectExists,
65
15
  } from '@/lib/content-loader';
66
- import { getBaseUrl, pathToSlug } from '@/lib/page-isr-helpers';
67
- import {
68
- parseOpenApiFrontmatter,
69
- getCachedSpec,
70
- parseEndpoint,
71
- generateCodeExamples,
72
- formatOpenApiWarning,
73
- deriveAuthFromSecurity,
74
- type OpenApiEndpointData,
75
- type CodeExample,
76
- type AuthMethod,
77
- } from '@/lib/openapi';
78
- import { classifyOpenApiLoadError } from '@/lib/openapi/classify-load-error';
79
- import { extractLanguageFromPath, isValidLanguageCode } from '@/lib/language-utils';
80
- import { findFirstNavPage } from '@/lib/find-first-nav-page';
81
- import { candidateSpecPaths } from '@/lib/openapi/lang-spec-path';
82
- import { ApiEndpoint } from '@/components/mdx/ApiEndpoint';
83
- import { ImagePriorityProvider } from '@/components/mdx/ImagePriorityProvider';
84
- import { AIActionsMenu } from '@/components/AIActionsMenu';
85
- import { getContextualOptions } from '@/lib/contextual-defaults';
86
-
87
- type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'TRACE';
88
-
89
- /**
90
- * Parse MDX api frontmatter field (e.g., "POST /analytics/post")
91
- * Returns the HTTP method and path
92
- */
93
- function parseMdxApiField(apiField: string): { method: HttpMethod; path: string } | null {
94
- if (!apiField || typeof apiField !== 'string') return null;
95
-
96
- const trimmed = apiField.trim();
97
- const methods: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE'];
98
-
99
- for (const method of methods) {
100
- if (trimmed.toUpperCase().startsWith(method)) {
101
- const path = trimmed.slice(method.length).trim();
102
- return { method, path };
103
- }
104
- }
105
- return null;
106
- }
16
+ import { renderDocPage, buildDocMetadata, type RenderInput } from '@/lib/render-doc-page';
107
17
 
108
- // Import generated snippet components (created at build time)
109
- // This file is auto-generated by scripts/compile-snippets.cjs
110
- // Note: ProjectSnippets.tsx is server-compatible (no 'use client'),
111
- // individual components have 'use client' in their own files
112
- import { SnippetComponents } from '@/components/snippets/ProjectSnippets';
18
+ export const dynamic = 'force-dynamic';
19
+ export const dynamicParams = true;
113
20
 
114
21
  interface PageProps {
115
22
  params: Promise<{
23
+ project?: string;
116
24
  slug?: string[];
117
25
  }>;
118
26
  }
119
27
 
120
- const DEFAULT_SITE_URL = 'https://docs.example.com';
121
-
122
- /**
123
- * Resolve the base URL for SEO metadata and JSON-LD.
124
- * ISR mode: derived from request headers (handles custom domains).
125
- * Static mode: uses SITE_URL env var set during build.
126
- */
127
- function resolveBaseUrl(
128
- requestHeaders: Headers | null,
129
- projectSlug: string | null,
130
- hostAtDocs: boolean
131
- ): string {
132
- if (requestHeaders && projectSlug) {
133
- return getBaseUrl(requestHeaders, projectSlug, hostAtDocs);
134
- }
135
- return process.env.SITE_URL || DEFAULT_SITE_URL;
136
- }
137
-
138
- /**
139
- * docs.json override is treated as a UNIT — if method is set, both method and name
140
- * come from docs.json, avoiding a stale customer-set `name` pairing with a
141
- * spec-derived `method`. Falls back to deriving auth from the OpenAPI security schemes.
142
- */
143
- function resolveAuth(
144
- endpoint: OpenApiEndpointData | null | undefined,
145
- config: DocsConfig,
146
- ): { method?: AuthMethod; headerName?: string } {
147
- const override = config.api?.mdx?.auth;
148
- if (override?.method) {
149
- return { method: override.method, headerName: override.name };
150
- }
151
- return endpoint ? deriveAuthFromSecurity(endpoint.security) : {};
152
- }
153
-
154
- /**
155
- * Frontmatter data from MDX files.
156
- */
157
- interface FrontmatterData {
158
- title?: string;
159
- description?: string;
160
- api?: string;
161
- openapi?: string;
162
- playground?: string;
163
- mode?: string;
164
- hideFooter?: boolean;
165
- rss?: boolean;
166
- [key: string]: unknown;
167
- }
168
-
169
28
  function getAllDocPaths(): string[] {
170
29
  const contentDir = getContentDir();
171
30
  const paths: string[] = [];
172
31
 
173
32
  function traverseDir(dir: string, basePath: string = '') {
174
33
  if (!fs.existsSync(dir)) return;
175
-
176
34
  const files = fs.readdirSync(dir);
177
-
178
35
  for (const file of files) {
179
- // Skip hidden files/directories (like .claude, .git, etc.)
36
+ // Skip dotfiles (.claude, .git, etc.)
180
37
  if (file.startsWith('.')) continue;
181
-
182
38
  const filePath = path.join(dir, file);
183
-
184
- // Handle broken symlinks gracefully
185
39
  let stat;
186
40
  try {
187
41
  stat = fs.statSync(filePath);
188
42
  } catch {
189
- // Broken symlink or inaccessible file - skip
43
+ // Broken symlink or inaccessible skip
190
44
  continue;
191
45
  }
192
-
193
46
  if (stat.isDirectory()) {
194
47
  traverseDir(filePath, path.join(basePath, file));
195
48
  } else if (file.endsWith('.mdx')) {
@@ -203,618 +56,70 @@ function getAllDocPaths(): string[] {
203
56
  return paths;
204
57
  }
205
58
 
206
- function findFirstPage(config: DocsConfig, lang?: string): string {
207
- const nav = config.navigation;
208
- const langBlock = lang ? nav.languages?.find((l) => l.language === lang) : undefined;
209
- const result = (langBlock && findFirstNavPage(langBlock)) || findFirstNavPage(nav);
210
- return result ? result.replace(/^\//, '') : 'introduction';
211
- }
212
-
213
- function needsSlugRewrite(slug: string[]): boolean {
214
- return slug.length === 0 || (slug.length === 1 && isValidLanguageCode(slug[0]));
215
- }
216
-
217
- function resolveSlug(normalizedSlug: string[], config: DocsConfig): string[] {
218
- if (normalizedSlug.length === 0) return pathToSlug(findFirstPage(config));
219
- if (normalizedSlug.length === 1 && isValidLanguageCode(normalizedSlug[0])) {
220
- return pathToSlug(findFirstPage(config, normalizedSlug[0]));
221
- }
222
- return normalizedSlug;
223
- }
224
-
225
59
  export async function generateStaticParams() {
226
- // In ISR mode, pages are generated on-demand, not at build time
227
- // Return empty array so Next.js doesn't try to pre-render pages
228
- if (isIsrMode()) {
229
- return [];
230
- }
60
+ // ISR: pages generated on-demand, no build-time pre-render.
61
+ if (isIsrMode()) return [];
231
62
 
232
- // Static mode: pre-render all pages from filesystem
233
63
  const paths = getAllDocPaths();
64
+ // next-mdx-remote can't compile relative MDX imports — skip those tests.
65
+ const unsupportedPatterns = ['deep-relative-test', 'relative-snippets-test'];
234
66
 
235
- // Filter out pages with relative MDX imports (not supported by next-mdx-remote)
236
- const unsupportedPatterns = [
237
- 'deep-relative-test',
238
- 'relative-snippets-test',
239
- ];
240
-
241
- const supportedPaths = paths.filter(path => {
242
- const hasUnsupportedPattern = unsupportedPatterns.some(pattern =>
243
- path.includes(pattern)
244
- );
245
- if (hasUnsupportedPattern) {
246
- console.log(`[Build] Skipping page with unsupported relative imports: ${path}`);
247
- }
248
- return !hasUnsupportedPattern;
67
+ const supportedPaths = paths.filter(p => {
68
+ const skip = unsupportedPatterns.some(pattern => p.includes(pattern));
69
+ if (skip) console.log(`[Build] Skipping page with unsupported relative imports: ${p}`);
70
+ return !skip;
249
71
  });
250
72
 
251
- // Include empty slug for root route (resolves to first page in-place)
252
73
  return [
253
74
  { slug: [] },
254
- ...supportedPaths.map((path) => ({
255
- slug: path.split('/'),
256
- })),
75
+ ...supportedPaths.map((p) => ({ slug: p.split('/') })),
257
76
  ];
258
77
  }
259
78
 
260
- export async function generateMetadata({ params }: PageProps) {
261
- const resolvedParams = await params;
262
-
263
- // Get project from request headers (only present in ISR mode)
264
- let projectSlug: string | null = null;
265
- let hostAtDocs = process.env.HOST_AT_DOCS === 'true'; // Local dev fallback
266
- let requestHeaders: Headers | null = null;
267
-
268
- if (isIsrMode()) {
269
- requestHeaders = await headers();
270
- projectSlug = getProjectFromRequest(requestHeaders);
271
- hostAtDocs = getHostAtDocs(requestHeaders);
272
-
273
- if (!projectSlug) {
274
- return { title: 'Not Found' };
275
- }
276
-
277
- const exists = await projectExists(projectSlug);
278
- if (!exists) {
279
- return { title: 'Not Found' };
280
- }
281
- }
282
-
283
- // Get content loader (ISR: R2, static: filesystem)
284
- const loader = getContentLoader(projectSlug ?? undefined);
285
-
286
- // Normalize slug: strip /docs prefix when hostAtDocs=true.
287
- // Empty root → resolve to first page (see DocPage for the full rationale).
288
- const normalizedSlug = normalizeSlugForContent(resolvedParams.slug || [], hostAtDocs);
289
- const configP = loader.getConfig();
290
- const slug = needsSlugRewrite(normalizedSlug)
291
- ? resolveSlug(normalizedSlug, await configP)
292
- : normalizedSlug;
293
- const isRoot = normalizedSlug.length === 0;
294
- const pagePath = slug.join('/');
295
-
296
- const [fileContents, config] = await Promise.all([
297
- loader.getContent(pagePath).catch(() => null),
298
- configP,
299
- ]);
300
-
301
- if (!fileContents) {
79
+ // Resolve project + hostAtDocs from URL params (direct `/[project]/...`
80
+ // hits in non-ISR local dev / tests) or from middleware-set headers (the
81
+ // production path). The header read is what forces this segment dynamic.
82
+ async function resolveInput(params: PageProps['params']): Promise<RenderInput> {
83
+ const resolved = await params;
84
+ const projectFromParams = resolved.project ?? null;
85
+
86
+ if (projectFromParams) {
87
+ // Direct /[project]/... URLs only hit this branch from tests and the
88
+ // non-ISR CLI dev server — production always goes through middleware
89
+ // and the header branch below. hostAtDocs derived from env so the
90
+ // segment stays statically analyzable when this branch is hit.
302
91
  return {
303
- title: 'Not Found',
92
+ slug: resolved.slug || [],
93
+ projectSlug: projectFromParams,
94
+ hostAtDocs: process.env.HOST_AT_DOCS === 'true',
95
+ requestHeaders: null,
304
96
  };
305
97
  }
306
98
 
307
- const parsed = parseFrontmatter(fileContents);
308
- const data = parsed.data as FrontmatterData;
309
-
310
- // Auto-generate description from content when frontmatter has none
311
- if (!data.description) {
312
- data.description = generateAutoDescription(parsed.content);
99
+ if (!isIsrMode()) {
100
+ return {
101
+ slug: resolved.slug || [],
102
+ projectSlug: null,
103
+ hostAtDocs: process.env.HOST_AT_DOCS === 'true',
104
+ requestHeaders: null,
105
+ };
313
106
  }
314
107
 
315
- const baseUrl = resolveBaseUrl(requestHeaders, projectSlug, hostAtDocs);
316
- const languages = config.navigation?.languages;
317
-
318
- const seoMetadata = buildSeoMetadata(config, data, pagePath, baseUrl, languages);
319
-
320
- // If page title matches config.name, use absolute to prevent "X — X" double-wrap.
321
- // If no title, use buildSiteTitle (which avoids "X Documentation Documentation").
322
- const titleValue = data.title
323
- ? (data.title === config.name ? { absolute: data.title } : data.title)
324
- : { absolute: buildSiteTitle(config.name) };
325
-
108
+ const requestHeaders = await headers();
326
109
  return {
327
- title: titleValue,
328
- description: data.description || '',
329
- ...seoMetadata,
330
- // Root serves first-page content but canonical points at /{firstPage};
331
- // noindex as a second dedup signal alongside the canonical tag.
332
- ...(isRoot && { robots: { index: false, follow: true } }),
333
- ...(data.rss ? {
334
- alternates: {
335
- ...seoMetadata.alternates,
336
- types: {
337
- 'application/rss+xml': hostAtDocs ? '/docs/feed.xml' : '/feed.xml',
338
- },
339
- },
340
- } : {}),
110
+ slug: resolved.slug || [],
111
+ projectSlug: getProjectFromRequest(requestHeaders),
112
+ hostAtDocs: getHostAtDocs(requestHeaders),
113
+ requestHeaders,
341
114
  };
342
115
  }
343
116
 
344
- export default async function DocPage({ params }: PageProps) {
345
- const resolvedParams = await params;
346
-
347
- // Get project from request headers (only present in ISR mode)
348
- let projectSlug: string | null = null;
349
- let hostAtDocs = process.env.HOST_AT_DOCS === 'true'; // Local dev fallback
350
- let requestHeaders: Headers | null = null;
351
- if (isIsrMode()) {
352
- requestHeaders = await headers();
353
- projectSlug = getProjectFromRequest(requestHeaders);
354
- hostAtDocs = getHostAtDocs(requestHeaders);
355
- if (!projectSlug) {
356
- notFound(); // Project not resolved by middleware
357
- }
358
-
359
- // Check if project has content in R2
360
- // This returns 404 instead of 500 for projects that haven't been built yet
361
- const exists = await projectExists(projectSlug);
362
- if (!exists) {
363
- notFound(); // Project not built to R2 yet
364
- }
365
- }
366
-
367
- // Get content loader (ISR: R2, static: filesystem)
368
- const loader = getContentLoader(projectSlug ?? undefined);
369
-
370
- // Normalize slug: strip /docs prefix when hostAtDocs=true.
371
- // Empty root renders the first page in place rather than 307'ing — Next's
372
- // redirect() emits cache-control: private, blocking CDN caching. Canonical
373
- // + noindex in generateMetadata prevent duplicate indexing.
374
- const normalizedSlug = normalizeSlugForContent(resolvedParams.slug || [], hostAtDocs);
375
- const configP = loader.getConfig();
376
- const slug = needsSlugRewrite(normalizedSlug)
377
- ? resolveSlug(normalizedSlug, await configP)
378
- : normalizedSlug;
379
- const pagePath = slug.join('/');
380
- const currentLang = extractLanguageFromPath(`/${pagePath}`);
381
- const [fileContents, config] = await Promise.all([
382
- loader.getContent(pagePath).catch(() => null),
383
- configP,
384
- ]);
385
-
386
- // Check if content exists (getContent returns null via catch if not found)
387
- if (!fileContents) {
388
- notFound();
389
- }
390
-
391
- const parsed = parseFrontmatter(fileContents);
392
- const data = parsed.data as FrontmatterData;
393
- const rawContent = parsed.content;
394
-
395
- // JSON-LD structured data for rich search results.
396
- // XSS-safe: replaces < with unicode escape per Next.js guide.
397
- // Same pattern as marketing/app/layout.tsx and marketing/app/blog/[slug]/page.tsx.
398
- const baseUrl = resolveBaseUrl(requestHeaders, projectSlug, hostAtDocs);
399
- const jsonLd = buildJsonLd({
400
- config,
401
- pagePath,
402
- pageTitle: data.title || pagePath,
403
- baseUrl,
404
- });
405
- const jsonLdScript = (
406
- <script
407
- type="application/ld+json"
408
- dangerouslySetInnerHTML={{
409
- __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
410
- }}
411
- />
412
- );
413
-
414
- // Get cached Shiki highlighter for syntax highlighting
415
- // This singleton is reused across all page renders for performance
416
- const highlighter = await getHighlighter();
417
-
418
- // Load snippets - different behavior for ISR vs static mode:
419
- // - ISR mode: Fetch and compile snippets dynamically from R2
420
- // - Static mode: Use pre-compiled SnippetComponents from build time
421
- let snippetAliases: Record<string, React.ComponentType<unknown>> = {};
422
-
423
- if (isIsrMode() && projectSlug) {
424
- // ISR mode: Load snippets dynamically from R2
425
- // This is required because ISR pages are rendered on-demand and can't use
426
- // the static SnippetComponents which are empty in ISR builds
427
- snippetAliases = await loadSnippetsForIsr(projectSlug, rawContent, MDXComponents);
428
- } else {
429
- // Static mode: Use pre-compiled snippets from build time
430
- // This maps import aliases to compiled components:
431
- // import Propagating from '/snippets/custom-subpath-propagating.mdx'
432
- // maps 'Propagating' -> SnippetComponents['CustomSubpathPropagating']
433
- snippetAliases = buildSnippetAliasMap(rawContent, SnippetComponents);
434
- }
435
-
436
- // Preprocess MDX content to strip snippet imports
437
- // (snippets are injected globally via AllComponents)
438
- const content = preprocessMdx(rawContent, { assetVersion: config.assetVersion });
439
-
440
- // Pre-resolve unique slugs for every <Step> on the page so duplicate titles
441
- // across separate <Steps> blocks get -2, -3, ... suffixes that match the
442
- // build-time TOC. Provider supplies the list to <Step> via context.
443
- const stepEntries: StepSlugEntry[] = extractHeadings(content)
444
- .filter(h => typeof h.stepNumber === 'number')
445
- .map(h => ({ title: h.text, slug: h.id }));
446
-
447
- // Extract and compile inline component exports from MDX
448
- // Only pass MDXComponents (server-compatible) to inline extraction
449
- const { inlineComponents, paramFields } = await extractInlineComponents(content, MDXComponents);
450
-
451
- // Check for component name collisions and warn
452
- const overriddenComponents = Object.keys(inlineComponents).filter(
453
- (name) => name in MDXComponents
454
- );
455
- if (overriddenComponents.length > 0) {
456
- console.warn(
457
- `[MDX] Inline component(s) override built-in: ${overriddenComponents.join(', ')} in ${slug.join('/')}`
458
- );
459
- }
460
-
461
- // Merge all components for MDXRemote:
462
- // 1. Built-in MDX components (Card, Note, etc.)
463
- // 2. Compiled snippet components (by filename-based names)
464
- // 3. Snippet alias mappings (user's import aliases -> components)
465
- // 4. Inline component exports from this MDX file
466
- // 5. Auto-prefix `a` for hostAtDocs sites (so /path → /docs/path)
467
- const AllComponentsWithInline = {
468
- ...MDXComponents,
469
- ...SnippetComponents,
470
- ...snippetAliases,
471
- ...inlineComponents,
472
- // When hostAtDocs is true, override the base `a` from MDXComponents.tsx
473
- // to auto-prefix absolute internal links with /docs.
474
- // Styling must stay in sync with the base `a` in MDXComponents.tsx.
475
- ...(hostAtDocs ? {
476
- a: ({ ariaLabel, href, ...props }: any) => {
477
- const needsPrefix = href?.startsWith('/') && !href.startsWith('/docs/') && href !== '/docs';
478
- return (
479
- <a
480
- className="text-theme-accent hover:text-theme-accent-hover transition-colors"
481
- aria-label={ariaLabel}
482
- href={needsPrefix ? `/docs${href}` : href}
483
- {...props}
484
- />
485
- );
486
- },
487
- } : {}),
488
- };
489
-
490
- // Check if this is an API page (has api or openapi frontmatter)
491
- const isApiPage = !!data.api || !!data.openapi;
492
-
493
- // Check if wide mode is enabled (hides TOC, content expands to full width)
494
- const isWideMode = data.mode === 'wide';
495
-
496
- // Check if content contains a Panel component (replaces ToC with custom sidebar content)
497
- const hasPanel = containsPanel(content);
498
-
499
- // Check if content contains View components (multi-view content like language-specific docs)
500
- const hasView = containsView(content);
501
-
502
- // Parse OpenAPI endpoint data if openapi frontmatter is present
503
- let openApiEndpointData: OpenApiEndpointData | null = null;
504
- let openApiCodeExamples: CodeExample[] | null = null;
505
- let openApiError: string | null = null;
506
-
507
- // OpenAPI spec parsing - supports both static and ISR modes
508
- let lastFailure: { err: unknown; specPath: string } | null = null;
509
- if (data.openapi && typeof data.openapi === 'string') {
510
- try {
511
- // Normalize config to array (handles string, array, or undefined)
512
- const openApiConfig = config.api?.openapi;
513
- const allSpecPaths: string[] = typeof openApiConfig === 'string'
514
- ? [openApiConfig]
515
- : Array.isArray(openApiConfig)
516
- ? openApiConfig
517
- : [];
518
-
519
- const parsed = parseOpenApiFrontmatter(
520
- data.openapi,
521
- allSpecPaths.length > 0 ? allSpecPaths : undefined
522
- );
523
-
524
- const baseSpecs = parsed.isShortFormat && allSpecPaths.length > 1
525
- ? allSpecPaths
526
- : [parsed.specPath];
527
- // For each base spec, expand into [<base>.<lang>.ext, <base>.ext] when
528
- // the page is under a localized URL. Resolver tries each in order; the
529
- // first that loads wins. Missing lang variant falls back to English.
530
- const specsToTry = baseSpecs.flatMap(p => candidateSpecPaths(p, currentLang));
531
-
532
- // Hoist mode-dependent values before the loop
533
- const useIsr = isIsrMode() && !!projectSlug;
534
- const resolveSpec = useIsr
535
- ? (await import('@/lib/openapi-isr')).resolveOpenApiSpec
536
- : null;
537
- const contentDir = useIsr ? null : getContentDir();
538
-
539
- for (let i = 0; i < specsToTry.length; i++) {
540
- const specPath = specsToTry[i];
541
- try {
542
- if (resolveSpec && projectSlug) {
543
- const spec = await resolveSpec(projectSlug, specPath);
544
- openApiEndpointData = parseEndpoint(
545
- spec as Parameters<typeof parseEndpoint>[0],
546
- parsed.method,
547
- parsed.path,
548
- specPath
549
- );
550
- } else {
551
- const { api } = await getCachedSpec(specPath, contentDir!);
552
- openApiEndpointData = parseEndpoint(api, parsed.method, parsed.path, specPath);
553
- }
554
- lastFailure = null;
555
- break;
556
- } catch (err) {
557
- lastFailure = { err, specPath };
558
- const isLast = i === specsToTry.length - 1;
559
- if (!isLast) {
560
- // Lang variant (or intermediate candidate) failed — log so we
561
- // notice transient R2 errors that silently fall through to English.
562
- console.warn(
563
- `[openapi] spec candidate "${specPath}" failed; trying next: ${(err as Error).message}`
564
- );
565
- }
566
- }
567
- }
568
-
569
- if (lastFailure) {
570
- throw lastFailure.err;
571
- }
572
-
573
- // Generate code examples
574
- if (openApiEndpointData) {
575
- const { method: authMethod, headerName: authHeaderName } = resolveAuth(openApiEndpointData, config);
576
- const languages = config.api?.examples?.languages;
577
- openApiCodeExamples = generateCodeExamples(openApiEndpointData, { authMethod, authHeaderName, languages });
578
- }
579
- } catch (err) {
580
- const typed = classifyOpenApiLoadError(err, lastFailure?.specPath ?? null);
581
- console.warn(formatOpenApiWarning(typed));
582
- openApiError = typed.suggestion
583
- ? `${typed.message} — ${typed.suggestion}`
584
- : typed.message;
585
- }
586
- }
587
-
588
- // Parse MDX api field for pages with api: frontmatter (but not openapi:)
589
- let mdxApiMethod: HttpMethod | null = null;
590
- let mdxApiPath: string | null = null;
591
-
592
- if (data.api && typeof data.api === 'string' && !data.openapi) {
593
- const parsed = parseMdxApiField(data.api);
594
- if (parsed) {
595
- mdxApiMethod = parsed.method;
596
- mdxApiPath = parsed.path;
597
- }
598
- }
599
-
600
- // RSS feed icon for pages with rss: true
601
- const rssIcon = data.rss ? (
602
- <a
603
- href={hostAtDocs ? '/docs/feed.xml' : '/feed.xml'}
604
- target="_blank"
605
- rel="noopener noreferrer"
606
- aria-label="Subscribe to RSS feed"
607
- title="RSS feed"
608
- className="flex-shrink-0 text-[var(--color-text-muted)] hover:text-[var(--color-primary)] transition-colors cursor-pointer"
609
- >
610
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5">
611
- <circle cx="6.18" cy="17.82" r="2.18" />
612
- <path d="M4 4.44v2.83c7.03 0 12.73 5.7 12.73 12.73h2.83c0-8.59-6.97-15.56-15.56-15.56zm0 5.66v2.83c3.9 0 7.07 3.17 7.07 7.07h2.83c0-5.47-4.43-9.9-9.9-9.9z" />
613
- </svg>
614
- </a>
615
- ) : null;
616
-
617
- // Contextual AI Actions menu options
618
- const contextualOptions = getContextualOptions(config);
619
- const hasAiActions = contextualOptions.length > 0;
620
-
621
- // Prose class for MDX content styling (defined in base.css)
622
- const proseClasses = 'prose max-w-none';
623
-
624
- // Playground configuration — covers both openapi: and api: pages
625
- const hasApiEndpoint = openApiEndpointData || (mdxApiMethod && mdxApiPath);
626
- const playgroundDisplay = hasApiEndpoint
627
- ? ((data.playground as 'interactive' | 'simple' | 'none' | undefined)
628
- || config.api?.playground?.display || 'interactive') as 'interactive' | 'simple' | 'none'
629
- : 'none';
630
- const mdxServerConfig = config.api?.mdx?.server;
631
- const fallbackServerUrl = Array.isArray(mdxServerConfig) ? mdxServerConfig[0] : mdxServerConfig;
632
- // Force proxy on hostAtDocs sites — direct mode is always cross-origin there
633
- const proxyEnabled = hostAtDocs
634
- || config.api?.playground?.proxy
635
- || (config.api?.playground?.proxy == null && playgroundDisplay === 'interactive');
636
-
637
- // Build endpoint data for api: pages (for playground)
638
- let mdxEndpointData: OpenApiEndpointData | undefined;
639
- if (!openApiEndpointData && mdxApiMethod && mdxApiPath && playgroundDisplay !== 'none') {
640
- mdxEndpointData = buildEndpointFromMdx(mdxApiMethod, mdxApiPath, paramFields, fallbackServerUrl);
641
- }
642
-
643
- const resolvedMdxAuth = resolveAuth(mdxEndpointData, config);
644
- const resolvedOpenApiAuth = resolveAuth(openApiEndpointData, config);
645
-
646
- // For API pages, wrap the entire content area with ApiPageWrapper
647
- // so code panels can be positioned as siblings at the page level
648
- if (isApiPage) {
649
- return (
650
- <>{jsonLdScript}<ApiPageWrapper>
651
- <article className="px-4 sm:px-6 lg:px-8 py-6 sm:py-10 flex-1 min-w-0">
652
- {/* Breadcrumb */}
653
- <Breadcrumb slug={slug} config={config} />
654
-
655
- {/* Page Header */}
656
- {data.title && (
657
- <header className="mb-4 sm:mb-6">
658
- <div className="flex items-center gap-3">
659
- <h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-theme-text-primary tracking-tight">
660
- {data.title}
661
- </h1>
662
- {rssIcon}
663
- {hasAiActions && <div className="ml-auto flex-shrink-0 hidden sm:block"><AIActionsMenu options={contextualOptions} projectName={config.name} /></div>}
664
- </div>
665
- {data.description && (
666
- <p className="text-base sm:text-lg text-theme-text-secondary leading-relaxed mt-2 sm:mt-3">
667
- {data.description}
668
- </p>
669
- )}
670
- {hasAiActions && <div className="mt-3 sm:hidden" style={{ paddingLeft: 0 }}><AIActionsMenu options={contextualOptions} projectName={config.name} /></div>}
671
- </header>
672
- )}
673
-
674
- {/* Content */}
675
- <div className={proseClasses}>
676
- {/* MDX API endpoint badge (for pages with api: frontmatter) */}
677
- {mdxApiMethod && mdxApiPath && (
678
- mdxEndpointData && playgroundDisplay !== 'none' ? (
679
- <OpenApiEndpoint
680
- endpoint={mdxEndpointData}
681
- playgroundOnly
682
- playgroundDisplay={playgroundDisplay}
683
- authMethod={resolvedMdxAuth.method}
684
- authHeaderName={resolvedMdxAuth.headerName}
685
- serverUrl={fallbackServerUrl}
686
- proxyEnabled={proxyEnabled}
687
- languages={config.api?.examples?.languages}
688
- />
689
- ) : (
690
- <ApiEndpoint
691
- method={mdxApiMethod}
692
- path={mdxApiPath}
693
- baseUrl={fallbackServerUrl}
694
- />
695
- )
696
- )}
697
-
698
- {/* OpenAPI endpoint documentation (auto-generated from spec) */}
699
- {openApiEndpointData && (
700
- <OpenApiEndpoint
701
- endpoint={openApiEndpointData}
702
- codeExamples={openApiCodeExamples || undefined}
703
- playgroundDisplay={playgroundDisplay}
704
- authMethod={resolvedOpenApiAuth.method}
705
- authHeaderName={resolvedOpenApiAuth.headerName}
706
- serverUrl={fallbackServerUrl}
707
- proxyEnabled={proxyEnabled}
708
- languages={config.api?.examples?.languages}
709
- />
710
- )}
711
-
712
- {/* OpenAPI error — shown when spec parsing fails */}
713
- {!openApiEndpointData && openApiError && (
714
- <OpenApiError message={openApiError} slug={slug.join('/')} />
715
- )}
716
-
717
- {/* Additional MDX content — strip <ResponseExample> on OpenAPI pages
718
- (auto-generated ResponseExamplePanel already handles responses) */}
719
- <ImagePriorityProvider>
720
- <StepSlugProvider entries={stepEntries}>
721
- <MDXRemote
722
- source={openApiEndpointData
723
- ? content.replace(/<ResponseExample>[\s\S]*?<\/ResponseExample>/g, '')
724
- : content}
725
- components={AllComponentsWithInline}
726
- options={{
727
- // Keep expression props (e.g. cols={2}) compatible under next-mdx-remote v6.
728
- ...mdxSecurityOptions,
729
- mdxOptions: {
730
- remarkPlugins: [remarkGfm, [remarkVisibility, { audience: 'humans' }], ...getTypographyRemarkPlugins(config), ...getLatexRemarkPlugins(config)],
731
- rehypePlugins: [rehypeNoZoomToData, rehypeClassToClassName, rehypeCodeMeta, createShikiRehypePlugin(highlighter, config), rehypeRestoreDataTitle, ...getLatexRehypePlugins(config), rehypeSlug],
732
- recmaPlugins: [recmaCompoundComponents],
733
- },
734
- }}
735
- />
736
- </StepSlugProvider>
737
- </ImagePriorityProvider>
738
- </div>
739
-
740
- {/* Previous/Next Navigation */}
741
- <PageNavigation currentSlug={slug.join('/')} config={config} />
742
- <SocialFooter config={config} hidden={data.hideFooter} projectSlug={projectSlug ?? undefined} />
743
- </article>
744
- </ApiPageWrapper></>
745
- );
746
- }
747
-
748
- // MDX content for non-API pages (extracted for conditional ViewWrapper wrapping)
749
- const mdxContent = (
750
- <StepSlugProvider entries={stepEntries}>
751
- <MDXRemote
752
- source={content}
753
- components={AllComponentsWithInline}
754
- options={{
755
- // Keep expression props (e.g. cols={2}) compatible under next-mdx-remote v6.
756
- ...mdxSecurityOptions,
757
- mdxOptions: {
758
- remarkPlugins: [remarkGfm, [remarkVisibility, { audience: 'humans' }], ...getTypographyRemarkPlugins(config), ...getLatexRemarkPlugins(config)],
759
- rehypePlugins: [rehypeNoZoomToData, rehypeClassToClassName, rehypeCodeMeta, createShikiRehypePlugin(highlighter, config), rehypeRestoreDataTitle, ...getLatexRehypePlugins(config), rehypeSlug],
760
- recmaPlugins: [recmaCompoundComponents],
761
- },
762
- }}
763
- />
764
- </StepSlugProvider>
765
- );
766
-
767
- // Shared article content for non-API pages
768
- const articleContent = (
769
- <article className="px-4 sm:px-6 lg:px-8 py-6 sm:py-10">
770
- {/* Breadcrumb */}
771
- <Breadcrumb slug={slug} config={config} />
772
-
773
- {/* Page Header */}
774
- {data.title && (
775
- <header className="mb-6 sm:mb-10">
776
- <div className="flex items-center gap-3">
777
- <h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-theme-text-primary tracking-tight">
778
- {data.title}
779
- </h1>
780
- {rssIcon}
781
- {hasAiActions && <div className="ml-auto flex-shrink-0 hidden sm:block"><AIActionsMenu options={contextualOptions} projectName={config.name} /></div>}
782
- </div>
783
- {data.description && (
784
- <p className="text-base sm:text-lg text-theme-text-secondary leading-relaxed mt-2 sm:mt-3">
785
- {data.description}
786
- </p>
787
- )}
788
- {hasAiActions && <div className="mt-3 sm:hidden" style={{ paddingLeft: 0 }}><AIActionsMenu options={contextualOptions} projectName={config.name} /></div>}
789
- </header>
790
- )}
791
-
792
- {/* Content */}
793
- <div className={proseClasses}>
794
- <ImagePriorityProvider>
795
- {hasView ? <ViewWrapper>{mdxContent}</ViewWrapper> : mdxContent}
796
- </ImagePriorityProvider>
797
-
798
- {/* Previous/Next Navigation - inside prose to match content width */}
799
- <PageNavigation currentSlug={slug.join('/')} config={config} isWideMode={isWideMode || hasPanel} />
800
- </div>
801
- <SocialFooter config={config} hidden={data.hideFooter} projectSlug={projectSlug ?? undefined} />
802
- </article>
803
- );
804
-
805
- // Pages with Panel component: use PanelWrapper to move Panel content to sidebar
806
- if (hasPanel) {
807
- return <>{jsonLdScript}<PanelWrapper>{articleContent}</PanelWrapper></>;
808
- }
117
+ export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
118
+ const input = await resolveInput(params);
119
+ return buildDocMetadata(input);
120
+ }
809
121
 
810
- // Non-API pages: standard layout with Table of Contents (or inline chat)
811
- // PageColumns handles TOC ↔ chat column switching on xl+ screens
812
- return (
813
- <>
814
- {jsonLdScript}
815
- <PageColumns toc={<TableOfContents content={content} />} isWideMode={isWideMode}>
816
- {articleContent}
817
- </PageColumns>
818
- </>
819
- );
122
+ export default async function DocPage({ params }: PageProps) {
123
+ const input = await resolveInput(params);
124
+ return renderDocPage(input);
820
125
  }