@aravindc26/velu 0.12.8 → 0.12.10
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 +60 -11
- 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/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/assets/[...path]/route.ts +1 -1
- 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} +238 -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 -26
- package/src/preview-engine/app/global.css +0 -29
- package/src/preview-engine/lib/session-config.ts +0 -86
- package/src/preview-engine/lib/session-layout.ts +0 -190
- 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/tsconfig.tsbuildinfo +0 -1
- /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,41 @@ 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
|
+
/**
|
|
26
|
+
* Copy only spec files (JSON/YAML) from workspace to public/ so the
|
|
27
|
+
* OpenAPI component can resolve them. Images and other assets are served
|
|
28
|
+
* on-demand through the session assets API route, so we skip them here
|
|
29
|
+
* to keep session init fast.
|
|
30
|
+
*/
|
|
31
|
+
const SPEC_EXTENSIONS = new Set(['.json', '.yaml', '.yml']);
|
|
32
|
+
|
|
33
|
+
function copySpecFiles(docsDir: string): void {
|
|
34
|
+
const publicDir = resolve('public');
|
|
35
|
+
|
|
36
|
+
function walk(dir: string): void {
|
|
37
|
+
if (!existsSync(dir)) return;
|
|
38
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (entry.name.startsWith('.')) continue;
|
|
41
|
+
if (entry.name === 'node_modules') continue;
|
|
42
|
+
const srcPath = join(dir, entry.name);
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
walk(srcPath);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const ext = extname(entry.name).toLowerCase();
|
|
48
|
+
if (!SPEC_EXTENSIONS.has(ext)) continue;
|
|
49
|
+
const rel = relative(docsDir, srcPath);
|
|
50
|
+
const destPath = join(publicDir, rel);
|
|
51
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
52
|
+
copyFileSync(srcPath, destPath);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
mkdirSync(publicDir, { recursive: true });
|
|
57
|
+
walk(docsDir);
|
|
58
|
+
}
|
|
59
|
+
|
|
26
60
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
27
61
|
|
|
28
62
|
interface VeluConfig {
|
|
@@ -136,7 +170,8 @@ function loadConfig(docsDir: string): {
|
|
|
136
170
|
if (!configPath) {
|
|
137
171
|
throw new Error(`No docs.json or velu.json found in ${docsDir}`);
|
|
138
172
|
}
|
|
139
|
-
const
|
|
173
|
+
const parsed = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
174
|
+
const raw = normalizeConfigNavigation(parsed) as VeluConfig;
|
|
140
175
|
const variables = resolveVariables(raw.variables);
|
|
141
176
|
return { config: raw, variables };
|
|
142
177
|
}
|
|
@@ -171,12 +206,126 @@ function sanitizeFrontmatterValue(value: string): string {
|
|
|
171
206
|
return value.replace(/\r?\n+/g, ' ').replace(/"/g, '\\"').trim();
|
|
172
207
|
}
|
|
173
208
|
|
|
209
|
+
// ── OpenAPI helpers ───────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
const OPENAPI_PATH_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']);
|
|
212
|
+
|
|
213
|
+
function extractOpenApiSource(openapi: unknown): string | string[] | undefined {
|
|
214
|
+
if (typeof openapi === 'string' || Array.isArray(openapi)) return openapi;
|
|
215
|
+
if (openapi && typeof openapi === 'object') {
|
|
216
|
+
const source = (openapi as Record<string, unknown>).source;
|
|
217
|
+
if (typeof source === 'string' || Array.isArray(source)) return source;
|
|
218
|
+
}
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function resolveOpenApiSpecList(openapi: unknown): string[] {
|
|
223
|
+
const source = extractOpenApiSource(openapi);
|
|
224
|
+
if (typeof source === 'string') {
|
|
225
|
+
const trimmed = source.trim();
|
|
226
|
+
return trimmed ? [trimmed] : [];
|
|
227
|
+
}
|
|
228
|
+
if (Array.isArray(source)) {
|
|
229
|
+
return source.filter((e): e is string => typeof e === 'string' && e.trim().length > 0).map(e => e.trim());
|
|
230
|
+
}
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function normalizeOpenApiSpecForFrontmatter(spec: string | undefined): string | undefined {
|
|
235
|
+
if (!spec) return undefined;
|
|
236
|
+
const trimmed = String(spec).trim();
|
|
237
|
+
if (!trimmed) return undefined;
|
|
238
|
+
if (/^https?:\/\//i.test(trimmed) || trimmed.startsWith('file://')) return trimmed;
|
|
239
|
+
if (trimmed.startsWith('/')) return trimmed;
|
|
240
|
+
return `/${trimmed.replace(/^\.?\/*/, '')}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function slugFromOpenApiOperation(method: string, endpoint: string): string {
|
|
244
|
+
const cleaned = endpoint
|
|
245
|
+
.toLowerCase()
|
|
246
|
+
.replace(/^\/+/, '')
|
|
247
|
+
.replace(/[{}]/g, '')
|
|
248
|
+
.replace(/[^a-z0-9/._-]+/g, '-')
|
|
249
|
+
.replace(/\/+/g, '-')
|
|
250
|
+
.replace(/[-_.]{2,}/g, '-')
|
|
251
|
+
.replace(/^[-_.]+|[-_.]+$/g, '');
|
|
252
|
+
return `${method.toLowerCase()}-${cleaned || 'endpoint'}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
interface OpenApiOperation {
|
|
256
|
+
spec: string;
|
|
257
|
+
method: string;
|
|
258
|
+
endpoint: string;
|
|
259
|
+
title?: string;
|
|
260
|
+
description?: string;
|
|
261
|
+
deprecated?: boolean;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function loadOpenApiOperations(specSource: string, docsDir: string): OpenApiOperation[] {
|
|
265
|
+
const resolvedPath = /^https?:\/\//i.test(specSource)
|
|
266
|
+
? undefined
|
|
267
|
+
: join(docsDir, specSource.replace(/^\/+/, ''));
|
|
268
|
+
if (!resolvedPath || !existsSync(resolvedPath)) return [];
|
|
269
|
+
|
|
270
|
+
let parsed: Record<string, unknown>;
|
|
271
|
+
try {
|
|
272
|
+
parsed = JSON.parse(readFileSync(resolvedPath, 'utf-8'));
|
|
273
|
+
} catch {
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const output: OpenApiOperation[] = [];
|
|
278
|
+
const paths = parsed.paths;
|
|
279
|
+
if (paths && typeof paths === 'object') {
|
|
280
|
+
for (const [endpoint, methods] of Object.entries(paths as Record<string, unknown>)) {
|
|
281
|
+
if (!endpoint.startsWith('/') || !methods || typeof methods !== 'object') continue;
|
|
282
|
+
for (const method of Object.keys(methods as Record<string, unknown>)) {
|
|
283
|
+
if (!OPENAPI_PATH_METHODS.has(method.toLowerCase())) continue;
|
|
284
|
+
const operation = (methods as Record<string, unknown>)[method];
|
|
285
|
+
if (!operation || typeof operation !== 'object') continue;
|
|
286
|
+
const op = operation as Record<string, unknown>;
|
|
287
|
+
output.push({
|
|
288
|
+
spec: specSource,
|
|
289
|
+
method: method.toUpperCase(),
|
|
290
|
+
endpoint,
|
|
291
|
+
title: typeof op.summary === 'string' ? op.summary : undefined,
|
|
292
|
+
description: typeof op.description === 'string' ? op.description : undefined,
|
|
293
|
+
deprecated: op.deprecated === true,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Webhooks
|
|
300
|
+
const webhooks = parsed.webhooks;
|
|
301
|
+
if (webhooks && typeof webhooks === 'object') {
|
|
302
|
+
for (const [webhookName, pathItem] of Object.entries(webhooks as Record<string, unknown>)) {
|
|
303
|
+
if (!pathItem || typeof pathItem !== 'object') continue;
|
|
304
|
+
// Pick the first valid HTTP method from the webhook path item
|
|
305
|
+
const pi = pathItem as Record<string, unknown>;
|
|
306
|
+
const resolvedMethod = Array.from(OPENAPI_PATH_METHODS).find(m => pi[m] && typeof pi[m] === 'object');
|
|
307
|
+
if (!resolvedMethod) continue;
|
|
308
|
+
const operation = pi[resolvedMethod] as Record<string, unknown>;
|
|
309
|
+
output.push({
|
|
310
|
+
spec: specSource,
|
|
311
|
+
method: 'WEBHOOK',
|
|
312
|
+
endpoint: webhookName,
|
|
313
|
+
title: typeof operation.summary === 'string' ? operation.summary : undefined,
|
|
314
|
+
description: typeof operation.description === 'string' ? operation.description : undefined,
|
|
315
|
+
deprecated: operation.deprecated === true,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return output;
|
|
321
|
+
}
|
|
322
|
+
|
|
174
323
|
// ── Build artifacts ────────────────────────────────────────────────────────
|
|
175
324
|
|
|
176
|
-
function buildArtifacts(config: VeluConfig): BuildArtifacts {
|
|
325
|
+
function buildArtifacts(config: VeluConfig, docsDir?: string): BuildArtifacts {
|
|
177
326
|
const pageMap: PageMapping[] = [];
|
|
178
327
|
const metaFiles: MetaFile[] = [];
|
|
179
|
-
const rootTabs = (config.navigation
|
|
328
|
+
const rootTabs = (config.navigation?.tabs || []).filter((tab) => !tab.href);
|
|
180
329
|
const rootPages = rootTabs.map((tab) => tab.slug);
|
|
181
330
|
let firstPage = 'quickstart';
|
|
182
331
|
let hasFirstPage = false;
|
|
@@ -237,13 +386,47 @@ function buildArtifacts(config: VeluConfig): BuildArtifacts {
|
|
|
237
386
|
}
|
|
238
387
|
}
|
|
239
388
|
|
|
389
|
+
// Auto-generate pages from OpenAPI spec when group has no explicit pages
|
|
390
|
+
if (group.pages.length === 0 && groupSpec && docsDir) {
|
|
391
|
+
const specs = resolveOpenApiSpecList(group.openapi ?? groupSpec);
|
|
392
|
+
if (specs.length === 0 && groupSpec) specs.push(groupSpec);
|
|
393
|
+
const seen = new Set<string>();
|
|
394
|
+
for (const spec of specs) {
|
|
395
|
+
for (const op of loadOpenApiOperations(spec, docsDir)) {
|
|
396
|
+
const key = `${op.method}::${op.endpoint}`;
|
|
397
|
+
if (seen.has(key)) continue;
|
|
398
|
+
seen.add(key);
|
|
399
|
+
const slug = slugFromOpenApiOperation(op.method, op.endpoint);
|
|
400
|
+
const dest = uniqueDestination(`${groupDir}/${slug}`);
|
|
401
|
+
pageMap.push({
|
|
402
|
+
src: `${op.spec} ${op.method} ${op.endpoint}`,
|
|
403
|
+
dest,
|
|
404
|
+
kind: 'openapi-operation',
|
|
405
|
+
openapiSpec: op.spec,
|
|
406
|
+
openapiMethod: op.method,
|
|
407
|
+
openapiEndpoint: op.endpoint,
|
|
408
|
+
title: op.title,
|
|
409
|
+
description: op.description,
|
|
410
|
+
deprecated: op.deprecated,
|
|
411
|
+
});
|
|
412
|
+
groupMetaPages.push(slug);
|
|
413
|
+
trackFirstPage(dest);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const groupMetaData: Record<string, unknown> = {
|
|
419
|
+
title: group.group,
|
|
420
|
+
pages: groupMetaPages,
|
|
421
|
+
defaultOpen: group.expanded !== false,
|
|
422
|
+
};
|
|
423
|
+
if (group.description) groupMetaData.description = group.description;
|
|
424
|
+
if (group.icon) groupMetaData.icon = group.icon;
|
|
425
|
+
if (group.iconType) groupMetaData.iconType = group.iconType;
|
|
426
|
+
|
|
240
427
|
metaFiles.push({
|
|
241
428
|
dir: groupDir,
|
|
242
|
-
data:
|
|
243
|
-
title: group.group,
|
|
244
|
-
...(group.description ? { description: group.description } : {}),
|
|
245
|
-
pages: groupMetaPages,
|
|
246
|
-
},
|
|
429
|
+
data: groupMetaData,
|
|
247
430
|
});
|
|
248
431
|
}
|
|
249
432
|
|
|
@@ -253,7 +436,6 @@ function buildArtifacts(config: VeluConfig): BuildArtifacts {
|
|
|
253
436
|
const tabMetaPages: string[] = [];
|
|
254
437
|
const tabSpec = typeof tab.openapi === 'string' ? tab.openapi : undefined;
|
|
255
438
|
|
|
256
|
-
// Process top-level pages in this tab
|
|
257
439
|
if (tab.pages) {
|
|
258
440
|
for (const item of tab.pages) {
|
|
259
441
|
if (typeof item === 'string') {
|
|
@@ -267,7 +449,6 @@ function buildArtifacts(config: VeluConfig): BuildArtifacts {
|
|
|
267
449
|
}
|
|
268
450
|
}
|
|
269
451
|
|
|
270
|
-
// Process groups
|
|
271
452
|
if (tab.groups) {
|
|
272
453
|
for (const group of tab.groups) {
|
|
273
454
|
const groupSlug = group.slug || pageBasename(group.group).toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
@@ -276,18 +457,45 @@ function buildArtifacts(config: VeluConfig): BuildArtifacts {
|
|
|
276
457
|
}
|
|
277
458
|
}
|
|
278
459
|
|
|
460
|
+
// Auto-generate pages from OpenAPI spec when tab has no explicit pages/groups
|
|
461
|
+
if (!tab.pages?.length && !tab.groups?.length && tab.openapi !== undefined && docsDir) {
|
|
462
|
+
const specs = resolveOpenApiSpecList(tab.openapi);
|
|
463
|
+
if (specs.length === 0 && tabSpec) specs.push(tabSpec);
|
|
464
|
+
const seen = new Set<string>();
|
|
465
|
+
for (const spec of specs) {
|
|
466
|
+
for (const op of loadOpenApiOperations(spec, docsDir)) {
|
|
467
|
+
const key = `${op.method}::${op.endpoint}`;
|
|
468
|
+
if (seen.has(key)) continue;
|
|
469
|
+
seen.add(key);
|
|
470
|
+
const slug = slugFromOpenApiOperation(op.method, op.endpoint);
|
|
471
|
+
const dest = uniqueDestination(`${tabDir}/${slug}`);
|
|
472
|
+
pageMap.push({
|
|
473
|
+
src: `${op.spec} ${op.method} ${op.endpoint}`,
|
|
474
|
+
dest,
|
|
475
|
+
kind: 'openapi-operation',
|
|
476
|
+
openapiSpec: op.spec,
|
|
477
|
+
openapiMethod: op.method,
|
|
478
|
+
openapiEndpoint: op.endpoint,
|
|
479
|
+
title: op.title,
|
|
480
|
+
description: op.description,
|
|
481
|
+
deprecated: op.deprecated,
|
|
482
|
+
});
|
|
483
|
+
tabMetaPages.push(slug);
|
|
484
|
+
trackFirstPage(dest);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
279
489
|
metaFiles.push({
|
|
280
490
|
dir: tabDir,
|
|
281
491
|
data: { title: tab.tab, pages: tabMetaPages },
|
|
282
492
|
});
|
|
283
493
|
}
|
|
284
494
|
|
|
285
|
-
// Process all tabs
|
|
286
495
|
for (const tab of rootTabs) {
|
|
287
496
|
processTab(tab);
|
|
288
497
|
}
|
|
289
498
|
|
|
290
|
-
// Root meta.json lists the tab slugs
|
|
291
499
|
metaFiles.push({
|
|
292
500
|
dir: '',
|
|
293
501
|
data: { pages: rootPages.filter((p): p is string => typeof p === 'string') },
|
|
@@ -333,7 +541,6 @@ function writeLangContent(
|
|
|
333
541
|
) {
|
|
334
542
|
const storagePrefix = useLangFolders ? langCode : (isDefault ? '' : langCode);
|
|
335
543
|
|
|
336
|
-
// Write meta files
|
|
337
544
|
const metas = storagePrefix
|
|
338
545
|
? artifacts.metaFiles.map((m) => ({
|
|
339
546
|
dir: m.dir ? `${storagePrefix}/${m.dir}` : storagePrefix,
|
|
@@ -347,7 +554,6 @@ function writeLangContent(
|
|
|
347
554
|
writeFileSync(metaPath, JSON.stringify(meta.data, null, 2) + '\n', 'utf-8');
|
|
348
555
|
}
|
|
349
556
|
|
|
350
|
-
// Copy and process pages
|
|
351
557
|
for (const mapping of artifacts.pageMap) {
|
|
352
558
|
const destPath = join(
|
|
353
559
|
contentDir,
|
|
@@ -357,14 +563,19 @@ function writeLangContent(
|
|
|
357
563
|
if (mapping.kind === 'openapi-operation') {
|
|
358
564
|
mkdirSync(dirname(destPath), { recursive: true });
|
|
359
565
|
const operationLabel = `${mapping.openapiMethod ?? 'GET'} ${mapping.openapiEndpoint ?? '/'}`;
|
|
566
|
+
const normalizedSpec = normalizeOpenApiSpecForFrontmatter(mapping.openapiSpec);
|
|
567
|
+
const openapiValue = normalizedSpec
|
|
568
|
+
? `${normalizedSpec} ${operationLabel}`
|
|
569
|
+
: operationLabel;
|
|
360
570
|
const title = sanitizeFrontmatterValue(mapping.title ?? operationLabel);
|
|
361
|
-
const openapi =
|
|
571
|
+
const openapi = openapiValue.replace(/"/g, '\\"');
|
|
362
572
|
const descriptionLine = mapping.description
|
|
363
573
|
? `\ndescription: "${sanitizeFrontmatterValue(mapping.description)}"`
|
|
364
574
|
: '';
|
|
575
|
+
const deprecatedLine = mapping.deprecated === true ? `\ndeprecated: true` : '';
|
|
365
576
|
writeFileSync(
|
|
366
577
|
destPath,
|
|
367
|
-
`---\ntitle: "${title}"${descriptionLine}\nopenapi: "${openapi}"\n---\n`,
|
|
578
|
+
`---\ntitle: "${title}"${descriptionLine}${deprecatedLine}\nopenapi: "${openapi}"\n---\n`,
|
|
368
579
|
'utf-8',
|
|
369
580
|
);
|
|
370
581
|
continue;
|
|
@@ -380,11 +591,6 @@ function writeLangContent(
|
|
|
380
591
|
processPage(srcPath, destPath, src, variables);
|
|
381
592
|
}
|
|
382
593
|
|
|
383
|
-
// Index page redirect
|
|
384
|
-
const urlPrefix = isDefault ? '' : langCode;
|
|
385
|
-
const href = urlPrefix
|
|
386
|
-
? `/${urlPrefix}/${artifacts.firstPage}/`
|
|
387
|
-
: `/${artifacts.firstPage}/`;
|
|
388
594
|
const indexPath = storagePrefix
|
|
389
595
|
? join(contentDir, storagePrefix, 'index.mdx')
|
|
390
596
|
: join(contentDir, 'index.mdx');
|
|
@@ -399,8 +605,6 @@ function writeLangContent(
|
|
|
399
605
|
|
|
400
606
|
/**
|
|
401
607
|
* 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
608
|
*/
|
|
405
609
|
export function generateSessionContent(sessionId: string): {
|
|
406
610
|
firstPage: string;
|
|
@@ -409,18 +613,19 @@ export function generateSessionContent(sessionId: string): {
|
|
|
409
613
|
const workspaceDir = join(WORKSPACE_DIR, sessionId);
|
|
410
614
|
const outputDir = join(PREVIEW_CONTENT_DIR, sessionId);
|
|
411
615
|
|
|
412
|
-
// Clean previous content
|
|
413
616
|
if (existsSync(outputDir)) {
|
|
414
617
|
rmSync(outputDir, { recursive: true, force: true });
|
|
415
618
|
}
|
|
416
619
|
mkdirSync(outputDir, { recursive: true });
|
|
417
620
|
|
|
621
|
+
// Copy spec files (JSON/YAML) to public/ so the OpenAPI component can resolve them
|
|
622
|
+
copySpecFiles(workspaceDir);
|
|
623
|
+
|
|
418
624
|
const { config, variables } = loadConfig(workspaceDir);
|
|
419
|
-
const navLanguages = config.navigation
|
|
625
|
+
const navLanguages = config.navigation?.languages;
|
|
420
626
|
const simpleLanguages = config.languages || [];
|
|
421
627
|
|
|
422
628
|
if (navLanguages && navLanguages.length > 0) {
|
|
423
|
-
// Per-language navigation
|
|
424
629
|
const rootPages: string[] = [];
|
|
425
630
|
let totalPages = 0;
|
|
426
631
|
let firstPage = 'quickstart';
|
|
@@ -432,7 +637,7 @@ export function generateSessionContent(sessionId: string): {
|
|
|
432
637
|
...config,
|
|
433
638
|
navigation: { ...config.navigation, tabs: langEntry.tabs },
|
|
434
639
|
} as VeluConfig;
|
|
435
|
-
const artifacts = buildArtifacts(langConfig);
|
|
640
|
+
const artifacts = buildArtifacts(langConfig, workspaceDir);
|
|
436
641
|
writeLangContent(workspaceDir, outputDir, langEntry.language, artifacts, variables, isDefault, true);
|
|
437
642
|
totalPages += artifacts.pageMap.length;
|
|
438
643
|
if (i === 0) firstPage = artifacts.firstPage;
|
|
@@ -448,8 +653,7 @@ export function generateSessionContent(sessionId: string): {
|
|
|
448
653
|
return { firstPage, pageCount: totalPages };
|
|
449
654
|
}
|
|
450
655
|
|
|
451
|
-
|
|
452
|
-
const artifacts = buildArtifacts(config);
|
|
656
|
+
const artifacts = buildArtifacts(config, workspaceDir);
|
|
453
657
|
const useLangFolders = simpleLanguages.length > 1;
|
|
454
658
|
writeLangContent(
|
|
455
659
|
workspaceDir, outputDir,
|
|
@@ -478,8 +682,6 @@ export function generateSessionContent(sessionId: string): {
|
|
|
478
682
|
|
|
479
683
|
/**
|
|
480
684
|
* 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
685
|
*/
|
|
484
686
|
export function syncSessionFile(
|
|
485
687
|
sessionId: string,
|
|
@@ -488,13 +690,11 @@ export function syncSessionFile(
|
|
|
488
690
|
const workspaceDir = join(WORKSPACE_DIR, sessionId);
|
|
489
691
|
const outputDir = join(PREVIEW_CONTENT_DIR, sessionId);
|
|
490
692
|
|
|
491
|
-
// If this is a config file change, do a full regeneration
|
|
492
693
|
if (filePath === PRIMARY_CONFIG_NAME || filePath === LEGACY_CONFIG_NAME) {
|
|
493
694
|
generateSessionContent(sessionId);
|
|
494
695
|
return { synced: true };
|
|
495
696
|
}
|
|
496
697
|
|
|
497
|
-
// Load config for variable substitution
|
|
498
698
|
let variables: Record<string, string> = {};
|
|
499
699
|
try {
|
|
500
700
|
const result = loadConfig(workspaceDir);
|
|
@@ -503,7 +703,6 @@ export function syncSessionFile(
|
|
|
503
703
|
// Config might not exist yet
|
|
504
704
|
}
|
|
505
705
|
|
|
506
|
-
// Find the source file
|
|
507
706
|
const stripped = filePath.replace(/\.(mdx?|md)$/, '');
|
|
508
707
|
let srcPath = join(workspaceDir, filePath);
|
|
509
708
|
if (!existsSync(srcPath)) {
|
|
@@ -516,11 +715,9 @@ export function syncSessionFile(
|
|
|
516
715
|
return { synced: false };
|
|
517
716
|
}
|
|
518
717
|
|
|
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
718
|
try {
|
|
522
719
|
const { config } = loadConfig(workspaceDir);
|
|
523
|
-
const artifacts = buildArtifacts(config);
|
|
720
|
+
const artifacts = buildArtifacts(config, workspaceDir);
|
|
524
721
|
const mapping = artifacts.pageMap.find((m) => {
|
|
525
722
|
return m.src === stripped || m.src === filePath;
|
|
526
723
|
});
|
|
@@ -534,7 +731,6 @@ export function syncSessionFile(
|
|
|
534
731
|
// Fall through to direct copy
|
|
535
732
|
}
|
|
536
733
|
|
|
537
|
-
// Fallback: try to process the file directly
|
|
538
734
|
const destPath = join(outputDir, `${stripped}.mdx`);
|
|
539
735
|
processPage(srcPath, destPath, stripped, variables);
|
|
540
736
|
return { synced: true };
|