@aravindc26/velu 0.12.8 → 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 (76) 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/{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} +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 -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
@@ -1,94 +1,21 @@
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';
5
+ import { z } from 'zod';
4
6
 
5
- function remarkCodeFilenameToTitle() {
6
- const booleanMetaFlags = new Set([
7
- 'wrap',
8
- 'copy',
9
- 'nocopy',
10
- 'lineNumbers',
11
- 'linenumbers',
12
- 'showLineNumbers',
13
- ]);
7
+ const contentDir = process.env.PREVIEW_CONTENT_DIR || 'content/docs';
14
8
 
15
- function quoteTitle(value: string): string {
16
- return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
17
- }
18
-
19
- function ensureTitleMeta(meta: string): string {
20
- const trimmed = meta.trim();
21
- if (!trimmed) return trimmed;
22
- if (/\btitle\s*=/.test(trimmed)) return trimmed;
23
-
24
- const fileWithRest = trimmed.match(/^([^\s]+?\.[a-z0-9_-]+)(\s+.*)?$/i);
25
- if (fileWithRest) {
26
- const file = fileWithRest[1];
27
- const rest = (fileWithRest[2] ?? '').trim();
28
- return rest ? `title="${quoteTitle(file)}" ${rest}` : `title="${quoteTitle(file)}"`;
29
- }
30
-
31
- if (!trimmed.includes('=') && !trimmed.includes('{') && !trimmed.includes('}')) {
32
- if (booleanMetaFlags.has(trimmed)) return trimmed;
33
- return `title="${quoteTitle(trimmed)}"`;
34
- }
35
-
36
- return trimmed;
37
- }
38
-
39
- function visit(node: any) {
40
- if (!node || typeof node !== 'object') return;
41
-
42
- if (node.type === 'code' && typeof node.meta === 'string') {
43
- let meta = node.meta.trim();
44
- // Mint-style fence syntax: ```lang filename.ext
45
- // Convert it into title metadata so code tabs can use file names.
46
- meta = ensureTitleMeta(meta);
47
-
48
- // Mint-style line highlight syntax: highlight=1 or highlight="1,3-5"
49
- // Convert to Shiki meta-highlight format: {1,3-5}
50
- const hlMatch = meta.match(/(?:^|\s)highlight=(?:"([^"]+)"|'([^']+)'|([^\s]+))/i);
51
- if (hlMatch) {
52
- const raw = (hlMatch[1] ?? hlMatch[2] ?? hlMatch[3] ?? '').trim();
53
- const lineSpec = raw.replace(/[{}]/g, '');
54
- meta = meta.replace(hlMatch[0], '').replace(/\s+/g, ' ').trim();
55
- if (lineSpec && !/\{\s*\d[\d,\-\s]*\s*\}/.test(meta)) {
56
- meta = `${meta} {${lineSpec}}`.trim();
57
- }
58
- }
59
-
60
- // theme={null} is a Mint docs hint; remove it from fence meta.
61
- meta = meta.replace(/\btheme=\{null\}\b/g, '').replace(/\s+/g, ' ').trim();
62
- node.meta = meta;
63
- }
64
- if (node.type === 'code' && typeof node.meta !== 'string') {
65
- return;
66
- }
67
-
68
- if (node.type === 'code' && node.meta === '') {
69
- delete node.meta;
70
- }
71
-
72
- if (node.type === 'code' && typeof node.meta === 'string') {
73
- node.meta = node.meta.trim();
74
- if (!node.meta) {
75
- delete node.meta;
76
- }
77
- }
78
-
79
- const children = node.children;
80
- if (Array.isArray(children)) {
81
- for (const child of children) visit(child);
82
- }
83
- }
84
-
85
- return (tree: any) => visit(tree);
86
- }
9
+ const extendedPageSchema = pageSchema.extend({
10
+ openapi: z.string().optional(),
11
+ deprecated: z.boolean().optional(),
12
+ status: z.string().optional(),
13
+ });
87
14
 
88
15
  export const docs = defineDocs({
89
- dir: 'content/docs',
16
+ dir: contentDir,
90
17
  docs: {
91
- schema: pageSchema,
18
+ schema: extendedPageSchema,
92
19
  postprocess: {
93
20
  includeProcessedMarkdown: true,
94
21
  },
@@ -102,13 +29,8 @@ export default defineConfig({
102
29
  mdxOptions: {
103
30
  remarkPlugins: [remarkCodeFilenameToTitle],
104
31
  rehypeCodeOptions: ({
105
- lazy: false,
106
- fallbackLanguage: 'bash',
32
+ ...sharedRehypeCodeOptions,
107
33
  transformers: [transformerMetaHighlight()],
108
- langAlias: {
109
- gradle: 'groovy',
110
- proguard: 'properties',
111
- },
112
34
  } as any),
113
35
  },
114
36
  });
@@ -17,6 +17,7 @@
17
17
  "incremental": true,
18
18
  "paths": {
19
19
  "@/*": ["./*"],
20
+ "@core/*": ["../engine-core/*"],
20
21
  "fumadocs-mdx:collections/*": [".source/*"]
21
22
  },
22
23
  "plugins": [{ "name": "next" }]
@@ -0,0 +1,361 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+
5
+ export function VeluAssistant() {
6
+ useEffect(() => {
7
+ // Guard against double-init
8
+ if (document.getElementById('veluAskBar')) return;
9
+
10
+ // ── Inject HTML ──
11
+ const askBar = document.createElement('div');
12
+ askBar.className = 'velu-ask-bar';
13
+ askBar.id = 'veluAskBar';
14
+ askBar.innerHTML = `
15
+ <div class="velu-ask-bar-inner">
16
+ <input type="text" class="velu-ask-input" id="veluAskInput" placeholder="Ask a question..." autocomplete="off" />
17
+ <button class="velu-ask-submit" id="veluAskSubmit" aria-label="Send">
18
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
19
+ </button>
20
+ </div>`;
21
+ document.body.appendChild(askBar);
22
+
23
+ const panel = document.createElement('div');
24
+ panel.className = 'velu-assistant-panel velu-panel-closed';
25
+ panel.id = 'veluAssistantPanel';
26
+ panel.innerHTML = `
27
+ <div class="velu-assistant-header">
28
+ <span class="velu-assistant-title">Assistant</span>
29
+ <div class="velu-assistant-actions">
30
+ <button class="velu-assistant-action" data-velu-action="expand" title="Expand" aria-label="Expand assistant" type="button">
31
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
32
+ </button>
33
+ <button class="velu-assistant-action" data-velu-action="reset" title="New chat" aria-label="New chat" type="button">
34
+ <svg width="14" height="14" 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"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
35
+ </button>
36
+ <button class="velu-assistant-action" data-velu-action="close" title="Close" aria-label="Close assistant" type="button">
37
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
38
+ </button>
39
+ </div>
40
+ </div>
41
+ <div class="velu-assistant-messages" id="veluAssistantMessages"></div>
42
+ <div class="velu-assistant-input-area">
43
+ <input type="text" class="velu-assistant-chat-input" id="veluAssistantChatInput" placeholder="Ask a question..." autocomplete="off" />
44
+ <button class="velu-assistant-send" id="veluAssistantSend" aria-label="Send">
45
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3.478 2.405a.75.75 0 00-.926.94l2.432 7.905H13.5a.75.75 0 010 1.5H4.984l-2.432 7.905a.75.75 0 00.926.94l18-8.5a.75.75 0 000-1.38l-18-8.5z"/></svg>
46
+ </button>
47
+ </div>`;
48
+ document.body.appendChild(panel);
49
+
50
+ // ── Logic ──
51
+ initAssistant();
52
+
53
+ return () => {
54
+ askBar.remove();
55
+ panel.remove();
56
+ };
57
+ }, []);
58
+
59
+ return null;
60
+ }
61
+
62
+ function initAssistant() {
63
+ const API_BASE = 'https://api.getvelu.com/api/v1/public/ai-assistant';
64
+ const state: {
65
+ conversationId: string | null;
66
+ conversationToken: string | null;
67
+ lastSeq: number;
68
+ eventSource: EventSource | null;
69
+ expanded: boolean;
70
+ bootstrapped: boolean;
71
+ } = {
72
+ conversationId: null,
73
+ conversationToken: null,
74
+ lastSeq: 0,
75
+ eventSource: null,
76
+ expanded: false,
77
+ bootstrapped: false,
78
+ };
79
+
80
+ const askBar = document.getElementById('veluAskBar')!;
81
+ const askInput = document.getElementById('veluAskInput') as HTMLInputElement;
82
+ const askSubmit = document.getElementById('veluAskSubmit')!;
83
+ const panel = document.getElementById('veluAssistantPanel')!;
84
+ const messagesEl = document.getElementById('veluAssistantMessages')!;
85
+ const chatInput = document.getElementById('veluAssistantChatInput') as HTMLInputElement;
86
+ const sendBtn = document.getElementById('veluAssistantSend')!;
87
+
88
+ function saveState() {
89
+ try {
90
+ sessionStorage.setItem('velu-panel-open', isPanelOpen() ? '1' : '');
91
+ sessionStorage.setItem('velu-panel-expanded', state.expanded ? '1' : '');
92
+ sessionStorage.setItem('velu-panel-messages', messagesEl.innerHTML);
93
+ sessionStorage.setItem('velu-conv-id', state.conversationId || '');
94
+ sessionStorage.setItem('velu-conv-token', state.conversationToken || '');
95
+ sessionStorage.setItem('velu-last-seq', String(state.lastSeq));
96
+ } catch {}
97
+ }
98
+
99
+ function openPanel() {
100
+ panel.classList.remove('velu-panel-closed');
101
+ askBar.classList.add('velu-ask-bar-hidden');
102
+ document.documentElement.classList.add('velu-assistant-open');
103
+ chatInput.focus();
104
+ saveState();
105
+ }
106
+
107
+ function closePanel() {
108
+ panel.classList.add('velu-panel-closed');
109
+ askBar.classList.remove('velu-ask-bar-hidden');
110
+ document.documentElement.classList.remove('velu-assistant-open');
111
+ document.documentElement.classList.remove('velu-assistant-wide');
112
+ if (state.eventSource) { state.eventSource.close(); state.eventSource = null; }
113
+ saveState();
114
+ }
115
+
116
+ function resetChat() {
117
+ state.conversationId = null;
118
+ state.conversationToken = null;
119
+ state.lastSeq = 0;
120
+ if (state.eventSource) { state.eventSource.close(); state.eventSource = null; }
121
+ messagesEl.innerHTML = '';
122
+ chatInput.value = '';
123
+ chatInput.focus();
124
+ saveState();
125
+ }
126
+
127
+ function toggleExpand() {
128
+ state.expanded = !state.expanded;
129
+ panel.classList.toggle('velu-assistant-expanded', state.expanded);
130
+ document.documentElement.classList.toggle('velu-assistant-wide', state.expanded);
131
+ saveState();
132
+ }
133
+
134
+ function isPanelOpen() {
135
+ return !panel.classList.contains('velu-panel-closed');
136
+ }
137
+
138
+ function bootstrap() {
139
+ if (state.bootstrapped) return Promise.resolve();
140
+ return fetch(API_BASE + '/bootstrap', { credentials: 'include' })
141
+ .then((r) => r.json())
142
+ .then(() => { state.bootstrapped = true; })
143
+ .catch(() => {});
144
+ }
145
+
146
+ function formatContent(text: string, citations: any[]) {
147
+ let html = text
148
+ .replace(/&/g, '&amp;')
149
+ .replace(/</g, '&lt;')
150
+ .replace(/>/g, '&gt;')
151
+ .replace(/\n/g, '<br>')
152
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
153
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
154
+ html = html.replace(/\[(\d+)\]/g, (m, n) => {
155
+ const idx = parseInt(n) - 1;
156
+ const c = citations[idx];
157
+ if (c) {
158
+ return '<a href="' + (c.url || c.route_path || '#') + '" class="velu-citation-ref" target="_blank">[' + n + ']</a>';
159
+ }
160
+ return m;
161
+ });
162
+ return html;
163
+ }
164
+
165
+ function addMessage(role: string, content: string, citations: any[] = []) {
166
+ const msgDiv = document.createElement('div');
167
+ msgDiv.className = 'velu-msg velu-msg-' + role;
168
+ const bubble = document.createElement('div');
169
+ bubble.className = 'velu-msg-bubble velu-msg-bubble-' + role;
170
+ bubble.innerHTML = formatContent(content, citations);
171
+ msgDiv.appendChild(bubble);
172
+
173
+ if (role === 'assistant' && citations.length > 0) {
174
+ const citDiv = document.createElement('div');
175
+ citDiv.className = 'velu-msg-citations';
176
+ citations.forEach((c, i) => {
177
+ const a = document.createElement('a');
178
+ a.href = c.url || c.route_path || '#';
179
+ a.className = 'velu-citation-link';
180
+ a.textContent = '[' + (i + 1) + '] ' + (c.title || c.route_path || 'Source');
181
+ a.target = '_blank';
182
+ citDiv.appendChild(a);
183
+ });
184
+ msgDiv.appendChild(citDiv);
185
+ }
186
+
187
+ if (role === 'assistant') {
188
+ const actions = document.createElement('div');
189
+ actions.className = 'velu-msg-actions';
190
+ 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>';
194
+ msgDiv.appendChild(actions);
195
+
196
+ const copyBtn = actions.querySelector('.velu-msg-copy');
197
+ if (copyBtn) {
198
+ (copyBtn as HTMLElement).onclick = () => {
199
+ navigator.clipboard.writeText(content);
200
+ (copyBtn as HTMLElement).title = 'Copied!';
201
+ setTimeout(() => { (copyBtn as HTMLElement).title = 'Copy'; }, 1500);
202
+ };
203
+ }
204
+ }
205
+
206
+ messagesEl.appendChild(msgDiv);
207
+ messagesEl.scrollTop = messagesEl.scrollHeight;
208
+ saveState();
209
+ return bubble;
210
+ }
211
+
212
+ function addThinking() {
213
+ const div = document.createElement('div');
214
+ div.className = 'velu-msg velu-msg-assistant';
215
+ div.id = 'veluThinking';
216
+ div.innerHTML = '<div class="velu-msg-bubble velu-msg-bubble-assistant"><span class="velu-thinking-dots"><span></span><span></span><span></span></span></div>';
217
+ messagesEl.appendChild(div);
218
+ messagesEl.scrollTop = messagesEl.scrollHeight;
219
+ }
220
+
221
+ function removeThinking() {
222
+ document.getElementById('veluThinking')?.remove();
223
+ }
224
+
225
+ function connectSSE() {
226
+ if (state.eventSource) state.eventSource.close();
227
+ const url = API_BASE + '/conversations/' + state.conversationId + '/events?after_seq=' + state.lastSeq + '&token=' + encodeURIComponent(state.conversationToken || '');
228
+ state.eventSource = new EventSource(url);
229
+
230
+ state.eventSource.addEventListener('assistant.completed', (e: MessageEvent) => {
231
+ removeThinking();
232
+ try {
233
+ const data = JSON.parse(e.data);
234
+ const msg = data.message || data;
235
+ if (msg.seq) state.lastSeq = msg.seq;
236
+ addMessage('assistant', msg.content || '', msg.citations || []);
237
+ } catch {}
238
+ });
239
+
240
+ state.eventSource.addEventListener('assistant.error', (e: MessageEvent) => {
241
+ removeThinking();
242
+ try {
243
+ const data = JSON.parse(e.data);
244
+ addMessage('assistant', data.error || 'Something went wrong. Please try again.');
245
+ } catch {
246
+ addMessage('assistant', 'Something went wrong. Please try again.');
247
+ }
248
+ });
249
+
250
+ state.eventSource.onerror = () => {};
251
+ }
252
+
253
+ function sendMessage(text: string) {
254
+ if (!text.trim()) return;
255
+ addMessage('user', text);
256
+ addThinking();
257
+
258
+ bootstrap()
259
+ .then(() =>
260
+ fetch(API_BASE + '/messages', {
261
+ method: 'POST',
262
+ headers: { 'Content-Type': 'application/json' },
263
+ credentials: 'include',
264
+ body: JSON.stringify({
265
+ message: text,
266
+ conversation_id: state.conversationId,
267
+ }),
268
+ })
269
+ )
270
+ .then((r) => {
271
+ if (r.status === 429) {
272
+ removeThinking();
273
+ addMessage('assistant', 'Rate limited. Please wait a moment and try again.');
274
+ return;
275
+ }
276
+ return r.json();
277
+ })
278
+ .then((data: any) => {
279
+ if (!data) return;
280
+ if (data.conversation_id) state.conversationId = data.conversation_id;
281
+ if (data.conversation_token) state.conversationToken = data.conversation_token;
282
+ saveState();
283
+ if (!state.eventSource || state.eventSource.readyState === 2) {
284
+ connectSSE();
285
+ }
286
+ })
287
+ .catch(() => {
288
+ removeThinking();
289
+ addMessage('assistant', 'Failed to connect. Please try again.');
290
+ });
291
+ }
292
+
293
+ // Event handlers
294
+ askInput.onkeydown = (e) => { if (e.key === 'Enter') { const t = askInput.value.trim(); if (!t) return; askInput.value = ''; openPanel(); sendMessage(t); } };
295
+ askSubmit.onclick = () => { const t = askInput.value.trim(); if (!t) return; askInput.value = ''; openPanel(); sendMessage(t); };
296
+ chatInput.onkeydown = (e) => { if (e.key === 'Enter') { const t = chatInput.value.trim(); if (!t) return; chatInput.value = ''; sendMessage(t); } };
297
+ sendBtn.onclick = () => { const t = chatInput.value.trim(); if (!t) return; chatInput.value = ''; sendMessage(t); };
298
+
299
+ panel.addEventListener('click', (e) => {
300
+ const actionBtn = (e.target as HTMLElement).closest('[data-velu-action]');
301
+ if (!actionBtn) return;
302
+ const action = actionBtn.getAttribute('data-velu-action');
303
+ if (action === 'close') closePanel();
304
+ else if (action === 'expand') toggleExpand();
305
+ else if (action === 'reset') resetChat();
306
+ });
307
+
308
+ // Hide ask bar only when truly near the page bottom.
309
+ function syncAskBarVisibility() {
310
+ if (isPanelOpen()) return;
311
+ const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
312
+ const docHeight = document.documentElement.scrollHeight;
313
+ const winHeight = window.innerHeight;
314
+ const bottomGap = docHeight - scrollTop - winHeight;
315
+ const nearBottomThreshold = 8;
316
+
317
+ if (docHeight <= winHeight + 2) {
318
+ askBar.classList.remove('velu-ask-bar-hidden');
319
+ return;
320
+ }
321
+
322
+ if (bottomGap <= nearBottomThreshold) {
323
+ askBar.classList.add('velu-ask-bar-hidden');
324
+ } else {
325
+ askBar.classList.remove('velu-ask-bar-hidden');
326
+ }
327
+ }
328
+
329
+ window.addEventListener('scroll', syncAskBarVisibility, { passive: true });
330
+ window.addEventListener('resize', syncAskBarVisibility, { passive: true });
331
+ syncAskBarVisibility();
332
+
333
+ document.addEventListener('keydown', (e) => {
334
+ if (e.key === 'Escape' && isPanelOpen()) closePanel();
335
+ });
336
+
337
+ // Restore from session
338
+ try {
339
+ const savedOpen = sessionStorage.getItem('velu-panel-open');
340
+ const savedExpanded = sessionStorage.getItem('velu-panel-expanded');
341
+ const savedMessages = sessionStorage.getItem('velu-panel-messages');
342
+ const savedConvId = sessionStorage.getItem('velu-conv-id');
343
+ const savedConvToken = sessionStorage.getItem('velu-conv-token');
344
+ const savedSeq = sessionStorage.getItem('velu-last-seq');
345
+ if (savedConvId) state.conversationId = savedConvId;
346
+ if (savedConvToken) state.conversationToken = savedConvToken;
347
+ if (savedSeq) state.lastSeq = parseInt(savedSeq, 10) || 0;
348
+ if (savedMessages) messagesEl.innerHTML = savedMessages;
349
+ if (savedExpanded === '1') {
350
+ state.expanded = true;
351
+ panel.classList.add('velu-assistant-expanded');
352
+ document.documentElement.classList.add('velu-assistant-wide');
353
+ }
354
+ if (savedOpen === '1') {
355
+ openPanel();
356
+ if (state.conversationId) connectSSE();
357
+ }
358
+ } catch {}
359
+
360
+ bootstrap();
361
+ }
@@ -0,0 +1,80 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
4
+
5
+ function hashContent(content: string): string {
6
+ let hash = 0;
7
+ for (let i = 0; i < content.length; i++) {
8
+ hash = ((hash << 5) - hash + content.charCodeAt(i)) | 0;
9
+ }
10
+ return `velu-banner-${hash}`;
11
+ }
12
+
13
+ function parseMarkdownLinks(text: string): string {
14
+ return text.replace(
15
+ /\[([^\]]+)\]\(([^)]+)\)/g,
16
+ '<a href="$2">$1</a>',
17
+ );
18
+ }
19
+
20
+ interface VeluBannerProps {
21
+ content: string;
22
+ dismissible: boolean;
23
+ }
24
+
25
+ function setSizeVar(el: HTMLElement | null) {
26
+ const h = el ? el.offsetHeight : 0;
27
+ document.documentElement.style.setProperty('--velu-announcement-h', `${h}px`);
28
+ }
29
+
30
+ export function VeluBanner({ content, dismissible }: VeluBannerProps) {
31
+ const storageKey = useMemo(() => hashContent(content), [content]);
32
+ const [dismissed, setDismissed] = useState(true);
33
+ const ref = useRef<HTMLDivElement>(null);
34
+
35
+ useEffect(() => {
36
+ if (dismissible) {
37
+ const stored = localStorage.getItem(storageKey);
38
+ setDismissed(stored === '1');
39
+ } else {
40
+ setDismissed(false);
41
+ }
42
+ }, [dismissible, storageKey]);
43
+
44
+ const measuredRef = useCallback((node: HTMLDivElement | null) => {
45
+ (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
46
+ setSizeVar(node);
47
+ }, []);
48
+
49
+ useEffect(() => {
50
+ if (dismissed) {
51
+ setSizeVar(null);
52
+ }
53
+ }, [dismissed]);
54
+
55
+ if (dismissed) return null;
56
+
57
+ const html = parseMarkdownLinks(content);
58
+
59
+ return (
60
+ <div className="velu-announcement" role="banner" ref={measuredRef}>
61
+ <span
62
+ className="velu-announcement-content"
63
+ dangerouslySetInnerHTML={{ __html: html }}
64
+ />
65
+ {dismissible && (
66
+ <button
67
+ type="button"
68
+ className="velu-announcement-dismiss"
69
+ aria-label="Dismiss banner"
70
+ onClick={() => {
71
+ localStorage.setItem(storageKey, '1');
72
+ setDismissed(true);
73
+ }}
74
+ >
75
+ &#x2715;
76
+ </button>
77
+ )}
78
+ </div>
79
+ );
80
+ }
@@ -0,0 +1,114 @@
1
+ 'use client';
2
+
3
+ import { createPortal } from 'react-dom';
4
+ import { useEffect, useMemo, useState } from 'react';
5
+
6
+ interface ChangelogFiltersProps {
7
+ tags: string[];
8
+ }
9
+
10
+ function normalize(value: string): string {
11
+ return value.trim().toLowerCase();
12
+ }
13
+
14
+ function parseNodeTags(value: string | undefined): string[] {
15
+ if (!value) return [];
16
+ return value
17
+ .split('|')
18
+ .map((tag) => normalize(tag))
19
+ .filter(Boolean);
20
+ }
21
+
22
+ const VELU_CHANGELOG_FILTER_HOST_ID = 'velu-changelog-filter-host';
23
+
24
+ function ensureTocHost(): HTMLDivElement | null {
25
+ if (typeof document === 'undefined') return null;
26
+ const toc = document.getElementById('nd-toc');
27
+ if (!toc) return null;
28
+ toc.classList.add('velu-changelog-filters-only');
29
+
30
+ let host = document.getElementById(VELU_CHANGELOG_FILTER_HOST_ID) as HTMLDivElement | null;
31
+ if (!host) {
32
+ host = document.createElement('div');
33
+ host.id = VELU_CHANGELOG_FILTER_HOST_ID;
34
+ host.className = 'velu-changelog-filter-host';
35
+ toc.prepend(host);
36
+ }
37
+
38
+ return host;
39
+ }
40
+
41
+ export function ChangelogFilters({ tags }: ChangelogFiltersProps) {
42
+ const [selected, setSelected] = useState<string[]>([]);
43
+ const [tocHost, setTocHost] = useState<HTMLDivElement | null>(null);
44
+ const uniqueTags = useMemo(
45
+ () => Array.from(new Set(tags.map((tag) => tag.trim()).filter(Boolean))),
46
+ [tags],
47
+ );
48
+
49
+ useEffect(() => {
50
+ let frame = 0;
51
+ const attach = () => {
52
+ const host = ensureTocHost();
53
+ if (host) {
54
+ setTocHost(host);
55
+ return;
56
+ }
57
+ frame = window.requestAnimationFrame(attach);
58
+ };
59
+ attach();
60
+
61
+ return () => {
62
+ if (frame) window.cancelAnimationFrame(frame);
63
+ const toc = document.getElementById('nd-toc');
64
+ toc?.classList.remove('velu-changelog-filters-only');
65
+ const host = document.getElementById(VELU_CHANGELOG_FILTER_HOST_ID);
66
+ host?.remove();
67
+ };
68
+ }, []);
69
+
70
+ useEffect(() => {
71
+ const updates = Array.from(document.querySelectorAll<HTMLElement>('.velu-update'));
72
+ const normalizedSelected = selected.map((tag) => normalize(tag));
73
+
74
+ for (const update of updates) {
75
+ const updateTags = parseNodeTags(update.dataset.updateTags);
76
+ const visible = normalizedSelected.length === 0
77
+ || normalizedSelected.some((tag) => updateTags.includes(tag));
78
+ update.hidden = !visible;
79
+ }
80
+ }, [selected]);
81
+
82
+ if (uniqueTags.length === 0) return null;
83
+
84
+ const content = (
85
+ <div className="velu-changelog-filter-block">
86
+ <div className="velu-changelog-filter-heading">Filters</div>
87
+ <div className="velu-changelog-filters" role="group" aria-label="Filter updates by tag">
88
+ {uniqueTags.map((tag) => {
89
+ const active = selected.some((entry) => normalize(entry) === normalize(tag));
90
+ return (
91
+ <button
92
+ key={tag}
93
+ type="button"
94
+ className={['velu-changelog-filter', active ? 'active' : ''].filter(Boolean).join(' ')}
95
+ onClick={() => {
96
+ setSelected((prev) => {
97
+ const hasTag = prev.some((entry) => normalize(entry) === normalize(tag));
98
+ return hasTag
99
+ ? prev.filter((entry) => normalize(entry) !== normalize(tag))
100
+ : [...prev, tag];
101
+ });
102
+ }}
103
+ >
104
+ {tag}
105
+ </button>
106
+ );
107
+ })}
108
+ </div>
109
+ </div>
110
+ );
111
+
112
+ if (!tocHost) return null;
113
+ return createPortal(content, tocHost);
114
+ }