@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,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, '&amp;')
12
+ .replace(/</g, '&lt;')
13
+ .replace(/>/g, '&gt;')
14
+ .replace(/"/g, '&quot;')
15
+ .replace(/'/g, '&apos;');
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
- // Scroll: hide ask bar at bottom of page
309
- window.addEventListener('scroll', () => {
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
- if (docHeight <= winHeight + 10) return;
315
- if (docHeight - scrollTop - winHeight < 60) {
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
- }, { passive: true });
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
+ }