@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.
Files changed (76) hide show
  1. package/package.json +1 -1
  2. package/src/build.ts +13 -0
  3. package/src/cli.ts +60 -11
  4. package/src/engine/app/(docs)/[...slug]/layout.tsx +21 -537
  5. package/src/engine/app/_preview/[sessionId]/[...slug]/layout.tsx +96 -0
  6. package/src/engine/app/_preview/[sessionId]/[...slug]/page.tsx +298 -0
  7. package/src/engine/app/_preview/[sessionId]/layout.tsx +56 -0
  8. package/src/{preview-engine/app → engine/app/_preview}/[sessionId]/page.tsx +7 -3
  9. package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/assets/[...path]/route.ts +1 -1
  10. package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/init/route.ts +2 -2
  11. package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/route.ts +3 -3
  12. package/src/{preview-engine/app → engine/app/_preview}/api/sessions/[sessionId]/sync/route.ts +2 -2
  13. package/src/{preview-engine/app → engine/app/_preview}/layout.tsx +4 -1
  14. package/src/engine/app/global.css +0 -3623
  15. package/src/engine/app/layout.tsx +4 -3
  16. package/src/engine/components/sidebar-links.tsx +11 -5
  17. package/src/engine/lib/docs-layout.tsx +605 -0
  18. package/src/engine/lib/layout.shared.ts +7 -7
  19. package/src/engine/lib/preview-config.ts +129 -0
  20. package/src/{preview-engine/lib/content-generator.ts → engine/lib/preview-content.ts} +238 -42
  21. package/src/engine/lib/source.ts +80 -97
  22. package/src/engine/lib/velu.ts +79 -55
  23. package/src/engine/mdx-components.tsx +14 -650
  24. package/src/engine/source.config.ts +11 -89
  25. package/src/engine/tsconfig.json +1 -0
  26. package/src/engine-core/components/assistant.tsx +361 -0
  27. package/src/engine-core/components/banner.tsx +80 -0
  28. package/src/engine-core/components/changelog-filters.tsx +114 -0
  29. package/src/engine-core/components/code-group.tsx +383 -0
  30. package/src/engine-core/components/color.tsx +118 -0
  31. package/src/engine-core/components/copy-page.tsx +223 -0
  32. package/src/engine-core/components/dropdown-switcher.tsx +142 -0
  33. package/src/engine-core/components/expandable.tsx +77 -0
  34. package/src/engine-core/components/header-tab-link.tsx +43 -0
  35. package/src/engine-core/components/icon.tsx +136 -0
  36. package/src/engine-core/components/image-zoom-fallback.tsx +147 -0
  37. package/src/engine-core/components/image.tsx +111 -0
  38. package/src/engine-core/components/lang-switcher.tsx +101 -0
  39. package/src/engine-core/components/manual-api-playground.tsx +154 -0
  40. package/src/engine-core/components/mermaid.tsx +142 -0
  41. package/src/engine-core/components/openapi-toc-sync.tsx +59 -0
  42. package/src/engine-core/components/openapi.tsx +1682 -0
  43. package/src/engine-core/components/page-feedback-api.test.ts +83 -0
  44. package/src/engine-core/components/page-feedback-api.ts +89 -0
  45. package/src/engine-core/components/page-feedback.tsx +200 -0
  46. package/src/engine-core/components/product-switcher.tsx +107 -0
  47. package/src/engine-core/components/prompt.tsx +90 -0
  48. package/src/engine-core/components/providers.tsx +21 -0
  49. package/src/engine-core/components/search.tsx +318 -0
  50. package/src/engine-core/components/sidebar-links.tsx +54 -0
  51. package/src/engine-core/components/synced-tabs.tsx +57 -0
  52. package/src/engine-core/components/theme-toggle.tsx +39 -0
  53. package/src/engine-core/components/toc-examples.tsx +110 -0
  54. package/src/engine-core/components/version-switcher.tsx +95 -0
  55. package/src/engine-core/components/view.tsx +344 -0
  56. package/src/engine-core/css/assistant.css +326 -0
  57. package/src/engine-core/css/copy-page.css +206 -0
  58. package/src/engine-core/css/search.css +142 -0
  59. package/src/engine-core/css/shared.css +3628 -0
  60. package/src/engine-core/lib/remark-plugins.ts +102 -0
  61. package/src/engine-core/lib/source-plugins.ts +105 -0
  62. package/src/engine-core/mdx-components.tsx +654 -0
  63. package/src/engine-core/types.ts +49 -0
  64. package/src/preview-engine/app/[sessionId]/[...slug]/page.tsx +0 -41
  65. package/src/preview-engine/app/[sessionId]/layout.tsx +0 -26
  66. package/src/preview-engine/app/global.css +0 -29
  67. package/src/preview-engine/lib/session-config.ts +0 -86
  68. package/src/preview-engine/lib/session-layout.ts +0 -190
  69. package/src/preview-engine/lib/source.ts +0 -60
  70. package/src/preview-engine/next.config.mjs +0 -20
  71. package/src/preview-engine/postcss.config.mjs +0 -8
  72. package/src/preview-engine/source.config.ts +0 -26
  73. package/src/preview-engine/tsconfig.json +0 -32
  74. package/src/preview-engine/tsconfig.tsbuildinfo +0 -1
  75. /package/src/{preview-engine/app → engine/app/_preview}/page.tsx +0 -0
  76. /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 raw = JSON.parse(readFileSync(configPath, 'utf-8')) as VeluConfig;
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.tabs || []).filter((tab) => !tab.href);
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 = operationLabel.replace(/"/g, '\\"');
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.languages;
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
- // Simple (single-lang or same-nav multi-lang)
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 };