@aravindc26/velu 0.12.7 → 0.12.9
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/build.ts +13 -0
- package/src/cli.ts +51 -9
- package/src/engine/app/(docs)/[...slug]/layout.tsx +21 -537
- package/src/engine/app/_preview/[sessionId]/[...slug]/layout.tsx +96 -0
- package/src/engine/app/_preview/[sessionId]/[...slug]/page.tsx +298 -0
- package/src/engine/app/_preview/[sessionId]/layout.tsx +56 -0
- package/src/{preview-engine/app → engine/app/_preview}/[sessionId]/page.tsx +7 -3
- package/src/engine/app/_preview/api/sessions/[sessionId]/assets/[...path]/route.ts +51 -0
- package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/init/route.ts +2 -2
- package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/route.ts +3 -3
- package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/sync/route.ts +2 -2
- package/src/{preview-engine/app → engine/app/_preview}/layout.tsx +4 -1
- package/src/engine/app/global.css +0 -3623
- package/src/engine/app/layout.tsx +4 -3
- package/src/engine/components/sidebar-links.tsx +11 -5
- package/src/engine/lib/docs-layout.tsx +605 -0
- package/src/engine/lib/layout.shared.ts +7 -7
- package/src/engine/lib/preview-config.ts +129 -0
- package/src/{preview-engine/lib/content-generator.ts → engine/lib/preview-content.ts} +242 -42
- package/src/engine/lib/source.ts +80 -97
- package/src/engine/lib/velu.ts +79 -55
- package/src/engine/mdx-components.tsx +14 -650
- package/src/engine/source.config.ts +11 -89
- package/src/engine/tsconfig.json +1 -0
- package/src/engine-core/components/assistant.tsx +361 -0
- package/src/engine-core/components/banner.tsx +80 -0
- package/src/engine-core/components/changelog-filters.tsx +114 -0
- package/src/engine-core/components/code-group.tsx +383 -0
- package/src/engine-core/components/color.tsx +118 -0
- package/src/engine-core/components/copy-page.tsx +223 -0
- package/src/engine-core/components/dropdown-switcher.tsx +142 -0
- package/src/engine-core/components/expandable.tsx +77 -0
- package/src/engine-core/components/header-tab-link.tsx +43 -0
- package/src/engine-core/components/icon.tsx +136 -0
- package/src/engine-core/components/image-zoom-fallback.tsx +147 -0
- package/src/engine-core/components/image.tsx +111 -0
- package/src/engine-core/components/lang-switcher.tsx +101 -0
- package/src/engine-core/components/manual-api-playground.tsx +154 -0
- package/src/engine-core/components/mermaid.tsx +142 -0
- package/src/engine-core/components/openapi-toc-sync.tsx +59 -0
- package/src/engine-core/components/openapi.tsx +1682 -0
- package/src/engine-core/components/page-feedback-api.test.ts +83 -0
- package/src/engine-core/components/page-feedback-api.ts +89 -0
- package/src/engine-core/components/page-feedback.tsx +200 -0
- package/src/engine-core/components/product-switcher.tsx +107 -0
- package/src/engine-core/components/prompt.tsx +90 -0
- package/src/engine-core/components/providers.tsx +21 -0
- package/src/engine-core/components/search.tsx +318 -0
- package/src/engine-core/components/sidebar-links.tsx +54 -0
- package/src/engine-core/components/synced-tabs.tsx +57 -0
- package/src/engine-core/components/theme-toggle.tsx +39 -0
- package/src/engine-core/components/toc-examples.tsx +110 -0
- package/src/engine-core/components/version-switcher.tsx +95 -0
- package/src/engine-core/components/view.tsx +344 -0
- package/src/engine-core/css/assistant.css +326 -0
- package/src/engine-core/css/copy-page.css +206 -0
- package/src/engine-core/css/search.css +142 -0
- package/src/engine-core/css/shared.css +3628 -0
- package/src/engine-core/lib/remark-plugins.ts +102 -0
- package/src/engine-core/lib/source-plugins.ts +105 -0
- package/src/engine-core/mdx-components.tsx +654 -0
- package/src/engine-core/types.ts +49 -0
- package/src/preview-engine/app/[sessionId]/[...slug]/page.tsx +0 -41
- package/src/preview-engine/app/[sessionId]/layout.tsx +0 -23
- package/src/preview-engine/app/global.css +0 -3
- package/src/preview-engine/lib/session-config.ts +0 -86
- package/src/preview-engine/lib/source.ts +0 -60
- package/src/preview-engine/next.config.mjs +0 -20
- package/src/preview-engine/postcss.config.mjs +0 -8
- package/src/preview-engine/source.config.ts +0 -26
- package/src/preview-engine/tsconfig.json +0 -32
- /package/src/{preview-engine/app → engine/app/_preview}/page.tsx +0 -0
- /package/src/{preview-engine/lib/auth.ts → engine/lib/preview-auth.ts} +0 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session configuration cache for preview mode.
|
|
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
|
+
import { normalizeConfigNavigation } from './navigation-normalize';
|
|
8
|
+
import type { VeluConfigSource, VeluConfig } from './velu';
|
|
9
|
+
|
|
10
|
+
const WORKSPACE_DIR = process.env.WORKSPACE_DIR || '/mnt/nfs_share/editor_sessions';
|
|
11
|
+
const PRIMARY_CONFIG_NAME = 'docs.json';
|
|
12
|
+
const LEGACY_CONFIG_NAME = 'velu.json';
|
|
13
|
+
|
|
14
|
+
interface CachedSession {
|
|
15
|
+
configSource: VeluConfigSource;
|
|
16
|
+
loadedAt: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sessionCache = new Map<string, CachedSession>();
|
|
20
|
+
const CACHE_TTL_MS = 60_000; // 1 minute
|
|
21
|
+
|
|
22
|
+
function resolveWorkspaceConfigPath(sessionId: string): string | null {
|
|
23
|
+
const wsDir = join(WORKSPACE_DIR, sessionId);
|
|
24
|
+
const primary = join(wsDir, PRIMARY_CONFIG_NAME);
|
|
25
|
+
if (existsSync(primary)) return primary;
|
|
26
|
+
const legacy = join(wsDir, LEGACY_CONFIG_NAME);
|
|
27
|
+
if (existsSync(legacy)) return legacy;
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function loadSessionConfigSource(sessionId: string): VeluConfigSource | null {
|
|
32
|
+
const cached = sessionCache.get(sessionId);
|
|
33
|
+
if (cached && Date.now() - cached.loadedAt < CACHE_TTL_MS) {
|
|
34
|
+
return cached.configSource;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const configPath = resolveWorkspaceConfigPath(sessionId);
|
|
38
|
+
if (!configPath) return null;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
42
|
+
const config = normalizeConfigNavigation(raw) as VeluConfig;
|
|
43
|
+
const rawConfig = raw && typeof raw === 'object' && !Array.isArray(raw)
|
|
44
|
+
? raw as Record<string, unknown>
|
|
45
|
+
: {};
|
|
46
|
+
const configSource: VeluConfigSource = { config, rawConfig };
|
|
47
|
+
sessionCache.set(sessionId, {
|
|
48
|
+
configSource,
|
|
49
|
+
loadedAt: Date.now(),
|
|
50
|
+
});
|
|
51
|
+
return configSource;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function clearSessionCache(sessionId: string): void {
|
|
58
|
+
sessionCache.delete(sessionId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getWorkspaceDir(sessionId: string): string {
|
|
62
|
+
return join(WORKSPACE_DIR, sessionId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Theme color generation ─────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function hexToRgb(hex: string): [number, number, number] {
|
|
68
|
+
const h = hex.replace('#', '');
|
|
69
|
+
return [
|
|
70
|
+
parseInt(h.substring(0, 2), 16),
|
|
71
|
+
parseInt(h.substring(2, 4), 16),
|
|
72
|
+
parseInt(h.substring(4, 6), 16),
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function rgbToHex(r: number, g: number, b: number): string {
|
|
77
|
+
const clamp = (v: number) => Math.round(Math.max(0, Math.min(255, v)));
|
|
78
|
+
return '#' + [clamp(r), clamp(g), clamp(b)].map((c) => c.toString(16).padStart(2, '0')).join('');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function mixColors(hex1: string, hex2: string, weight: number): string {
|
|
82
|
+
const [r1, g1, b1] = hexToRgb(hex1);
|
|
83
|
+
const [r2, g2, b2] = hexToRgb(hex2);
|
|
84
|
+
return rgbToHex(r1 * weight + r2 * (1 - weight), g1 * weight + g2 * (1 - weight), b1 * weight + b2 * (1 - weight));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function textColorFor(hex: string): string {
|
|
88
|
+
const [r, g, b] = hexToRgb(hex);
|
|
89
|
+
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
|
|
90
|
+
return yiq >= 140 ? '#111111' : '#ffffff';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Generate CSS custom properties for a session's primary color theme.
|
|
95
|
+
*/
|
|
96
|
+
export function getSessionThemeCss(sessionId: string): string | null {
|
|
97
|
+
const configSource = loadSessionConfigSource(sessionId);
|
|
98
|
+
const colors = configSource?.config.colors;
|
|
99
|
+
if (!colors?.primary) return null;
|
|
100
|
+
|
|
101
|
+
const { primary, light, dark } = colors;
|
|
102
|
+
const lightAccent = light || primary;
|
|
103
|
+
const darkAccent = dark || primary;
|
|
104
|
+
const lines: string[] = [];
|
|
105
|
+
|
|
106
|
+
if (lightAccent) {
|
|
107
|
+
const accentLow = mixColors(lightAccent, '#ffffff', 0.15);
|
|
108
|
+
lines.push(':root {');
|
|
109
|
+
lines.push(` --color-fd-primary: ${lightAccent};`);
|
|
110
|
+
lines.push(` --color-fd-primary-foreground: ${textColorFor(lightAccent)};`);
|
|
111
|
+
lines.push(` --color-fd-accent: ${accentLow};`);
|
|
112
|
+
lines.push(` --color-fd-accent-foreground: ${textColorFor(accentLow)};`);
|
|
113
|
+
lines.push(` --color-fd-ring: ${lightAccent};`);
|
|
114
|
+
lines.push('}');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (darkAccent) {
|
|
118
|
+
const accentLow = mixColors(darkAccent, '#000000', 0.3);
|
|
119
|
+
lines.push('.dark {');
|
|
120
|
+
lines.push(` --color-fd-primary: ${darkAccent};`);
|
|
121
|
+
lines.push(` --color-fd-primary-foreground: ${textColorFor(darkAccent)};`);
|
|
122
|
+
lines.push(` --color-fd-accent: ${accentLow};`);
|
|
123
|
+
lines.push(` --color-fd-accent-foreground: ${textColorFor(accentLow)};`);
|
|
124
|
+
lines.push(` --color-fd-ring: ${darkAccent};`);
|
|
125
|
+
lines.push('}');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return lines.length > 0 ? lines.join('\n') : null;
|
|
129
|
+
}
|
|
@@ -3,11 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Reads a workspace directory (docs.json + MDX source files) and writes
|
|
5
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
6
|
*/
|
|
10
7
|
import {
|
|
8
|
+
copyFileSync,
|
|
11
9
|
existsSync,
|
|
12
10
|
mkdirSync,
|
|
13
11
|
readFileSync,
|
|
@@ -15,7 +13,8 @@ import {
|
|
|
15
13
|
rmSync,
|
|
16
14
|
writeFileSync,
|
|
17
15
|
} from 'node:fs';
|
|
18
|
-
import { basename, dirname, extname, join, relative } from 'node:path';
|
|
16
|
+
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
|
|
17
|
+
import { normalizeConfigNavigation } from './navigation-normalize';
|
|
19
18
|
|
|
20
19
|
const PREVIEW_CONTENT_DIR = process.env.PREVIEW_CONTENT_DIR || './content';
|
|
21
20
|
const WORKSPACE_DIR = process.env.WORKSPACE_DIR || '/mnt/nfs_share/editor_sessions';
|
|
@@ -23,6 +22,45 @@ const WORKSPACE_DIR = process.env.WORKSPACE_DIR || '/mnt/nfs_share/editor_sessio
|
|
|
23
22
|
const PRIMARY_CONFIG_NAME = 'docs.json';
|
|
24
23
|
const LEGACY_CONFIG_NAME = 'velu.json';
|
|
25
24
|
|
|
25
|
+
const STATIC_EXTENSIONS = new Set([
|
|
26
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico',
|
|
27
|
+
'.mp4', '.webm',
|
|
28
|
+
'.mp3', '.wav',
|
|
29
|
+
'.json', '.yaml', '.yml',
|
|
30
|
+
'.css',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Copy static assets (images, JSON specs, etc.) from workspace to public/
|
|
35
|
+
* so that OpenAPI spec files and images are resolvable by components.
|
|
36
|
+
*/
|
|
37
|
+
function copyStaticAssets(docsDir: string): void {
|
|
38
|
+
const publicDir = resolve('public');
|
|
39
|
+
|
|
40
|
+
function walk(dir: string): void {
|
|
41
|
+
if (!existsSync(dir)) return;
|
|
42
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (entry.name.startsWith('.')) continue;
|
|
45
|
+
if (entry.name === 'node_modules') continue;
|
|
46
|
+
const srcPath = join(dir, entry.name);
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
walk(srcPath);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const ext = extname(entry.name).toLowerCase();
|
|
52
|
+
if (!STATIC_EXTENSIONS.has(ext)) continue;
|
|
53
|
+
const rel = relative(docsDir, srcPath);
|
|
54
|
+
const destPath = join(publicDir, rel);
|
|
55
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
56
|
+
copyFileSync(srcPath, destPath);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
mkdirSync(publicDir, { recursive: true });
|
|
61
|
+
walk(docsDir);
|
|
62
|
+
}
|
|
63
|
+
|
|
26
64
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
27
65
|
|
|
28
66
|
interface VeluConfig {
|
|
@@ -136,7 +174,8 @@ function loadConfig(docsDir: string): {
|
|
|
136
174
|
if (!configPath) {
|
|
137
175
|
throw new Error(`No docs.json or velu.json found in ${docsDir}`);
|
|
138
176
|
}
|
|
139
|
-
const
|
|
177
|
+
const parsed = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
178
|
+
const raw = normalizeConfigNavigation(parsed) as VeluConfig;
|
|
140
179
|
const variables = resolveVariables(raw.variables);
|
|
141
180
|
return { config: raw, variables };
|
|
142
181
|
}
|
|
@@ -171,12 +210,126 @@ function sanitizeFrontmatterValue(value: string): string {
|
|
|
171
210
|
return value.replace(/\r?\n+/g, ' ').replace(/"/g, '\\"').trim();
|
|
172
211
|
}
|
|
173
212
|
|
|
213
|
+
// ── OpenAPI helpers ───────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
const OPENAPI_PATH_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']);
|
|
216
|
+
|
|
217
|
+
function extractOpenApiSource(openapi: unknown): string | string[] | undefined {
|
|
218
|
+
if (typeof openapi === 'string' || Array.isArray(openapi)) return openapi;
|
|
219
|
+
if (openapi && typeof openapi === 'object') {
|
|
220
|
+
const source = (openapi as Record<string, unknown>).source;
|
|
221
|
+
if (typeof source === 'string' || Array.isArray(source)) return source;
|
|
222
|
+
}
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function resolveOpenApiSpecList(openapi: unknown): string[] {
|
|
227
|
+
const source = extractOpenApiSource(openapi);
|
|
228
|
+
if (typeof source === 'string') {
|
|
229
|
+
const trimmed = source.trim();
|
|
230
|
+
return trimmed ? [trimmed] : [];
|
|
231
|
+
}
|
|
232
|
+
if (Array.isArray(source)) {
|
|
233
|
+
return source.filter((e): e is string => typeof e === 'string' && e.trim().length > 0).map(e => e.trim());
|
|
234
|
+
}
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function normalizeOpenApiSpecForFrontmatter(spec: string | undefined): string | undefined {
|
|
239
|
+
if (!spec) return undefined;
|
|
240
|
+
const trimmed = String(spec).trim();
|
|
241
|
+
if (!trimmed) return undefined;
|
|
242
|
+
if (/^https?:\/\//i.test(trimmed) || trimmed.startsWith('file://')) return trimmed;
|
|
243
|
+
if (trimmed.startsWith('/')) return trimmed;
|
|
244
|
+
return `/${trimmed.replace(/^\.?\/*/, '')}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function slugFromOpenApiOperation(method: string, endpoint: string): string {
|
|
248
|
+
const cleaned = endpoint
|
|
249
|
+
.toLowerCase()
|
|
250
|
+
.replace(/^\/+/, '')
|
|
251
|
+
.replace(/[{}]/g, '')
|
|
252
|
+
.replace(/[^a-z0-9/._-]+/g, '-')
|
|
253
|
+
.replace(/\/+/g, '-')
|
|
254
|
+
.replace(/[-_.]{2,}/g, '-')
|
|
255
|
+
.replace(/^[-_.]+|[-_.]+$/g, '');
|
|
256
|
+
return `${method.toLowerCase()}-${cleaned || 'endpoint'}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
interface OpenApiOperation {
|
|
260
|
+
spec: string;
|
|
261
|
+
method: string;
|
|
262
|
+
endpoint: string;
|
|
263
|
+
title?: string;
|
|
264
|
+
description?: string;
|
|
265
|
+
deprecated?: boolean;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function loadOpenApiOperations(specSource: string, docsDir: string): OpenApiOperation[] {
|
|
269
|
+
const resolvedPath = /^https?:\/\//i.test(specSource)
|
|
270
|
+
? undefined
|
|
271
|
+
: join(docsDir, specSource.replace(/^\/+/, ''));
|
|
272
|
+
if (!resolvedPath || !existsSync(resolvedPath)) return [];
|
|
273
|
+
|
|
274
|
+
let parsed: Record<string, unknown>;
|
|
275
|
+
try {
|
|
276
|
+
parsed = JSON.parse(readFileSync(resolvedPath, 'utf-8'));
|
|
277
|
+
} catch {
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const output: OpenApiOperation[] = [];
|
|
282
|
+
const paths = parsed.paths;
|
|
283
|
+
if (paths && typeof paths === 'object') {
|
|
284
|
+
for (const [endpoint, methods] of Object.entries(paths as Record<string, unknown>)) {
|
|
285
|
+
if (!endpoint.startsWith('/') || !methods || typeof methods !== 'object') continue;
|
|
286
|
+
for (const method of Object.keys(methods as Record<string, unknown>)) {
|
|
287
|
+
if (!OPENAPI_PATH_METHODS.has(method.toLowerCase())) continue;
|
|
288
|
+
const operation = (methods as Record<string, unknown>)[method];
|
|
289
|
+
if (!operation || typeof operation !== 'object') continue;
|
|
290
|
+
const op = operation as Record<string, unknown>;
|
|
291
|
+
output.push({
|
|
292
|
+
spec: specSource,
|
|
293
|
+
method: method.toUpperCase(),
|
|
294
|
+
endpoint,
|
|
295
|
+
title: typeof op.summary === 'string' ? op.summary : undefined,
|
|
296
|
+
description: typeof op.description === 'string' ? op.description : undefined,
|
|
297
|
+
deprecated: op.deprecated === true,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Webhooks
|
|
304
|
+
const webhooks = parsed.webhooks;
|
|
305
|
+
if (webhooks && typeof webhooks === 'object') {
|
|
306
|
+
for (const [webhookName, pathItem] of Object.entries(webhooks as Record<string, unknown>)) {
|
|
307
|
+
if (!pathItem || typeof pathItem !== 'object') continue;
|
|
308
|
+
// Pick the first valid HTTP method from the webhook path item
|
|
309
|
+
const pi = pathItem as Record<string, unknown>;
|
|
310
|
+
const resolvedMethod = Array.from(OPENAPI_PATH_METHODS).find(m => pi[m] && typeof pi[m] === 'object');
|
|
311
|
+
if (!resolvedMethod) continue;
|
|
312
|
+
const operation = pi[resolvedMethod] as Record<string, unknown>;
|
|
313
|
+
output.push({
|
|
314
|
+
spec: specSource,
|
|
315
|
+
method: 'WEBHOOK',
|
|
316
|
+
endpoint: webhookName,
|
|
317
|
+
title: typeof operation.summary === 'string' ? operation.summary : undefined,
|
|
318
|
+
description: typeof operation.description === 'string' ? operation.description : undefined,
|
|
319
|
+
deprecated: operation.deprecated === true,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return output;
|
|
325
|
+
}
|
|
326
|
+
|
|
174
327
|
// ── Build artifacts ────────────────────────────────────────────────────────
|
|
175
328
|
|
|
176
|
-
function buildArtifacts(config: VeluConfig): BuildArtifacts {
|
|
329
|
+
function buildArtifacts(config: VeluConfig, docsDir?: string): BuildArtifacts {
|
|
177
330
|
const pageMap: PageMapping[] = [];
|
|
178
331
|
const metaFiles: MetaFile[] = [];
|
|
179
|
-
const rootTabs = (config.navigation
|
|
332
|
+
const rootTabs = (config.navigation?.tabs || []).filter((tab) => !tab.href);
|
|
180
333
|
const rootPages = rootTabs.map((tab) => tab.slug);
|
|
181
334
|
let firstPage = 'quickstart';
|
|
182
335
|
let hasFirstPage = false;
|
|
@@ -237,13 +390,47 @@ function buildArtifacts(config: VeluConfig): BuildArtifacts {
|
|
|
237
390
|
}
|
|
238
391
|
}
|
|
239
392
|
|
|
393
|
+
// Auto-generate pages from OpenAPI spec when group has no explicit pages
|
|
394
|
+
if (group.pages.length === 0 && groupSpec && docsDir) {
|
|
395
|
+
const specs = resolveOpenApiSpecList(group.openapi ?? groupSpec);
|
|
396
|
+
if (specs.length === 0 && groupSpec) specs.push(groupSpec);
|
|
397
|
+
const seen = new Set<string>();
|
|
398
|
+
for (const spec of specs) {
|
|
399
|
+
for (const op of loadOpenApiOperations(spec, docsDir)) {
|
|
400
|
+
const key = `${op.method}::${op.endpoint}`;
|
|
401
|
+
if (seen.has(key)) continue;
|
|
402
|
+
seen.add(key);
|
|
403
|
+
const slug = slugFromOpenApiOperation(op.method, op.endpoint);
|
|
404
|
+
const dest = uniqueDestination(`${groupDir}/${slug}`);
|
|
405
|
+
pageMap.push({
|
|
406
|
+
src: `${op.spec} ${op.method} ${op.endpoint}`,
|
|
407
|
+
dest,
|
|
408
|
+
kind: 'openapi-operation',
|
|
409
|
+
openapiSpec: op.spec,
|
|
410
|
+
openapiMethod: op.method,
|
|
411
|
+
openapiEndpoint: op.endpoint,
|
|
412
|
+
title: op.title,
|
|
413
|
+
description: op.description,
|
|
414
|
+
deprecated: op.deprecated,
|
|
415
|
+
});
|
|
416
|
+
groupMetaPages.push(slug);
|
|
417
|
+
trackFirstPage(dest);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const groupMetaData: Record<string, unknown> = {
|
|
423
|
+
title: group.group,
|
|
424
|
+
pages: groupMetaPages,
|
|
425
|
+
defaultOpen: group.expanded !== false,
|
|
426
|
+
};
|
|
427
|
+
if (group.description) groupMetaData.description = group.description;
|
|
428
|
+
if (group.icon) groupMetaData.icon = group.icon;
|
|
429
|
+
if (group.iconType) groupMetaData.iconType = group.iconType;
|
|
430
|
+
|
|
240
431
|
metaFiles.push({
|
|
241
432
|
dir: groupDir,
|
|
242
|
-
data:
|
|
243
|
-
title: group.group,
|
|
244
|
-
...(group.description ? { description: group.description } : {}),
|
|
245
|
-
pages: groupMetaPages,
|
|
246
|
-
},
|
|
433
|
+
data: groupMetaData,
|
|
247
434
|
});
|
|
248
435
|
}
|
|
249
436
|
|
|
@@ -253,7 +440,6 @@ function buildArtifacts(config: VeluConfig): BuildArtifacts {
|
|
|
253
440
|
const tabMetaPages: string[] = [];
|
|
254
441
|
const tabSpec = typeof tab.openapi === 'string' ? tab.openapi : undefined;
|
|
255
442
|
|
|
256
|
-
// Process top-level pages in this tab
|
|
257
443
|
if (tab.pages) {
|
|
258
444
|
for (const item of tab.pages) {
|
|
259
445
|
if (typeof item === 'string') {
|
|
@@ -267,7 +453,6 @@ function buildArtifacts(config: VeluConfig): BuildArtifacts {
|
|
|
267
453
|
}
|
|
268
454
|
}
|
|
269
455
|
|
|
270
|
-
// Process groups
|
|
271
456
|
if (tab.groups) {
|
|
272
457
|
for (const group of tab.groups) {
|
|
273
458
|
const groupSlug = group.slug || pageBasename(group.group).toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
@@ -276,18 +461,45 @@ function buildArtifacts(config: VeluConfig): BuildArtifacts {
|
|
|
276
461
|
}
|
|
277
462
|
}
|
|
278
463
|
|
|
464
|
+
// Auto-generate pages from OpenAPI spec when tab has no explicit pages/groups
|
|
465
|
+
if (!tab.pages?.length && !tab.groups?.length && tab.openapi !== undefined && docsDir) {
|
|
466
|
+
const specs = resolveOpenApiSpecList(tab.openapi);
|
|
467
|
+
if (specs.length === 0 && tabSpec) specs.push(tabSpec);
|
|
468
|
+
const seen = new Set<string>();
|
|
469
|
+
for (const spec of specs) {
|
|
470
|
+
for (const op of loadOpenApiOperations(spec, docsDir)) {
|
|
471
|
+
const key = `${op.method}::${op.endpoint}`;
|
|
472
|
+
if (seen.has(key)) continue;
|
|
473
|
+
seen.add(key);
|
|
474
|
+
const slug = slugFromOpenApiOperation(op.method, op.endpoint);
|
|
475
|
+
const dest = uniqueDestination(`${tabDir}/${slug}`);
|
|
476
|
+
pageMap.push({
|
|
477
|
+
src: `${op.spec} ${op.method} ${op.endpoint}`,
|
|
478
|
+
dest,
|
|
479
|
+
kind: 'openapi-operation',
|
|
480
|
+
openapiSpec: op.spec,
|
|
481
|
+
openapiMethod: op.method,
|
|
482
|
+
openapiEndpoint: op.endpoint,
|
|
483
|
+
title: op.title,
|
|
484
|
+
description: op.description,
|
|
485
|
+
deprecated: op.deprecated,
|
|
486
|
+
});
|
|
487
|
+
tabMetaPages.push(slug);
|
|
488
|
+
trackFirstPage(dest);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
279
493
|
metaFiles.push({
|
|
280
494
|
dir: tabDir,
|
|
281
495
|
data: { title: tab.tab, pages: tabMetaPages },
|
|
282
496
|
});
|
|
283
497
|
}
|
|
284
498
|
|
|
285
|
-
// Process all tabs
|
|
286
499
|
for (const tab of rootTabs) {
|
|
287
500
|
processTab(tab);
|
|
288
501
|
}
|
|
289
502
|
|
|
290
|
-
// Root meta.json lists the tab slugs
|
|
291
503
|
metaFiles.push({
|
|
292
504
|
dir: '',
|
|
293
505
|
data: { pages: rootPages.filter((p): p is string => typeof p === 'string') },
|
|
@@ -333,7 +545,6 @@ function writeLangContent(
|
|
|
333
545
|
) {
|
|
334
546
|
const storagePrefix = useLangFolders ? langCode : (isDefault ? '' : langCode);
|
|
335
547
|
|
|
336
|
-
// Write meta files
|
|
337
548
|
const metas = storagePrefix
|
|
338
549
|
? artifacts.metaFiles.map((m) => ({
|
|
339
550
|
dir: m.dir ? `${storagePrefix}/${m.dir}` : storagePrefix,
|
|
@@ -347,7 +558,6 @@ function writeLangContent(
|
|
|
347
558
|
writeFileSync(metaPath, JSON.stringify(meta.data, null, 2) + '\n', 'utf-8');
|
|
348
559
|
}
|
|
349
560
|
|
|
350
|
-
// Copy and process pages
|
|
351
561
|
for (const mapping of artifacts.pageMap) {
|
|
352
562
|
const destPath = join(
|
|
353
563
|
contentDir,
|
|
@@ -357,14 +567,19 @@ function writeLangContent(
|
|
|
357
567
|
if (mapping.kind === 'openapi-operation') {
|
|
358
568
|
mkdirSync(dirname(destPath), { recursive: true });
|
|
359
569
|
const operationLabel = `${mapping.openapiMethod ?? 'GET'} ${mapping.openapiEndpoint ?? '/'}`;
|
|
570
|
+
const normalizedSpec = normalizeOpenApiSpecForFrontmatter(mapping.openapiSpec);
|
|
571
|
+
const openapiValue = normalizedSpec
|
|
572
|
+
? `${normalizedSpec} ${operationLabel}`
|
|
573
|
+
: operationLabel;
|
|
360
574
|
const title = sanitizeFrontmatterValue(mapping.title ?? operationLabel);
|
|
361
|
-
const openapi =
|
|
575
|
+
const openapi = openapiValue.replace(/"/g, '\\"');
|
|
362
576
|
const descriptionLine = mapping.description
|
|
363
577
|
? `\ndescription: "${sanitizeFrontmatterValue(mapping.description)}"`
|
|
364
578
|
: '';
|
|
579
|
+
const deprecatedLine = mapping.deprecated === true ? `\ndeprecated: true` : '';
|
|
365
580
|
writeFileSync(
|
|
366
581
|
destPath,
|
|
367
|
-
`---\ntitle: "${title}"${descriptionLine}\nopenapi: "${openapi}"\n---\n`,
|
|
582
|
+
`---\ntitle: "${title}"${descriptionLine}${deprecatedLine}\nopenapi: "${openapi}"\n---\n`,
|
|
368
583
|
'utf-8',
|
|
369
584
|
);
|
|
370
585
|
continue;
|
|
@@ -380,11 +595,6 @@ function writeLangContent(
|
|
|
380
595
|
processPage(srcPath, destPath, src, variables);
|
|
381
596
|
}
|
|
382
597
|
|
|
383
|
-
// Index page redirect
|
|
384
|
-
const urlPrefix = isDefault ? '' : langCode;
|
|
385
|
-
const href = urlPrefix
|
|
386
|
-
? `/${urlPrefix}/${artifacts.firstPage}/`
|
|
387
|
-
: `/${artifacts.firstPage}/`;
|
|
388
598
|
const indexPath = storagePrefix
|
|
389
599
|
? join(contentDir, storagePrefix, 'index.mdx')
|
|
390
600
|
: join(contentDir, 'index.mdx');
|
|
@@ -399,8 +609,6 @@ function writeLangContent(
|
|
|
399
609
|
|
|
400
610
|
/**
|
|
401
611
|
* 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
612
|
*/
|
|
405
613
|
export function generateSessionContent(sessionId: string): {
|
|
406
614
|
firstPage: string;
|
|
@@ -409,18 +617,19 @@ export function generateSessionContent(sessionId: string): {
|
|
|
409
617
|
const workspaceDir = join(WORKSPACE_DIR, sessionId);
|
|
410
618
|
const outputDir = join(PREVIEW_CONTENT_DIR, sessionId);
|
|
411
619
|
|
|
412
|
-
// Clean previous content
|
|
413
620
|
if (existsSync(outputDir)) {
|
|
414
621
|
rmSync(outputDir, { recursive: true, force: true });
|
|
415
622
|
}
|
|
416
623
|
mkdirSync(outputDir, { recursive: true });
|
|
417
624
|
|
|
625
|
+
// Copy static assets (images, OpenAPI specs, etc.) to public/ so components can resolve them
|
|
626
|
+
copyStaticAssets(workspaceDir);
|
|
627
|
+
|
|
418
628
|
const { config, variables } = loadConfig(workspaceDir);
|
|
419
|
-
const navLanguages = config.navigation
|
|
629
|
+
const navLanguages = config.navigation?.languages;
|
|
420
630
|
const simpleLanguages = config.languages || [];
|
|
421
631
|
|
|
422
632
|
if (navLanguages && navLanguages.length > 0) {
|
|
423
|
-
// Per-language navigation
|
|
424
633
|
const rootPages: string[] = [];
|
|
425
634
|
let totalPages = 0;
|
|
426
635
|
let firstPage = 'quickstart';
|
|
@@ -432,7 +641,7 @@ export function generateSessionContent(sessionId: string): {
|
|
|
432
641
|
...config,
|
|
433
642
|
navigation: { ...config.navigation, tabs: langEntry.tabs },
|
|
434
643
|
} as VeluConfig;
|
|
435
|
-
const artifacts = buildArtifacts(langConfig);
|
|
644
|
+
const artifacts = buildArtifacts(langConfig, workspaceDir);
|
|
436
645
|
writeLangContent(workspaceDir, outputDir, langEntry.language, artifacts, variables, isDefault, true);
|
|
437
646
|
totalPages += artifacts.pageMap.length;
|
|
438
647
|
if (i === 0) firstPage = artifacts.firstPage;
|
|
@@ -448,8 +657,7 @@ export function generateSessionContent(sessionId: string): {
|
|
|
448
657
|
return { firstPage, pageCount: totalPages };
|
|
449
658
|
}
|
|
450
659
|
|
|
451
|
-
|
|
452
|
-
const artifacts = buildArtifacts(config);
|
|
660
|
+
const artifacts = buildArtifacts(config, workspaceDir);
|
|
453
661
|
const useLangFolders = simpleLanguages.length > 1;
|
|
454
662
|
writeLangContent(
|
|
455
663
|
workspaceDir, outputDir,
|
|
@@ -478,8 +686,6 @@ export function generateSessionContent(sessionId: string): {
|
|
|
478
686
|
|
|
479
687
|
/**
|
|
480
688
|
* 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
689
|
*/
|
|
484
690
|
export function syncSessionFile(
|
|
485
691
|
sessionId: string,
|
|
@@ -488,13 +694,11 @@ export function syncSessionFile(
|
|
|
488
694
|
const workspaceDir = join(WORKSPACE_DIR, sessionId);
|
|
489
695
|
const outputDir = join(PREVIEW_CONTENT_DIR, sessionId);
|
|
490
696
|
|
|
491
|
-
// If this is a config file change, do a full regeneration
|
|
492
697
|
if (filePath === PRIMARY_CONFIG_NAME || filePath === LEGACY_CONFIG_NAME) {
|
|
493
698
|
generateSessionContent(sessionId);
|
|
494
699
|
return { synced: true };
|
|
495
700
|
}
|
|
496
701
|
|
|
497
|
-
// Load config for variable substitution
|
|
498
702
|
let variables: Record<string, string> = {};
|
|
499
703
|
try {
|
|
500
704
|
const result = loadConfig(workspaceDir);
|
|
@@ -503,7 +707,6 @@ export function syncSessionFile(
|
|
|
503
707
|
// Config might not exist yet
|
|
504
708
|
}
|
|
505
709
|
|
|
506
|
-
// Find the source file
|
|
507
710
|
const stripped = filePath.replace(/\.(mdx?|md)$/, '');
|
|
508
711
|
let srcPath = join(workspaceDir, filePath);
|
|
509
712
|
if (!existsSync(srcPath)) {
|
|
@@ -516,11 +719,9 @@ export function syncSessionFile(
|
|
|
516
719
|
return { synced: false };
|
|
517
720
|
}
|
|
518
721
|
|
|
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
722
|
try {
|
|
522
723
|
const { config } = loadConfig(workspaceDir);
|
|
523
|
-
const artifacts = buildArtifacts(config);
|
|
724
|
+
const artifacts = buildArtifacts(config, workspaceDir);
|
|
524
725
|
const mapping = artifacts.pageMap.find((m) => {
|
|
525
726
|
return m.src === stripped || m.src === filePath;
|
|
526
727
|
});
|
|
@@ -534,7 +735,6 @@ export function syncSessionFile(
|
|
|
534
735
|
// Fall through to direct copy
|
|
535
736
|
}
|
|
536
737
|
|
|
537
|
-
// Fallback: try to process the file directly
|
|
538
738
|
const destPath = join(outputDir, `${stripped}.mdx`);
|
|
539
739
|
processPage(srcPath, destPath, stripped, variables);
|
|
540
740
|
return { synced: true };
|