@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aravindc26/velu",
3
- "version": "0.11.22",
3
+ "version": "0.12.1",
4
4
  "description": "A modern documentation site generator powered by Markdown and JSON configuration",
5
5
  "type": "module",
6
6
  "license": "MIT",
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 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)
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,3 @@
1
+ @import 'tailwindcss';
2
+ @import 'fumadocs-ui/css/neutral.css';
3
+ @import 'fumadocs-ui/css/preset.css';
@@ -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&apos;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,8 @@
1
+ import { createRequire } from 'node:module';
2
+ const require = createRequire(import.meta.url);
3
+
4
+ export default {
5
+ plugins: {
6
+ '@tailwindcss/postcss': {},
7
+ },
8
+ };
@@ -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
+ }