@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/package.json CHANGED
@@ -1,64 +1,64 @@
1
- {
2
- "name": "@veluai/velu",
3
- "version": "0.2.19",
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 paragraphs = [[]];
200
- for (const tk of tokens) {
201
- if (tk.kind === 't' && tk.v.includes('\n\n')) {
202
- const split = tk.v.split('\n\n');
203
- paragraphs[paragraphs.length - 1].push({ kind: 't', v: split[0] });
204
- for (let i = 1; i < split.length; i++) {
205
- paragraphs.push([]);
206
- if (split[i]) paragraphs[paragraphs.length - 1].push({ kind: 't', v: split[i] });
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
- } else {
209
- paragraphs[paragraphs.length - 1].push(tk);
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 paragraphs.map((para, pi) => (
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
- const mdUrlForPage = (url) => (url === '/' ? '/index.md' : `${url}.md`);
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 npx command to install MCP server', kind: 'mcp' },
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: 'Install MCP Server on Devin', kind: 'cursor', external: true },
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
- // Stubbed: the Velu MCP server isn't hosted yet this is the intended
196
- // command, wired to <origin>/mcp once it exists.
197
- return copyText(`npx -y @veluai/mcp@latest ${origin}/mcp`);
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
- const cfg = encodeURIComponent(JSON.stringify({ name: siteName, url: `${origin}/mcp` }));
209
- return void window.open(
210
- `https://insiders.vscode.dev/redirect/mcp/install?${cfg}`,
211
- '_blank',
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
+ }