@aravindc26/velu 0.13.8 → 0.13.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aravindc26/velu",
3
- "version": "0.13.8",
3
+ "version": "0.13.11",
4
4
  "description": "A modern documentation site generator powered by Markdown and JSON configuration",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -181,10 +181,12 @@ async function lint(docsDir: string) {
181
181
  interface PathEntry {
182
182
  path: string;
183
183
  file: string | null;
184
+ tab: string | null;
185
+ tabSlug: string | null;
184
186
  }
185
187
 
186
188
  async function paths(docsDir: string) {
187
- const { collectPagesByLanguage } = await import("./validate.js");
189
+ const { collectPagesWithTabsByLanguage } = await import("./validate.js");
188
190
  const { normalizeConfigNavigation } = await import("./navigation-normalize.js");
189
191
  const { readFileSync, existsSync } = await import("node:fs");
190
192
  const { join } = await import("node:path");
@@ -197,23 +199,31 @@ async function paths(docsDir: string) {
197
199
 
198
200
  const raw = JSON.parse(readFileSync(configPath, "utf-8"));
199
201
  const config = normalizeConfigNavigation(raw);
200
- const pagesByLanguage = collectPagesByLanguage(config);
202
+ const pagesByLanguage = collectPagesWithTabsByLanguage(config);
201
203
  const groupedEntries: Record<string, PathEntry[]> = {};
202
204
  const flatEntries: PathEntry[] = [];
203
205
 
204
206
  for (const [language, pages] of Object.entries(pagesByLanguage)) {
205
- const entries: PathEntry[] = pages.map((pagePath) => {
207
+ const entries: PathEntry[] = pages.map((pageWithTab) => {
208
+ const pagePath = pageWithTab.page;
206
209
  // Check for .mdx first, then .md
207
210
  const mdxPath = join(docsDir, `${pagePath}.mdx`);
208
211
  const mdPath = join(docsDir, `${pagePath}.md`);
209
212
 
213
+ const entry: PathEntry = {
214
+ path: pagePath,
215
+ file: null,
216
+ tab: pageWithTab.tab,
217
+ tabSlug: pageWithTab.tabSlug,
218
+ };
219
+
210
220
  if (existsSync(mdxPath)) {
211
- return { path: pagePath, file: `${pagePath}.mdx` };
212
- }
213
- if (existsSync(mdPath)) {
214
- return { path: pagePath, file: `${pagePath}.md` };
221
+ entry.file = `${pagePath}.mdx`;
222
+ } else if (existsSync(mdPath)) {
223
+ entry.file = `${pagePath}.md`;
215
224
  }
216
- return { path: pagePath, file: null };
225
+
226
+ return entry;
217
227
  });
218
228
  groupedEntries[language] = entries;
219
229
  flatEntries.push(...entries);
@@ -1,6 +1,7 @@
1
+ import type { Metadata } from 'next';
1
2
  import type { ReactNode } from 'react';
2
3
  import { loadSessionConfigSource, getSessionThemeCss } from '@/lib/preview-config';
3
- import { getBannerConfig, getFontsConfig, getSiteFavicon } from '@/lib/velu';
4
+ import { getBannerConfig, getFontsConfig, getSiteDescription, getSiteFavicon, getSiteName } from '@/lib/velu';
4
5
  import { VeluBanner } from '@/components/banner';
5
6
 
6
7
  interface LayoutProps {
@@ -8,6 +9,18 @@ interface LayoutProps {
8
9
  params: Promise<{ sessionId: string }>;
9
10
  }
10
11
 
12
+ export async function generateMetadata({ params }: { params: Promise<{ sessionId: string }> }): Promise<Metadata> {
13
+ const { sessionId } = await params;
14
+ const configSource = loadSessionConfigSource(sessionId);
15
+ if (!configSource) return {};
16
+ const title = getSiteName(configSource);
17
+ const description = getSiteDescription(configSource);
18
+ return {
19
+ ...(title ? { title: { default: title, template: `%s - ${title}` } } : {}),
20
+ ...(description ? { description } : {}),
21
+ };
22
+ }
23
+
11
24
  /**
12
25
  * Session layout: injects per-session theme CSS, Google Fonts, and banner.
13
26
  * Uses React 19 resource hoisting (<style precedence> / <link precedence>)
@@ -6,6 +6,7 @@ import { existsSync, readFileSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { normalizeConfigNavigation } from './navigation-normalize';
8
8
  import type { VeluConfigSource, VeluConfig } from './velu';
9
+ import { getFontsConfig } from './velu';
9
10
 
10
11
  const WORKSPACE_DIR = process.env.WORKSPACE_DIR || '/mnt/nfs_share/editor_sessions';
11
12
  const PRIMARY_CONFIG_NAME = 'docs.json';
@@ -91,38 +92,80 @@ function textColorFor(hex: string): string {
91
92
  }
92
93
 
93
94
  /**
94
- * Generate CSS custom properties for a session's primary color theme.
95
+ * Generate CSS custom properties for a session's primary color theme and font overrides.
95
96
  */
96
97
  export function getSessionThemeCss(sessionId: string): string | null {
97
98
  const configSource = loadSessionConfigSource(sessionId);
98
- const colors = configSource?.config.colors;
99
- if (!colors?.primary) return null;
99
+ if (!configSource) return null;
100
100
 
101
- const { primary, light, dark } = colors;
102
- const lightAccent = light || primary;
103
- const darkAccent = dark || primary;
104
101
  const lines: string[] = [];
105
102
 
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('}');
103
+ // ── Color overrides ──
104
+ const colors = configSource.config.colors;
105
+ if (colors?.primary) {
106
+ const { primary, light, dark } = colors;
107
+ const lightAccent = light || primary;
108
+ const darkAccent = dark || primary;
109
+
110
+ if (lightAccent) {
111
+ const accentLow = mixColors(lightAccent, '#ffffff', 0.15);
112
+ lines.push(':root {');
113
+ lines.push(` --color-fd-primary: ${lightAccent};`);
114
+ lines.push(` --color-fd-primary-foreground: ${textColorFor(lightAccent)};`);
115
+ lines.push(` --color-fd-accent: ${accentLow};`);
116
+ lines.push(` --color-fd-accent-foreground: ${textColorFor(accentLow)};`);
117
+ lines.push(` --color-fd-ring: ${lightAccent};`);
118
+ lines.push('}');
119
+ }
120
+
121
+ if (darkAccent) {
122
+ const accentLow = mixColors(darkAccent, '#000000', 0.3);
123
+ lines.push('.dark {');
124
+ lines.push(` --color-fd-primary: ${darkAccent};`);
125
+ lines.push(` --color-fd-primary-foreground: ${textColorFor(darkAccent)};`);
126
+ lines.push(` --color-fd-accent: ${accentLow};`);
127
+ lines.push(` --color-fd-accent-foreground: ${textColorFor(accentLow)};`);
128
+ lines.push(` --color-fd-ring: ${darkAccent};`);
129
+ lines.push('}');
130
+ }
115
131
  }
116
132
 
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('}');
133
+ // ── Font overrides ──
134
+ const fontsConfig = getFontsConfig(configSource);
135
+ if (fontsConfig) {
136
+ const { heading, body } = fontsConfig;
137
+ // @font-face for custom sources
138
+ for (const def of [heading, body]) {
139
+ if (def?.source) {
140
+ const fmt = def.format || (def.source.endsWith('.woff2') ? 'woff2' : 'woff');
141
+ lines.push(`@font-face {`);
142
+ lines.push(` font-family: '${def.family}';`);
143
+ lines.push(` src: url('${def.source}') format('${fmt}');`);
144
+ if (def.weight) lines.push(` font-weight: ${def.weight};`);
145
+ lines.push(` font-display: swap;`);
146
+ lines.push(`}`);
147
+ }
148
+ }
149
+ // CSS variable overrides
150
+ const vars: string[] = [];
151
+ if (body) {
152
+ vars.push(` --font-fd-sans: '${body.family}', ui-sans-serif, system-ui, sans-serif;`);
153
+ }
154
+ if (heading) {
155
+ vars.push(` --velu-font-heading: '${heading.family}', ui-sans-serif, system-ui, sans-serif;`);
156
+ }
157
+ if (vars.length) {
158
+ lines.push(':root {');
159
+ lines.push(...vars);
160
+ lines.push('}');
161
+ }
162
+ // Heading font-family rule
163
+ if (heading) {
164
+ lines.push('h1, h2, h3, h4, h5, h6 {');
165
+ lines.push(` font-family: var(--velu-font-heading);`);
166
+ if (heading.weight) lines.push(` font-weight: ${heading.weight};`);
167
+ lines.push('}');
168
+ }
126
169
  }
127
170
 
128
171
  return lines.length > 0 ? lines.join('\n') : null;
@@ -1,7 +1,7 @@
1
1
  import { defineConfig, defineDocs } from 'fumadocs-mdx/config';
2
2
  import { metaSchema, pageSchema } from 'fumadocs-core/source/schema';
3
3
  import { transformerMetaHighlight } from '@shikijs/transformers';
4
- import { remarkCodeFilenameToTitle, sharedRehypeCodeOptions } from '@core/lib/remark-plugins';
4
+ import { remarkCodeFilenameToTitle, remarkStripJsxFromHeadings, sharedRehypeCodeOptions } from '@core/lib/remark-plugins';
5
5
  import { z } from 'zod';
6
6
 
7
7
  const contentDir = process.env.PREVIEW_CONTENT_DIR || 'content/docs';
@@ -27,7 +27,7 @@ export const docs = defineDocs({
27
27
 
28
28
  export default defineConfig({
29
29
  mdxOptions: {
30
- remarkPlugins: [remarkCodeFilenameToTitle],
30
+ remarkPlugins: [remarkStripJsxFromHeadings, remarkCodeFilenameToTitle],
31
31
  rehypeCodeOptions: ({
32
32
  ...sharedRehypeCodeOptions,
33
33
  transformers: [transformerMetaHighlight()],
@@ -68,6 +68,7 @@ function initAssistant() {
68
68
  eventSource: EventSource | null;
69
69
  expanded: boolean;
70
70
  bootstrapped: boolean;
71
+ feedback: Record<string, 'up' | 'down'>;
71
72
  } = {
72
73
  conversationId: null,
73
74
  conversationToken: null,
@@ -75,6 +76,7 @@ function initAssistant() {
75
76
  eventSource: null,
76
77
  expanded: false,
77
78
  bootstrapped: false,
79
+ feedback: {},
78
80
  };
79
81
 
80
82
  const askBar = document.getElementById('veluAskBar')!;
@@ -93,6 +95,7 @@ function initAssistant() {
93
95
  sessionStorage.setItem('velu-conv-id', state.conversationId || '');
94
96
  sessionStorage.setItem('velu-conv-token', state.conversationToken || '');
95
97
  sessionStorage.setItem('velu-last-seq', String(state.lastSeq));
98
+ sessionStorage.setItem('velu-feedback', JSON.stringify(state.feedback));
96
99
  } catch {}
97
100
  }
98
101
 
@@ -117,6 +120,7 @@ function initAssistant() {
117
120
  state.conversationId = null;
118
121
  state.conversationToken = null;
119
122
  state.lastSeq = 0;
123
+ state.feedback = {};
120
124
  if (state.eventSource) { state.eventSource.close(); state.eventSource = null; }
121
125
  messagesEl.innerHTML = '';
122
126
  chatInput.value = '';
@@ -143,6 +147,28 @@ function initAssistant() {
143
147
  .catch(() => {});
144
148
  }
145
149
 
150
+ function submitFeedback(messageId: number, rating: 'up' | 'down') {
151
+ return fetch(API_BASE + '/messages/' + messageId + '/feedback', {
152
+ method: 'POST',
153
+ headers: { 'Content-Type': 'application/json' },
154
+ credentials: 'include',
155
+ body: JSON.stringify({ rating }),
156
+ }).then((r) => r.json());
157
+ }
158
+
159
+ function retractFeedback(messageId: number) {
160
+ return fetch(API_BASE + '/messages/' + messageId + '/feedback', {
161
+ method: 'DELETE',
162
+ credentials: 'include',
163
+ }).then((r) => r.json());
164
+ }
165
+
166
+ function saveFeedbackState() {
167
+ try {
168
+ sessionStorage.setItem('velu-feedback', JSON.stringify(state.feedback));
169
+ } catch {}
170
+ }
171
+
146
172
  function formatContent(text: string, citations: any[]) {
147
173
  let html = text
148
174
  .replace(/&/g, '&amp;')
@@ -162,9 +188,10 @@ function initAssistant() {
162
188
  return html;
163
189
  }
164
190
 
165
- function addMessage(role: string, content: string, citations: any[] = []) {
191
+ function addMessage(role: string, content: string, citations: any[] = [], messageId?: number) {
166
192
  const msgDiv = document.createElement('div');
167
193
  msgDiv.className = 'velu-msg velu-msg-' + role;
194
+ if (messageId) msgDiv.setAttribute('data-message-id', String(messageId));
168
195
  const bubble = document.createElement('div');
169
196
  bubble.className = 'velu-msg-bubble velu-msg-bubble-' + role;
170
197
  bubble.innerHTML = formatContent(content, citations);
@@ -188,17 +215,121 @@ function initAssistant() {
188
215
  const actions = document.createElement('div');
189
216
  actions.className = 'velu-msg-actions';
190
217
  actions.innerHTML =
191
- '<button class="velu-msg-action" title="Like"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg></button>' +
192
- '<button class="velu-msg-action" title="Dislike"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg></button>' +
193
- '<button class="velu-msg-action velu-msg-copy" title="Copy"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>';
218
+ '<button class="velu-msg-action velu-msg-like" title="Like"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg></button>' +
219
+ '<button class="velu-msg-action velu-msg-dislike" title="Dislike"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg></button>' +
220
+ '<button class="velu-msg-action velu-msg-copy" title="Copy"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>' +
221
+ '<button class="velu-msg-action velu-msg-regenerate" title="Regenerate"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><polyline points="23 20 23 14 17 14"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>';
194
222
  msgDiv.appendChild(actions);
195
223
 
196
- const copyBtn = actions.querySelector('.velu-msg-copy');
224
+ // Restore feedback state if we have it for this message
225
+ const existingRating = messageId ? state.feedback[messageId] : null;
226
+ const likeBtn = actions.querySelector('.velu-msg-like') as HTMLElement;
227
+ const dislikeBtn = actions.querySelector('.velu-msg-dislike') as HTMLElement;
228
+ if (existingRating === 'up') likeBtn.classList.add('velu-msg-action-active');
229
+ if (existingRating === 'down') dislikeBtn.classList.add('velu-msg-action-active');
230
+
231
+ // Like button
232
+ if (likeBtn && dislikeBtn) {
233
+ likeBtn.onclick = () => {
234
+ if (!messageId) return;
235
+ const isActive = likeBtn.classList.contains('velu-msg-action-active');
236
+ if (isActive) {
237
+ // Retract
238
+ likeBtn.classList.remove('velu-msg-action-active');
239
+ delete state.feedback[messageId];
240
+ saveFeedbackState();
241
+ retractFeedback(messageId).catch(() => {});
242
+ } else {
243
+ // Submit up
244
+ likeBtn.classList.add('velu-msg-action-active');
245
+ dislikeBtn.classList.remove('velu-msg-action-active');
246
+ state.feedback[messageId] = 'up';
247
+ saveFeedbackState();
248
+ submitFeedback(messageId, 'up').catch(() => {});
249
+ }
250
+ };
251
+ dislikeBtn.onclick = () => {
252
+ if (!messageId) return;
253
+ const isActive = dislikeBtn.classList.contains('velu-msg-action-active');
254
+ if (isActive) {
255
+ // Retract
256
+ dislikeBtn.classList.remove('velu-msg-action-active');
257
+ delete state.feedback[messageId];
258
+ saveFeedbackState();
259
+ retractFeedback(messageId).catch(() => {});
260
+ } else {
261
+ // Submit down
262
+ dislikeBtn.classList.add('velu-msg-action-active');
263
+ likeBtn.classList.remove('velu-msg-action-active');
264
+ state.feedback[messageId] = 'down';
265
+ saveFeedbackState();
266
+ submitFeedback(messageId, 'down').catch(() => {});
267
+ }
268
+ };
269
+ }
270
+
271
+ // Copy button
272
+ const copyBtn = actions.querySelector('.velu-msg-copy') as HTMLElement;
197
273
  if (copyBtn) {
198
- (copyBtn as HTMLElement).onclick = () => {
274
+ copyBtn.onclick = () => {
199
275
  navigator.clipboard.writeText(content);
200
- (copyBtn as HTMLElement).title = 'Copied!';
201
- setTimeout(() => { (copyBtn as HTMLElement).title = 'Copy'; }, 1500);
276
+ copyBtn.classList.add('velu-msg-action-active');
277
+ copyBtn.title = 'Copied!';
278
+ setTimeout(() => { copyBtn.classList.remove('velu-msg-action-active'); copyBtn.title = 'Copy'; }, 1500);
279
+ };
280
+ }
281
+
282
+ // Regenerate button
283
+ const regenBtn = actions.querySelector('.velu-msg-regenerate') as HTMLElement;
284
+ if (regenBtn) {
285
+ regenBtn.onclick = () => {
286
+ const allMsgs = messagesEl.querySelectorAll('.velu-msg');
287
+ let lastUserText = '';
288
+ for (let i = allMsgs.length - 1; i >= 0; i--) {
289
+ if (allMsgs[i].classList.contains('velu-msg-user')) {
290
+ const bubble = allMsgs[i].querySelector('.velu-msg-bubble');
291
+ if (bubble) lastUserText = bubble.textContent || '';
292
+ break;
293
+ }
294
+ }
295
+ if (lastUserText) {
296
+ msgDiv.remove();
297
+ saveState();
298
+ addThinking();
299
+ bootstrap()
300
+ .then(() =>
301
+ fetch(API_BASE + '/messages', {
302
+ method: 'POST',
303
+ headers: { 'Content-Type': 'application/json' },
304
+ credentials: 'include',
305
+ body: JSON.stringify({
306
+ message: lastUserText,
307
+ conversation_id: state.conversationId,
308
+ }),
309
+ })
310
+ )
311
+ .then((r) => {
312
+ if (r.status === 429) {
313
+ removeThinking();
314
+ addMessage('assistant', 'Rate limited. Please wait a moment and try again.');
315
+ return;
316
+ }
317
+ return r.json();
318
+ })
319
+ .then((data: any) => {
320
+ if (!data) return;
321
+ if (data.conversation_id) state.conversationId = data.conversation_id;
322
+ if (data.conversation_token) state.conversationToken = data.conversation_token;
323
+ saveState();
324
+ if (!state.eventSource || state.eventSource.readyState === 2) {
325
+ connectSSE();
326
+ }
327
+ })
328
+ .catch(() => {
329
+ removeThinking();
330
+ addMessage('assistant', 'Failed to connect. Please try again.');
331
+ });
332
+ }
202
333
  };
203
334
  }
204
335
  }
@@ -233,7 +364,7 @@ function initAssistant() {
233
364
  const data = JSON.parse(e.data);
234
365
  const msg = data.message || data;
235
366
  if (msg.seq) state.lastSeq = msg.seq;
236
- addMessage('assistant', msg.content || '', msg.citations || []);
367
+ addMessage('assistant', msg.content || '', msg.citations || [], msg.id);
237
368
  } catch {}
238
369
  });
239
370
 
@@ -342,9 +473,11 @@ function initAssistant() {
342
473
  const savedConvId = sessionStorage.getItem('velu-conv-id');
343
474
  const savedConvToken = sessionStorage.getItem('velu-conv-token');
344
475
  const savedSeq = sessionStorage.getItem('velu-last-seq');
476
+ const savedFeedback = sessionStorage.getItem('velu-feedback');
345
477
  if (savedConvId) state.conversationId = savedConvId;
346
478
  if (savedConvToken) state.conversationToken = savedConvToken;
347
479
  if (savedSeq) state.lastSeq = parseInt(savedSeq, 10) || 0;
480
+ if (savedFeedback) try { state.feedback = JSON.parse(savedFeedback); } catch {}
348
481
  if (savedMessages) messagesEl.innerHTML = savedMessages;
349
482
  if (savedExpanded === '1') {
350
483
  state.expanded = true;
@@ -225,6 +225,14 @@
225
225
  background-color: var(--color-fd-accent, #27272a);
226
226
  }
227
227
 
228
+ .velu-msg-action.velu-msg-action-active {
229
+ color: var(--color-fd-primary, #818cf8);
230
+ }
231
+
232
+ .velu-msg-action.velu-msg-action-active svg {
233
+ fill: currentColor;
234
+ }
235
+
228
236
  /* Thinking dots */
229
237
  .velu-thinking-dots {
230
238
  display: inline-flex;
@@ -1008,6 +1008,11 @@ nextjs-portal {
1008
1008
  margin: 0 !important;
1009
1009
  }
1010
1010
 
1011
+ /* Add spacing between consecutive card groups (Mintlify compat) */
1012
+ .velu-card-group + .velu-card-group {
1013
+ margin-top: 0.75rem;
1014
+ }
1015
+
1011
1016
  .velu-card-image {
1012
1017
  width: 100%;
1013
1018
  border-radius: 0.55rem;
@@ -89,6 +89,44 @@ export function remarkCodeFilenameToTitle() {
89
89
  return (tree: any) => visit(tree);
90
90
  }
91
91
 
92
+ /**
93
+ * Remark plugin that strips JSX/MDX elements (like <Icon />) from headings.
94
+ *
95
+ * fumadocs' rehypeToc plugin converts heading content into a static
96
+ * `export const toc = [...]` at the MDX module level. MDX components
97
+ * (e.g. Icon) are only injected at render time via getMDXComponents(),
98
+ * so any JSX reference in a heading causes "ReferenceError: X is not defined"
99
+ * when the toc export evaluates.
100
+ *
101
+ * This plugin removes mdxJsxFlowElement / mdxJsxTextElement nodes from
102
+ * headings before TOC extraction runs. The heading body still renders
103
+ * with the component because MDX components are in scope at render time.
104
+ */
105
+ export function remarkStripJsxFromHeadings() {
106
+ return (tree: any) => {
107
+ if (!tree || !Array.isArray(tree.children)) return;
108
+
109
+ function visitNode(node: any) {
110
+ if (
111
+ node.type === 'heading' &&
112
+ Array.isArray(node.children)
113
+ ) {
114
+ node.children = node.children.filter(
115
+ (child: any) =>
116
+ child.type !== 'mdxJsxTextElement' &&
117
+ child.type !== 'mdxJsxFlowElement',
118
+ );
119
+ }
120
+
121
+ if (Array.isArray(node.children)) {
122
+ for (const child of node.children) visitNode(child);
123
+ }
124
+ }
125
+
126
+ visitNode(tree);
127
+ };
128
+ }
129
+
92
130
  /**
93
131
  * Shared rehype code options for consistent syntax highlighting.
94
132
  */
@@ -190,6 +190,7 @@ export function getMDXComponents(opts: { iconLibrary: VeluIconLibrary; apiConfig
190
190
  },
191
191
  Accordions: FumaAccordions as any,
192
192
  Accordion: FumaAccordion as any,
193
+ AccordionGroup: FumaAccordions as any,
193
194
  CodeGroup: VeluCodeGroup as any,
194
195
  Frame: ({ children, caption, hint, className }: any) => (
195
196
  <>
package/src/validate.ts CHANGED
@@ -233,30 +233,43 @@ function isPageString(item: unknown): item is string {
233
233
  return typeof item === "string";
234
234
  }
235
235
 
236
+ interface PageWithTab {
237
+ page: string;
238
+ tab: string | null;
239
+ tabSlug: string | null;
240
+ }
241
+
236
242
  function collectPagesFromTabs(tabs: VeluTab[]): string[] {
237
- const pages: string[] = [];
243
+ return collectPagesWithTabsFromTabs(tabs).map((p) => p.page);
244
+ }
238
245
 
239
- function collectFromGroup(group: VeluGroup) {
246
+ function collectPagesWithTabsFromTabs(tabs: VeluTab[]): PageWithTab[] {
247
+ const pages: PageWithTab[] = [];
248
+
249
+ function collectFromGroup(group: VeluGroup, tab: string | null, tabSlug: string | null) {
240
250
  for (const item of group.pages) {
241
251
  if (isPageString(item)) {
242
- pages.push(item);
252
+ pages.push({ page: item, tab, tabSlug });
243
253
  } else if (isGroup(item)) {
244
- collectFromGroup(item);
254
+ collectFromGroup(item, tab, tabSlug);
245
255
  }
246
256
  }
247
257
  }
248
258
 
249
259
  for (const tab of tabs) {
260
+ const tabName = tab.tab || null;
261
+ const tabSlug = tab.slug || tabName;
262
+
250
263
  if (tab.pages) {
251
264
  for (const item of tab.pages) {
252
265
  if (isPageString(item)) {
253
- pages.push(item);
266
+ pages.push({ page: item, tab: tabName, tabSlug });
254
267
  }
255
268
  }
256
269
  }
257
270
  if (tab.groups) {
258
271
  for (const group of tab.groups) {
259
- collectFromGroup(group);
272
+ collectFromGroup(group, tabName, tabSlug);
260
273
  }
261
274
  }
262
275
  }
@@ -293,6 +306,28 @@ function collectPagesByLanguage(config: VeluConfig): Record<string, string[]> {
293
306
  return grouped;
294
307
  }
295
308
 
309
+ function collectPagesWithTabsByLanguage(config: VeluConfig): Record<string, PageWithTab[]> {
310
+ const grouped: Record<string, PageWithTab[]> = {};
311
+
312
+ if (config.navigation.languages && config.navigation.languages.length > 0) {
313
+ for (const lang of config.navigation.languages) {
314
+ grouped[lang.language] = collectPagesWithTabsFromTabs(lang.tabs);
315
+ }
316
+ return grouped;
317
+ }
318
+
319
+ const basePages = collectPagesWithTabsFromTabs(config.navigation.tabs ?? []);
320
+ if (config.languages && config.languages.length > 0) {
321
+ for (const lang of config.languages) {
322
+ grouped[lang] = [...basePages];
323
+ }
324
+ return grouped;
325
+ }
326
+
327
+ grouped.en = basePages;
328
+ return grouped;
329
+ }
330
+
296
331
  function validateVeluConfig(docsDir: string, schemaPath: string): { valid: boolean; errors: string[] } {
297
332
  const errors: string[] = [];
298
333
 
@@ -362,4 +397,4 @@ function validateVeluConfig(docsDir: string, schemaPath: string): { valid: boole
362
397
  return { valid: errors.length === 0, errors };
363
398
  }
364
399
 
365
- export { validateVeluConfig, collectPages, collectPagesByLanguage, VeluConfig, VeluGroup, VeluTab, VeluSeparator, VeluLink, VeluAnchor };
400
+ export { validateVeluConfig, collectPages, collectPagesByLanguage, collectPagesWithTabsByLanguage, VeluConfig, VeluGroup, VeluTab, VeluSeparator, VeluLink, VeluAnchor, type PageWithTab };