@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.
Files changed (74) hide show
  1. package/package.json +1 -1
  2. package/src/build.ts +13 -0
  3. package/src/cli.ts +51 -9
  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/engine/app/_preview/api/sessions/[sessionId]/assets/[...path]/route.ts +51 -0
  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} +242 -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 -23
  66. package/src/preview-engine/app/global.css +0 -3
  67. package/src/preview-engine/lib/session-config.ts +0 -86
  68. package/src/preview-engine/lib/source.ts +0 -60
  69. package/src/preview-engine/next.config.mjs +0 -20
  70. package/src/preview-engine/postcss.config.mjs +0 -8
  71. package/src/preview-engine/source.config.ts +0 -26
  72. package/src/preview-engine/tsconfig.json +0 -32
  73. /package/src/{preview-engine/app → engine/app/_preview}/page.tsx +0 -0
  74. /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 raw = JSON.parse(readFileSync(configPath, 'utf-8')) as VeluConfig;
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.tabs || []).filter((tab) => !tab.href);
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 = operationLabel.replace(/"/g, '\\"');
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.languages;
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
- // Simple (single-lang or same-nav multi-lang)
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 };