@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,63 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { getSeoConfig, getSiteOrigin } from '@/lib/velu';
|
|
5
|
+
|
|
6
|
+
export const dynamic = 'force-static';
|
|
7
|
+
|
|
8
|
+
async function readCustomRobotsFile(): Promise<string | null> {
|
|
9
|
+
const docsDir = process.env.VELU_DOCS_DIR?.trim();
|
|
10
|
+
if (docsDir) {
|
|
11
|
+
const docsPath = join(docsDir, 'robots.txt');
|
|
12
|
+
if (!existsSync(docsPath)) return null;
|
|
13
|
+
try {
|
|
14
|
+
return await readFile(docsPath, 'utf-8');
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const candidates = [
|
|
21
|
+
join(process.cwd(), 'robots.txt'),
|
|
22
|
+
join(process.cwd(), 'public', 'robots.txt'),
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
for (const candidate of candidates) {
|
|
26
|
+
if (!existsSync(candidate)) continue;
|
|
27
|
+
try {
|
|
28
|
+
return await readFile(candidate, 'utf-8');
|
|
29
|
+
} catch {
|
|
30
|
+
// ignore and continue
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function GET(): Promise<Response> {
|
|
38
|
+
const custom = await readCustomRobotsFile();
|
|
39
|
+
if (custom !== null) {
|
|
40
|
+
return new Response(custom, {
|
|
41
|
+
headers: {
|
|
42
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const seo = getSeoConfig();
|
|
48
|
+
const origin = getSiteOrigin();
|
|
49
|
+
const robotsTag = (seo.metatags.robots ?? '').toLowerCase();
|
|
50
|
+
const blockAll = robotsTag.includes('noindex') || robotsTag.includes('none');
|
|
51
|
+
const lines = [
|
|
52
|
+
'User-agent: *',
|
|
53
|
+
blockAll ? 'Disallow: /' : 'Allow: /',
|
|
54
|
+
`Sitemap: ${origin}/sitemap.xml`,
|
|
55
|
+
'',
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
return new Response(lines.join('\n'), {
|
|
59
|
+
headers: {
|
|
60
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { source } from '@/lib/source';
|
|
4
|
+
import {
|
|
5
|
+
getUpdateRssEntries,
|
|
6
|
+
parseChangelogFromMarkdown,
|
|
7
|
+
parseFrontmatterValue,
|
|
8
|
+
} from '@/lib/changelog';
|
|
9
|
+
import { getLanguages, getSiteOrigin } from '@/lib/velu';
|
|
10
|
+
|
|
11
|
+
interface RouteParams {
|
|
12
|
+
slug?: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const dynamic = 'force-static';
|
|
16
|
+
|
|
17
|
+
function normalizePath(value: string): string {
|
|
18
|
+
if (!value) return '/';
|
|
19
|
+
const withLeadingSlash = value.startsWith('/') ? value : `/${value}`;
|
|
20
|
+
const collapsed = withLeadingSlash.replace(/\/{2,}/g, '/');
|
|
21
|
+
if (collapsed !== '/' && collapsed.endsWith('/')) return collapsed.slice(0, -1);
|
|
22
|
+
return collapsed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function cdata(value: string): string {
|
|
26
|
+
return `<![CDATA[${value.replace(/]]>/g, ']]]]><![CDATA[>')}]]>`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toUtcDate(value: string | undefined): string {
|
|
30
|
+
if (!value) return new Date().toUTCString();
|
|
31
|
+
const parsed = new Date(value);
|
|
32
|
+
if (Number.isNaN(parsed.getTime())) return new Date().toUTCString();
|
|
33
|
+
return parsed.toUTCString();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveLocaleSlug(slugInput: string[] | undefined) {
|
|
37
|
+
const languages = getLanguages();
|
|
38
|
+
const defaultLanguage = languages[0] ?? 'en';
|
|
39
|
+
const slug = slugInput ?? [];
|
|
40
|
+
const firstSeg = slug[0];
|
|
41
|
+
const hasLocalePrefix = languages.includes(firstSeg ?? '');
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
locale: hasLocalePrefix ? firstSeg! : defaultLanguage,
|
|
45
|
+
pageSlug: hasLocalePrefix ? slug.slice(1) : slug,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function loadMarkdownForSlug(slug: string[], locale: string, hasI18n: boolean): Promise<string | undefined> {
|
|
50
|
+
const rel = slug.join('/');
|
|
51
|
+
const docsRoots = [
|
|
52
|
+
join(process.cwd(), 'content', 'docs'),
|
|
53
|
+
join(process.cwd(), '.velu-out', 'content', 'docs'),
|
|
54
|
+
];
|
|
55
|
+
const roots = hasI18n
|
|
56
|
+
? docsRoots.flatMap((root) => [join(root, locale), root])
|
|
57
|
+
: docsRoots;
|
|
58
|
+
const paths = roots.flatMap((root) => [join(root, `${rel}.md`), join(root, `${rel}.mdx`)]);
|
|
59
|
+
|
|
60
|
+
for (const filePath of paths) {
|
|
61
|
+
try {
|
|
62
|
+
return await readFile(filePath, 'utf-8');
|
|
63
|
+
} catch {
|
|
64
|
+
// ignore and continue
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function generateStaticParams() {
|
|
72
|
+
const generated = source.generateParams('slug') as Array<{ slug?: string[] }>;
|
|
73
|
+
const seen = new Set<string>();
|
|
74
|
+
const out: Array<{ slug: string[] }> = [];
|
|
75
|
+
|
|
76
|
+
for (const entry of generated) {
|
|
77
|
+
const slug = entry.slug ?? [];
|
|
78
|
+
if (slug.length === 0) continue;
|
|
79
|
+
const key = slug.join('/');
|
|
80
|
+
if (seen.has(key)) continue;
|
|
81
|
+
seen.add(key);
|
|
82
|
+
out.push({ slug });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function GET(_request: Request, { params }: { params: Promise<RouteParams> }) {
|
|
89
|
+
const resolvedParams = await params;
|
|
90
|
+
const fullSlug = resolvedParams.slug ?? [];
|
|
91
|
+
|
|
92
|
+
if (fullSlug.length === 0) {
|
|
93
|
+
return new Response('Not Found', {
|
|
94
|
+
status: 404,
|
|
95
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { locale, pageSlug } = resolveLocaleSlug(fullSlug);
|
|
100
|
+
const hasI18n = getLanguages().length > 1;
|
|
101
|
+
const page = hasI18n ? source.getPage(pageSlug, locale) : source.getPage(pageSlug);
|
|
102
|
+
|
|
103
|
+
if (!page) {
|
|
104
|
+
return new Response('Not Found', {
|
|
105
|
+
status: 404,
|
|
106
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const fromFile = await loadMarkdownForSlug(pageSlug, locale, hasI18n);
|
|
111
|
+
const fromData = ((page.data as unknown) as Record<string, unknown>).processedMarkdown;
|
|
112
|
+
const markdown = typeof fromFile === 'string' && fromFile.trim().length > 0
|
|
113
|
+
? fromFile
|
|
114
|
+
: (typeof fromData === 'string' ? fromData : undefined);
|
|
115
|
+
const parsed = parseChangelogFromMarkdown(markdown);
|
|
116
|
+
if (parsed.updates.length === 0) {
|
|
117
|
+
return new Response('Not Found', {
|
|
118
|
+
status: 404,
|
|
119
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const origin = getSiteOrigin();
|
|
124
|
+
const pagePath = normalizePath(fullSlug.join('/'));
|
|
125
|
+
const pageUrl = `${origin}${pagePath}`;
|
|
126
|
+
const rssUrl = `${pageUrl.replace(/\/$/, '')}/rss.xml`;
|
|
127
|
+
|
|
128
|
+
const channelTitle = parseFrontmatterValue(markdown, 'title') ?? page.data.title ?? 'Changelog';
|
|
129
|
+
const channelDescription = parseFrontmatterValue(markdown, 'description')
|
|
130
|
+
?? page.data.description
|
|
131
|
+
?? 'Product updates and announcements';
|
|
132
|
+
|
|
133
|
+
const items = parsed.updates.flatMap((update) => {
|
|
134
|
+
const entries = getUpdateRssEntries(update);
|
|
135
|
+
const pubDate = toUtcDate(update.date ?? update.label);
|
|
136
|
+
return entries.map((entry) => {
|
|
137
|
+
const link = `${pageUrl.replace(/\/$/, '')}#${entry.anchor || update.anchor}`;
|
|
138
|
+
return `<item>
|
|
139
|
+
<title>${cdata(entry.title)}</title>
|
|
140
|
+
<description>${cdata(entry.description)}</description>
|
|
141
|
+
<link>${link}</link>
|
|
142
|
+
<guid isPermaLink="true">${link}</guid>
|
|
143
|
+
<pubDate>${pubDate}</pubDate>
|
|
144
|
+
</item>`;
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
149
|
+
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
|
|
150
|
+
<channel>
|
|
151
|
+
<title>${cdata(channelTitle)}</title>
|
|
152
|
+
<description>${cdata(channelDescription)}</description>
|
|
153
|
+
<link>${pageUrl}</link>
|
|
154
|
+
<generator>Velu</generator>
|
|
155
|
+
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
|
|
156
|
+
<atom:link href="${rssUrl}" rel="self" type="application/rss+xml"/>
|
|
157
|
+
${items.join('\n ')}
|
|
158
|
+
</channel>
|
|
159
|
+
</rss>
|
|
160
|
+
`;
|
|
161
|
+
|
|
162
|
+
return new Response(xml, {
|
|
163
|
+
status: 200,
|
|
164
|
+
headers: {
|
|
165
|
+
'content-type': 'application/rss+xml; charset=utf-8',
|
|
166
|
+
'cache-control': 'public, max-age=300',
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { collectLlmsPages, normalizePath } from '@/lib/llms';
|
|
5
|
+
import { getSeoConfig, getSiteOrigin } from '@/lib/velu';
|
|
6
|
+
|
|
7
|
+
export const dynamic = 'force-static';
|
|
8
|
+
|
|
9
|
+
function escapeXml(value: string): string {
|
|
10
|
+
return value
|
|
11
|
+
.replace(/&/g, '&')
|
|
12
|
+
.replace(/</g, '<')
|
|
13
|
+
.replace(/>/g, '>')
|
|
14
|
+
.replace(/"/g, '"')
|
|
15
|
+
.replace(/'/g, ''');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function readCustomSitemapFile(): Promise<string | null> {
|
|
19
|
+
const docsDir = process.env.VELU_DOCS_DIR?.trim();
|
|
20
|
+
if (docsDir) {
|
|
21
|
+
const docsPath = join(docsDir, 'sitemap.xml');
|
|
22
|
+
if (!existsSync(docsPath)) return null;
|
|
23
|
+
try {
|
|
24
|
+
return await readFile(docsPath, 'utf-8');
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const candidates = [
|
|
31
|
+
join(process.cwd(), 'sitemap.xml'),
|
|
32
|
+
join(process.cwd(), 'public', 'sitemap.xml'),
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
for (const candidate of candidates) {
|
|
36
|
+
if (!existsSync(candidate)) continue;
|
|
37
|
+
try {
|
|
38
|
+
return await readFile(candidate, 'utf-8');
|
|
39
|
+
} catch {
|
|
40
|
+
// ignore and continue
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function GET(): Promise<Response> {
|
|
48
|
+
const custom = await readCustomSitemapFile();
|
|
49
|
+
if (custom !== null) {
|
|
50
|
+
return new Response(custom, {
|
|
51
|
+
headers: {
|
|
52
|
+
'content-type': 'application/xml; charset=utf-8',
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const seo = getSeoConfig();
|
|
58
|
+
const origin = getSiteOrigin();
|
|
59
|
+
const pages = await collectLlmsPages({ indexing: seo.indexing });
|
|
60
|
+
const now = new Date().toISOString();
|
|
61
|
+
|
|
62
|
+
const urls = pages
|
|
63
|
+
.filter((page) => !page.noindex)
|
|
64
|
+
.map((page) => {
|
|
65
|
+
const loc = escapeXml(`${origin}${normalizePath(page.path)}`);
|
|
66
|
+
return ` <url><loc>${loc}</loc><lastmod>${now}</lastmod></url>`;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const xml = [
|
|
70
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
71
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
72
|
+
...urls,
|
|
73
|
+
'</urlset>',
|
|
74
|
+
'',
|
|
75
|
+
].join('\n');
|
|
76
|
+
|
|
77
|
+
return new Response(xml, {
|
|
78
|
+
headers: {
|
|
79
|
+
'content-type': 'application/xml; charset=utf-8',
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -305,19 +305,30 @@ function initAssistant() {
|
|
|
305
305
|
else if (action === 'reset') resetChat();
|
|
306
306
|
});
|
|
307
307
|
|
|
308
|
-
//
|
|
309
|
-
|
|
308
|
+
// Hide ask bar only when truly near the page bottom.
|
|
309
|
+
function syncAskBarVisibility() {
|
|
310
310
|
if (isPanelOpen()) return;
|
|
311
311
|
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
|
312
312
|
const docHeight = document.documentElement.scrollHeight;
|
|
313
313
|
const winHeight = window.innerHeight;
|
|
314
|
-
|
|
315
|
-
|
|
314
|
+
const bottomGap = docHeight - scrollTop - winHeight;
|
|
315
|
+
const nearBottomThreshold = 8;
|
|
316
|
+
|
|
317
|
+
if (docHeight <= winHeight + 2) {
|
|
318
|
+
askBar.classList.remove('velu-ask-bar-hidden');
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (bottomGap <= nearBottomThreshold) {
|
|
316
323
|
askBar.classList.add('velu-ask-bar-hidden');
|
|
317
324
|
} else {
|
|
318
325
|
askBar.classList.remove('velu-ask-bar-hidden');
|
|
319
326
|
}
|
|
320
|
-
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
window.addEventListener('scroll', syncAskBarVisibility, { passive: true });
|
|
330
|
+
window.addEventListener('resize', syncAskBarVisibility, { passive: true });
|
|
331
|
+
syncAskBarVisibility();
|
|
321
332
|
|
|
322
333
|
document.addEventListener('keydown', (e) => {
|
|
323
334
|
if (e.key === 'Escape' && isPanelOpen()) closePanel();
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createPortal } from 'react-dom';
|
|
4
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
5
|
+
|
|
6
|
+
interface ChangelogFiltersProps {
|
|
7
|
+
tags: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalize(value: string): string {
|
|
11
|
+
return value.trim().toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseNodeTags(value: string | undefined): string[] {
|
|
15
|
+
if (!value) return [];
|
|
16
|
+
return value
|
|
17
|
+
.split('|')
|
|
18
|
+
.map((tag) => normalize(tag))
|
|
19
|
+
.filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const VELU_CHANGELOG_FILTER_HOST_ID = 'velu-changelog-filter-host';
|
|
23
|
+
|
|
24
|
+
function ensureTocHost(): HTMLDivElement | null {
|
|
25
|
+
if (typeof document === 'undefined') return null;
|
|
26
|
+
const toc = document.getElementById('nd-toc');
|
|
27
|
+
if (!toc) return null;
|
|
28
|
+
toc.classList.add('velu-changelog-filters-only');
|
|
29
|
+
|
|
30
|
+
let host = document.getElementById(VELU_CHANGELOG_FILTER_HOST_ID) as HTMLDivElement | null;
|
|
31
|
+
if (!host) {
|
|
32
|
+
host = document.createElement('div');
|
|
33
|
+
host.id = VELU_CHANGELOG_FILTER_HOST_ID;
|
|
34
|
+
host.className = 'velu-changelog-filter-host';
|
|
35
|
+
toc.prepend(host);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return host;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function ChangelogFilters({ tags }: ChangelogFiltersProps) {
|
|
42
|
+
const [selected, setSelected] = useState<string[]>([]);
|
|
43
|
+
const [tocHost, setTocHost] = useState<HTMLDivElement | null>(null);
|
|
44
|
+
const uniqueTags = useMemo(
|
|
45
|
+
() => Array.from(new Set(tags.map((tag) => tag.trim()).filter(Boolean))),
|
|
46
|
+
[tags],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
let frame = 0;
|
|
51
|
+
const attach = () => {
|
|
52
|
+
const host = ensureTocHost();
|
|
53
|
+
if (host) {
|
|
54
|
+
setTocHost(host);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
frame = window.requestAnimationFrame(attach);
|
|
58
|
+
};
|
|
59
|
+
attach();
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
if (frame) window.cancelAnimationFrame(frame);
|
|
63
|
+
const toc = document.getElementById('nd-toc');
|
|
64
|
+
toc?.classList.remove('velu-changelog-filters-only');
|
|
65
|
+
const host = document.getElementById(VELU_CHANGELOG_FILTER_HOST_ID);
|
|
66
|
+
host?.remove();
|
|
67
|
+
};
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const updates = Array.from(document.querySelectorAll<HTMLElement>('.velu-update'));
|
|
72
|
+
const normalizedSelected = selected.map((tag) => normalize(tag));
|
|
73
|
+
|
|
74
|
+
for (const update of updates) {
|
|
75
|
+
const updateTags = parseNodeTags(update.dataset.updateTags);
|
|
76
|
+
const visible = normalizedSelected.length === 0
|
|
77
|
+
|| normalizedSelected.some((tag) => updateTags.includes(tag));
|
|
78
|
+
update.hidden = !visible;
|
|
79
|
+
}
|
|
80
|
+
}, [selected]);
|
|
81
|
+
|
|
82
|
+
if (uniqueTags.length === 0) return null;
|
|
83
|
+
|
|
84
|
+
const content = (
|
|
85
|
+
<div className="velu-changelog-filter-block">
|
|
86
|
+
<div className="velu-changelog-filter-heading">Filters</div>
|
|
87
|
+
<div className="velu-changelog-filters" role="group" aria-label="Filter updates by tag">
|
|
88
|
+
{uniqueTags.map((tag) => {
|
|
89
|
+
const active = selected.some((entry) => normalize(entry) === normalize(tag));
|
|
90
|
+
return (
|
|
91
|
+
<button
|
|
92
|
+
key={tag}
|
|
93
|
+
type="button"
|
|
94
|
+
className={['velu-changelog-filter', active ? 'active' : ''].filter(Boolean).join(' ')}
|
|
95
|
+
onClick={() => {
|
|
96
|
+
setSelected((prev) => {
|
|
97
|
+
const hasTag = prev.some((entry) => normalize(entry) === normalize(tag));
|
|
98
|
+
return hasTag
|
|
99
|
+
? prev.filter((entry) => normalize(entry) !== normalize(tag))
|
|
100
|
+
: [...prev, tag];
|
|
101
|
+
});
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
{tag}
|
|
105
|
+
</button>
|
|
106
|
+
);
|
|
107
|
+
})}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (!tocHost) return null;
|
|
113
|
+
return createPortal(content, tocHost);
|
|
114
|
+
}
|