@aravindc26/velu 0.12.17 → 0.13.0

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": "@aravindc26/velu",
3
- "version": "0.12.17",
3
+ "version": "0.13.0",
4
4
  "description": "A modern documentation site generator powered by Markdown and JSON configuration",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,6 +28,7 @@
28
28
  "dev": "tsx src/cli.ts run"
29
29
  },
30
30
  "dependencies": {
31
+ "@fumadocs/mdx-remote": "^1.4.6",
31
32
  "@tailwindcss/postcss": "~4.1.18",
32
33
  "@types/mdx": "^2.0.13",
33
34
  "@types/node": "^22.0.0",
package/src/cli.ts CHANGED
@@ -571,25 +571,37 @@ async function previewServer(port: number) {
571
571
  // Resolve the next binary from the CLI's own node_modules
572
572
  const nextBinPath = join(NODE_MODULES_PATH, "next", "dist", "bin", "next");
573
573
 
574
- const child = spawn(process.execPath, [nextBinPath, "dev", "--port", String(port), "--turbopack"], {
575
- cwd: runtimeDir,
576
- stdio: ["inherit", "pipe", "inherit"],
577
- env: {
578
- ...previewServerEnv(),
579
- // Align source.config.ts and content-generator on the same content dir
580
- PREVIEW_CONTENT_DIR: process.env.PREVIEW_CONTENT_DIR || "./content",
581
- WATCHPACK_POLLING: process.env.WATCHPACK_POLLING || "false",
582
- },
583
- });
574
+ const previewEnv = {
575
+ ...previewServerEnv(),
576
+ // Align source.config.ts and content-generator on the same content dir
577
+ PREVIEW_CONTENT_DIR: process.env.PREVIEW_CONTENT_DIR || "./content",
578
+ // Enable preview mode so next.config.mjs produces a server build (not static export)
579
+ PREVIEW_MODE: "true",
580
+ };
584
581
 
585
- // Pipe stdout through while watching for the "Ready" signal to trigger warmup
586
- let warmedUp = false;
587
- child.stdout?.on("data", (data: Buffer) => {
588
- process.stdout.write(data);
589
- if (!warmedUp && data.toString().includes("Ready")) {
590
- warmedUp = true;
591
- warmupRoutes(port);
592
- }
582
+ // Build the app if not already pre-built (Docker pre-builds at image time)
583
+ const nextOutputDir = join(runtimeDir, ".next");
584
+ if (!existsSync(nextOutputDir)) {
585
+ console.log(" Building preview app (first run)...");
586
+ const buildChild = spawn(process.execPath, [nextBinPath, "build"], {
587
+ cwd: runtimeDir,
588
+ stdio: "inherit",
589
+ env: previewEnv,
590
+ });
591
+ await new Promise<void>((res, rej) => {
592
+ buildChild.on("exit", (code) => {
593
+ if (code === 0) res();
594
+ else rej(new Error(`next build exited with code ${code}`));
595
+ });
596
+ });
597
+ console.log(" Build complete.");
598
+ }
599
+
600
+ // Start production server
601
+ const child = spawn(process.execPath, [nextBinPath, "start", "--port", String(port)], {
602
+ cwd: runtimeDir,
603
+ stdio: "inherit",
604
+ env: previewEnv,
593
605
  });
594
606
 
595
607
  const cleanup = () => child.kill("SIGTERM");
@@ -605,18 +617,6 @@ async function previewServer(port: number) {
605
617
  });
606
618
  }
607
619
 
608
- /** Pre-warm key routes so Turbopack compiles them before real user requests. */
609
- function warmupRoutes(port: number) {
610
- const routes = [
611
- "/api/sessions/_warmup/init", // compile API route
612
- "/_warmup/docs/warmup", // compile [sessionId]/[...slug] page route
613
- ];
614
- console.log(" Pre-warming routes...");
615
- for (const route of routes) {
616
- fetch(`http://localhost:${port}${route}`).catch(() => {});
617
- }
618
- }
619
-
620
620
  // ── run ──────────────────────────────────────────────────────────────────────────
621
621
 
622
622
  function spawnServer(outDir: string, command: string, port: number, docsDir: string) {
@@ -13,7 +13,7 @@ import {
13
13
  getSiteLogoAsset,
14
14
  } from '@/lib/velu';
15
15
  import { baseOptions } from '@/lib/layout.shared';
16
- import { getSessionPageTree } from '@/lib/source';
16
+ import { getSessionPageTree } from '@/lib/preview-source';
17
17
  import { renderDocsLayout } from '@/lib/docs-layout';
18
18
 
19
19
  interface LayoutProps {
@@ -74,7 +74,7 @@ export default async function SessionDocsLayout({ children, params }: LayoutProp
74
74
  };
75
75
 
76
76
  const languages = getLanguages(src);
77
- const tree = getSessionPageTree(sessionId);
77
+ const tree = await getSessionPageTree(sessionId);
78
78
 
79
79
  return renderDocsLayout(
80
80
  {
@@ -1,4 +1,5 @@
1
1
  import { createElement } from 'react';
2
+ import { readFileSync } from 'node:fs';
2
3
  import { notFound } from 'next/navigation';
3
4
  import { createRelativeLink } from 'fumadocs-ui/mdx';
4
5
  import {
@@ -7,14 +8,13 @@ import {
7
8
  DocsPage,
8
9
  DocsTitle,
9
10
  } from 'fumadocs-ui/layouts/notebook/page';
10
- import { source } from '@/lib/source';
11
+ import { getSessionSource } from '@/lib/preview-source';
11
12
  import { getMDXComponents } from '@/mdx-components';
12
13
  import { loadSessionConfigSource } from '@/lib/preview-config';
13
14
  import {
14
15
  getApiConfig,
15
16
  getContextualOptions,
16
17
  getFooterSocials,
17
- getIconLibrary,
18
18
  } from '@/lib/velu';
19
19
  import { CopyPageButton } from '@/components/copy-page';
20
20
  import { VeluImageZoomFallback } from '@/components/image-zoom-fallback';
@@ -138,24 +138,44 @@ function prefixMdxComponentLinks(
138
138
  export default async function PreviewPage({ params }: PageProps) {
139
139
  const { sessionId, slug } = await params;
140
140
 
141
- // The full slug for fumadocs lookup includes the session ID prefix
142
- const fullSlug = [sessionId, ...slug];
143
- const page = source.getPage(fullSlug);
141
+ const src = await getSessionSource(sessionId);
142
+ const page = src.getPage(slug);
144
143
 
145
144
  if (!page) notFound();
146
145
 
147
146
  const pageDataRecord = page.data as unknown as Record<string, unknown>;
148
- const MDX = pageDataRecord.body as any;
147
+
148
+ // Dynamic mode: call load() to trigger on-demand MDX compilation
149
+ const loadFn = pageDataRecord.load as (() => Promise<{ body: any; toc: any }>) | undefined;
150
+ let MDX: any;
151
+ let pageToc: any;
152
+
153
+ if (typeof loadFn === 'function') {
154
+ // Dynamic/async entry — compile MDX on demand
155
+ const loaded = await loadFn();
156
+ MDX = loaded.body;
157
+ pageToc = loaded.toc;
158
+ } else {
159
+ // Fallback: pre-compiled entry (shouldn't happen in preview mode, but safe)
160
+ MDX = pageDataRecord.body as any;
161
+ pageToc = pageDataRecord.toc as any;
162
+ }
163
+
149
164
  if (typeof MDX !== 'function') notFound();
150
165
 
151
166
  const configSource = loadSessionConfigSource(sessionId);
152
- const iconLibrary = configSource ? getIconLibrary(configSource) : 'fontawesome';
153
167
  const footerSocials = configSource ? getFooterSocials(configSource) : [];
154
168
  const apiConfig = getApiConfig(configSource ?? undefined);
155
169
 
156
- const effectiveMarkdown = typeof pageDataRecord.processedMarkdown === 'string'
157
- ? String(pageDataRecord.processedMarkdown)
158
- : undefined;
170
+ // Read raw markdown for pattern detection (processedMarkdown not available in dynamic mode)
171
+ const pageInfo = pageDataRecord.info as { fullPath?: string } | undefined;
172
+ let effectiveMarkdown: string | undefined;
173
+ if (pageInfo?.fullPath) {
174
+ try {
175
+ effectiveMarkdown = readFileSync(pageInfo.fullPath, 'utf-8');
176
+ } catch { /* file may not exist */ }
177
+ }
178
+
159
179
  const hasPanelExamples = typeof effectiveMarkdown === 'string'
160
180
  && /<(?:Panel|RequestExample|ResponseExample)(?:\s|>)/.test(effectiveMarkdown);
161
181
 
@@ -172,7 +192,6 @@ export default async function PreviewPage({ params }: PageProps) {
172
192
  const proxyUrl = apiConfig.playgroundProxyEnabled ? '/api/proxy' : '';
173
193
  const isDeprecatedPage = (pageDataRecord.deprecated === true)
174
194
  || String((pageDataRecord.status ?? '')).trim().toLowerCase() === 'deprecated';
175
- const pageToc = pageDataRecord.toc as any;
176
195
  const pageFull = typeof pageDataRecord.full === 'boolean' ? pageDataRecord.full : undefined;
177
196
  const hasApiTocRail = shouldShowOpenApi || hasPanelExamples;
178
197
  const tableOfContentHeader = hasApiTocRail
@@ -181,10 +200,10 @@ export default async function PreviewPage({ params }: PageProps) {
181
200
 
182
201
  // Prev/next navigation
183
202
  const sessionPrefix = `/${sessionId}`;
184
- const allPages = source.getPages();
185
- const orderedSessionPages = allPages.filter((p) => p.url.startsWith(`${sessionPrefix}/`));
203
+ const allPages = src.getPages();
204
+ const orderedSessionPages = allPages;
186
205
  const sourcePageUrl = (page as unknown as { url?: string }).url;
187
- const fallbackPath = `/${fullSlug.join('/')}`.replace(/\/{2,}/g, '/');
206
+ const fallbackPath = `/${slug.join('/')}`.replace(/\/{2,}/g, '/');
188
207
  const pageUrl = (typeof sourcePageUrl === 'string' && sourcePageUrl.trim())
189
208
  ? sourcePageUrl
190
209
  : (fallbackPath === '' ? '/' : fallbackPath);
@@ -232,7 +251,7 @@ export default async function PreviewPage({ params }: PageProps) {
232
251
  <MDX
233
252
  components={prefixMdxComponentLinks(
234
253
  getMDXComponents({
235
- a: createRelativeLink(source, page),
254
+ a: createRelativeLink(src, page),
236
255
  }, configSource ?? undefined),
237
256
  sessionPrefix,
238
257
  )}
@@ -1,5 +1,5 @@
1
1
  import { redirect } from 'next/navigation';
2
- import { getSessionPageTree } from '@/lib/source';
2
+ import { getSessionPageTree } from '@/lib/preview-source';
3
3
 
4
4
  interface PageProps {
5
5
  params: Promise<{ sessionId: string }>;
@@ -23,7 +23,7 @@ function findFirstPageUrl(node: any): string | undefined {
23
23
 
24
24
  export default async function SessionIndexPage({ params }: PageProps) {
25
25
  const { sessionId } = await params;
26
- const tree = getSessionPageTree(sessionId);
26
+ const tree = await getSessionPageTree(sessionId);
27
27
  const firstUrl = findFirstPageUrl(tree);
28
28
 
29
29
  if (firstUrl) {
@@ -1,30 +1,8 @@
1
1
  import { NextRequest } from 'next/server';
2
- import { readFileSync, writeFileSync } from 'node:fs';
3
- import { resolve } from 'node:path';
4
2
  import { generateSessionContent } from '@/lib/preview-content';
3
+ import { invalidateSessionSource } from '@/lib/preview-source';
5
4
  import { verifyApiSecret, unauthorizedResponse } from '@/lib/preview-auth';
6
5
 
7
- /**
8
- * Toggle a comment in source.config.ts to force fumadocs-mdx to restart
9
- * and rescan the content directory. Fire-and-forget — don't wait for completion.
10
- *
11
- * This is needed because on Cloud Run (gVisor), neither inotify nor chokidar
12
- * polling reliably detects new files in the content directory.
13
- */
14
- function triggerMdxRescan(): void {
15
- try {
16
- const configPath = resolve(process.cwd(), 'source.config.ts');
17
- const marker = '\n// __RELOAD__\n';
18
- const content = readFileSync(configPath, 'utf-8');
19
- const newContent = content.includes(marker)
20
- ? content.replace(marker, '\n')
21
- : content + marker;
22
- writeFileSync(configPath, newContent, 'utf-8');
23
- } catch (e) {
24
- console.warn('[PREVIEW] Failed to trigger MDX rescan:', e);
25
- }
26
- }
27
-
28
6
  export async function POST(
29
7
  request: NextRequest,
30
8
  { params }: { params: Promise<{ sessionId: string }> },
@@ -36,9 +14,9 @@ export async function POST(
36
14
  try {
37
15
  const result = generateSessionContent(sessionId);
38
16
 
39
- // Fire-and-forget: trigger fumadocs-mdx to discover new content.
40
- // The subsequent page request will wait for Turbopack recompilation.
41
- triggerMdxRescan();
17
+ // Invalidate the cached dynamic source so the next page request
18
+ // re-scans the content directory and picks up the new files.
19
+ invalidateSessionSource(sessionId);
42
20
 
43
21
  return Response.json({
44
22
  status: 'ready',
@@ -1,5 +1,6 @@
1
1
  import { NextRequest } from 'next/server';
2
2
  import { syncSessionFile } from '@/lib/preview-content';
3
+ import { invalidateSessionSource } from '@/lib/preview-source';
3
4
  import { verifyApiSecret, unauthorizedResponse } from '@/lib/preview-auth';
4
5
 
5
6
  export async function POST(
@@ -20,6 +21,10 @@ export async function POST(
20
21
 
21
22
  try {
22
23
  const result = syncSessionFile(sessionId, file);
24
+
25
+ // Invalidate cached source so next page request re-scans content
26
+ invalidateSessionSource(sessionId);
27
+
23
28
  return Response.json({
24
29
  status: 'synced',
25
30
  file,
@@ -504,6 +504,64 @@ function buildArtifacts(config: VeluConfig, docsDir?: string): BuildArtifacts {
504
504
  return { pageMap, metaFiles, firstPage };
505
505
  }
506
506
 
507
+ // ── Image path rewriting ────────────────────────────────────────────────────
508
+
509
+ /**
510
+ * Rewrite image references to use the session assets API.
511
+ * Handles markdown images, HTML/JSX img tags, and various path formats.
512
+ * Skips external URLs (http://, https://).
513
+ */
514
+ function rewriteImagePaths(
515
+ content: string,
516
+ sessionId: string,
517
+ srcFilePath: string,
518
+ ): string {
519
+ const fileDir = dirname(srcFilePath);
520
+ const workspaceDir = join(WORKSPACE_DIR, sessionId);
521
+
522
+ function resolveAssetPath(rawPath: string): string {
523
+ const trimmed = rawPath.trim();
524
+ // Skip external URLs
525
+ if (/^https?:\/\//i.test(trimmed)) return trimmed;
526
+ // Skip data URIs
527
+ if (trimmed.startsWith('data:')) return trimmed;
528
+ // Skip already-rewritten paths
529
+ if (trimmed.includes(`/api/sessions/${sessionId}/assets/`)) return trimmed;
530
+
531
+ let resolved: string;
532
+ if (trimmed.startsWith('/')) {
533
+ // Absolute path relative to workspace root
534
+ resolved = trimmed.replace(/^\/+/, '');
535
+ } else {
536
+ // Relative path — resolve relative to source file's directory
537
+ const abs = resolve(fileDir, trimmed);
538
+ resolved = relative(workspaceDir, abs).replace(/\\/g, '/');
539
+ }
540
+
541
+ return `/api/sessions/${sessionId}/assets/${resolved}`;
542
+ }
543
+
544
+ // Markdown images: ![alt](path)
545
+ content = content.replace(
546
+ /!\[([^\]]*)\]\(([^)]+)\)/g,
547
+ (match, alt, path) => {
548
+ const rewritten = resolveAssetPath(path);
549
+ return `![${alt}](${rewritten})`;
550
+ },
551
+ );
552
+
553
+ // HTML/JSX img src: <img src="path"> or src={...}
554
+ content = content.replace(
555
+ /(<img\b[^>]*?\bsrc\s*=\s*)(["'])([^"']+)\2/gi,
556
+ (match, prefix, quote, path) => {
557
+ const rewritten = resolveAssetPath(path);
558
+ return `${prefix}${quote}${rewritten}${quote}`;
559
+ },
560
+ );
561
+
562
+ return content;
563
+ }
564
+
507
565
  // ── Page processing ────────────────────────────────────────────────────────
508
566
 
509
567
  function processPage(
@@ -511,10 +569,16 @@ function processPage(
511
569
  destPath: string,
512
570
  slug: string,
513
571
  variables: Record<string, string>,
572
+ sessionId?: string,
514
573
  ): void {
515
574
  let content = readFileSync(srcPath, 'utf-8');
516
575
  content = replaceVariablesInString(content, variables);
517
576
 
577
+ // Rewrite image paths to use the assets API
578
+ if (sessionId) {
579
+ content = rewriteImagePaths(content, sessionId, srcPath);
580
+ }
581
+
518
582
  if (!content.startsWith('---')) {
519
583
  const titleMatch = content.match(/^#\s+(.+)$/m);
520
584
  const title = titleMatch ? titleMatch[1] : pageLabelFromSlug(slug);
@@ -538,6 +602,7 @@ function writeLangContent(
538
602
  variables: Record<string, string>,
539
603
  isDefault: boolean,
540
604
  useLangFolders = false,
605
+ sessionId?: string,
541
606
  ) {
542
607
  const storagePrefix = useLangFolders ? langCode : (isDefault ? '' : langCode);
543
608
 
@@ -588,7 +653,7 @@ function writeLangContent(
588
653
  }
589
654
  if (!existsSync(srcPath)) continue;
590
655
 
591
- processPage(srcPath, destPath, src, variables);
656
+ processPage(srcPath, destPath, src, variables, sessionId);
592
657
  }
593
658
 
594
659
  const indexPath = storagePrefix
@@ -638,7 +703,7 @@ export function generateSessionContent(sessionId: string): {
638
703
  navigation: { ...config.navigation, tabs: langEntry.tabs },
639
704
  } as VeluConfig;
640
705
  const artifacts = buildArtifacts(langConfig, workspaceDir);
641
- writeLangContent(workspaceDir, outputDir, langEntry.language, artifacts, variables, isDefault, true);
706
+ writeLangContent(workspaceDir, outputDir, langEntry.language, artifacts, variables, isDefault, true, sessionId);
642
707
  totalPages += artifacts.pageMap.length;
643
708
  if (i === 0) firstPage = artifacts.firstPage;
644
709
  rootPages.push(`!${langEntry.language}`);
@@ -658,7 +723,7 @@ export function generateSessionContent(sessionId: string): {
658
723
  writeLangContent(
659
724
  workspaceDir, outputDir,
660
725
  simpleLanguages[0] || 'en', artifacts, variables,
661
- true, useLangFolders,
726
+ true, useLangFolders, sessionId,
662
727
  );
663
728
 
664
729
  let totalPages = artifacts.pageMap.length;
@@ -666,7 +731,7 @@ export function generateSessionContent(sessionId: string): {
666
731
  if (simpleLanguages.length > 1) {
667
732
  const rootPages = [`!${simpleLanguages[0] || 'en'}`];
668
733
  for (const lang of simpleLanguages.slice(1)) {
669
- writeLangContent(workspaceDir, outputDir, lang, artifacts, variables, false, true);
734
+ writeLangContent(workspaceDir, outputDir, lang, artifacts, variables, false, true, sessionId);
670
735
  rootPages.push(`!${lang}`);
671
736
  totalPages += artifacts.pageMap.length;
672
737
  }
@@ -724,7 +789,7 @@ export function syncSessionFile(
724
789
 
725
790
  if (mapping) {
726
791
  const destPath = join(outputDir, `${mapping.dest}.mdx`);
727
- processPage(srcPath, destPath, stripped, variables);
792
+ processPage(srcPath, destPath, stripped, variables, sessionId);
728
793
  return { synced: true };
729
794
  }
730
795
  } catch {
@@ -732,7 +797,7 @@ export function syncSessionFile(
732
797
  }
733
798
 
734
799
  const destPath = join(outputDir, `${stripped}.mdx`);
735
- processPage(srcPath, destPath, stripped, variables);
800
+ processPage(srcPath, destPath, stripped, variables, sessionId);
736
801
  return { synced: true };
737
802
  }
738
803
 
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Dynamic MDX source for preview mode.
3
+ *
4
+ * Instead of relying on fumadocs-mdx's build-time file scanning (which requires
5
+ * `next dev` + chokidar), this module uses the `dynamic()` runtime API to compile
6
+ * MDX files on-demand at request time. Content written after `next build` is
7
+ * discovered by scanning the filesystem directly.
8
+ *
9
+ * Used only in preview mode (PREVIEW_MODE=true) — production builds still use
10
+ * the standard `source.ts` with build-time collections.
11
+ */
12
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
13
+ import { join, relative } from 'node:path';
14
+ import { loader } from 'fumadocs-core/source';
15
+ import { dynamic } from 'fumadocs-mdx/runtime/dynamic';
16
+ import type { LazyEntry } from 'fumadocs-mdx/runtime/dynamic';
17
+ import { openApiSidebarMethodBadgePlugin, createStatusBadgesPlugin } from '@core/lib/source-plugins';
18
+ import { getLanguages } from '@/lib/velu';
19
+ import { loadSessionConfigSource } from '@/lib/preview-config';
20
+
21
+ // Import config exports + core options from source.config.ts so that dynamic()
22
+ // picks up the schema, remark/rehype plugins, etc.
23
+ import * as sourceConfigExports from '../source.config';
24
+
25
+ const PREVIEW_CONTENT_DIR = process.env.PREVIEW_CONTENT_DIR || './content';
26
+
27
+ // ── Cache ──────────────────────────────────────────────────────────────────
28
+
29
+ interface CachedSource {
30
+ source: ReturnType<typeof loader>;
31
+ createdAt: number;
32
+ }
33
+
34
+ const sourceCache = new Map<string, CachedSource>();
35
+
36
+ // Dynamic instance — singleton, initialized lazily
37
+ let dynamicInstance: Awaited<ReturnType<typeof dynamic>> | null = null;
38
+ let dynamicInitPromise: Promise<Awaited<ReturnType<typeof dynamic>>> | null = null;
39
+
40
+ async function getDynamic() {
41
+ if (dynamicInstance) return dynamicInstance;
42
+ if (dynamicInitPromise) return dynamicInitPromise;
43
+
44
+ dynamicInitPromise = dynamic(
45
+ sourceConfigExports,
46
+ // CoreOptions — the second argument to dynamic(). Pass empty object to use
47
+ // defaults from source.config.ts which dynamic() reads via buildConfig().
48
+ {},
49
+ ).then((inst) => {
50
+ dynamicInstance = inst;
51
+ dynamicInitPromise = null;
52
+ return inst;
53
+ });
54
+
55
+ return dynamicInitPromise;
56
+ }
57
+
58
+ // ── File scanning ──────────────────────────────────────────────────────────
59
+
60
+ function parseFrontmatter(content: string): Record<string, unknown> {
61
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
62
+ if (!match) return {};
63
+
64
+ const output: Record<string, unknown> = {};
65
+ const lines = match[1].split(/\r?\n/);
66
+ for (const rawLine of lines) {
67
+ const line = rawLine.trim();
68
+ if (!line || line.startsWith('#')) continue;
69
+ const entry = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.+)$/);
70
+ if (!entry) continue;
71
+ const key = entry[1];
72
+ let rawValue = entry[2].trim();
73
+ // Strip quotes
74
+ if ((rawValue.startsWith('"') && rawValue.endsWith('"')) ||
75
+ (rawValue.startsWith("'") && rawValue.endsWith("'"))) {
76
+ rawValue = rawValue.slice(1, -1);
77
+ }
78
+ // Parse booleans
79
+ if (rawValue === 'true') { output[key] = true; continue; }
80
+ if (rawValue === 'false') { output[key] = false; continue; }
81
+ output[key] = rawValue;
82
+ }
83
+
84
+ return output;
85
+ }
86
+
87
+ function scanContentDir(sessionDir: string): {
88
+ entries: LazyEntry<Record<string, unknown>>[];
89
+ metaFiles: Record<string, unknown>;
90
+ } {
91
+ const entries: LazyEntry<Record<string, unknown>>[] = [];
92
+ const metaFiles: Record<string, unknown> = {};
93
+
94
+ function walk(dir: string) {
95
+ if (!existsSync(dir)) return;
96
+ const items = readdirSync(dir, { withFileTypes: true });
97
+ for (const item of items) {
98
+ if (item.name.startsWith('.')) continue;
99
+ const fullPath = join(dir, item.name);
100
+
101
+ if (item.isDirectory()) {
102
+ walk(fullPath);
103
+ continue;
104
+ }
105
+
106
+ const relPath = relative(sessionDir, fullPath).replace(/\\/g, '/');
107
+
108
+ if (item.name === 'meta.json') {
109
+ try {
110
+ const content = readFileSync(fullPath, 'utf-8');
111
+ metaFiles[relPath] = JSON.parse(content);
112
+ } catch { /* skip invalid meta */ }
113
+ continue;
114
+ }
115
+
116
+ if (!item.name.endsWith('.mdx') && !item.name.endsWith('.md')) continue;
117
+
118
+ try {
119
+ const content = readFileSync(fullPath, 'utf-8');
120
+ const frontmatter = parseFrontmatter(content);
121
+
122
+ // Ensure title exists
123
+ if (!frontmatter.title) {
124
+ const titleMatch = content.match(/^#\s+(.+)$/m);
125
+ frontmatter.title = titleMatch ? titleMatch[1] : item.name.replace(/\.mdx?$/, '');
126
+ }
127
+
128
+ entries.push({
129
+ info: {
130
+ path: relPath,
131
+ fullPath,
132
+ },
133
+ data: frontmatter,
134
+ });
135
+ } catch { /* skip unreadable files */ }
136
+ }
137
+ }
138
+
139
+ walk(sessionDir);
140
+ return { entries, metaFiles };
141
+ }
142
+
143
+ // ── Public API ─────────────────────────────────────────────────────────────
144
+
145
+ /**
146
+ * Get a fumadocs source loader for a specific session's content.
147
+ * Scans the content directory at call time and compiles MDX on-demand.
148
+ */
149
+ export async function getSessionSource(sessionId: string) {
150
+ const cached = sourceCache.get(sessionId);
151
+ if (cached) return cached.source;
152
+
153
+ const sessionDir = join(PREVIEW_CONTENT_DIR, sessionId);
154
+ if (!existsSync(sessionDir)) {
155
+ // Return an empty source if no content yet
156
+ const emptySource = loader({
157
+ baseUrl: '/',
158
+ source: { files: [] },
159
+ });
160
+ return emptySource;
161
+ }
162
+
163
+ const dyn = await getDynamic();
164
+ const { entries, metaFiles } = scanContentDir(sessionDir);
165
+
166
+ const collection = await dyn.docs('docs', sessionDir, metaFiles, entries);
167
+ const fumadocsSource = collection.toFumadocsSource();
168
+
169
+ const configSource = loadSessionConfigSource(sessionId);
170
+ const languages = configSource ? getLanguages(configSource) : [];
171
+ const defaultLanguage = languages[0] ?? 'en';
172
+
173
+ const src = loader({
174
+ baseUrl: '/',
175
+ source: fumadocsSource as any,
176
+ plugins: [
177
+ openApiSidebarMethodBadgePlugin() as any,
178
+ createStatusBadgesPlugin(),
179
+ ],
180
+ i18n:
181
+ languages.length > 1
182
+ ? {
183
+ languages,
184
+ defaultLanguage,
185
+ hideLocale: 'default-locale',
186
+ parser: 'dir',
187
+ fallbackLanguage: defaultLanguage,
188
+ }
189
+ : undefined,
190
+ });
191
+
192
+ sourceCache.set(sessionId, { source: src, createdAt: Date.now() });
193
+ return src;
194
+ }
195
+
196
+ /**
197
+ * Invalidate the cached source for a session.
198
+ * Call after content generation or sync so the next request re-scans.
199
+ */
200
+ export function invalidateSessionSource(sessionId: string): void {
201
+ sourceCache.delete(sessionId);
202
+ }
203
+
204
+ /**
205
+ * Get the page tree filtered to a specific session's content.
206
+ * Unlike source.ts's synchronous version, this is async because
207
+ * it may need to initialize the dynamic MDX compiler.
208
+ */
209
+ export async function getSessionPageTree(sessionId: string) {
210
+ const src = await getSessionSource(sessionId);
211
+ const fullTree = src.getPageTree();
212
+
213
+ // In preview-source, content is loaded directly from the session dir,
214
+ // so the page tree is already scoped to that session's content.
215
+ // No need to filter by session prefix — URLs are relative to the session.
216
+ return fullTree;
217
+ }
@@ -8,13 +8,13 @@ const withMDX = createMDX({
8
8
  /** @type {import('next').NextConfig} */
9
9
  const config = {
10
10
  reactStrictMode: false,
11
- output: process.env.NODE_ENV === 'production' ? 'export' : undefined,
11
+ output: process.env.PREVIEW_MODE ? undefined : (process.env.NODE_ENV === 'production' ? 'export' : undefined),
12
12
  basePath: process.env.VELU_BASE_PATH || '',
13
13
  // For static hosts without rewrite rules, emit directory routes
14
14
  // (e.g. /docs/page/index.html) so extensionless URLs resolve.
15
15
  trailingSlash: true,
16
16
  skipTrailingSlashRedirect: true,
17
- distDir: 'dist',
17
+ distDir: process.env.PREVIEW_MODE ? '.next' : 'dist',
18
18
  devIndicators: false,
19
19
  turbopack: {
20
20
  root: resolve('..'),