@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.
- package/package.json +15 -6
- package/schema/velu.schema.json +1251 -115
- package/src/build.ts +1121 -304
- package/src/cli.ts +90 -26
- package/src/engine/_server.mjs +1684 -277
- package/src/engine/app/(docs)/[...slug]/layout.tsx +371 -0
- package/src/engine/app/(docs)/[...slug]/page.tsx +926 -0
- package/src/engine/app/api/proxy/route.ts +23 -0
- package/src/engine/app/copy-page.css +59 -1
- package/src/engine/app/global.css +3157 -3
- package/src/engine/app/layout.tsx +56 -1
- package/src/engine/app/llms-file/route.ts +87 -0
- package/src/engine/app/llms-full-file/route.ts +62 -0
- package/src/engine/app/md-file/[...slug]/route.ts +409 -0
- package/src/engine/app/page.tsx +45 -0
- package/src/engine/app/robots.txt/route.ts +63 -0
- package/src/engine/app/rss-file/[...slug]/route.ts +169 -0
- package/src/engine/app/sitemap.xml/route.ts +82 -0
- package/src/engine/components/assistant.tsx +16 -5
- package/src/engine/components/changelog-filters.tsx +114 -0
- package/src/engine/components/code-group.tsx +383 -0
- package/src/engine/components/color.tsx +118 -0
- package/src/engine/components/expandable.tsx +77 -0
- package/src/engine/components/icon.tsx +136 -0
- package/src/engine/components/image-zoom-fallback.tsx +147 -0
- package/src/engine/components/image.tsx +111 -0
- package/src/engine/components/manual-api-playground.tsx +154 -0
- package/src/engine/components/mermaid.tsx +142 -0
- package/src/engine/components/openapi-toc-sync.tsx +59 -0
- package/src/engine/components/openapi.tsx +1682 -0
- package/src/engine/components/page-feedback.tsx +153 -0
- package/src/engine/components/product-switcher.tsx +27 -3
- package/src/engine/components/prompt.tsx +90 -0
- package/src/engine/components/providers.tsx +1 -6
- package/src/engine/components/search.tsx +4 -0
- package/src/engine/components/sidebar-links.tsx +13 -15
- package/src/engine/components/synced-tabs.tsx +57 -0
- package/src/engine/components/toc-examples.tsx +110 -0
- package/src/engine/components/view.tsx +344 -0
- package/src/engine/generated/redirects.ts +3 -0
- package/src/engine/lib/changelog.ts +246 -0
- package/src/engine/lib/layout.shared.ts +30 -2
- package/src/engine/lib/llms.ts +444 -0
- package/src/engine/lib/navigation-normalize.mjs +481 -412
- package/src/engine/lib/navigation-normalize.ts +261 -54
- package/src/engine/lib/redirects.ts +194 -0
- package/src/engine/lib/source.ts +107 -4
- package/src/engine/lib/velu.ts +368 -2
- package/src/engine/mdx-components.tsx +648 -0
- package/src/engine/middleware.ts +66 -0
- package/src/engine/public/icons/cursor-dark.svg +12 -0
- package/src/engine/public/icons/cursor-light.svg +12 -0
- package/src/engine/source.config.ts +98 -1
- package/src/engine/src/components/PageTitle.astro +16 -5
- package/src/engine/src/lib/velu.ts +11 -3
- package/src/navigation-normalize.ts +252 -54
- package/src/themes.ts +6 -6
- package/src/validate.ts +119 -6
- package/src/engine/app/(docs)/[[...slug]]/layout.tsx +0 -87
- 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,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:
|
|
55
|
+
title: navTitle,
|
|
56
|
+
url: logoHref,
|
|
29
57
|
children:
|
|
30
58
|
versions.length > 1
|
|
31
59
|
? createElement(
|