@aravindc26/velu 0.11.0 → 0.11.3

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 (60) hide show
  1. package/package.json +15 -6
  2. package/schema/velu.schema.json +1251 -115
  3. package/src/build.ts +1121 -304
  4. package/src/cli.ts +90 -26
  5. package/src/engine/_server.mjs +1684 -277
  6. package/src/engine/app/(docs)/[...slug]/layout.tsx +371 -0
  7. package/src/engine/app/(docs)/[...slug]/page.tsx +926 -0
  8. package/src/engine/app/api/proxy/route.ts +23 -0
  9. package/src/engine/app/copy-page.css +59 -1
  10. package/src/engine/app/global.css +3157 -3
  11. package/src/engine/app/layout.tsx +56 -1
  12. package/src/engine/app/llms-file/route.ts +87 -0
  13. package/src/engine/app/llms-full-file/route.ts +62 -0
  14. package/src/engine/app/md-file/[...slug]/route.ts +409 -0
  15. package/src/engine/app/page.tsx +45 -0
  16. package/src/engine/app/robots.txt/route.ts +63 -0
  17. package/src/engine/app/rss-file/[...slug]/route.ts +169 -0
  18. package/src/engine/app/sitemap.xml/route.ts +82 -0
  19. package/src/engine/components/assistant.tsx +16 -5
  20. package/src/engine/components/changelog-filters.tsx +114 -0
  21. package/src/engine/components/code-group.tsx +383 -0
  22. package/src/engine/components/color.tsx +118 -0
  23. package/src/engine/components/expandable.tsx +77 -0
  24. package/src/engine/components/icon.tsx +136 -0
  25. package/src/engine/components/image-zoom-fallback.tsx +147 -0
  26. package/src/engine/components/image.tsx +111 -0
  27. package/src/engine/components/manual-api-playground.tsx +154 -0
  28. package/src/engine/components/mermaid.tsx +142 -0
  29. package/src/engine/components/openapi-toc-sync.tsx +59 -0
  30. package/src/engine/components/openapi.tsx +1682 -0
  31. package/src/engine/components/page-feedback.tsx +153 -0
  32. package/src/engine/components/product-switcher.tsx +27 -3
  33. package/src/engine/components/prompt.tsx +90 -0
  34. package/src/engine/components/providers.tsx +1 -6
  35. package/src/engine/components/search.tsx +4 -0
  36. package/src/engine/components/sidebar-links.tsx +13 -15
  37. package/src/engine/components/synced-tabs.tsx +57 -0
  38. package/src/engine/components/toc-examples.tsx +110 -0
  39. package/src/engine/components/view.tsx +344 -0
  40. package/src/engine/generated/redirects.ts +3 -0
  41. package/src/engine/lib/changelog.ts +246 -0
  42. package/src/engine/lib/layout.shared.ts +30 -2
  43. package/src/engine/lib/llms.ts +444 -0
  44. package/src/engine/lib/navigation-normalize.mjs +481 -412
  45. package/src/engine/lib/navigation-normalize.ts +261 -54
  46. package/src/engine/lib/redirects.ts +194 -0
  47. package/src/engine/lib/source.ts +107 -4
  48. package/src/engine/lib/velu.ts +368 -2
  49. package/src/engine/mdx-components.tsx +648 -0
  50. package/src/engine/middleware.ts +66 -0
  51. package/src/engine/public/icons/cursor-dark.svg +12 -0
  52. package/src/engine/public/icons/cursor-light.svg +12 -0
  53. package/src/engine/source.config.ts +98 -1
  54. package/src/engine/src/components/PageTitle.astro +16 -5
  55. package/src/engine/src/lib/velu.ts +11 -3
  56. package/src/navigation-normalize.ts +252 -54
  57. package/src/themes.ts +6 -6
  58. package/src/validate.ts +119 -6
  59. package/src/engine/app/(docs)/[[...slug]]/layout.tsx +0 -87
  60. package/src/engine/app/(docs)/[[...slug]]/page.tsx +0 -146
@@ -0,0 +1,344 @@
1
+ "use client";
2
+
3
+ import { createPortal } from "react-dom";
4
+ import { useEffect, useMemo, useState, type ReactNode } from "react";
5
+ import { VeluIcon } from "@/components/icon";
6
+
7
+ const VELU_VIEW_OPTIONS_KEY = "__veluViewOptions";
8
+ const VELU_VIEW_OPTIONS_EVENT = "velu:view-options";
9
+ const VELU_VIEW_TOC_HOST_ID = "velu-view-toc-host";
10
+ const VELU_TAB_SYNC_KEY = "__veluTabSyncLabel";
11
+ const VELU_TAB_SYNC_EVENT = "velu:tab-sync";
12
+
13
+ type ViewOption = {
14
+ title: string;
15
+ icon?: string;
16
+ iconType?: string;
17
+ };
18
+
19
+ const VIEW_ICON_URL: Record<string, string> = {
20
+ javascript: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/javascript/javascript-original.svg",
21
+ typescript: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/typescript/typescript-original.svg",
22
+ python: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/python/python-original.svg",
23
+ java: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/java/java-original.svg",
24
+ ruby: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/ruby/ruby-original.svg",
25
+ shell: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/bash/bash-original.svg",
26
+ bash: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/bash/bash-original.svg",
27
+ yaml: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/yaml/yaml-original.svg",
28
+ markdown: "https://cdn.jsdelivr.net/gh/devicons/devicon/icons/markdown/markdown-original.svg",
29
+ };
30
+
31
+ const VIEW_ICON_ALIAS: Record<string, string> = {
32
+ js: "javascript",
33
+ jsx: "javascript",
34
+ javascript: "javascript",
35
+ ts: "typescript",
36
+ tsx: "typescript",
37
+ typescript: "typescript",
38
+ py: "python",
39
+ python: "python",
40
+ java: "java",
41
+ rb: "ruby",
42
+ ruby: "ruby",
43
+ sh: "shell",
44
+ shell: "shell",
45
+ bash: "bash",
46
+ yml: "yaml",
47
+ yaml: "yaml",
48
+ md: "markdown",
49
+ markdown: "markdown",
50
+ };
51
+
52
+ function normalizeTitle(value: string): string {
53
+ return value.trim().toLowerCase();
54
+ }
55
+
56
+ function normalizeIconKey(value: string | undefined): string | undefined {
57
+ if (!value || !value.trim()) return undefined;
58
+ const key = value.trim().toLowerCase();
59
+ return VIEW_ICON_ALIAS[key] ?? key;
60
+ }
61
+
62
+ function resolveViewIconUrl(icon: string | undefined, title: string): string | undefined {
63
+ const iconKey = normalizeIconKey(icon);
64
+ if (iconKey && VIEW_ICON_URL[iconKey]) return VIEW_ICON_URL[iconKey];
65
+
66
+ const titleKey = normalizeIconKey(title);
67
+ if (titleKey && VIEW_ICON_URL[titleKey]) return VIEW_ICON_URL[titleKey];
68
+ return undefined;
69
+ }
70
+
71
+ function ViewOptionIcon({ title, icon, iconType }: { title: string; icon?: string; iconType?: string }) {
72
+ const src = resolveViewIconUrl(icon, title);
73
+ if (src) {
74
+ return <img src={src} alt="" aria-hidden="true" className="velu-view-option-icon velu-view-option-icon-img" loading="lazy" decoding="async" />;
75
+ }
76
+ if (!icon) return null;
77
+ return <VeluIcon name={icon} iconType={iconType} className="velu-view-option-icon" />;
78
+ }
79
+
80
+ function readSharedOptions(): ViewOption[] {
81
+ if (typeof window === "undefined") return [];
82
+ const value = (window as any)[VELU_VIEW_OPTIONS_KEY];
83
+ if (!Array.isArray(value)) return [];
84
+ return value.filter((item) => item && typeof item.title === "string" && item.title.trim());
85
+ }
86
+
87
+ function writeSharedOptions(options: ViewOption[]) {
88
+ if (typeof window === "undefined") return;
89
+ (window as any)[VELU_VIEW_OPTIONS_KEY] = options;
90
+ }
91
+
92
+ function upsertSharedOption(option: ViewOption): ViewOption[] {
93
+ const existing = readSharedOptions();
94
+ const key = normalizeTitle(option.title);
95
+ const next = [...existing];
96
+ const index = next.findIndex((item) => normalizeTitle(item.title) === key);
97
+ if (index >= 0) {
98
+ next[index] = { ...next[index], ...option };
99
+ } else {
100
+ next.push(option);
101
+ }
102
+ writeSharedOptions(next);
103
+ return next;
104
+ }
105
+
106
+ function broadcastOptions(options: ViewOption[]) {
107
+ if (typeof window === "undefined") return;
108
+ window.dispatchEvent(new CustomEvent(VELU_VIEW_OPTIONS_EVENT, { detail: { options } }));
109
+ }
110
+
111
+ function readSharedSelected(): string | null {
112
+ if (typeof window === "undefined") return null;
113
+ const value = (window as any)[VELU_TAB_SYNC_KEY];
114
+ return typeof value === "string" && value.trim() ? value : null;
115
+ }
116
+
117
+ function writeSharedSelected(title: string) {
118
+ if (typeof window === "undefined") return;
119
+ (window as any)[VELU_TAB_SYNC_KEY] = title;
120
+ }
121
+
122
+ function broadcastSelected(title: string) {
123
+ if (typeof window === "undefined") return;
124
+ const existing = readSharedSelected();
125
+ if (existing && normalizeTitle(existing) === normalizeTitle(title)) return;
126
+ writeSharedSelected(title);
127
+ window.dispatchEvent(new CustomEvent(VELU_TAB_SYNC_EVENT, { detail: { label: title } }));
128
+ }
129
+
130
+ function syncTocForVisibleHeadings() {
131
+ if (typeof document === "undefined") return;
132
+ const toc = document.getElementById("nd-toc");
133
+ if (!toc) return;
134
+
135
+ const links = Array.from(toc.querySelectorAll<HTMLAnchorElement>('a[href^="#"]'));
136
+ for (const link of links) {
137
+ const href = link.getAttribute("href");
138
+ if (!href || href === "#") continue;
139
+ const id = decodeURIComponent(href.slice(1));
140
+ const target = document.getElementById(id);
141
+ const row = link.closest("li") ?? link.parentElement;
142
+ if (!row) continue;
143
+ row.style.display = target ? "" : "none";
144
+ }
145
+ }
146
+
147
+ function ensureTocHost(): HTMLDivElement | null {
148
+ if (typeof document === "undefined") return null;
149
+ const toc = document.getElementById("nd-toc");
150
+ if (!toc) return null;
151
+
152
+ let host = document.getElementById(VELU_VIEW_TOC_HOST_ID) as HTMLDivElement | null;
153
+ if (!host) {
154
+ host = document.createElement("div");
155
+ host.id = VELU_VIEW_TOC_HOST_ID;
156
+ host.className = "velu-view-toc-host";
157
+ toc.prepend(host);
158
+ }
159
+ return host;
160
+ }
161
+
162
+ export function VeluView({
163
+ title,
164
+ icon,
165
+ iconType,
166
+ children,
167
+ className,
168
+ }: {
169
+ title?: string;
170
+ icon?: string;
171
+ iconType?: string;
172
+ children?: ReactNode;
173
+ className?: string;
174
+ }) {
175
+ const resolvedTitle = (typeof title === "string" && title.trim()) ? title.trim() : "View";
176
+ const normalizedResolvedTitle = useMemo(() => normalizeTitle(resolvedTitle), [resolvedTitle]);
177
+ const [selectedTitle, setSelectedTitle] = useState<string>(resolvedTitle);
178
+ const [menuOpen, setMenuOpen] = useState(false);
179
+ const [tocHost, setTocHost] = useState<HTMLDivElement | null>(null);
180
+ const [options, setOptions] = useState<ViewOption[]>(() => {
181
+ const initial = readSharedOptions();
182
+ return initial.length ? initial : [{ title: resolvedTitle, icon, iconType }];
183
+ });
184
+
185
+ useEffect(() => {
186
+ const nextOptions = upsertSharedOption({ title: resolvedTitle, icon, iconType });
187
+ setOptions(nextOptions);
188
+ broadcastOptions(nextOptions);
189
+
190
+ const existingSelected = readSharedSelected();
191
+ const hasExistingSelection = Boolean(
192
+ existingSelected && nextOptions.some((option) => normalizeTitle(option.title) === normalizeTitle(existingSelected)),
193
+ );
194
+
195
+ if (existingSelected && hasExistingSelection) {
196
+ setSelectedTitle(existingSelected);
197
+ } else {
198
+ setSelectedTitle(resolvedTitle);
199
+ broadcastSelected(resolvedTitle);
200
+ }
201
+ queueMicrotask(syncTocForVisibleHeadings);
202
+
203
+ const onSelected = (event: Event) => {
204
+ const detail = (event as CustomEvent<{ title?: string; label?: string }>).detail;
205
+ const next = detail?.label ?? detail?.title;
206
+ if (!next || !next.trim()) return;
207
+ setSelectedTitle(next);
208
+ queueMicrotask(syncTocForVisibleHeadings);
209
+ };
210
+
211
+ const onOptions = (event: Event) => {
212
+ const incoming = (event as CustomEvent<{ options?: ViewOption[] }>).detail?.options;
213
+ const next = Array.isArray(incoming) ? incoming : readSharedOptions();
214
+ setOptions(next.length ? next : [{ title: resolvedTitle, icon, iconType }]);
215
+ };
216
+
217
+ window.addEventListener(VELU_TAB_SYNC_EVENT, onSelected);
218
+ window.addEventListener(VELU_VIEW_OPTIONS_EVENT, onOptions);
219
+ return () => {
220
+ window.removeEventListener(VELU_TAB_SYNC_EVENT, onSelected);
221
+ window.removeEventListener(VELU_VIEW_OPTIONS_EVENT, onOptions);
222
+ };
223
+ }, [resolvedTitle, icon, iconType]);
224
+
225
+ useEffect(() => {
226
+ let frame = 0;
227
+ const attachHost = () => {
228
+ const host = ensureTocHost();
229
+ if (host) {
230
+ setTocHost(host);
231
+ return;
232
+ }
233
+ frame = window.requestAnimationFrame(attachHost);
234
+ };
235
+ attachHost();
236
+ return () => {
237
+ if (frame) window.cancelAnimationFrame(frame);
238
+ };
239
+ }, []);
240
+
241
+ useEffect(() => {
242
+ if (!menuOpen) return;
243
+ const onPointerDown = (event: PointerEvent) => {
244
+ const target = event.target as Element | null;
245
+ if (target?.closest(`#${VELU_VIEW_TOC_HOST_ID}`)) return;
246
+ setMenuOpen(false);
247
+ };
248
+ const onEscape = (event: KeyboardEvent) => {
249
+ if (event.key === "Escape") setMenuOpen(false);
250
+ };
251
+ window.addEventListener("pointerdown", onPointerDown);
252
+ window.addEventListener("keydown", onEscape);
253
+ return () => {
254
+ window.removeEventListener("pointerdown", onPointerDown);
255
+ window.removeEventListener("keydown", onEscape);
256
+ };
257
+ }, [menuOpen]);
258
+
259
+ useEffect(() => {
260
+ if (!options.length) return;
261
+ const hasCurrent = options.some((option) => normalizeTitle(option.title) === normalizeTitle(selectedTitle));
262
+ if (hasCurrent) return;
263
+ const fallback = options[0]?.title;
264
+ if (!fallback) return;
265
+ setSelectedTitle(fallback);
266
+ broadcastSelected(fallback);
267
+ queueMicrotask(syncTocForVisibleHeadings);
268
+ }, [options, selectedTitle]);
269
+
270
+ const effectiveOptions = options.length ? options : [{ title: resolvedTitle, icon, iconType }];
271
+ const isActive = normalizeTitle(selectedTitle) === normalizedResolvedTitle;
272
+ const selectedOption = effectiveOptions.find((option) => normalizeTitle(option.title) === normalizeTitle(selectedTitle))
273
+ ?? effectiveOptions[0];
274
+
275
+ const selectView = (nextTitle: string) => {
276
+ if (!nextTitle || normalizeTitle(nextTitle) === normalizeTitle(selectedTitle)) return;
277
+ setSelectedTitle(nextTitle);
278
+ setMenuOpen(false);
279
+ broadcastSelected(nextTitle);
280
+ queueMicrotask(syncTocForVisibleHeadings);
281
+ };
282
+
283
+ const viewSwitcher = (
284
+ tocHost && isActive && effectiveOptions.length > 1
285
+ ? createPortal(
286
+ <div className="velu-view-toc-switcher">
287
+ <button
288
+ type="button"
289
+ className="velu-view-toc-trigger"
290
+ aria-haspopup="listbox"
291
+ aria-expanded={menuOpen}
292
+ onClick={() => setMenuOpen((prev) => !prev)}
293
+ >
294
+ {selectedOption ? <ViewOptionIcon title={selectedOption.title} icon={selectedOption.icon} iconType={selectedOption.iconType} /> : null}
295
+ <span>{selectedOption?.title ?? "View"}</span>
296
+ <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className={["velu-view-toc-chevron", menuOpen ? "open" : ""].join(" ")}>
297
+ <path d="m6 8 4 4 4-4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
298
+ </svg>
299
+ </button>
300
+ {menuOpen ? (
301
+ <div className="velu-view-toc-menu" role="listbox" aria-label="Select view">
302
+ {effectiveOptions.map((option) => {
303
+ const key = normalizeTitle(option.title);
304
+ const selected = normalizeTitle(selectedTitle) === key;
305
+ return (
306
+ <button
307
+ key={key}
308
+ type="button"
309
+ role="option"
310
+ aria-selected={selected}
311
+ className={["velu-view-toc-option", selected ? "active" : ""].filter(Boolean).join(" ")}
312
+ onClick={() => selectView(option.title)}
313
+ >
314
+ <span className="velu-view-toc-option-main">
315
+ <ViewOptionIcon title={option.title} icon={option.icon} iconType={option.iconType} />
316
+ <span>{option.title}</span>
317
+ </span>
318
+ {selected ? (
319
+ <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="velu-view-check">
320
+ <path d="m4.5 10.5 3.2 3.2 7.8-7.8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
321
+ </svg>
322
+ ) : null}
323
+ </button>
324
+ );
325
+ })}
326
+ </div>
327
+ ) : null}
328
+ </div>,
329
+ tocHost,
330
+ )
331
+ : null
332
+ );
333
+
334
+ if (!isActive) {
335
+ return viewSwitcher;
336
+ }
337
+
338
+ return (
339
+ <section className={["velu-view", className].filter(Boolean).join(" ")} data-velu-view={resolvedTitle}>
340
+ {viewSwitcher}
341
+ <div className="velu-view-content">{children}</div>
342
+ </section>
343
+ );
344
+ }
@@ -0,0 +1,3 @@
1
+ const redirects: Array<{ source: string; destination: string; permanent?: boolean }> = [];
2
+
3
+ export default redirects;
@@ -0,0 +1,246 @@
1
+ export interface ChangelogTocItem {
2
+ title: string;
3
+ url: string;
4
+ depth: number;
5
+ }
6
+
7
+ export interface ChangelogUpdateEntry {
8
+ label: string;
9
+ anchor: string;
10
+ date?: string;
11
+ description?: string;
12
+ tags: string[];
13
+ contentMarkdown: string;
14
+ rssTitle?: string;
15
+ rssDescription?: string;
16
+ }
17
+
18
+ export interface ChangelogHeadingEntry {
19
+ title: string;
20
+ anchor: string;
21
+ contentMarkdown: string;
22
+ }
23
+
24
+ export interface ParsedChangelogData {
25
+ toc: ChangelogTocItem[];
26
+ tags: string[];
27
+ updates: ChangelogUpdateEntry[];
28
+ }
29
+
30
+ interface ParsedRssProp {
31
+ title?: string;
32
+ description?: string;
33
+ }
34
+
35
+ const STRING_LITERAL = /"(.*?)"|'(.*?)'/;
36
+
37
+ export function slugifyUpdateLabel(value: string): string {
38
+ const base = value
39
+ .toLowerCase()
40
+ .trim()
41
+ .replace(/[^a-z0-9]+/g, '-')
42
+ .replace(/^-+|-+$/g, '');
43
+ return `update-${base || 'item'}`;
44
+ }
45
+
46
+ export function slugifyHeading(value: string): string {
47
+ return value
48
+ .toLowerCase()
49
+ .trim()
50
+ .replace(/[^a-z0-9]+/g, '-')
51
+ .replace(/^-+|-+$/g, '') || 'section';
52
+ }
53
+
54
+ function parseStringProp(attributes: string, name: string): string | undefined {
55
+ const regex = new RegExp(`\\b${name}\\s*=\\s*(\"([^\"]+)\"|'([^']+)')`, 'i');
56
+ const match = attributes.match(regex);
57
+ if (!match) return undefined;
58
+ return (match[2] ?? match[3] ?? '').trim() || undefined;
59
+ }
60
+
61
+ function parseTags(attributes: string): string[] {
62
+ const direct = parseStringProp(attributes, 'tags');
63
+ if (direct) return [direct];
64
+
65
+ const match = attributes.match(/\btags\s*=\s*\{\s*\[([\s\S]*?)\]\s*\}/i);
66
+ if (!match) return [];
67
+
68
+ const tokens = match[1].split(',').map((entry) => entry.trim()).filter(Boolean);
69
+ const tags: string[] = [];
70
+ for (const token of tokens) {
71
+ const literal = token.match(STRING_LITERAL);
72
+ const value = (literal?.[1] ?? literal?.[2] ?? '').trim();
73
+ if (value) tags.push(value);
74
+ }
75
+ return tags;
76
+ }
77
+
78
+ function parseRss(attributes: string): ParsedRssProp {
79
+ const inline = parseStringProp(attributes, 'rss');
80
+ if (inline) {
81
+ return { description: inline };
82
+ }
83
+
84
+ const objectMatch = attributes.match(/\brss\s*=\s*\{\s*\{([\s\S]*?)\}\s*\}/i);
85
+ if (!objectMatch) return {};
86
+ const body = objectMatch[1];
87
+
88
+ return {
89
+ title: parseStringProp(body, 'title'),
90
+ description: parseStringProp(body, 'description'),
91
+ };
92
+ }
93
+
94
+ export function markdownToPlainText(markdown: string): string {
95
+ return markdown
96
+ .replace(/```[\s\S]*?```/g, '')
97
+ .replace(/<[^>]+>/g, '')
98
+ .replace(/\{[^}]+\}/g, '')
99
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
100
+ .replace(/`([^`]+)`/g, '$1')
101
+ .replace(/\n{3,}/g, '\n\n')
102
+ .trim();
103
+ }
104
+
105
+ function parseHeadings(markdown: string): ChangelogHeadingEntry[] {
106
+ if (!markdown.trim()) return [];
107
+
108
+ const matches = Array.from(markdown.matchAll(/^#{1,6}\s+(.+)$/gm));
109
+ if (matches.length === 0) return [];
110
+
111
+ const seen = new Map<string, number>();
112
+ const entries: ChangelogHeadingEntry[] = [];
113
+
114
+ for (let index = 0; index < matches.length; index += 1) {
115
+ const current = matches[index];
116
+ const next = matches[index + 1];
117
+ const rawTitle = (current[1] ?? '').trim();
118
+ const title = markdownToPlainText(rawTitle) || 'Update';
119
+
120
+ const baseSlug = slugifyHeading(title);
121
+ const duplicateCount = seen.get(baseSlug) ?? 0;
122
+ seen.set(baseSlug, duplicateCount + 1);
123
+ const anchor = duplicateCount === 0 ? baseSlug : `${baseSlug}-${duplicateCount}`;
124
+
125
+ const bodyStart = (current.index ?? 0) + current[0].length;
126
+ const bodyEnd = next?.index ?? markdown.length;
127
+ const body = markdown.slice(bodyStart, bodyEnd).trim();
128
+
129
+ entries.push({
130
+ title,
131
+ anchor,
132
+ contentMarkdown: body,
133
+ });
134
+ }
135
+
136
+ return entries;
137
+ }
138
+
139
+ export function parseChangelogFromMarkdown(markdown: string | undefined): ParsedChangelogData {
140
+ if (!markdown) return { toc: [], tags: [], updates: [] };
141
+
142
+ const searchable = markdown
143
+ .replace(/```[\s\S]*?```/g, '')
144
+ .replace(/~~~[\s\S]*?~~~/g, '');
145
+
146
+ const updates: ChangelogUpdateEntry[] = [];
147
+ const tagSet = new Set<string>();
148
+ const regex = /<Update\b([^>]*)>([\s\S]*?)<\/Update>/gi;
149
+
150
+ for (const match of searchable.matchAll(regex)) {
151
+ const attrs = match[1] ?? '';
152
+ const body = (match[2] ?? '').trim();
153
+ const date = parseStringProp(attrs, 'date');
154
+ const label = parseStringProp(attrs, 'label') ?? date ?? 'Update';
155
+ const description = parseStringProp(attrs, 'description');
156
+ const tags = parseTags(attrs);
157
+ const rss = parseRss(attrs);
158
+ const anchor = slugifyUpdateLabel(label);
159
+
160
+ for (const tag of tags) tagSet.add(tag);
161
+
162
+ updates.push({
163
+ label,
164
+ anchor,
165
+ date,
166
+ description,
167
+ tags,
168
+ contentMarkdown: body,
169
+ rssTitle: rss.title,
170
+ rssDescription: rss.description,
171
+ });
172
+ }
173
+
174
+ return {
175
+ toc: updates.map((update) => ({
176
+ title: update.label,
177
+ url: `#${update.anchor}`,
178
+ depth: 2,
179
+ })),
180
+ tags: Array.from(tagSet),
181
+ updates,
182
+ };
183
+ }
184
+
185
+ export function parseFrontmatterValue(markdown: string | undefined, key: string): string | undefined {
186
+ const frontmatterMatch = markdown?.match(/^---\r?\n([\s\S]*?)\r?\n---/);
187
+ if (!frontmatterMatch) return undefined;
188
+ const frontmatter = frontmatterMatch[1];
189
+ const line = frontmatter
190
+ .split(/\r?\n/)
191
+ .find((entry) => entry.trim().toLowerCase().startsWith(`${key.toLowerCase()}:`));
192
+ if (!line) return undefined;
193
+
194
+ const raw = line.slice(line.indexOf(':') + 1).trim();
195
+ if (!raw) return undefined;
196
+ const literal = raw.match(STRING_LITERAL);
197
+ return (literal?.[1] ?? literal?.[2] ?? raw).trim();
198
+ }
199
+
200
+ export function parseFrontmatterBoolean(markdown: string | undefined, key: string): boolean {
201
+ const value = parseFrontmatterValue(markdown, key);
202
+ if (!value) return false;
203
+ const normalized = value.trim().toLowerCase();
204
+ return normalized === 'true' || normalized === 'yes' || normalized === '1';
205
+ }
206
+
207
+ export function getUpdateRssEntries(update: ChangelogUpdateEntry): Array<{
208
+ title: string;
209
+ anchor: string;
210
+ description: string;
211
+ }> {
212
+ if (update.rssTitle || update.rssDescription) {
213
+ return [
214
+ {
215
+ title: update.rssTitle?.trim() || update.label,
216
+ anchor: update.anchor,
217
+ description: toRssDescription(update),
218
+ },
219
+ ];
220
+ }
221
+
222
+ const headings = parseHeadings(update.contentMarkdown);
223
+ if (headings.length > 0) {
224
+ return headings.map((heading) => ({
225
+ title: heading.title,
226
+ anchor: heading.anchor,
227
+ description: markdownToPlainText(heading.contentMarkdown) || toRssDescription(update),
228
+ }));
229
+ }
230
+
231
+ return [
232
+ {
233
+ title: update.label,
234
+ anchor: update.anchor,
235
+ description: toRssDescription(update),
236
+ },
237
+ ];
238
+ }
239
+
240
+ export function toRssDescription(update: ChangelogUpdateEntry): string {
241
+ if (update.rssDescription && update.rssDescription.trim()) return update.rssDescription.trim();
242
+ if (update.description && update.description.trim()) return update.description.trim();
243
+
244
+ const plain = markdownToPlainText(update.contentMarkdown);
245
+ return plain || update.label;
246
+ }
@@ -1,12 +1,39 @@
1
1
  import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
2
2
  import { createElement } from 'react';
3
3
  import { VersionSwitcher } from '@/components/version-switcher';
4
- import { getExternalTabs, getNavbarAnchors, getVersionOptions } from '@/lib/velu';
4
+ import { getExternalTabs, getNavbarAnchors, getSiteLogoAsset, getSiteName, getVersionOptions } from '@/lib/velu';
5
5
 
6
6
  export function baseOptions(): BaseLayoutProps {
7
7
  const externalTabs = getExternalTabs();
8
8
  const navAnchors = getNavbarAnchors();
9
9
  const versions = getVersionOptions();
10
+ const siteName = getSiteName();
11
+ const logo = getSiteLogoAsset();
12
+ const lightLogo = logo.light ?? logo.dark;
13
+ const darkLogo = logo.dark ?? logo.light;
14
+ const logoHref = typeof logo.href === 'string' && logo.href.trim().length > 0 ? logo.href.trim() : '/';
15
+
16
+ const navTitle =
17
+ lightLogo || darkLogo
18
+ ? createElement(
19
+ 'span',
20
+ { className: 'velu-nav-brand' },
21
+ lightLogo
22
+ ? createElement('img', {
23
+ src: lightLogo,
24
+ alt: siteName,
25
+ className: 'velu-nav-logo velu-nav-logo-light',
26
+ })
27
+ : null,
28
+ darkLogo
29
+ ? createElement('img', {
30
+ src: darkLogo,
31
+ alt: siteName,
32
+ className: 'velu-nav-logo velu-nav-logo-dark',
33
+ })
34
+ : null,
35
+ )
36
+ : siteName;
10
37
 
11
38
  const links = [
12
39
  ...externalTabs.map((tab: { label: string; href: string }) => ({
@@ -25,7 +52,8 @@ export function baseOptions(): BaseLayoutProps {
25
52
 
26
53
  return {
27
54
  nav: {
28
- title: 'Velu Docs',
55
+ title: navTitle,
56
+ url: logoHref,
29
57
  children:
30
58
  versions.length > 1
31
59
  ? createElement(