@veluai/velu 0.2.19 → 0.2.23
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/dist/cli.js +52 -35
- package/package.json +64 -64
- package/runtime/velu-ui/components/ChangelogFilters.jsx +75 -0
- package/runtime/velu-ui/components/Chatbot.jsx +125 -38
- package/runtime/velu-ui/components/ContextMenu.jsx +45 -16
- package/runtime/velu-ui/components/Update.jsx +92 -0
- package/runtime/velu-ui/components/changelog-filters.css +43 -0
- package/runtime/velu-ui/components/chatbot.css +66 -1
- package/runtime/velu-ui/components/context-menu.css +26 -0
- package/runtime/velu-ui/components/page-feedback.css +20 -0
- package/runtime/velu-ui/components/update.css +110 -0
- package/runtime/velu-ui/index.js +2 -0
- package/runtime/velu-ui/lib/docs-assistant.js +26 -3
- package/runtime/velu-ui/lib/pagefind.js +4 -1
- package/runtime/velu-ui/mdx-components.jsx +2 -0
- package/runtime/velu-ui/styles.css +2 -0
- package/schema/velu.schema.json +0 -4
- package/src/lib/known-components.js +1 -0
- package/src/runtime/App.jsx +49 -6
- package/src/runtime/client-entry.jsx +6 -1
- package/src/runtime/server-entry.jsx +9 -1
- package/templates/starter/index.mdx +4 -4
- package/templates/starter/velu.json +0 -1
package/package.json
CHANGED
|
@@ -1,64 +1,64 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@veluai/velu",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"type": "module",
|
|
5
|
-
"bin": "./dist/cli.js",
|
|
6
|
-
"publishConfig": {
|
|
7
|
-
"access": "public"
|
|
8
|
-
},
|
|
9
|
-
"files": [
|
|
10
|
-
"dist/**",
|
|
11
|
-
"src/runtime/**",
|
|
12
|
-
"src/navigation.js",
|
|
13
|
-
"src/template.html",
|
|
14
|
-
"src/lib/issues.js",
|
|
15
|
-
"src/lib/extract-mdx-error.js",
|
|
16
|
-
"src/lib/known-components.js",
|
|
17
|
-
"runtime/velu-ui/**",
|
|
18
|
-
"schema/**",
|
|
19
|
-
"templates/**"
|
|
20
|
-
],
|
|
21
|
-
"scripts": {
|
|
22
|
-
"build": "node scripts/build.mjs",
|
|
23
|
-
"prepack": "node scripts/build.mjs",
|
|
24
|
-
"build:preview-runtime": "vite build -c vite.preview.config.mjs",
|
|
25
|
-
"test:parity": "node scripts/parity-test.mjs"
|
|
26
|
-
},
|
|
27
|
-
"dependencies": {
|
|
28
|
-
"@apidevtools/swagger-parser": "^10.1.0",
|
|
29
|
-
"@icons-pack/react-simple-icons": ">=13.0.0 <13.13.0",
|
|
30
|
-
"@mdx-js/mdx": "^3.1.1",
|
|
31
|
-
"@mdx-js/react": "^3.1.1",
|
|
32
|
-
"@mdx-js/rollup": "^3.1.1",
|
|
33
|
-
"@tailwindcss/vite": "^4.1.0",
|
|
34
|
-
"@vitejs/plugin-react": "^4.3.4",
|
|
35
|
-
"chokidar": "^3.6.0",
|
|
36
|
-
"estree-util-value-to-estree": "^3.5.0",
|
|
37
|
-
"express": "^4.21.1",
|
|
38
|
-
"github-slugger": "^2.0.0",
|
|
39
|
-
"iso-639-1": "^3.1.5",
|
|
40
|
-
"js-yaml": "^4.1.0",
|
|
41
|
-
"lucide-react": "^0.460.0",
|
|
42
|
-
"mdast-util-to-string": "^4.0.0",
|
|
43
|
-
"prism-react-renderer": "^2.4.0",
|
|
44
|
-
"prismjs": "^1.29.0",
|
|
45
|
-
"react": "^18.3.1",
|
|
46
|
-
"react-dom": "^18.3.1",
|
|
47
|
-
"react-router-dom": "^6.28.0",
|
|
48
|
-
"rehype-slug": "^6.0.0",
|
|
49
|
-
"remark-frontmatter": "^5.0.0",
|
|
50
|
-
"remark-gfm": "^4.0.1",
|
|
51
|
-
"remark-mdx-frontmatter": "^5.2.0",
|
|
52
|
-
"tailwindcss": "^4.1.0",
|
|
53
|
-
"unist-util-visit": "^5.1.0",
|
|
54
|
-
"vite": "^5.4.11"
|
|
55
|
-
},
|
|
56
|
-
"devDependencies": {
|
|
57
|
-
"@fontsource/outfit": "^5.0.0",
|
|
58
|
-
"@resvg/resvg-js": "^2.6.2",
|
|
59
|
-
"esbuild": "^0.28.0",
|
|
60
|
-
"pagefind": "^1.3.0",
|
|
61
|
-
"playwright": "^1.48.0",
|
|
62
|
-
"satori": "^0.12.0"
|
|
63
|
-
}
|
|
64
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@veluai/velu",
|
|
3
|
+
"version": "0.2.23",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": "./dist/cli.js",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/**",
|
|
11
|
+
"src/runtime/**",
|
|
12
|
+
"src/navigation.js",
|
|
13
|
+
"src/template.html",
|
|
14
|
+
"src/lib/issues.js",
|
|
15
|
+
"src/lib/extract-mdx-error.js",
|
|
16
|
+
"src/lib/known-components.js",
|
|
17
|
+
"runtime/velu-ui/**",
|
|
18
|
+
"schema/**",
|
|
19
|
+
"templates/**"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "node scripts/build.mjs",
|
|
23
|
+
"prepack": "node scripts/build.mjs",
|
|
24
|
+
"build:preview-runtime": "vite build -c vite.preview.config.mjs",
|
|
25
|
+
"test:parity": "node scripts/parity-test.mjs"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@apidevtools/swagger-parser": "^10.1.0",
|
|
29
|
+
"@icons-pack/react-simple-icons": ">=13.0.0 <13.13.0",
|
|
30
|
+
"@mdx-js/mdx": "^3.1.1",
|
|
31
|
+
"@mdx-js/react": "^3.1.1",
|
|
32
|
+
"@mdx-js/rollup": "^3.1.1",
|
|
33
|
+
"@tailwindcss/vite": "^4.1.0",
|
|
34
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
35
|
+
"chokidar": "^3.6.0",
|
|
36
|
+
"estree-util-value-to-estree": "^3.5.0",
|
|
37
|
+
"express": "^4.21.1",
|
|
38
|
+
"github-slugger": "^2.0.0",
|
|
39
|
+
"iso-639-1": "^3.1.5",
|
|
40
|
+
"js-yaml": "^4.1.0",
|
|
41
|
+
"lucide-react": "^0.460.0",
|
|
42
|
+
"mdast-util-to-string": "^4.0.0",
|
|
43
|
+
"prism-react-renderer": "^2.4.0",
|
|
44
|
+
"prismjs": "^1.29.0",
|
|
45
|
+
"react": "^18.3.1",
|
|
46
|
+
"react-dom": "^18.3.1",
|
|
47
|
+
"react-router-dom": "^6.28.0",
|
|
48
|
+
"rehype-slug": "^6.0.0",
|
|
49
|
+
"remark-frontmatter": "^5.0.0",
|
|
50
|
+
"remark-gfm": "^4.0.1",
|
|
51
|
+
"remark-mdx-frontmatter": "^5.2.0",
|
|
52
|
+
"tailwindcss": "^4.1.0",
|
|
53
|
+
"unist-util-visit": "^5.1.0",
|
|
54
|
+
"vite": "^5.4.11"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@fontsource/outfit": "^5.0.0",
|
|
58
|
+
"@resvg/resvg-js": "^2.6.2",
|
|
59
|
+
"esbuild": "^0.28.0",
|
|
60
|
+
"pagefind": "^1.3.0",
|
|
61
|
+
"playwright": "^1.48.0",
|
|
62
|
+
"satori": "^0.12.0"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ChangelogFilters — replaces the table of contents on a changelog page with a
|
|
5
|
+
* tag filter. The tag set is passed in (collected at build time from each
|
|
6
|
+
* <Update tags=...>); toggling a tag shows/hides the matching entries purely
|
|
7
|
+
* from the DOM via the `data-update-tags` attribute each <Update> emits.
|
|
8
|
+
*
|
|
9
|
+
* An entry is shown when no tag is selected, or when it carries any selected
|
|
10
|
+
* tag (OR semantics).
|
|
11
|
+
*
|
|
12
|
+
* @param {{ tags?: string[] }} props
|
|
13
|
+
*/
|
|
14
|
+
export default function ChangelogFilters({ tags = [] }) {
|
|
15
|
+
const [selected, setSelected] = useState([]);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (typeof document === 'undefined') return undefined;
|
|
19
|
+
const sel = selected.map((s) => s.toLowerCase());
|
|
20
|
+
const nodes = document.querySelectorAll('.velu-update');
|
|
21
|
+
nodes.forEach((el) => {
|
|
22
|
+
const own = (el.getAttribute('data-update-tags') || '')
|
|
23
|
+
.split('|')
|
|
24
|
+
.map((t) => t.trim().toLowerCase())
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
const visible = sel.length === 0 || sel.some((s) => own.includes(s));
|
|
27
|
+
el.hidden = !visible;
|
|
28
|
+
});
|
|
29
|
+
return () => {
|
|
30
|
+
document.querySelectorAll('.velu-update').forEach((el) => {
|
|
31
|
+
el.hidden = false;
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
}, [selected]);
|
|
35
|
+
|
|
36
|
+
if (!tags.length) return null;
|
|
37
|
+
|
|
38
|
+
const toggle = (tag) => {
|
|
39
|
+
const lower = tag.toLowerCase();
|
|
40
|
+
setSelected((prev) =>
|
|
41
|
+
prev.some((s) => s.toLowerCase() === lower)
|
|
42
|
+
? prev.filter((s) => s.toLowerCase() !== lower)
|
|
43
|
+
: [...prev, tag],
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="velu-changelog-filters">
|
|
49
|
+
<div className="velu-changelog-filters__heading">Filters</div>
|
|
50
|
+
<div
|
|
51
|
+
className="velu-changelog-filters__list"
|
|
52
|
+
role="group"
|
|
53
|
+
aria-label="Filter updates by tag"
|
|
54
|
+
>
|
|
55
|
+
{tags.map((tag) => {
|
|
56
|
+
const active = selected.some((s) => s.toLowerCase() === tag.toLowerCase());
|
|
57
|
+
return (
|
|
58
|
+
<button
|
|
59
|
+
key={tag}
|
|
60
|
+
type="button"
|
|
61
|
+
className={
|
|
62
|
+
'velu-changelog-filter' +
|
|
63
|
+
(active ? ' velu-changelog-filter--active' : '')
|
|
64
|
+
}
|
|
65
|
+
aria-pressed={active}
|
|
66
|
+
onClick={() => toggle(tag)}
|
|
67
|
+
>
|
|
68
|
+
{tag}
|
|
69
|
+
</button>
|
|
70
|
+
);
|
|
71
|
+
})}
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -195,48 +195,135 @@ function onSourceClick(e, s, onNavigate) {
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
/* Render partial tokens as paragraphs (split on \n\n, single \n → <br>). */
|
|
198
|
+
/* Flatten the token stream back to a markdown string (code → `…`, citation →
|
|
199
|
+
[n]) so the assistant's markdown answer can be rendered with formatting. */
|
|
200
|
+
function tokensToMarkdown(tokens) {
|
|
201
|
+
return tokens
|
|
202
|
+
.map((tk) => (tk.kind === 'cite' ? `[${tk.n}]` : tk.kind === 'code' ? `\`${tk.v}\`` : tk.v))
|
|
203
|
+
.join('');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const CITE_RE = /\[(\d+)\]/;
|
|
207
|
+
const INLINE_RULES = [
|
|
208
|
+
{ re: /\*\*([^*]+)\*\*/, type: 'bold' },
|
|
209
|
+
{ re: /__([^_]+)__/, type: 'bold' },
|
|
210
|
+
{ re: /\*([^*\n]+)\*/, type: 'italic' },
|
|
211
|
+
{ re: /(?<![A-Za-z0-9_])_([^_\n]+)_(?![A-Za-z0-9_])/, type: 'italic' },
|
|
212
|
+
{ re: /`([^`]+)`/, type: 'code' },
|
|
213
|
+
{ re: /\[([^\]]+)\]\(([^)]+)\)/, type: 'link' },
|
|
214
|
+
{ re: CITE_RE, type: 'cite' },
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
/* Inline markdown → React nodes: **bold**, *italic*, `code`, [text](url) links,
|
|
218
|
+
and [n] citation chips. Builds React elements (never innerHTML) so it's XSS-safe. */
|
|
219
|
+
function renderInline(text, sources, onNavigate, keyBase) {
|
|
220
|
+
const out = [];
|
|
221
|
+
let rest = text;
|
|
222
|
+
let k = 0;
|
|
223
|
+
while (rest) {
|
|
224
|
+
let best = null;
|
|
225
|
+
for (const rule of INLINE_RULES) {
|
|
226
|
+
const m = rule.re.exec(rest);
|
|
227
|
+
if (m && (best === null || m.index < best.m.index)) best = { rule, m };
|
|
228
|
+
}
|
|
229
|
+
if (!best) {
|
|
230
|
+
out.push(<React.Fragment key={`${keyBase}-${k++}`}>{rest}</React.Fragment>);
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
const { rule, m } = best;
|
|
234
|
+
if (m.index > 0) out.push(<React.Fragment key={`${keyBase}-${k++}`}>{rest.slice(0, m.index)}</React.Fragment>);
|
|
235
|
+
const key = `${keyBase}-${k++}`;
|
|
236
|
+
if (rule.type === 'bold') out.push(<strong key={key}>{m[1]}</strong>);
|
|
237
|
+
else if (rule.type === 'italic') out.push(<em key={key}>{m[1]}</em>);
|
|
238
|
+
else if (rule.type === 'code') out.push(<code key={key}>{m[1]}</code>);
|
|
239
|
+
else if (rule.type === 'link') out.push(
|
|
240
|
+
<a key={key} href={m[2]} target="_blank" rel="noopener noreferrer">{m[1]}</a>,
|
|
241
|
+
);
|
|
242
|
+
else if (rule.type === 'cite') {
|
|
243
|
+
const n = Number(m[1]);
|
|
244
|
+
const src = sources?.find((s) => s.num === n);
|
|
245
|
+
out.push(
|
|
246
|
+
<a
|
|
247
|
+
key={key}
|
|
248
|
+
className="velu-chatbot__cite"
|
|
249
|
+
href={sourceHref(src) || '#'}
|
|
250
|
+
onClick={(e) => onSourceClick(e, src, onNavigate)}
|
|
251
|
+
title={citeTitle(src)}
|
|
252
|
+
>
|
|
253
|
+
{n}
|
|
254
|
+
</a>,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
rest = rest.slice(m.index + m[0].length);
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* Block-level markdown → React: headings, bullet/numbered lists, fenced code
|
|
263
|
+
blocks, blockquotes, and paragraphs (each rendered through renderInline). */
|
|
198
264
|
function renderStream(tokens, sources, onNavigate) {
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
265
|
+
const text = tokensToMarkdown(tokens);
|
|
266
|
+
const lines = text.replace(/\r/g, '').split('\n');
|
|
267
|
+
const blocks = [];
|
|
268
|
+
let i = 0;
|
|
269
|
+
let b = 0;
|
|
270
|
+
const listRe = /^\s*([-*+]|\d+\.)\s+/;
|
|
271
|
+
while (i < lines.length) {
|
|
272
|
+
const line = lines[i];
|
|
273
|
+
if (!line.trim()) { i++; continue; }
|
|
274
|
+
|
|
275
|
+
if (line.trim().startsWith('```')) {
|
|
276
|
+
const buf = [];
|
|
277
|
+
i++;
|
|
278
|
+
while (i < lines.length && !lines[i].trim().startsWith('```')) { buf.push(lines[i]); i++; }
|
|
279
|
+
i++;
|
|
280
|
+
blocks.push(<pre key={`b${b++}`} className="velu-chatbot__pre"><code>{buf.join('\n')}</code></pre>);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const h = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
285
|
+
if (h) {
|
|
286
|
+
const lvl = Math.min(h[1].length + 2, 6);
|
|
287
|
+
const Tag = `h${lvl}`;
|
|
288
|
+
blocks.push(<Tag key={`b${b++}`}>{renderInline(h[2], sources, onNavigate, `b${b}`)}</Tag>);
|
|
289
|
+
i++;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (listRe.test(line)) {
|
|
294
|
+
const ordered = /^\s*\d+\.\s+/.test(line);
|
|
295
|
+
const items = [];
|
|
296
|
+
while (i < lines.length && listRe.test(lines[i])) {
|
|
297
|
+
const content = lines[i].replace(listRe, '');
|
|
298
|
+
items.push(<li key={`li${b}-${i}`}>{renderInline(content, sources, onNavigate, `li${b}-${i}`)}</li>);
|
|
299
|
+
i++;
|
|
207
300
|
}
|
|
208
|
-
|
|
209
|
-
|
|
301
|
+
const ListTag = ordered ? 'ol' : 'ul';
|
|
302
|
+
blocks.push(<ListTag key={`b${b++}`}>{items}</ListTag>);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (line.trim().startsWith('>')) {
|
|
307
|
+
const buf = [];
|
|
308
|
+
while (i < lines.length && lines[i].trim().startsWith('>')) { buf.push(lines[i].replace(/^\s*>\s?/, '')); i++; }
|
|
309
|
+
blocks.push(<blockquote key={`b${b++}`}>{renderInline(buf.join(' '), sources, onNavigate, `bq${b}`)}</blockquote>);
|
|
310
|
+
continue;
|
|
210
311
|
}
|
|
312
|
+
|
|
313
|
+
const buf = [];
|
|
314
|
+
while (
|
|
315
|
+
i < lines.length && lines[i].trim()
|
|
316
|
+
&& !listRe.test(lines[i]) && !/^#{1,6}\s/.test(lines[i])
|
|
317
|
+
&& !lines[i].trim().startsWith('```') && !lines[i].trim().startsWith('>')
|
|
318
|
+
) { buf.push(lines[i]); i++; }
|
|
319
|
+
const pnodes = [];
|
|
320
|
+
buf.forEach((ln, li) => {
|
|
321
|
+
if (li > 0) pnodes.push(<br key={`p${b}-br${li}`} />);
|
|
322
|
+
pnodes.push(...renderInline(ln, sources, onNavigate, `p${b}-${li}`));
|
|
323
|
+
});
|
|
324
|
+
blocks.push(<p key={`b${b++}`}>{pnodes}</p>);
|
|
211
325
|
}
|
|
212
|
-
return
|
|
213
|
-
<p key={pi}>
|
|
214
|
-
{para.map((tk, i) => {
|
|
215
|
-
if (tk.kind === 'cite') {
|
|
216
|
-
const src = sources?.find((s) => s.num === tk.n);
|
|
217
|
-
return (
|
|
218
|
-
<a
|
|
219
|
-
key={i}
|
|
220
|
-
className="velu-chatbot__cite"
|
|
221
|
-
href={sourceHref(src) || '#'}
|
|
222
|
-
onClick={(e) => onSourceClick(e, src, onNavigate)}
|
|
223
|
-
title={citeTitle(src)}
|
|
224
|
-
>
|
|
225
|
-
{tk.n}
|
|
226
|
-
</a>
|
|
227
|
-
);
|
|
228
|
-
}
|
|
229
|
-
if (tk.kind === 'code') return <code key={i}>{tk.v}</code>;
|
|
230
|
-
const lines = tk.v.split('\n');
|
|
231
|
-
return lines.map((line, li) => (
|
|
232
|
-
<React.Fragment key={`${i}-${li}`}>
|
|
233
|
-
{li > 0 && <br />}
|
|
234
|
-
{line}
|
|
235
|
-
</React.Fragment>
|
|
236
|
-
));
|
|
237
|
-
})}
|
|
238
|
-
</p>
|
|
239
|
-
));
|
|
326
|
+
return blocks;
|
|
240
327
|
}
|
|
241
328
|
|
|
242
329
|
/* ── Header ─────────────────────────────────────────────────────────── */
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
Download,
|
|
10
10
|
Bot,
|
|
11
11
|
Wind,
|
|
12
|
+
Rss,
|
|
12
13
|
} from 'lucide-react';
|
|
13
14
|
import Cluster from '../primitives/Cluster.jsx';
|
|
14
15
|
import {
|
|
@@ -42,11 +43,15 @@ import {
|
|
|
42
43
|
* getMarkdown?: () => string|Promise<string>, // override the .md-twin fetch
|
|
43
44
|
* // (instant preview: no .md twin exists —
|
|
44
45
|
* // the raw source comes from the store)
|
|
46
|
+
* basePath?: string, // subpath prefix (e.g. "/docs") for the
|
|
47
|
+
* // server-served .md twins + /mcp endpoint
|
|
45
48
|
* }} props
|
|
46
49
|
*/
|
|
47
50
|
|
|
48
|
-
// The Markdown URL for a page path (`/` → `/index.md`)
|
|
49
|
-
|
|
51
|
+
// The Markdown URL for a page path (`/` → `/index.md`), under an optional
|
|
52
|
+
// subpath prefix (e.g. "/docs" → "/docs/quickstart.md").
|
|
53
|
+
const mdUrlForPage = (url, basePath = '') =>
|
|
54
|
+
basePath + (url === '/' ? '/index.md' : `${url}.md`);
|
|
50
55
|
|
|
51
56
|
// key → { icon, label, desc, external?, apiOnly?, kind, url? }
|
|
52
57
|
const REGISTRY = {
|
|
@@ -61,11 +66,11 @@ const REGISTRY = {
|
|
|
61
66
|
aistudio: { icon: Sparkles, label: 'Open in AI Studio', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://aistudio.google.com/app/prompts/new_chat?prompt=${q}` },
|
|
62
67
|
devin: { icon: Bot, label: 'Open in Devin', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://app.devin.ai/?prompt=${q}` },
|
|
63
68
|
windsurf: { icon: Wind, label: 'Open in Windsurf', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://windsurf.com/?q=${q}` },
|
|
64
|
-
mcp: { icon: Terminal, label: 'Copy MCP install command', desc: 'Copy
|
|
69
|
+
mcp: { icon: Terminal, label: 'Copy MCP install command', desc: 'Copy command to install the MCP server', kind: 'mcp' },
|
|
65
70
|
'add-mcp': { icon: Terminal, label: 'Add MCP server', desc: 'Copy command to add the MCP server', kind: 'mcp' },
|
|
66
71
|
cursor: { icon: CursorIcon, label: 'Connect to Cursor', desc: 'Install MCP Server on Cursor', kind: 'cursor', external: true },
|
|
67
72
|
vscode: { icon: VscodeIcon, label: 'Connect to VS Code', desc: 'Install MCP Server on VS Code', kind: 'vscode', external: true },
|
|
68
|
-
'devin-mcp': { icon: Terminal, label: 'Connect to Devin', desc: '
|
|
73
|
+
'devin-mcp': { icon: Terminal, label: 'Connect to Devin', desc: 'Copy command to add the MCP server on Devin', kind: 'devin' },
|
|
69
74
|
};
|
|
70
75
|
|
|
71
76
|
export default function ContextMenu({
|
|
@@ -77,6 +82,8 @@ export default function ContextMenu({
|
|
|
77
82
|
options = [],
|
|
78
83
|
onAssistant,
|
|
79
84
|
getMarkdown,
|
|
85
|
+
basePath = '',
|
|
86
|
+
rssHref,
|
|
80
87
|
}) {
|
|
81
88
|
const [open, setOpen] = React.useState(false);
|
|
82
89
|
const [copied, setCopied] = React.useState(false);
|
|
@@ -107,7 +114,7 @@ export default function ContextMenu({
|
|
|
107
114
|
copiedTimer.current = setTimeout(() => setCopied(false), 1600);
|
|
108
115
|
};
|
|
109
116
|
|
|
110
|
-
const mdUrl = mdUrlForPage(pageUrl);
|
|
117
|
+
const mdUrl = mdUrlForPage(pageUrl, basePath);
|
|
111
118
|
|
|
112
119
|
// Build the descriptor list from the configured options (skip API-only items
|
|
113
120
|
// off API pages, and any unsupported keys).
|
|
@@ -166,6 +173,9 @@ export default function ContextMenu({
|
|
|
166
173
|
setOpen(false);
|
|
167
174
|
const origin = window.location.origin;
|
|
168
175
|
const absMd = origin + mdUrl;
|
|
176
|
+
const mcpUrl = `${origin}${basePath}/mcp`;
|
|
177
|
+
// CLI-safe server name (siteName may contain spaces).
|
|
178
|
+
const serverName = (siteName || 'docs').trim().replace(/\s+/g, '-').toLowerCase() || 'docs';
|
|
169
179
|
switch (it.kind) {
|
|
170
180
|
case 'copy':
|
|
171
181
|
return copyPage();
|
|
@@ -192,12 +202,12 @@ export default function ContextMenu({
|
|
|
192
202
|
return void window.open(it.url(q), '_blank', 'noopener');
|
|
193
203
|
}
|
|
194
204
|
case 'mcp':
|
|
195
|
-
//
|
|
196
|
-
// command
|
|
197
|
-
|
|
205
|
+
// Hosted HTTP MCP server lives at <origin>/mcp. Copy a client-neutral
|
|
206
|
+
// command: `mcp-remote` bridges the HTTP server to stdio, so it works
|
|
207
|
+
// in any MCP client config (Claude, Cursor, VS Code, Windsurf, …).
|
|
208
|
+
return copyText(`npx mcp-remote ${mcpUrl}`);
|
|
198
209
|
case 'cursor': {
|
|
199
|
-
const cfg =
|
|
200
|
-
typeof btoa === 'function' ? btoa(JSON.stringify({ url: `${origin}/mcp` })) : '';
|
|
210
|
+
const cfg = typeof btoa === 'function' ? btoa(JSON.stringify({ url: mcpUrl })) : '';
|
|
201
211
|
return void window.open(
|
|
202
212
|
`cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(siteName)}&config=${cfg}`,
|
|
203
213
|
'_blank',
|
|
@@ -205,13 +215,16 @@ export default function ContextMenu({
|
|
|
205
215
|
);
|
|
206
216
|
}
|
|
207
217
|
case 'vscode': {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
'
|
|
212
|
-
'noopener',
|
|
218
|
+
// Official VS Code install URI: `vscode:mcp/install?<urlencoded JSON>`,
|
|
219
|
+
// with the transport `type` inside the payload.
|
|
220
|
+
const cfg = encodeURIComponent(
|
|
221
|
+
JSON.stringify({ name: siteName, type: 'http', url: mcpUrl }),
|
|
213
222
|
);
|
|
223
|
+
return void window.open(`vscode:mcp/install?${cfg}`, '_blank', 'noopener');
|
|
214
224
|
}
|
|
225
|
+
case 'devin':
|
|
226
|
+
// Devin has no install deep-link — copy its CLI add command instead.
|
|
227
|
+
return copyText(`devin mcp add ${serverName} ${mcpUrl}`);
|
|
215
228
|
case 'custom':
|
|
216
229
|
return void window.open(it.href, '_blank', 'noopener');
|
|
217
230
|
default:
|
|
@@ -220,7 +233,20 @@ export default function ContextMenu({
|
|
|
220
233
|
};
|
|
221
234
|
|
|
222
235
|
// Nothing to show → render nothing (keeps the page top clean).
|
|
223
|
-
if (!eyebrow && !items.length) return null;
|
|
236
|
+
if (!eyebrow && !items.length && !rssHref) return null;
|
|
237
|
+
|
|
238
|
+
const rssLink = rssHref ? (
|
|
239
|
+
<a
|
|
240
|
+
className="velu-context-menu__rss"
|
|
241
|
+
href={rssHref}
|
|
242
|
+
target="_blank"
|
|
243
|
+
rel="noopener"
|
|
244
|
+
aria-label="Subscribe to the RSS feed"
|
|
245
|
+
title="RSS feed"
|
|
246
|
+
>
|
|
247
|
+
<Rss size="1em" aria-hidden="true" />
|
|
248
|
+
</a>
|
|
249
|
+
) : null;
|
|
224
250
|
|
|
225
251
|
return (
|
|
226
252
|
<Cluster
|
|
@@ -232,6 +258,7 @@ export default function ContextMenu({
|
|
|
232
258
|
>
|
|
233
259
|
{eyebrow ? <span className="velu-context-bar__eyebrow">{eyebrow}</span> : <span />}
|
|
234
260
|
|
|
261
|
+
<div className="velu-context-bar__actions">
|
|
235
262
|
{items.length > 0 && (
|
|
236
263
|
<div ref={rootRef} className="velu-context-menu" data-open={open ? 'true' : 'false'}>
|
|
237
264
|
<div className="velu-context-menu__split">
|
|
@@ -293,6 +320,8 @@ export default function ContextMenu({
|
|
|
293
320
|
</ul>
|
|
294
321
|
</div>
|
|
295
322
|
)}
|
|
323
|
+
{rssLink}
|
|
324
|
+
</div>
|
|
296
325
|
</Cluster>
|
|
297
326
|
);
|
|
298
327
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import resolveIcon from '../lib/resolveIcon.jsx';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Update — a single changelog entry.
|
|
6
|
+
*
|
|
7
|
+
* A changelog page is just a normal docs page that contains one or more
|
|
8
|
+
* <Update> blocks; there is no special page type. Each entry renders as a
|
|
9
|
+
* two-column row: a sticky left rail with the date/label pill (+ optional
|
|
10
|
+
* description and tags) and a right column with the entry body (the MDX
|
|
11
|
+
* children — usually `##` sub-headings and prose).
|
|
12
|
+
*
|
|
13
|
+
* The section gets a stable `id` derived from the label so it is linkable
|
|
14
|
+
* and shows up in the table of contents. The id MUST match the slug the
|
|
15
|
+
* `extract-toc` remark plugin computes for the same label (see
|
|
16
|
+
* velu-cli/src/mdx-plugins/extract-toc.js → `slugifyUpdate`), so click→scroll
|
|
17
|
+
* and scroll-spy line up.
|
|
18
|
+
*
|
|
19
|
+
* `data-update-tags` (pipe-joined) is emitted so a future tag-filter can show
|
|
20
|
+
* / hide entries purely from the DOM.
|
|
21
|
+
*
|
|
22
|
+
* SSR-safe & presentational.
|
|
23
|
+
*
|
|
24
|
+
* @param {{ label?: string, date?: string, description?: string,
|
|
25
|
+
* tags?: string | string[], rss?: any, children?: React.ReactNode,
|
|
26
|
+
* className?: string }} props
|
|
27
|
+
*/
|
|
28
|
+
export function slugifyUpdate(value) {
|
|
29
|
+
const base = String(value || '')
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
32
|
+
.replace(/^-+|-+$/g, '');
|
|
33
|
+
return `update-${base || 'item'}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toTagList(value) {
|
|
37
|
+
if (Array.isArray(value)) return value.map(String).filter(Boolean);
|
|
38
|
+
if (value == null || value === false || value === '') return [];
|
|
39
|
+
return [String(value)];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default function Update({
|
|
43
|
+
label,
|
|
44
|
+
date,
|
|
45
|
+
description,
|
|
46
|
+
tags,
|
|
47
|
+
rss, // accepted for authoring parity; RSS metadata is handled at build time
|
|
48
|
+
children,
|
|
49
|
+
className = '',
|
|
50
|
+
...rest
|
|
51
|
+
}) {
|
|
52
|
+
void rss;
|
|
53
|
+
const updateLabel = String(label ?? date ?? 'Update');
|
|
54
|
+
const anchorId = slugifyUpdate(updateLabel);
|
|
55
|
+
const tagList = toTagList(tags);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<section
|
|
59
|
+
id={anchorId}
|
|
60
|
+
data-update-label={updateLabel}
|
|
61
|
+
data-update-tags={tagList.join('|')}
|
|
62
|
+
className={`velu-update ${className}`.trim()}
|
|
63
|
+
{...rest}
|
|
64
|
+
>
|
|
65
|
+
<div className="velu-update__meta">
|
|
66
|
+
<a
|
|
67
|
+
className="velu-update__anchor"
|
|
68
|
+
href={`#${anchorId}`}
|
|
69
|
+
aria-label={`Link to ${updateLabel}`}
|
|
70
|
+
>
|
|
71
|
+
{resolveIcon('link', { size: '1em' })}
|
|
72
|
+
</a>
|
|
73
|
+
<a className="velu-update__label" href={`#${anchorId}`}>
|
|
74
|
+
{updateLabel}
|
|
75
|
+
</a>
|
|
76
|
+
{description ? (
|
|
77
|
+
<div className="velu-update__description">{String(description)}</div>
|
|
78
|
+
) : null}
|
|
79
|
+
{tagList.length ? (
|
|
80
|
+
<div className="velu-update__tags">
|
|
81
|
+
{tagList.map((tag, i) => (
|
|
82
|
+
<span key={`${tag}-${i}`} className="velu-update__tag">
|
|
83
|
+
{tag}
|
|
84
|
+
</span>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
) : null}
|
|
88
|
+
</div>
|
|
89
|
+
{children ? <div className="velu-update__content">{children}</div> : null}
|
|
90
|
+
</section>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/* Changelog tag filters — shown in the right rail in place of the TOC on
|
|
2
|
+
changelog pages. Tokenized; light/dark via [data-theme]. */
|
|
3
|
+
|
|
4
|
+
.velu-changelog-filters {
|
|
5
|
+
display: flex;
|
|
6
|
+
flex-direction: column;
|
|
7
|
+
gap: var(--s-1);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.velu-changelog-filters__heading {
|
|
11
|
+
font-size: var(--f-h6);
|
|
12
|
+
font-weight: var(--weight-semibold);
|
|
13
|
+
color: var(--accent-color);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.velu-changelog-filters__list {
|
|
17
|
+
display: flex;
|
|
18
|
+
flex-wrap: wrap;
|
|
19
|
+
gap: var(--s-2);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.velu-changelog-filter {
|
|
23
|
+
cursor: pointer;
|
|
24
|
+
border-radius: 999px;
|
|
25
|
+
padding: 0.22rem 0.65rem;
|
|
26
|
+
font-size: var(--f-h6);
|
|
27
|
+
font-weight: var(--weight-medium);
|
|
28
|
+
color: var(--text-color);
|
|
29
|
+
background: color-mix(in srgb, var(--muted-color) 14%, transparent);
|
|
30
|
+
border: var(--border-width) solid transparent;
|
|
31
|
+
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.velu-changelog-filter:hover {
|
|
35
|
+
border-color: color-mix(in srgb, var(--accent-color) 40%, var(--border-color));
|
|
36
|
+
background: color-mix(in srgb, var(--accent-color) 8%, transparent);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.velu-changelog-filter--active {
|
|
40
|
+
color: var(--accent-color);
|
|
41
|
+
border-color: color-mix(in srgb, var(--accent-color) 55%, transparent);
|
|
42
|
+
background: color-mix(in srgb, var(--accent-color) 12%, transparent);
|
|
43
|
+
}
|