@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 +2 -1
- package/src/cli.ts +30 -30
- package/src/engine/app/_preview/[sessionId]/[...slug]/layout.tsx +2 -2
- package/src/engine/app/_preview/[sessionId]/[...slug]/page.tsx +34 -15
- package/src/engine/app/_preview/[sessionId]/page.tsx +2 -2
- package/src/engine/app/_preview/api/sessions/[sessionId]/init/route.ts +4 -26
- package/src/engine/app/_preview/api/sessions/[sessionId]/sync/route.ts +5 -0
- package/src/engine/lib/preview-content.ts +71 -6
- package/src/engine/lib/preview-source.ts +217 -0
- package/src/engine/next.config.mjs +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aravindc26/velu",
|
|
3
|
-
"version": "0.
|
|
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
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
//
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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 {
|
|
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
|
-
|
|
142
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 =
|
|
185
|
-
const orderedSessionPages = allPages
|
|
203
|
+
const allPages = src.getPages();
|
|
204
|
+
const orderedSessionPages = allPages;
|
|
186
205
|
const sourcePageUrl = (page as unknown as { url?: string }).url;
|
|
187
|
-
const fallbackPath = `/${
|
|
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(
|
|
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
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
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: 
|
|
545
|
+
content = content.replace(
|
|
546
|
+
/!\[([^\]]*)\]\(([^)]+)\)/g,
|
|
547
|
+
(match, alt, path) => {
|
|
548
|
+
const rewritten = resolveAssetPath(path);
|
|
549
|
+
return ``;
|
|
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('..'),
|