jamdesk 1.1.49 → 1.1.50

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.
@@ -0,0 +1,595 @@
1
+ // Header-free render path for documentation pages. Called from
2
+ // `app/[[...slug]]/page.tsx` only; this module and its imports do not
3
+ // touch `next/headers` so the page can pass header data in (or null) and
4
+ // drive base-URL resolution explicitly.
5
+ //
6
+ // Caller contract (both branches live in the catch-all page):
7
+ // - Header-resolved branch (middleware-set `x-project-slug`): passes
8
+ // `requestHeaders` so `resolveBaseUrl` derives a forwarded-host
9
+ // canonical.
10
+ // - `projectFromParams` branch (direct `/[project]/...` URLs and
11
+ // non-ISR local dev): passes `requestHeaders=null` and gets a
12
+ // subdomain-canonical via `getBaseUrlFromConfig`.
13
+ import { notFound } from 'next/navigation';
14
+ import { MDXRemote } from 'next-mdx-remote/rsc';
15
+ import type { Metadata } from 'next';
16
+ import type { ReactElement } from 'react';
17
+ import { MDXComponents } from '@/components/mdx/MDXComponents';
18
+ import { Breadcrumb } from '@/components/navigation/Breadcrumb';
19
+ import { TableOfContents } from '@/components/navigation/TableOfContents';
20
+ import { PageColumns } from '@/components/layout/PageColumns';
21
+ import { PageNavigation } from '@/components/navigation/PageNavigation';
22
+ import { SocialFooter } from '@/components/navigation/SocialFooter';
23
+ import { ApiPageWrapper } from '@/components/mdx/ApiPage';
24
+ import { OpenApiEndpoint } from '@/components/mdx/OpenApiEndpoint';
25
+ import { OpenApiError } from '@/components/openapi/OpenApiError';
26
+ import { getHighlighter } from '@/lib/shiki-highlighter';
27
+ import { createShikiRehypePlugin } from '@/lib/shiki-config';
28
+ import rehypeSlug from 'rehype-slug';
29
+ import remarkGfm from 'remark-gfm';
30
+ import { rehypeCodeMeta, rehypeRestoreDataTitle } from '@/lib/rehype-code-meta';
31
+ import { rehypeClassToClassName } from '@/lib/rehype-class-to-classname';
32
+ import { rehypeNoZoomToData } from '@/lib/rehype-nozoom-to-data';
33
+ import { preprocessMdx, containsPanel, containsView, buildSnippetAliasMap } from '@/lib/preprocess-mdx';
34
+ import { loadSnippetsForIsr } from '@/lib/snippet-loader-isr';
35
+ import { PanelWrapper } from '@/components/mdx/PanelWrapper';
36
+ import { ViewWrapper } from '@/components/mdx/View';
37
+ import { getLatexRemarkPlugins, getLatexRehypePlugins } from '@/lib/latex-config';
38
+ import { getTypographyRemarkPlugins } from '@/lib/typography-config';
39
+ import { remarkVisibility } from '@/lib/remark-visibility';
40
+ import { recmaCompoundComponents } from '@/lib/recma-compound-components';
41
+ import { extractInlineComponents } from '@/lib/process-mdx-with-exports';
42
+ import { extractHeadings } from '@/lib/heading-extractor';
43
+ import { StepSlugProvider, type StepSlugEntry } from '@/components/mdx/StepSlugContext';
44
+ import { buildEndpointFromMdx } from '@/lib/build-endpoint-from-mdx';
45
+ import { mdxSecurityOptions } from '@/lib/mdx-security-options';
46
+ import { getContentDir } from '@/lib/docs';
47
+ import type { DocsConfig } from '@/lib/docs-types';
48
+ import { buildSeoMetadata, generateAutoDescription, buildSiteTitle } from '@/lib/seo';
49
+ import { buildJsonLd } from '@/lib/json-ld';
50
+ import {
51
+ getContentLoader,
52
+ isIsrMode,
53
+ normalizeSlugForContent,
54
+ parseFrontmatter,
55
+ projectExists,
56
+ } from '@/lib/content-loader';
57
+ import { getBaseUrl, getBaseUrlFromConfig, pathToSlug } from '@/lib/page-isr-helpers';
58
+ import {
59
+ parseOpenApiFrontmatter,
60
+ getCachedSpec,
61
+ parseEndpoint,
62
+ generateCodeExamples,
63
+ formatOpenApiWarning,
64
+ deriveAuthFromSecurity,
65
+ type OpenApiEndpointData,
66
+ type CodeExample,
67
+ type AuthMethod,
68
+ } from '@/lib/openapi';
69
+ import { classifyOpenApiLoadError } from '@/lib/openapi/classify-load-error';
70
+ import { extractLanguageFromPath, isValidLanguageCode } from '@/lib/language-utils';
71
+ import { findFirstNavPage } from '@/lib/find-first-nav-page';
72
+ import { candidateSpecPaths } from '@/lib/openapi/lang-spec-path';
73
+ import { ApiEndpoint } from '@/components/mdx/ApiEndpoint';
74
+ import { ImagePriorityProvider } from '@/components/mdx/ImagePriorityProvider';
75
+ import { AIActionsMenu } from '@/components/AIActionsMenu';
76
+ import { getContextualOptions } from '@/lib/contextual-defaults';
77
+ import { SnippetComponents } from '@/components/snippets/ProjectSnippets';
78
+
79
+ type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'TRACE';
80
+
81
+ const DEFAULT_SITE_URL = 'https://docs.example.com';
82
+
83
+ export interface RenderInput {
84
+ slug: string[];
85
+ projectSlug: string | null;
86
+ hostAtDocs: boolean;
87
+ requestHeaders: Headers | null;
88
+ }
89
+
90
+ interface FrontmatterData {
91
+ title?: string;
92
+ description?: string;
93
+ api?: string;
94
+ openapi?: string;
95
+ playground?: string;
96
+ mode?: string;
97
+ hideFooter?: boolean;
98
+ rss?: boolean;
99
+ [key: string]: unknown;
100
+ }
101
+
102
+ function parseMdxApiField(apiField: string): { method: HttpMethod; path: string } | null {
103
+ if (!apiField || typeof apiField !== 'string') return null;
104
+ const trimmed = apiField.trim();
105
+ const methods: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE'];
106
+ for (const method of methods) {
107
+ if (trimmed.toUpperCase().startsWith(method)) {
108
+ const path = trimmed.slice(method.length).trim();
109
+ return { method, path };
110
+ }
111
+ }
112
+ return null;
113
+ }
114
+
115
+ function resolveBaseUrl(
116
+ requestHeaders: Headers | null,
117
+ projectSlug: string | null,
118
+ hostAtDocs: boolean,
119
+ ): string {
120
+ if (!projectSlug) return process.env.SITE_URL || DEFAULT_SITE_URL;
121
+ if (requestHeaders) return getBaseUrl(requestHeaders, projectSlug, hostAtDocs);
122
+ return getBaseUrlFromConfig(projectSlug, hostAtDocs);
123
+ }
124
+
125
+ function resolveAuth(
126
+ endpoint: OpenApiEndpointData | null | undefined,
127
+ config: DocsConfig,
128
+ ): { method?: AuthMethod; headerName?: string } {
129
+ const override = config.api?.mdx?.auth;
130
+ if (override?.method) {
131
+ return { method: override.method, headerName: override.name };
132
+ }
133
+ return endpoint ? deriveAuthFromSecurity(endpoint.security) : {};
134
+ }
135
+
136
+ function findFirstPage(config: DocsConfig, lang?: string): string {
137
+ const nav = config.navigation;
138
+ const langBlock = lang ? nav.languages?.find((l) => l.language === lang) : undefined;
139
+ const result = (langBlock && findFirstNavPage(langBlock)) || findFirstNavPage(nav);
140
+ return result ? result.replace(/^\//, '') : 'introduction';
141
+ }
142
+
143
+ function needsSlugRewrite(slug: string[]): boolean {
144
+ return slug.length === 0 || (slug.length === 1 && isValidLanguageCode(slug[0]));
145
+ }
146
+
147
+ function resolveSlug(normalizedSlug: string[], config: DocsConfig): string[] {
148
+ if (normalizedSlug.length === 0) return pathToSlug(findFirstPage(config));
149
+ if (normalizedSlug.length === 1 && isValidLanguageCode(normalizedSlug[0])) {
150
+ return pathToSlug(findFirstPage(config, normalizedSlug[0]));
151
+ }
152
+ return normalizedSlug;
153
+ }
154
+
155
+ export async function buildDocMetadata(input: RenderInput): Promise<Metadata> {
156
+ const { slug: slugInput, projectSlug, hostAtDocs, requestHeaders } = input;
157
+
158
+ if (isIsrMode()) {
159
+ if (!projectSlug) return { title: 'Not Found' };
160
+ const exists = await projectExists(projectSlug);
161
+ if (!exists) return { title: 'Not Found' };
162
+ }
163
+
164
+ const loader = getContentLoader(projectSlug ?? undefined);
165
+ const configP = loader.getConfig();
166
+
167
+ const normalizedSlug = normalizeSlugForContent(slugInput || [], hostAtDocs);
168
+ const slug = needsSlugRewrite(normalizedSlug)
169
+ ? resolveSlug(normalizedSlug, await configP)
170
+ : normalizedSlug;
171
+ const isRoot = normalizedSlug.length === 0;
172
+ const pagePath = slug.join('/');
173
+
174
+ const [fileContents, config] = await Promise.all([
175
+ loader.getContent(pagePath).catch(() => null),
176
+ configP,
177
+ ]);
178
+
179
+ if (!fileContents) {
180
+ return { title: 'Not Found' };
181
+ }
182
+
183
+ const parsed = parseFrontmatter(fileContents);
184
+ const data = parsed.data as FrontmatterData;
185
+
186
+ if (!data.description) {
187
+ data.description = generateAutoDescription(parsed.content);
188
+ }
189
+
190
+ const baseUrl = resolveBaseUrl(requestHeaders, projectSlug, hostAtDocs);
191
+ const languages = config.navigation?.languages;
192
+
193
+ const seoMetadata = buildSeoMetadata(config, data, pagePath, baseUrl, languages);
194
+
195
+ const titleValue = data.title
196
+ ? (data.title === config.name ? { absolute: data.title } : data.title)
197
+ : { absolute: buildSiteTitle(config.name) };
198
+
199
+ return {
200
+ title: titleValue,
201
+ description: data.description || '',
202
+ ...seoMetadata,
203
+ ...(isRoot && { robots: { index: false, follow: true } }),
204
+ ...(data.rss ? {
205
+ alternates: {
206
+ ...seoMetadata.alternates,
207
+ types: {
208
+ 'application/rss+xml': hostAtDocs ? '/docs/feed.xml' : '/feed.xml',
209
+ },
210
+ },
211
+ } : {}),
212
+ };
213
+ }
214
+
215
+ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
216
+ const { slug: slugInput, projectSlug, hostAtDocs, requestHeaders } = input;
217
+
218
+ if (isIsrMode()) {
219
+ if (!projectSlug) notFound();
220
+ const exists = await projectExists(projectSlug);
221
+ if (!exists) notFound();
222
+ }
223
+
224
+ const loader = getContentLoader(projectSlug ?? undefined);
225
+ const configP = loader.getConfig();
226
+
227
+ const normalizedSlug = normalizeSlugForContent(slugInput || [], hostAtDocs);
228
+ const slug = needsSlugRewrite(normalizedSlug)
229
+ ? resolveSlug(normalizedSlug, await configP)
230
+ : normalizedSlug;
231
+ const pagePath = slug.join('/');
232
+ const currentLang = extractLanguageFromPath(`/${pagePath}`);
233
+ const [fileContents, config] = await Promise.all([
234
+ loader.getContent(pagePath).catch(() => null),
235
+ configP,
236
+ ]);
237
+
238
+ if (!fileContents) {
239
+ notFound();
240
+ }
241
+
242
+ const parsed = parseFrontmatter(fileContents);
243
+ const data = parsed.data as FrontmatterData;
244
+ const rawContent = parsed.content;
245
+
246
+ const baseUrl = resolveBaseUrl(requestHeaders, projectSlug, hostAtDocs);
247
+ const jsonLd = buildJsonLd({
248
+ config,
249
+ pagePath,
250
+ pageTitle: data.title || pagePath,
251
+ baseUrl,
252
+ });
253
+ const jsonLdScript = renderJsonLdScript(jsonLd);
254
+
255
+ const highlighter = await getHighlighter();
256
+
257
+ let snippetAliases: Record<string, React.ComponentType<unknown>> = {};
258
+ if (isIsrMode() && projectSlug) {
259
+ snippetAliases = await loadSnippetsForIsr(projectSlug, rawContent, MDXComponents);
260
+ } else {
261
+ snippetAliases = buildSnippetAliasMap(rawContent, SnippetComponents);
262
+ }
263
+
264
+ const content = preprocessMdx(rawContent, { assetVersion: config.assetVersion });
265
+
266
+ const stepEntries: StepSlugEntry[] = extractHeadings(content)
267
+ .filter(h => typeof h.stepNumber === 'number')
268
+ .map(h => ({ title: h.text, slug: h.id }));
269
+
270
+ const { inlineComponents, paramFields } = await extractInlineComponents(content, MDXComponents);
271
+
272
+ const overriddenComponents = Object.keys(inlineComponents).filter(
273
+ (name) => name in MDXComponents,
274
+ );
275
+ if (overriddenComponents.length > 0) {
276
+ console.warn(
277
+ `[MDX] Inline component(s) override built-in: ${overriddenComponents.join(', ')} in ${slug.join('/')}`,
278
+ );
279
+ }
280
+
281
+ const AllComponentsWithInline = {
282
+ ...MDXComponents,
283
+ ...SnippetComponents,
284
+ ...snippetAliases,
285
+ ...inlineComponents,
286
+ ...(hostAtDocs ? {
287
+ a: ({ ariaLabel, href, ...props }: any) => {
288
+ const needsPrefix = href?.startsWith('/') && !href.startsWith('/docs/') && href !== '/docs';
289
+ return (
290
+ <a
291
+ className="text-theme-accent hover:text-theme-accent-hover transition-colors"
292
+ aria-label={ariaLabel}
293
+ href={needsPrefix ? `/docs${href}` : href}
294
+ {...props}
295
+ />
296
+ );
297
+ },
298
+ } : {}),
299
+ };
300
+
301
+ const isApiPage = !!data.api || !!data.openapi;
302
+ const isWideMode = data.mode === 'wide';
303
+ const hasPanel = containsPanel(content);
304
+ const hasView = containsView(content);
305
+
306
+ let openApiEndpointData: OpenApiEndpointData | null = null;
307
+ let openApiCodeExamples: CodeExample[] | null = null;
308
+ let openApiError: string | null = null;
309
+
310
+ let lastFailure: { err: unknown; specPath: string } | null = null;
311
+ if (data.openapi && typeof data.openapi === 'string') {
312
+ try {
313
+ const openApiConfig = config.api?.openapi;
314
+ const allSpecPaths: string[] = typeof openApiConfig === 'string'
315
+ ? [openApiConfig]
316
+ : Array.isArray(openApiConfig)
317
+ ? openApiConfig
318
+ : [];
319
+
320
+ const parsed = parseOpenApiFrontmatter(
321
+ data.openapi,
322
+ allSpecPaths.length > 0 ? allSpecPaths : undefined,
323
+ );
324
+
325
+ const baseSpecs = parsed.isShortFormat && allSpecPaths.length > 1
326
+ ? allSpecPaths
327
+ : [parsed.specPath];
328
+ const specsToTry = baseSpecs.flatMap(p => candidateSpecPaths(p, currentLang));
329
+
330
+ const useIsr = isIsrMode() && !!projectSlug;
331
+ const resolveSpec = useIsr
332
+ ? (await import('@/lib/openapi-isr')).resolveOpenApiSpec
333
+ : null;
334
+ const contentDir = useIsr ? null : getContentDir();
335
+
336
+ for (let i = 0; i < specsToTry.length; i++) {
337
+ const specPath = specsToTry[i];
338
+ try {
339
+ if (resolveSpec && projectSlug) {
340
+ const spec = await resolveSpec(projectSlug, specPath);
341
+ openApiEndpointData = parseEndpoint(
342
+ spec as Parameters<typeof parseEndpoint>[0],
343
+ parsed.method,
344
+ parsed.path,
345
+ specPath,
346
+ );
347
+ } else {
348
+ const { api } = await getCachedSpec(specPath, contentDir!);
349
+ openApiEndpointData = parseEndpoint(api, parsed.method, parsed.path, specPath);
350
+ }
351
+ lastFailure = null;
352
+ break;
353
+ } catch (err) {
354
+ lastFailure = { err, specPath };
355
+ const isLast = i === specsToTry.length - 1;
356
+ if (!isLast) {
357
+ console.warn(
358
+ `[openapi] spec candidate "${specPath}" failed; trying next: ${(err as Error).message}`,
359
+ );
360
+ }
361
+ }
362
+ }
363
+
364
+ if (lastFailure) throw lastFailure.err;
365
+
366
+ if (openApiEndpointData) {
367
+ const { method: authMethod, headerName: authHeaderName } = resolveAuth(openApiEndpointData, config);
368
+ const languages = config.api?.examples?.languages;
369
+ openApiCodeExamples = generateCodeExamples(openApiEndpointData, { authMethod, authHeaderName, languages });
370
+ }
371
+ } catch (err) {
372
+ const typed = classifyOpenApiLoadError(err, lastFailure?.specPath ?? null);
373
+ console.warn(formatOpenApiWarning(typed));
374
+ openApiError = typed.suggestion
375
+ ? `${typed.message} — ${typed.suggestion}`
376
+ : typed.message;
377
+ }
378
+ }
379
+
380
+ let mdxApiMethod: HttpMethod | null = null;
381
+ let mdxApiPath: string | null = null;
382
+ if (data.api && typeof data.api === 'string' && !data.openapi) {
383
+ const parsed = parseMdxApiField(data.api);
384
+ if (parsed) {
385
+ mdxApiMethod = parsed.method;
386
+ mdxApiPath = parsed.path;
387
+ }
388
+ }
389
+
390
+ const rssIcon = data.rss ? (
391
+ <a
392
+ href={hostAtDocs ? '/docs/feed.xml' : '/feed.xml'}
393
+ target="_blank"
394
+ rel="noopener noreferrer"
395
+ aria-label="Subscribe to RSS feed"
396
+ title="RSS feed"
397
+ className="flex-shrink-0 text-[var(--color-text-muted)] hover:text-[var(--color-primary)] transition-colors cursor-pointer"
398
+ >
399
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5">
400
+ <circle cx="6.18" cy="17.82" r="2.18" />
401
+ <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" />
402
+ </svg>
403
+ </a>
404
+ ) : null;
405
+
406
+ const contextualOptions = getContextualOptions(config);
407
+ const hasAiActions = contextualOptions.length > 0;
408
+
409
+ const proseClasses = 'prose max-w-none';
410
+
411
+ const hasApiEndpoint = openApiEndpointData || (mdxApiMethod && mdxApiPath);
412
+ const playgroundDisplay = hasApiEndpoint
413
+ ? ((data.playground as 'interactive' | 'simple' | 'none' | undefined)
414
+ || config.api?.playground?.display || 'interactive') as 'interactive' | 'simple' | 'none'
415
+ : 'none';
416
+ const mdxServerConfig = config.api?.mdx?.server;
417
+ const fallbackServerUrl = Array.isArray(mdxServerConfig) ? mdxServerConfig[0] : mdxServerConfig;
418
+ const proxyEnabled = hostAtDocs
419
+ || config.api?.playground?.proxy
420
+ || (config.api?.playground?.proxy == null && playgroundDisplay === 'interactive');
421
+
422
+ let mdxEndpointData: OpenApiEndpointData | undefined;
423
+ if (!openApiEndpointData && mdxApiMethod && mdxApiPath && playgroundDisplay !== 'none') {
424
+ mdxEndpointData = buildEndpointFromMdx(mdxApiMethod, mdxApiPath, paramFields, fallbackServerUrl);
425
+ }
426
+
427
+ const resolvedMdxAuth = resolveAuth(mdxEndpointData, config);
428
+ const resolvedOpenApiAuth = resolveAuth(openApiEndpointData, config);
429
+
430
+ if (isApiPage) {
431
+ return (
432
+ <>{jsonLdScript}<ApiPageWrapper>
433
+ <article className="px-4 sm:px-6 lg:px-8 py-6 sm:py-10 flex-1 min-w-0">
434
+ <Breadcrumb slug={slug} config={config} />
435
+
436
+ {data.title && (
437
+ <header className="mb-4 sm:mb-6">
438
+ <div className="flex items-center gap-3">
439
+ <h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-theme-text-primary tracking-tight">
440
+ {data.title}
441
+ </h1>
442
+ {rssIcon}
443
+ {hasAiActions && <div className="ml-auto flex-shrink-0 hidden sm:block"><AIActionsMenu options={contextualOptions} projectName={config.name} /></div>}
444
+ </div>
445
+ {data.description && (
446
+ <p className="text-base sm:text-lg text-theme-text-secondary leading-relaxed mt-2 sm:mt-3">
447
+ {data.description}
448
+ </p>
449
+ )}
450
+ {hasAiActions && <div className="mt-3 sm:hidden" style={{ paddingLeft: 0 }}><AIActionsMenu options={contextualOptions} projectName={config.name} /></div>}
451
+ </header>
452
+ )}
453
+
454
+ <div className={proseClasses}>
455
+ {mdxApiMethod && mdxApiPath && (
456
+ mdxEndpointData && playgroundDisplay !== 'none' ? (
457
+ <OpenApiEndpoint
458
+ endpoint={mdxEndpointData}
459
+ playgroundOnly
460
+ playgroundDisplay={playgroundDisplay}
461
+ authMethod={resolvedMdxAuth.method}
462
+ authHeaderName={resolvedMdxAuth.headerName}
463
+ serverUrl={fallbackServerUrl}
464
+ proxyEnabled={proxyEnabled}
465
+ languages={config.api?.examples?.languages}
466
+ />
467
+ ) : (
468
+ <ApiEndpoint
469
+ method={mdxApiMethod}
470
+ path={mdxApiPath}
471
+ baseUrl={fallbackServerUrl}
472
+ />
473
+ )
474
+ )}
475
+
476
+ {openApiEndpointData && (
477
+ <OpenApiEndpoint
478
+ endpoint={openApiEndpointData}
479
+ codeExamples={openApiCodeExamples || undefined}
480
+ playgroundDisplay={playgroundDisplay}
481
+ authMethod={resolvedOpenApiAuth.method}
482
+ authHeaderName={resolvedOpenApiAuth.headerName}
483
+ serverUrl={fallbackServerUrl}
484
+ proxyEnabled={proxyEnabled}
485
+ languages={config.api?.examples?.languages}
486
+ />
487
+ )}
488
+
489
+ {!openApiEndpointData && openApiError && (
490
+ <OpenApiError message={openApiError} slug={slug.join('/')} />
491
+ )}
492
+
493
+ <ImagePriorityProvider>
494
+ <StepSlugProvider entries={stepEntries}>
495
+ <MDXRemote
496
+ source={openApiEndpointData
497
+ ? content.replace(/<ResponseExample>[\s\S]*?<\/ResponseExample>/g, '')
498
+ : content}
499
+ components={AllComponentsWithInline}
500
+ options={{
501
+ ...mdxSecurityOptions,
502
+ mdxOptions: {
503
+ remarkPlugins: [remarkGfm, [remarkVisibility, { audience: 'humans' }], ...getTypographyRemarkPlugins(config), ...getLatexRemarkPlugins(config)],
504
+ rehypePlugins: [rehypeNoZoomToData, rehypeClassToClassName, rehypeCodeMeta, createShikiRehypePlugin(highlighter, config), rehypeRestoreDataTitle, ...getLatexRehypePlugins(config), rehypeSlug],
505
+ recmaPlugins: [recmaCompoundComponents],
506
+ },
507
+ }}
508
+ />
509
+ </StepSlugProvider>
510
+ </ImagePriorityProvider>
511
+ </div>
512
+
513
+ <PageNavigation currentSlug={slug.join('/')} config={config} />
514
+ <SocialFooter config={config} hidden={data.hideFooter} projectSlug={projectSlug ?? undefined} />
515
+ </article>
516
+ </ApiPageWrapper></>
517
+ );
518
+ }
519
+
520
+ const mdxContent = (
521
+ <StepSlugProvider entries={stepEntries}>
522
+ <MDXRemote
523
+ source={content}
524
+ components={AllComponentsWithInline}
525
+ options={{
526
+ ...mdxSecurityOptions,
527
+ mdxOptions: {
528
+ remarkPlugins: [remarkGfm, [remarkVisibility, { audience: 'humans' }], ...getTypographyRemarkPlugins(config), ...getLatexRemarkPlugins(config)],
529
+ rehypePlugins: [rehypeNoZoomToData, rehypeClassToClassName, rehypeCodeMeta, createShikiRehypePlugin(highlighter, config), rehypeRestoreDataTitle, ...getLatexRehypePlugins(config), rehypeSlug],
530
+ recmaPlugins: [recmaCompoundComponents],
531
+ },
532
+ }}
533
+ />
534
+ </StepSlugProvider>
535
+ );
536
+
537
+ const articleContent = (
538
+ <article className="px-4 sm:px-6 lg:px-8 py-6 sm:py-10">
539
+ <Breadcrumb slug={slug} config={config} />
540
+
541
+ {data.title && (
542
+ <header className="mb-6 sm:mb-10">
543
+ <div className="flex items-center gap-3">
544
+ <h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-theme-text-primary tracking-tight">
545
+ {data.title}
546
+ </h1>
547
+ {rssIcon}
548
+ {hasAiActions && <div className="ml-auto flex-shrink-0 hidden sm:block"><AIActionsMenu options={contextualOptions} projectName={config.name} /></div>}
549
+ </div>
550
+ {data.description && (
551
+ <p className="text-base sm:text-lg text-theme-text-secondary leading-relaxed mt-2 sm:mt-3">
552
+ {data.description}
553
+ </p>
554
+ )}
555
+ {hasAiActions && <div className="mt-3 sm:hidden" style={{ paddingLeft: 0 }}><AIActionsMenu options={contextualOptions} projectName={config.name} /></div>}
556
+ </header>
557
+ )}
558
+
559
+ <div className={proseClasses}>
560
+ <ImagePriorityProvider>
561
+ {hasView ? <ViewWrapper>{mdxContent}</ViewWrapper> : mdxContent}
562
+ </ImagePriorityProvider>
563
+
564
+ <PageNavigation currentSlug={slug.join('/')} config={config} isWideMode={isWideMode || hasPanel} />
565
+ </div>
566
+ <SocialFooter config={config} hidden={data.hideFooter} projectSlug={projectSlug ?? undefined} />
567
+ </article>
568
+ );
569
+
570
+ if (hasPanel) {
571
+ return <>{jsonLdScript}<PanelWrapper>{articleContent}</PanelWrapper></>;
572
+ }
573
+
574
+ return (
575
+ <>
576
+ {jsonLdScript}
577
+ <PageColumns toc={<TableOfContents content={content} />} isWideMode={isWideMode}>
578
+ {articleContent}
579
+ </PageColumns>
580
+ </>
581
+ );
582
+ }
583
+
584
+ // Inline JSON-LD for SEO. The `<` escape is the documented Next.js
585
+ // XSS-safe pattern (see app/layout.tsx and marketing/blog/[slug]/page.tsx).
586
+ // Trusted source: built from project config + page metadata, never user MDX.
587
+ function renderJsonLdScript(jsonLd: unknown): ReactElement {
588
+ const safe = JSON.stringify(jsonLd).replace(/</g, '\\u003c');
589
+ return (
590
+ <script
591
+ type="application/ld+json"
592
+ dangerouslySetInnerHTML={{ __html: safe }}
593
+ />
594
+ );
595
+ }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { Metadata } from 'next';
11
11
  import type { DocsConfig, Logo, LogoConfig, Favicon, FaviconConfig, LanguageConfig, LanguageCode } from './docs-types';
12
+ import { transformConfigImagePath } from './docs-types';
12
13
  import { transformLanguagePath, extractLanguageFromPath, isValidLanguageCode } from './language-utils';
13
14
 
14
15
  const HAS_DOCS_SUFFIX = /\b(?:Documentation|Docs)\s*$/i;
@@ -23,6 +24,26 @@ export function buildSiteTitle(configName: string): string {
23
24
  : `${configName} Documentation`;
24
25
  }
25
26
 
27
+ /**
28
+ * Resolve a Favicon config (string | { light, dark }) to a single asset
29
+ * path through the /_jd/ pipeline. Returns undefined when no favicon is
30
+ * configured — no default Jamdesk favicon is injected.
31
+ */
32
+ export function getFaviconPath(
33
+ favicon: Favicon | undefined,
34
+ assetVersion?: string,
35
+ ): string | undefined {
36
+ if (!favicon) return undefined;
37
+ const raw = typeof favicon === 'string' ? favicon : favicon.light;
38
+ return transformConfigImagePath(raw, assetVersion) || undefined;
39
+ }
40
+
41
+ /** Metadata used when the project can't be resolved or its config is missing. */
42
+ export const FALLBACK_METADATA: Metadata = {
43
+ title: { template: '%s — Documentation', default: 'Documentation' },
44
+ description: 'Documentation',
45
+ };
46
+
26
47
  /** Resolve a Logo or Favicon config to a path string, preferring light variant. */
27
48
  function resolveImagePath(image?: Logo | Favicon): string {
28
49
  if (!image) return '';
@@ -2301,12 +2301,12 @@
2301
2301
  }
2302
2302
  },
2303
2303
  "node_modules/chevrotain-allstar": {
2304
- "version": "0.4.1",
2305
- "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz",
2306
- "integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==",
2304
+ "version": "0.4.3",
2305
+ "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.3.tgz",
2306
+ "integrity": "sha512-2X4mkroolSMKqW+H22pyPMUVDqYZzPhephTmg/NODKb1IGYPHfxfhcW0EjS7wcPJNbze2i4vBWT7zT5FKF2lrQ==",
2307
2307
  "license": "MIT",
2308
2308
  "dependencies": {
2309
- "lodash-es": "^4.17.21"
2309
+ "lodash-es": "^4.18.1"
2310
2310
  },
2311
2311
  "peerDependencies": {
2312
2312
  "chevrotain": "^12.0.0"
@@ -2980,9 +2980,9 @@
2980
2980
  "license": "MIT"
2981
2981
  },
2982
2982
  "node_modules/electron-to-chromium": {
2983
- "version": "1.5.344",
2984
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
2985
- "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==",
2983
+ "version": "1.5.345",
2984
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.345.tgz",
2985
+ "integrity": "sha512-F9JXQGiMrz6yVNPI2qOVPvB9HzjH5cGzhs8oJ6A28V5L/YnzN/0KsuiibqF+F1Fd9qxFzD1BUnYSd8JfULxTwg==",
2986
2986
  "license": "ISC"
2987
2987
  },
2988
2988
  "node_modules/enhanced-resolve": {