@aravindc26/velu 0.11.22 ā 0.12.1
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 +1 -1
- package/src/cli.ts +69 -6
- package/src/preview-engine/app/[sessionId]/[...slug]/page.tsx +41 -0
- package/src/preview-engine/app/[sessionId]/layout.tsx +23 -0
- package/src/preview-engine/app/[sessionId]/page.tsx +35 -0
- package/src/preview-engine/app/api/sessions/[sessionId]/init/route.ts +29 -0
- package/src/preview-engine/app/api/sessions/[sessionId]/route.ts +26 -0
- package/src/preview-engine/app/api/sessions/[sessionId]/sync/route.ts +36 -0
- package/src/preview-engine/app/global.css +3 -0
- package/src/preview-engine/app/layout.tsx +16 -0
- package/src/preview-engine/app/page.tsx +12 -0
- package/src/preview-engine/lib/auth.ts +16 -0
- package/src/preview-engine/lib/content-generator.ts +551 -0
- package/src/preview-engine/lib/session-config.ts +86 -0
- package/src/preview-engine/lib/source.ts +60 -0
- package/src/preview-engine/next.config.mjs +20 -0
- package/src/preview-engine/postcss.config.mjs +8 -0
- package/src/preview-engine/source.config.ts +26 -0
- package/src/preview-engine/tsconfig.json +32 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -35,18 +35,22 @@ function printHelp() {
|
|
|
35
35
|
velu ā documentation site generator
|
|
36
36
|
|
|
37
37
|
Usage:
|
|
38
|
-
velu version
|
|
39
|
-
velu init
|
|
40
|
-
velu lint
|
|
41
|
-
velu run [--port N]
|
|
42
|
-
velu build
|
|
43
|
-
velu paths
|
|
38
|
+
velu version Print Velu CLI version
|
|
39
|
+
velu init Scaffold a new docs project with example files
|
|
40
|
+
velu lint Validate docs.json (or velu.json) and check referenced pages
|
|
41
|
+
velu run [--port N] Build site and start dev server (default: 4321)
|
|
42
|
+
velu build Build a deployable static site (SSG)
|
|
43
|
+
velu paths Output navigation paths and source files as JSON (grouped by language)
|
|
44
|
+
velu preview-server [opts] Start multi-tenant preview server (no docs.json needed)
|
|
44
45
|
|
|
45
46
|
Options:
|
|
46
47
|
--version Show Velu CLI version
|
|
47
48
|
--port <number> Port for the dev server (default: 4321)
|
|
48
49
|
--help Show this help message
|
|
49
50
|
|
|
51
|
+
Preview server options:
|
|
52
|
+
--port <number> Port for the preview server (default: 8080)
|
|
53
|
+
|
|
50
54
|
Run lint/run/build/paths from a directory containing docs.json (or velu.json).
|
|
51
55
|
`);
|
|
52
56
|
}
|
|
@@ -483,6 +487,52 @@ async function buildSite(docsDir: string) {
|
|
|
483
487
|
console.log(`\nš Static site output: ${staticOutDir}`);
|
|
484
488
|
}
|
|
485
489
|
|
|
490
|
+
// āā preview-server āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
491
|
+
|
|
492
|
+
const PREVIEW_ENGINE_DIR = (() => {
|
|
493
|
+
const dev = join(PACKAGE_ROOT, "src", "preview-engine");
|
|
494
|
+
const packaged = join(dirname(__filename), "preview-engine");
|
|
495
|
+
return existsSync(dev) ? dev : packaged;
|
|
496
|
+
})();
|
|
497
|
+
|
|
498
|
+
function previewServerEnv(): NodeJS.ProcessEnv {
|
|
499
|
+
const existing = process.env.NODE_PATH || "";
|
|
500
|
+
return {
|
|
501
|
+
...process.env,
|
|
502
|
+
NODE_PATH: existing ? `${NODE_MODULES_PATH}${delimiter}${existing}` : NODE_MODULES_PATH,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function previewServer(port: number) {
|
|
507
|
+
const runtimeDir = join(PACKAGE_ROOT, ".velu-preview-out");
|
|
508
|
+
|
|
509
|
+
// Clean and copy preview engine to runtime dir
|
|
510
|
+
try { rmSync(runtimeDir, { recursive: true, force: true }); } catch {}
|
|
511
|
+
copyDirMerge(PREVIEW_ENGINE_DIR, runtimeDir);
|
|
512
|
+
|
|
513
|
+
console.log(` Starting preview server on port ${port}...`);
|
|
514
|
+
console.log(` Preview content dir: ${process.env.PREVIEW_CONTENT_DIR || "(default)"}`);
|
|
515
|
+
console.log(` Workspace dir: ${process.env.WORKSPACE_DIR || "(default)"}`);
|
|
516
|
+
|
|
517
|
+
// Resolve the next binary from the CLI's own node_modules
|
|
518
|
+
const nextBinPath = join(NODE_MODULES_PATH, "next", "dist", "bin", "next");
|
|
519
|
+
|
|
520
|
+
const child = spawn("node", [nextBinPath, "dev", "--port", String(port), "--turbopack"], {
|
|
521
|
+
cwd: runtimeDir,
|
|
522
|
+
stdio: "inherit",
|
|
523
|
+
env: {
|
|
524
|
+
...previewServerEnv(),
|
|
525
|
+
WATCHPACK_POLLING: process.env.WATCHPACK_POLLING || "true",
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
|
530
|
+
|
|
531
|
+
const cleanup = () => child.kill("SIGTERM");
|
|
532
|
+
process.on("SIGINT", cleanup);
|
|
533
|
+
process.on("SIGTERM", cleanup);
|
|
534
|
+
}
|
|
535
|
+
|
|
486
536
|
// āā run āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
487
537
|
|
|
488
538
|
function spawnServer(outDir: string, command: string, port: number, docsDir: string) {
|
|
@@ -528,6 +578,19 @@ if (command === "init") {
|
|
|
528
578
|
process.exit(0);
|
|
529
579
|
}
|
|
530
580
|
|
|
581
|
+
// preview-server doesn't require docs.json
|
|
582
|
+
if (command === "preview-server") {
|
|
583
|
+
const portIdx = args.indexOf("--port");
|
|
584
|
+
const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : 8080;
|
|
585
|
+
if (isNaN(port)) {
|
|
586
|
+
console.error("ā Invalid port number.");
|
|
587
|
+
process.exit(1);
|
|
588
|
+
}
|
|
589
|
+
await previewServer(port);
|
|
590
|
+
// previewServer keeps running (child process), so we never reach here
|
|
591
|
+
process.exit(0);
|
|
592
|
+
}
|
|
593
|
+
|
|
531
594
|
if (!resolveConfigPath(docsDir)) {
|
|
532
595
|
console.error("ā No docs.json or velu.json found in the current directory.");
|
|
533
596
|
console.error(" Run `velu init` to scaffold a new project, or run from a directory containing docs.json.");
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { notFound } from 'next/navigation';
|
|
2
|
+
import {
|
|
3
|
+
DocsBody,
|
|
4
|
+
DocsDescription,
|
|
5
|
+
DocsPage,
|
|
6
|
+
DocsTitle,
|
|
7
|
+
} from 'fumadocs-ui/layouts/notebook/page';
|
|
8
|
+
import { source } from '@/lib/source';
|
|
9
|
+
|
|
10
|
+
interface PageProps {
|
|
11
|
+
params: Promise<{ sessionId: string; slug: string[] }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default async function PreviewPage({ params }: PageProps) {
|
|
15
|
+
const { sessionId, slug } = await params;
|
|
16
|
+
|
|
17
|
+
// The full slug for fumadocs lookup includes the session ID prefix
|
|
18
|
+
const fullSlug = [sessionId, ...slug];
|
|
19
|
+
const page = source.getPage(fullSlug);
|
|
20
|
+
|
|
21
|
+
if (!page) notFound();
|
|
22
|
+
|
|
23
|
+
const pageDataRecord = page.data as unknown as Record<string, unknown>;
|
|
24
|
+
const MDX = pageDataRecord.body as any;
|
|
25
|
+
if (typeof MDX !== 'function') notFound();
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<DocsPage
|
|
29
|
+
toc={pageDataRecord.toc as any}
|
|
30
|
+
footer={{ enabled: false }}
|
|
31
|
+
>
|
|
32
|
+
<DocsTitle>{page.data.title}</DocsTitle>
|
|
33
|
+
{page.data.description ? (
|
|
34
|
+
<DocsDescription>{page.data.description}</DocsDescription>
|
|
35
|
+
) : null}
|
|
36
|
+
<DocsBody>
|
|
37
|
+
<MDX />
|
|
38
|
+
</DocsBody>
|
|
39
|
+
</DocsPage>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { DocsLayout } from 'fumadocs-ui/layouts/notebook';
|
|
3
|
+
import { getSessionPageTree } from '@/lib/source';
|
|
4
|
+
|
|
5
|
+
interface LayoutProps {
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
params: Promise<{ sessionId: string }>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default async function SessionLayout({ children, params }: LayoutProps) {
|
|
11
|
+
const { sessionId } = await params;
|
|
12
|
+
const tree = getSessionPageTree(sessionId);
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<DocsLayout
|
|
16
|
+
tree={tree}
|
|
17
|
+
sidebar={{ collapsible: true }}
|
|
18
|
+
themeSwitch={{ enabled: false }}
|
|
19
|
+
>
|
|
20
|
+
{children}
|
|
21
|
+
</DocsLayout>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { redirect } from 'next/navigation';
|
|
2
|
+
import { source, getSessionPageTree } from '@/lib/source';
|
|
3
|
+
|
|
4
|
+
interface PageProps {
|
|
5
|
+
params: Promise<{ sessionId: string }>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function findFirstPageUrl(node: any): string | undefined {
|
|
9
|
+
if (!node || typeof node !== 'object') return undefined;
|
|
10
|
+
if (node.type === 'page' && !node.external && typeof node.url === 'string') {
|
|
11
|
+
return node.url;
|
|
12
|
+
}
|
|
13
|
+
if (node.type === 'folder' && typeof node.index?.url === 'string') {
|
|
14
|
+
return node.index.url;
|
|
15
|
+
}
|
|
16
|
+
const children = Array.isArray(node.children) ? node.children : [];
|
|
17
|
+
for (const child of children) {
|
|
18
|
+
const url = findFirstPageUrl(child);
|
|
19
|
+
if (url) return url;
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default async function SessionIndexPage({ params }: PageProps) {
|
|
25
|
+
const { sessionId } = await params;
|
|
26
|
+
const tree = getSessionPageTree(sessionId);
|
|
27
|
+
const firstUrl = findFirstPageUrl(tree);
|
|
28
|
+
|
|
29
|
+
if (firstUrl) {
|
|
30
|
+
redirect(firstUrl.endsWith('/') ? firstUrl : `${firstUrl}/`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Fallback: redirect to session root
|
|
34
|
+
redirect(`/${sessionId}/`);
|
|
35
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NextRequest } from 'next/server';
|
|
2
|
+
import { generateSessionContent } from '@/lib/content-generator';
|
|
3
|
+
import { verifyApiSecret, unauthorizedResponse } from '@/lib/auth';
|
|
4
|
+
|
|
5
|
+
export async function POST(
|
|
6
|
+
request: NextRequest,
|
|
7
|
+
{ params }: { params: Promise<{ sessionId: string }> },
|
|
8
|
+
) {
|
|
9
|
+
if (!verifyApiSecret(request)) return unauthorizedResponse();
|
|
10
|
+
|
|
11
|
+
const { sessionId } = await params;
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const result = generateSessionContent(sessionId);
|
|
15
|
+
return Response.json({
|
|
16
|
+
status: 'ready',
|
|
17
|
+
url: `/${sessionId}/`,
|
|
18
|
+
firstPage: result.firstPage,
|
|
19
|
+
pageCount: result.pageCount,
|
|
20
|
+
});
|
|
21
|
+
} catch (error) {
|
|
22
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
23
|
+
console.error(`[PREVIEW] Init failed for session ${sessionId}:`, message);
|
|
24
|
+
return Response.json(
|
|
25
|
+
{ status: 'error', error: message },
|
|
26
|
+
{ status: 500 },
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { NextRequest } from 'next/server';
|
|
2
|
+
import { removeSessionContent } from '@/lib/content-generator';
|
|
3
|
+
import { clearSessionCache } from '@/lib/session-config';
|
|
4
|
+
import { verifyApiSecret, unauthorizedResponse } from '@/lib/auth';
|
|
5
|
+
|
|
6
|
+
export async function DELETE(
|
|
7
|
+
request: NextRequest,
|
|
8
|
+
{ params }: { params: Promise<{ sessionId: string }> },
|
|
9
|
+
) {
|
|
10
|
+
if (!verifyApiSecret(request)) return unauthorizedResponse();
|
|
11
|
+
|
|
12
|
+
const { sessionId } = await params;
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
removeSessionContent(sessionId);
|
|
16
|
+
clearSessionCache(sessionId);
|
|
17
|
+
return Response.json({ status: 'removed', sessionId });
|
|
18
|
+
} catch (error) {
|
|
19
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
20
|
+
console.error(`[PREVIEW] Cleanup failed for session ${sessionId}:`, message);
|
|
21
|
+
return Response.json(
|
|
22
|
+
{ status: 'error', error: message },
|
|
23
|
+
{ status: 500 },
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { NextRequest } from 'next/server';
|
|
2
|
+
import { syncSessionFile } from '@/lib/content-generator';
|
|
3
|
+
import { verifyApiSecret, unauthorizedResponse } from '@/lib/auth';
|
|
4
|
+
|
|
5
|
+
export async function POST(
|
|
6
|
+
request: NextRequest,
|
|
7
|
+
{ params }: { params: Promise<{ sessionId: string }> },
|
|
8
|
+
) {
|
|
9
|
+
if (!verifyApiSecret(request)) return unauthorizedResponse();
|
|
10
|
+
|
|
11
|
+
const { sessionId } = await params;
|
|
12
|
+
const file = request.nextUrl.searchParams.get('file');
|
|
13
|
+
|
|
14
|
+
if (!file) {
|
|
15
|
+
return Response.json(
|
|
16
|
+
{ error: 'Missing "file" query parameter' },
|
|
17
|
+
{ status: 400 },
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const result = syncSessionFile(sessionId, file);
|
|
23
|
+
return Response.json({
|
|
24
|
+
status: 'synced',
|
|
25
|
+
file,
|
|
26
|
+
...result,
|
|
27
|
+
});
|
|
28
|
+
} catch (error) {
|
|
29
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
30
|
+
console.error(`[PREVIEW] Sync failed for session ${sessionId}, file ${file}:`, message);
|
|
31
|
+
return Response.json(
|
|
32
|
+
{ status: 'error', error: message },
|
|
33
|
+
{ status: 500 },
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import './global.css';
|
|
3
|
+
|
|
4
|
+
export const metadata = {
|
|
5
|
+
title: 'Preview',
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
9
|
+
return (
|
|
10
|
+
<html lang="en" suppressHydrationWarning>
|
|
11
|
+
<body className="min-h-screen" suppressHydrationWarning>
|
|
12
|
+
{children}
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export default function Home() {
|
|
2
|
+
return (
|
|
3
|
+
<div style={{ padding: '2rem', fontFamily: 'system-ui' }}>
|
|
4
|
+
<h1>Velu Preview Service</h1>
|
|
5
|
+
<p>Multi-tenant documentation preview server.</p>
|
|
6
|
+
<p>
|
|
7
|
+
Access a session's preview at{' '}
|
|
8
|
+
<code>/{'{sessionId}'}/docs/...</code>
|
|
9
|
+
</p>
|
|
10
|
+
</div>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared API authentication via PREVIEW_API_SECRET.
|
|
3
|
+
*/
|
|
4
|
+
import { NextRequest } from 'next/server';
|
|
5
|
+
|
|
6
|
+
const PREVIEW_API_SECRET = process.env.PREVIEW_API_SECRET || '';
|
|
7
|
+
|
|
8
|
+
export function verifyApiSecret(request: NextRequest): boolean {
|
|
9
|
+
if (!PREVIEW_API_SECRET) return true; // No secret configured ā allow all
|
|
10
|
+
const header = request.headers.get('x-preview-secret') || '';
|
|
11
|
+
return header === PREVIEW_API_SECRET;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function unauthorizedResponse() {
|
|
15
|
+
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
16
|
+
}
|
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content generator for preview sessions.
|
|
3
|
+
*
|
|
4
|
+
* Reads a workspace directory (docs.json + MDX source files) and writes
|
|
5
|
+
* processed content to an output directory that fumadocs-mdx scans.
|
|
6
|
+
*
|
|
7
|
+
* This is a simplified version of the build pipeline in build.ts/_server.mjs,
|
|
8
|
+
* focused only on content generation (no engine scaffolding, theme CSS, etc.).
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
readdirSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from 'node:fs';
|
|
18
|
+
import { basename, dirname, extname, join, relative } from 'node:path';
|
|
19
|
+
|
|
20
|
+
const PREVIEW_CONTENT_DIR = process.env.PREVIEW_CONTENT_DIR || './content';
|
|
21
|
+
const WORKSPACE_DIR = process.env.WORKSPACE_DIR || '/mnt/nfs_share/editor_sessions';
|
|
22
|
+
|
|
23
|
+
const PRIMARY_CONFIG_NAME = 'docs.json';
|
|
24
|
+
const LEGACY_CONFIG_NAME = 'velu.json';
|
|
25
|
+
|
|
26
|
+
// āā Types āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
27
|
+
|
|
28
|
+
interface VeluConfig {
|
|
29
|
+
navigation: {
|
|
30
|
+
tabs?: VeluTab[];
|
|
31
|
+
languages?: Array<{ language: string; tabs: VeluTab[] }>;
|
|
32
|
+
anchors?: any[];
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
};
|
|
35
|
+
languages?: string[];
|
|
36
|
+
openapi?: unknown;
|
|
37
|
+
variables?: Record<string, string>;
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface VeluTab {
|
|
42
|
+
tab: string;
|
|
43
|
+
slug?: string;
|
|
44
|
+
href?: string;
|
|
45
|
+
pages?: Array<string | VeluSeparator | VeluLink>;
|
|
46
|
+
groups?: VeluGroup[];
|
|
47
|
+
openapi?: unknown;
|
|
48
|
+
[key: string]: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface VeluGroup {
|
|
52
|
+
group: string;
|
|
53
|
+
slug?: string;
|
|
54
|
+
pages: Array<string | VeluGroup | VeluSeparator | VeluLink>;
|
|
55
|
+
description?: string;
|
|
56
|
+
openapi?: unknown;
|
|
57
|
+
[key: string]: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface VeluSeparator { separator: string }
|
|
61
|
+
interface VeluLink { href: string; label: string; icon?: string }
|
|
62
|
+
|
|
63
|
+
interface PageMapping {
|
|
64
|
+
src: string;
|
|
65
|
+
dest: string;
|
|
66
|
+
kind: 'file' | 'openapi-operation';
|
|
67
|
+
title?: string;
|
|
68
|
+
description?: string;
|
|
69
|
+
openapiSpec?: string;
|
|
70
|
+
openapiMethod?: string;
|
|
71
|
+
openapiEndpoint?: string;
|
|
72
|
+
deprecated?: boolean;
|
|
73
|
+
version?: string;
|
|
74
|
+
content?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface MetaFile {
|
|
78
|
+
dir: string;
|
|
79
|
+
data: { pages: string[]; title?: string; description?: string };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface BuildArtifacts {
|
|
83
|
+
pageMap: PageMapping[];
|
|
84
|
+
metaFiles: MetaFile[];
|
|
85
|
+
firstPage: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// āā Config loading āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
89
|
+
|
|
90
|
+
function resolveConfigPath(docsDir: string): string | null {
|
|
91
|
+
const primary = join(docsDir, PRIMARY_CONFIG_NAME);
|
|
92
|
+
if (existsSync(primary)) return primary;
|
|
93
|
+
const legacy = join(docsDir, LEGACY_CONFIG_NAME);
|
|
94
|
+
if (existsSync(legacy)) return legacy;
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveVariables(
|
|
99
|
+
variables: Record<string, string> | undefined,
|
|
100
|
+
): Record<string, string> {
|
|
101
|
+
if (!variables) return {};
|
|
102
|
+
const resolved: Record<string, string> = {};
|
|
103
|
+
const MAX_DEPTH = 10;
|
|
104
|
+
|
|
105
|
+
for (const [key, rawValue] of Object.entries(variables)) {
|
|
106
|
+
let value = rawValue;
|
|
107
|
+
for (let depth = 0; depth < MAX_DEPTH; depth++) {
|
|
108
|
+
const replaced = value.replace(
|
|
109
|
+
/\{\{(\w+)\}\}/g,
|
|
110
|
+
(_, name) => variables[name] ?? `{{${name}}}`,
|
|
111
|
+
);
|
|
112
|
+
if (replaced === value) break;
|
|
113
|
+
value = replaced;
|
|
114
|
+
}
|
|
115
|
+
resolved[key] = value;
|
|
116
|
+
}
|
|
117
|
+
return resolved;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function replaceVariablesInString(
|
|
121
|
+
content: string,
|
|
122
|
+
variables: Record<string, string>,
|
|
123
|
+
): string {
|
|
124
|
+
if (!content || Object.keys(variables).length === 0) return content;
|
|
125
|
+
return content.replace(
|
|
126
|
+
/\{\{(\w+)\}\}/g,
|
|
127
|
+
(match, name) => variables[name] ?? match,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function loadConfig(docsDir: string): {
|
|
132
|
+
config: VeluConfig;
|
|
133
|
+
variables: Record<string, string>;
|
|
134
|
+
} {
|
|
135
|
+
const configPath = resolveConfigPath(docsDir);
|
|
136
|
+
if (!configPath) {
|
|
137
|
+
throw new Error(`No docs.json or velu.json found in ${docsDir}`);
|
|
138
|
+
}
|
|
139
|
+
const raw = JSON.parse(readFileSync(configPath, 'utf-8')) as VeluConfig;
|
|
140
|
+
const variables = resolveVariables(raw.variables);
|
|
141
|
+
return { config: raw, variables };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// āā Helpers āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
145
|
+
|
|
146
|
+
function isSeparator(item: unknown): item is VeluSeparator {
|
|
147
|
+
return typeof item === 'object' && item !== null && 'separator' in item;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isLink(item: unknown): item is VeluLink {
|
|
151
|
+
return typeof item === 'object' && item !== null && 'href' in item && 'label' in item;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isGroup(item: unknown): item is VeluGroup {
|
|
155
|
+
return typeof item === 'object' && item !== null && 'group' in item;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function pageBasename(page: string): string {
|
|
159
|
+
const parts = page.split('/').filter(Boolean);
|
|
160
|
+
return parts[parts.length - 1] ?? page;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function pageLabelFromSlug(slug: string): string {
|
|
164
|
+
const base = pageBasename(slug);
|
|
165
|
+
return base
|
|
166
|
+
.replace(/[-_]+/g, ' ')
|
|
167
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function sanitizeFrontmatterValue(value: string): string {
|
|
171
|
+
return value.replace(/\r?\n+/g, ' ').replace(/"/g, '\\"').trim();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// āā Build artifacts āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
175
|
+
|
|
176
|
+
function buildArtifacts(config: VeluConfig): BuildArtifacts {
|
|
177
|
+
const pageMap: PageMapping[] = [];
|
|
178
|
+
const metaFiles: MetaFile[] = [];
|
|
179
|
+
const rootTabs = (config.navigation.tabs || []).filter((tab) => !tab.href);
|
|
180
|
+
const rootPages = rootTabs.map((tab) => tab.slug);
|
|
181
|
+
let firstPage = 'quickstart';
|
|
182
|
+
let hasFirstPage = false;
|
|
183
|
+
const usedDestinations = new Set<string>();
|
|
184
|
+
|
|
185
|
+
function trackFirstPage(dest: string) {
|
|
186
|
+
if (!hasFirstPage) {
|
|
187
|
+
firstPage = dest;
|
|
188
|
+
hasFirstPage = true;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function metaEntry(item: string | VeluSeparator | VeluLink): string {
|
|
193
|
+
if (typeof item === 'string') return item;
|
|
194
|
+
if (isSeparator(item)) return `---${item.separator}---`;
|
|
195
|
+
if (isLink(item)) {
|
|
196
|
+
return item.icon
|
|
197
|
+
? `[${item.icon}][${item.label}](${item.href})`
|
|
198
|
+
: `[${item.label}](${item.href})`;
|
|
199
|
+
}
|
|
200
|
+
return String(item);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function uniqueDestination(dest: string): string {
|
|
204
|
+
if (!usedDestinations.has(dest)) {
|
|
205
|
+
usedDestinations.add(dest);
|
|
206
|
+
return dest;
|
|
207
|
+
}
|
|
208
|
+
let count = 2;
|
|
209
|
+
while (usedDestinations.has(`${dest}-${count}`)) count += 1;
|
|
210
|
+
const candidate = `${dest}-${count}`;
|
|
211
|
+
usedDestinations.add(candidate);
|
|
212
|
+
return candidate;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function processGroup(
|
|
216
|
+
group: VeluGroup,
|
|
217
|
+
parentDir: string,
|
|
218
|
+
inheritedSpec?: string,
|
|
219
|
+
) {
|
|
220
|
+
const groupSlug = group.slug || pageBasename(group.group).toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
221
|
+
const groupDir = `${parentDir}/${groupSlug}`;
|
|
222
|
+
const groupMetaPages: string[] = [];
|
|
223
|
+
const groupSpec = typeof group.openapi === 'string' ? group.openapi : inheritedSpec;
|
|
224
|
+
|
|
225
|
+
for (const item of group.pages) {
|
|
226
|
+
if (typeof item === 'string') {
|
|
227
|
+
const dest = uniqueDestination(`${groupDir}/${pageBasename(item)}`);
|
|
228
|
+
pageMap.push({ src: item, dest, kind: 'file' });
|
|
229
|
+
groupMetaPages.push(pageBasename(item));
|
|
230
|
+
trackFirstPage(dest);
|
|
231
|
+
} else if (isGroup(item)) {
|
|
232
|
+
const nestedSlug = item.slug || pageBasename(item.group).toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
233
|
+
groupMetaPages.push(nestedSlug);
|
|
234
|
+
processGroup(item, groupDir, groupSpec);
|
|
235
|
+
} else if (isSeparator(item) || isLink(item)) {
|
|
236
|
+
groupMetaPages.push(metaEntry(item));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
metaFiles.push({
|
|
241
|
+
dir: groupDir,
|
|
242
|
+
data: {
|
|
243
|
+
title: group.group,
|
|
244
|
+
...(group.description ? { description: group.description } : {}),
|
|
245
|
+
pages: groupMetaPages,
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function processTab(tab: VeluTab) {
|
|
251
|
+
const tabSlug = tab.slug || tab.tab.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
252
|
+
const tabDir = tabSlug;
|
|
253
|
+
const tabMetaPages: string[] = [];
|
|
254
|
+
const tabSpec = typeof tab.openapi === 'string' ? tab.openapi : undefined;
|
|
255
|
+
|
|
256
|
+
// Process top-level pages in this tab
|
|
257
|
+
if (tab.pages) {
|
|
258
|
+
for (const item of tab.pages) {
|
|
259
|
+
if (typeof item === 'string') {
|
|
260
|
+
const dest = uniqueDestination(`${tabDir}/${pageBasename(item)}`);
|
|
261
|
+
pageMap.push({ src: item, dest, kind: 'file' });
|
|
262
|
+
tabMetaPages.push(pageBasename(item));
|
|
263
|
+
trackFirstPage(dest);
|
|
264
|
+
} else if (isSeparator(item) || isLink(item)) {
|
|
265
|
+
tabMetaPages.push(metaEntry(item));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Process groups
|
|
271
|
+
if (tab.groups) {
|
|
272
|
+
for (const group of tab.groups) {
|
|
273
|
+
const groupSlug = group.slug || pageBasename(group.group).toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
274
|
+
tabMetaPages.push(groupSlug);
|
|
275
|
+
processGroup(group, tabDir, tabSpec);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
metaFiles.push({
|
|
280
|
+
dir: tabDir,
|
|
281
|
+
data: { title: tab.tab, pages: tabMetaPages },
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Process all tabs
|
|
286
|
+
for (const tab of rootTabs) {
|
|
287
|
+
processTab(tab);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Root meta.json lists the tab slugs
|
|
291
|
+
metaFiles.push({
|
|
292
|
+
dir: '',
|
|
293
|
+
data: { pages: rootPages.filter((p): p is string => typeof p === 'string') },
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
return { pageMap, metaFiles, firstPage };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// āā Page processing āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
300
|
+
|
|
301
|
+
function processPage(
|
|
302
|
+
srcPath: string,
|
|
303
|
+
destPath: string,
|
|
304
|
+
slug: string,
|
|
305
|
+
variables: Record<string, string>,
|
|
306
|
+
): void {
|
|
307
|
+
let content = readFileSync(srcPath, 'utf-8');
|
|
308
|
+
content = replaceVariablesInString(content, variables);
|
|
309
|
+
|
|
310
|
+
if (!content.startsWith('---')) {
|
|
311
|
+
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
312
|
+
const title = titleMatch ? titleMatch[1] : pageLabelFromSlug(slug);
|
|
313
|
+
if (titleMatch) {
|
|
314
|
+
content = content.replace(/^#\s+.+$/m, '').trimStart();
|
|
315
|
+
}
|
|
316
|
+
content = `---\ntitle: "${sanitizeFrontmatterValue(title)}"\n---\n\n${content}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
320
|
+
writeFileSync(destPath, content, 'utf-8');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// āā Content writing āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
324
|
+
|
|
325
|
+
function writeLangContent(
|
|
326
|
+
docsDir: string,
|
|
327
|
+
contentDir: string,
|
|
328
|
+
langCode: string,
|
|
329
|
+
artifacts: BuildArtifacts,
|
|
330
|
+
variables: Record<string, string>,
|
|
331
|
+
isDefault: boolean,
|
|
332
|
+
useLangFolders = false,
|
|
333
|
+
) {
|
|
334
|
+
const storagePrefix = useLangFolders ? langCode : (isDefault ? '' : langCode);
|
|
335
|
+
|
|
336
|
+
// Write meta files
|
|
337
|
+
const metas = storagePrefix
|
|
338
|
+
? artifacts.metaFiles.map((m) => ({
|
|
339
|
+
dir: m.dir ? `${storagePrefix}/${m.dir}` : storagePrefix,
|
|
340
|
+
data: { ...m.data },
|
|
341
|
+
}))
|
|
342
|
+
: artifacts.metaFiles;
|
|
343
|
+
|
|
344
|
+
for (const meta of metas) {
|
|
345
|
+
const metaPath = join(contentDir, meta.dir, 'meta.json');
|
|
346
|
+
mkdirSync(dirname(metaPath), { recursive: true });
|
|
347
|
+
writeFileSync(metaPath, JSON.stringify(meta.data, null, 2) + '\n', 'utf-8');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Copy and process pages
|
|
351
|
+
for (const mapping of artifacts.pageMap) {
|
|
352
|
+
const destPath = join(
|
|
353
|
+
contentDir,
|
|
354
|
+
storagePrefix ? `${storagePrefix}/${mapping.dest}.mdx` : `${mapping.dest}.mdx`,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
if (mapping.kind === 'openapi-operation') {
|
|
358
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
359
|
+
const operationLabel = `${mapping.openapiMethod ?? 'GET'} ${mapping.openapiEndpoint ?? '/'}`;
|
|
360
|
+
const title = sanitizeFrontmatterValue(mapping.title ?? operationLabel);
|
|
361
|
+
const openapi = operationLabel.replace(/"/g, '\\"');
|
|
362
|
+
const descriptionLine = mapping.description
|
|
363
|
+
? `\ndescription: "${sanitizeFrontmatterValue(mapping.description)}"`
|
|
364
|
+
: '';
|
|
365
|
+
writeFileSync(
|
|
366
|
+
destPath,
|
|
367
|
+
`---\ntitle: "${title}"${descriptionLine}\nopenapi: "${openapi}"\n---\n`,
|
|
368
|
+
'utf-8',
|
|
369
|
+
);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const src = mapping.src;
|
|
374
|
+
let srcPath = join(docsDir, `${src}.mdx`);
|
|
375
|
+
if (!existsSync(srcPath)) {
|
|
376
|
+
srcPath = join(docsDir, `${src}.md`);
|
|
377
|
+
}
|
|
378
|
+
if (!existsSync(srcPath)) continue;
|
|
379
|
+
|
|
380
|
+
processPage(srcPath, destPath, src, variables);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Index page redirect
|
|
384
|
+
const urlPrefix = isDefault ? '' : langCode;
|
|
385
|
+
const href = urlPrefix
|
|
386
|
+
? `/${urlPrefix}/${artifacts.firstPage}/`
|
|
387
|
+
: `/${artifacts.firstPage}/`;
|
|
388
|
+
const indexPath = storagePrefix
|
|
389
|
+
? join(contentDir, storagePrefix, 'index.mdx')
|
|
390
|
+
: join(contentDir, 'index.mdx');
|
|
391
|
+
writeFileSync(
|
|
392
|
+
indexPath,
|
|
393
|
+
`---\ntitle: "Overview"\n---\n\nWelcome to the documentation.\n`,
|
|
394
|
+
'utf-8',
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// āā Public API āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Generate all content for a session from its workspace.
|
|
402
|
+
* Reads from workspaceDir (docs.json + MDX sources) and writes
|
|
403
|
+
* processed content to outputDir.
|
|
404
|
+
*/
|
|
405
|
+
export function generateSessionContent(sessionId: string): {
|
|
406
|
+
firstPage: string;
|
|
407
|
+
pageCount: number;
|
|
408
|
+
} {
|
|
409
|
+
const workspaceDir = join(WORKSPACE_DIR, sessionId);
|
|
410
|
+
const outputDir = join(PREVIEW_CONTENT_DIR, sessionId);
|
|
411
|
+
|
|
412
|
+
// Clean previous content
|
|
413
|
+
if (existsSync(outputDir)) {
|
|
414
|
+
rmSync(outputDir, { recursive: true, force: true });
|
|
415
|
+
}
|
|
416
|
+
mkdirSync(outputDir, { recursive: true });
|
|
417
|
+
|
|
418
|
+
const { config, variables } = loadConfig(workspaceDir);
|
|
419
|
+
const navLanguages = config.navigation.languages;
|
|
420
|
+
const simpleLanguages = config.languages || [];
|
|
421
|
+
|
|
422
|
+
if (navLanguages && navLanguages.length > 0) {
|
|
423
|
+
// Per-language navigation
|
|
424
|
+
const rootPages: string[] = [];
|
|
425
|
+
let totalPages = 0;
|
|
426
|
+
let firstPage = 'quickstart';
|
|
427
|
+
|
|
428
|
+
for (let i = 0; i < navLanguages.length; i++) {
|
|
429
|
+
const langEntry = navLanguages[i];
|
|
430
|
+
const isDefault = i === 0;
|
|
431
|
+
const langConfig = {
|
|
432
|
+
...config,
|
|
433
|
+
navigation: { ...config.navigation, tabs: langEntry.tabs },
|
|
434
|
+
} as VeluConfig;
|
|
435
|
+
const artifacts = buildArtifacts(langConfig);
|
|
436
|
+
writeLangContent(workspaceDir, outputDir, langEntry.language, artifacts, variables, isDefault, true);
|
|
437
|
+
totalPages += artifacts.pageMap.length;
|
|
438
|
+
if (i === 0) firstPage = artifacts.firstPage;
|
|
439
|
+
rootPages.push(`!${langEntry.language}`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
writeFileSync(
|
|
443
|
+
join(outputDir, 'meta.json'),
|
|
444
|
+
JSON.stringify({ pages: rootPages }, null, 2) + '\n',
|
|
445
|
+
'utf-8',
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
return { firstPage, pageCount: totalPages };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Simple (single-lang or same-nav multi-lang)
|
|
452
|
+
const artifacts = buildArtifacts(config);
|
|
453
|
+
const useLangFolders = simpleLanguages.length > 1;
|
|
454
|
+
writeLangContent(
|
|
455
|
+
workspaceDir, outputDir,
|
|
456
|
+
simpleLanguages[0] || 'en', artifacts, variables,
|
|
457
|
+
true, useLangFolders,
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
let totalPages = artifacts.pageMap.length;
|
|
461
|
+
|
|
462
|
+
if (simpleLanguages.length > 1) {
|
|
463
|
+
const rootPages = [`!${simpleLanguages[0] || 'en'}`];
|
|
464
|
+
for (const lang of simpleLanguages.slice(1)) {
|
|
465
|
+
writeLangContent(workspaceDir, outputDir, lang, artifacts, variables, false, true);
|
|
466
|
+
rootPages.push(`!${lang}`);
|
|
467
|
+
totalPages += artifacts.pageMap.length;
|
|
468
|
+
}
|
|
469
|
+
writeFileSync(
|
|
470
|
+
join(outputDir, 'meta.json'),
|
|
471
|
+
JSON.stringify({ pages: rootPages }, null, 2) + '\n',
|
|
472
|
+
'utf-8',
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return { firstPage: artifacts.firstPage, pageCount: totalPages };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Sync a single file after an edit in the workspace.
|
|
481
|
+
* Re-reads the file from the workspace and writes the processed
|
|
482
|
+
* version to the preview content directory.
|
|
483
|
+
*/
|
|
484
|
+
export function syncSessionFile(
|
|
485
|
+
sessionId: string,
|
|
486
|
+
filePath: string,
|
|
487
|
+
): { synced: boolean } {
|
|
488
|
+
const workspaceDir = join(WORKSPACE_DIR, sessionId);
|
|
489
|
+
const outputDir = join(PREVIEW_CONTENT_DIR, sessionId);
|
|
490
|
+
|
|
491
|
+
// If this is a config file change, do a full regeneration
|
|
492
|
+
if (filePath === PRIMARY_CONFIG_NAME || filePath === LEGACY_CONFIG_NAME) {
|
|
493
|
+
generateSessionContent(sessionId);
|
|
494
|
+
return { synced: true };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Load config for variable substitution
|
|
498
|
+
let variables: Record<string, string> = {};
|
|
499
|
+
try {
|
|
500
|
+
const result = loadConfig(workspaceDir);
|
|
501
|
+
variables = result.variables;
|
|
502
|
+
} catch {
|
|
503
|
+
// Config might not exist yet
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Find the source file
|
|
507
|
+
const stripped = filePath.replace(/\.(mdx?|md)$/, '');
|
|
508
|
+
let srcPath = join(workspaceDir, filePath);
|
|
509
|
+
if (!existsSync(srcPath)) {
|
|
510
|
+
srcPath = join(workspaceDir, `${stripped}.mdx`);
|
|
511
|
+
}
|
|
512
|
+
if (!existsSync(srcPath)) {
|
|
513
|
+
srcPath = join(workspaceDir, `${stripped}.md`);
|
|
514
|
+
}
|
|
515
|
+
if (!existsSync(srcPath)) {
|
|
516
|
+
return { synced: false };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// We need to figure out where this file maps in the output.
|
|
520
|
+
// Rebuild from config to get the page map, then find the mapping for this file.
|
|
521
|
+
try {
|
|
522
|
+
const { config } = loadConfig(workspaceDir);
|
|
523
|
+
const artifacts = buildArtifacts(config);
|
|
524
|
+
const mapping = artifacts.pageMap.find((m) => {
|
|
525
|
+
return m.src === stripped || m.src === filePath;
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
if (mapping) {
|
|
529
|
+
const destPath = join(outputDir, `${mapping.dest}.mdx`);
|
|
530
|
+
processPage(srcPath, destPath, stripped, variables);
|
|
531
|
+
return { synced: true };
|
|
532
|
+
}
|
|
533
|
+
} catch {
|
|
534
|
+
// Fall through to direct copy
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Fallback: try to process the file directly
|
|
538
|
+
const destPath = join(outputDir, `${stripped}.mdx`);
|
|
539
|
+
processPage(srcPath, destPath, stripped, variables);
|
|
540
|
+
return { synced: true };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Remove all preview content for a session.
|
|
545
|
+
*/
|
|
546
|
+
export function removeSessionContent(sessionId: string): void {
|
|
547
|
+
const outputDir = join(PREVIEW_CONTENT_DIR, sessionId);
|
|
548
|
+
if (existsSync(outputDir)) {
|
|
549
|
+
rmSync(outputDir, { recursive: true, force: true });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session configuration cache.
|
|
3
|
+
* Reads docs.json from workspace directories and caches the parsed config.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
const WORKSPACE_DIR = process.env.WORKSPACE_DIR || '/mnt/nfs_share/editor_sessions';
|
|
9
|
+
const PRIMARY_CONFIG_NAME = 'docs.json';
|
|
10
|
+
const LEGACY_CONFIG_NAME = 'velu.json';
|
|
11
|
+
|
|
12
|
+
interface SessionConfig {
|
|
13
|
+
name?: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
title?: string;
|
|
16
|
+
theme?: string;
|
|
17
|
+
colors?: { primary?: string; light?: string; dark?: string };
|
|
18
|
+
navigation: {
|
|
19
|
+
tabs?: any[];
|
|
20
|
+
languages?: any[];
|
|
21
|
+
anchors?: any[];
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
};
|
|
24
|
+
languages?: string[];
|
|
25
|
+
openapi?: unknown;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface CachedSession {
|
|
30
|
+
config: SessionConfig;
|
|
31
|
+
rawConfig: Record<string, unknown>;
|
|
32
|
+
loadedAt: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sessionCache = new Map<string, CachedSession>();
|
|
36
|
+
const CACHE_TTL_MS = 60_000; // 1 minute
|
|
37
|
+
|
|
38
|
+
function resolveWorkspaceConfigPath(sessionId: string): string | null {
|
|
39
|
+
const wsDir = join(WORKSPACE_DIR, sessionId);
|
|
40
|
+
const primary = join(wsDir, PRIMARY_CONFIG_NAME);
|
|
41
|
+
if (existsSync(primary)) return primary;
|
|
42
|
+
const legacy = join(wsDir, LEGACY_CONFIG_NAME);
|
|
43
|
+
if (existsSync(legacy)) return legacy;
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getSessionConfig(sessionId: string): SessionConfig | null {
|
|
48
|
+
const cached = sessionCache.get(sessionId);
|
|
49
|
+
if (cached && Date.now() - cached.loadedAt < CACHE_TTL_MS) {
|
|
50
|
+
return cached.config;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const configPath = resolveWorkspaceConfigPath(sessionId);
|
|
54
|
+
if (!configPath) return null;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
58
|
+
const config = raw as SessionConfig;
|
|
59
|
+
sessionCache.set(sessionId, {
|
|
60
|
+
config,
|
|
61
|
+
rawConfig: raw,
|
|
62
|
+
loadedAt: Date.now(),
|
|
63
|
+
});
|
|
64
|
+
return config;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getSessionRawConfig(sessionId: string): Record<string, unknown> | null {
|
|
71
|
+
getSessionConfig(sessionId); // ensure loaded
|
|
72
|
+
return sessionCache.get(sessionId)?.rawConfig ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function clearSessionCache(sessionId: string): void {
|
|
76
|
+
sessionCache.delete(sessionId);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getWorkspaceDir(sessionId: string): string {
|
|
80
|
+
return join(WORKSPACE_DIR, sessionId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getSiteName(sessionId: string): string {
|
|
84
|
+
const config = getSessionConfig(sessionId);
|
|
85
|
+
return config?.name || config?.title || 'Docs Preview';
|
|
86
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { loader } from 'fumadocs-core/source';
|
|
2
|
+
import * as mdxCollections from 'fumadocs-mdx:collections/server';
|
|
3
|
+
|
|
4
|
+
const docsCollection = (mdxCollections as { docs?: { toFumadocsSource?: () => unknown } }).docs;
|
|
5
|
+
|
|
6
|
+
if (!docsCollection?.toFumadocsSource) {
|
|
7
|
+
throw new Error('MDX collections are not ready yet. Please retry in a moment.');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const source = loader({
|
|
11
|
+
baseUrl: '/',
|
|
12
|
+
source: docsCollection.toFumadocsSource() as any,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get the page tree filtered to a specific session's content.
|
|
17
|
+
* The content directory has files at {sessionId}/{slug}.mdx,
|
|
18
|
+
* so the page tree has top-level folders per session.
|
|
19
|
+
*/
|
|
20
|
+
export function getSessionPageTree(sessionId: string) {
|
|
21
|
+
const fullTree = source.getPageTree();
|
|
22
|
+
const children = Array.isArray(fullTree.children) ? fullTree.children : [];
|
|
23
|
+
|
|
24
|
+
// Find the root folder matching this session ID
|
|
25
|
+
const sessionFolder = children.find((child: any) => {
|
|
26
|
+
if (child?.type !== 'folder') return false;
|
|
27
|
+
// The folder's URL or name should match the session ID
|
|
28
|
+
const urls = collectUrls(child);
|
|
29
|
+
for (const url of urls) {
|
|
30
|
+
const firstSegment = url.replace(/^\/+/, '').split('/')[0];
|
|
31
|
+
if (firstSegment === sessionId) return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}) as any;
|
|
35
|
+
|
|
36
|
+
if (sessionFolder && Array.isArray(sessionFolder.children)) {
|
|
37
|
+
return { ...fullTree, children: sessionFolder.children };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Fallback: filter children by URL prefix
|
|
41
|
+
const filtered = children.filter((child: any) => {
|
|
42
|
+
const urls = collectUrls(child);
|
|
43
|
+
return urls.some((url: string) => {
|
|
44
|
+
const segments = url.replace(/^\/+/, '').split('/');
|
|
45
|
+
return segments[0] === sessionId;
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return { ...fullTree, children: filtered };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function collectUrls(node: any, out: string[] = []): string[] {
|
|
53
|
+
if (!node || typeof node !== 'object') return out;
|
|
54
|
+
if (typeof node.url === 'string') out.push(node.url);
|
|
55
|
+
if (node.index && typeof node.index.url === 'string') out.push(node.index.url);
|
|
56
|
+
if (Array.isArray(node.children)) {
|
|
57
|
+
for (const child of node.children) collectUrls(child, out);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { createMDX } from 'fumadocs-mdx/next';
|
|
3
|
+
|
|
4
|
+
const withMDX = createMDX({
|
|
5
|
+
configPath: './source.config.ts',
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
/** @type {import('next').NextConfig} */
|
|
9
|
+
const config = {
|
|
10
|
+
reactStrictMode: false,
|
|
11
|
+
devIndicators: false,
|
|
12
|
+
turbopack: {
|
|
13
|
+
root: resolve('..'),
|
|
14
|
+
},
|
|
15
|
+
images: {
|
|
16
|
+
unoptimized: true,
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default withMDX(config);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { defineConfig, defineDocs } from 'fumadocs-mdx/config';
|
|
2
|
+
import { metaSchema, pageSchema } from 'fumadocs-core/source/schema';
|
|
3
|
+
|
|
4
|
+
// Content directory: NFS-backed preview_content root.
|
|
5
|
+
// Each session writes to {PREVIEW_CONTENT_DIR}/{sessionId}/.
|
|
6
|
+
// fumadocs scans the entire directory; routes filter by session.
|
|
7
|
+
const contentDir = process.env.PREVIEW_CONTENT_DIR || './content';
|
|
8
|
+
|
|
9
|
+
export const docs = defineDocs({
|
|
10
|
+
dir: contentDir,
|
|
11
|
+
docs: {
|
|
12
|
+
schema: pageSchema,
|
|
13
|
+
},
|
|
14
|
+
meta: {
|
|
15
|
+
schema: metaSchema,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export default defineConfig({
|
|
20
|
+
mdxOptions: {
|
|
21
|
+
rehypeCodeOptions: ({
|
|
22
|
+
lazy: false,
|
|
23
|
+
fallbackLanguage: 'bash',
|
|
24
|
+
} as any),
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"baseUrl": ".",
|
|
4
|
+
"target": "ESNext",
|
|
5
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
6
|
+
"allowJs": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"module": "esnext",
|
|
13
|
+
"moduleResolution": "bundler",
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"isolatedModules": true,
|
|
16
|
+
"jsx": "react-jsx",
|
|
17
|
+
"incremental": true,
|
|
18
|
+
"paths": {
|
|
19
|
+
"@/*": ["./*"],
|
|
20
|
+
"fumadocs-mdx:collections/*": [".source/*"]
|
|
21
|
+
},
|
|
22
|
+
"plugins": [{ "name": "next" }]
|
|
23
|
+
},
|
|
24
|
+
"include": [
|
|
25
|
+
"next-env.d.ts",
|
|
26
|
+
"**/*.ts",
|
|
27
|
+
"**/*.tsx",
|
|
28
|
+
".next/types/**/*.ts",
|
|
29
|
+
".next/dev/types/**/*.ts"
|
|
30
|
+
],
|
|
31
|
+
"exclude": ["node_modules"]
|
|
32
|
+
}
|