@aravindc26/velu 0.9.1 → 0.11.0

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.
@@ -3,6 +3,7 @@ import { createRequire } from 'node:module';
3
3
  import { watch } from 'node:fs';
4
4
  import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
5
  import { dirname, extname, join, resolve } from 'node:path';
6
+ import { normalizeConfigNavigation } from './lib/navigation-normalize.mjs';
6
7
 
7
8
  const require = createRequire(import.meta.url);
8
9
  const nextBinPath = require.resolve('next/dist/bin/next');
@@ -13,7 +14,7 @@ const contentDir = resolve('content', 'docs');
13
14
 
14
15
  function loadConfig() {
15
16
  const raw = readFileSync(join(docsDir, 'velu.json'), 'utf-8');
16
- return JSON.parse(raw);
17
+ return normalizeConfigNavigation(JSON.parse(raw));
17
18
  }
18
19
 
19
20
  function pageBasename(page) {
@@ -25,6 +26,29 @@ function pageLabelFromSlug(slug) {
25
26
  return last.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
26
27
  }
27
28
 
29
+ function isSeparator(item) {
30
+ return typeof item === 'object' && item !== null && 'separator' in item;
31
+ }
32
+
33
+ function isLink(item) {
34
+ return typeof item === 'object' && item !== null && 'href' in item && 'label' in item;
35
+ }
36
+
37
+ function isGroup(item) {
38
+ return typeof item === 'object' && item !== null && 'group' in item;
39
+ }
40
+
41
+ function metaEntry(item) {
42
+ if (typeof item === 'string') return item;
43
+ if (isSeparator(item)) return `---${item.separator}---`;
44
+ if (isLink(item)) {
45
+ return item.icon
46
+ ? `[${item.icon}][${item.label}](${item.href})`
47
+ : `[${item.label}](${item.href})`;
48
+ }
49
+ return String(item);
50
+ }
51
+
28
52
  function buildArtifacts(config) {
29
53
  const pageMap = [];
30
54
  const metaFiles = [];
@@ -51,9 +75,17 @@ function buildArtifacts(config) {
51
75
  pageMap.push({ src: item, dest });
52
76
  pages.push(basename);
53
77
  trackFirstPage(dest);
54
- } else {
78
+ } else if (isGroup(item)) {
55
79
  addGroup(item, groupDir);
56
- pages.push(item.slug);
80
+ pages.push(item.hidden ? `!${item.slug}` : item.slug);
81
+ } else if (isSeparator(item)) {
82
+ pages.push(`---${item.separator}---`);
83
+ } else if (isLink(item)) {
84
+ pages.push(
85
+ item.icon
86
+ ? `[${item.icon}][${item.label}](${item.href})`
87
+ : `[${item.label}](${item.href})`
88
+ );
57
89
  }
58
90
  }
59
91
 
@@ -64,6 +96,7 @@ function buildArtifacts(config) {
64
96
  };
65
97
 
66
98
  if (group.icon) groupMeta.icon = group.icon;
99
+ if (group.description) groupMeta.description = group.description;
67
100
 
68
101
  metaFiles.push({ dir: groupDir, data: groupMeta });
69
102
  }
@@ -73,15 +106,19 @@ function buildArtifacts(config) {
73
106
 
74
107
  for (const group of tab.groups || []) {
75
108
  addGroup(group, tab.slug);
76
- tabPages.push(group.slug);
109
+ tabPages.push(group.hidden ? `!${group.slug}` : group.slug);
77
110
  }
78
111
 
79
- for (const page of tab.pages || []) {
80
- const basename = pageBasename(page);
81
- const dest = `${tab.slug}/${basename}`;
82
- pageMap.push({ src: page, dest });
83
- tabPages.push(basename);
84
- trackFirstPage(dest);
112
+ for (const item of tab.pages || []) {
113
+ if (typeof item === 'string') {
114
+ const basename = pageBasename(item);
115
+ const dest = `${tab.slug}/${basename}`;
116
+ pageMap.push({ src: item, dest });
117
+ tabPages.push(basename);
118
+ trackFirstPage(dest);
119
+ } else {
120
+ tabPages.push(metaEntry(item));
121
+ }
85
122
  }
86
123
 
87
124
  const tabMeta = {
@@ -128,28 +165,100 @@ function writeMetaFiles(metaFiles) {
128
165
  function writeIndexPage(firstPage) {
129
166
  writeFileSync(
130
167
  join(contentDir, 'index.mdx'),
131
- `---\ntitle: "Overview"\ndescription: Documentation powered by Velu + Fumadocs\n---\n\nimport { Card, Cards } from "fumadocs-ui/components/card"\nimport { Callout } from "fumadocs-ui/components/callout"\n\n<Callout type="info">\n This site is powered by Velu + Fumadocs.\n</Callout>\n\n## Start here\n\n<Cards>\n <Card\n title="Read the docs"\n href="/${firstPage}/"\n description="Begin with the first page in your configured navigation."\n />\n</Cards>\n`,
168
+ `---\ntitle: "Overview"\ndescription: Documentation powered by Velu\n---\n\nimport { Card, Cards } from "fumadocs-ui/components/card"\nimport { Callout } from "fumadocs-ui/components/callout"\n\n<Callout type="info">\n Welcome to your documentation site.\n</Callout>\n\n## Start here\n\n<Cards>\n <Card\n title="Read the docs"\n href="/${firstPage}/"\n description="Begin with the first page in your configured navigation."\n />\n</Cards>\n`,
169
+ 'utf-8'
170
+ );
171
+ }
172
+
173
+ function writeLangContent(langCode, artifacts, isDefault, useLangFolders = false) {
174
+ const storagePrefix = useLangFolders ? langCode : (isDefault ? '' : langCode);
175
+ const urlPrefix = isDefault ? '' : langCode;
176
+
177
+ // Write meta files (prefixed for non-default)
178
+ const metaFiles = storagePrefix
179
+ ? artifacts.metaFiles.map((meta) => ({
180
+ dir: meta.dir ? `${storagePrefix}/${meta.dir}` : storagePrefix,
181
+ data: { ...meta.data },
182
+ }))
183
+ : artifacts.metaFiles;
184
+ writeMetaFiles(metaFiles);
185
+
186
+ // Copy pages using explicit source paths from velu.json
187
+ for (const { src, dest } of artifacts.pageMap) {
188
+ const srcPath = join(docsDir, `${src}.md`);
189
+ if (!existsSync(srcPath)) {
190
+ console.warn(` \x1b[33m⚠\x1b[0m Missing page source: ${src}.md (language: ${langCode})`);
191
+ continue;
192
+ }
193
+ const destPath = join(contentDir, storagePrefix ? `${storagePrefix}/${dest}.mdx` : `${dest}.mdx`);
194
+ processPage(srcPath, destPath, src);
195
+ }
196
+
197
+ // Index page
198
+ const href = urlPrefix ? `/${urlPrefix}/${artifacts.firstPage}/` : `/${artifacts.firstPage}/`;
199
+ const indexPath = storagePrefix ? join(contentDir, storagePrefix, 'index.mdx') : join(contentDir, 'index.mdx');
200
+ writeFileSync(
201
+ indexPath,
202
+ `---\ntitle: "Overview"\ndescription: Documentation powered by Velu\n---\n\nimport { Card, Cards } from "fumadocs-ui/components/card"\nimport { Callout } from "fumadocs-ui/components/callout"\n\n<Callout type="info">\n Welcome to your documentation site.\n</Callout>\n\n## Start here\n\n<Cards>\n <Card\n title="Read the docs"\n href="${href}"\n description="Begin with the first page in your configured navigation."\n />\n</Cards>\n`,
132
203
  'utf-8'
133
204
  );
134
205
  }
135
206
 
136
207
  function rebuildFromConfig() {
137
208
  const config = loadConfig();
138
- const artifacts = buildArtifacts(config);
209
+ const navLanguages = config.navigation?.languages;
210
+ const simpleLanguages = config.languages || [];
139
211
 
140
212
  rmSync(contentDir, { recursive: true, force: true });
141
213
  mkdirSync(contentDir, { recursive: true });
142
214
 
143
- writeMetaFiles(artifacts.metaFiles);
215
+ // ── Mode 1: Per-language navigation (Mintlify-style) ──────────────
216
+ if (navLanguages && navLanguages.length > 0) {
217
+ const rootPages = [];
144
218
 
145
- for (const { src, dest } of artifacts.pageMap) {
146
- const srcPath = join(docsDir, `${src}.md`);
147
- if (!existsSync(srcPath)) continue;
148
- const destPath = join(contentDir, `${dest}.mdx`);
149
- processPage(srcPath, destPath, src);
219
+ for (let i = 0; i < navLanguages.length; i++) {
220
+ const langEntry = navLanguages[i];
221
+ const langCode = langEntry.language;
222
+ const isDefault = i === 0;
223
+
224
+ // Build artifacts using this language's own tabs
225
+ const langConfig = { ...config, navigation: { ...config.navigation, tabs: langEntry.tabs } };
226
+ const artifacts = buildArtifacts(langConfig);
227
+
228
+ writeLangContent(langCode, artifacts, isDefault, true);
229
+ rootPages.push(`!${langCode}`);
230
+ }
231
+
232
+ // Write root meta with default tabs + hidden language folders
233
+ writeFileSync(
234
+ join(contentDir, 'meta.json'),
235
+ JSON.stringify({ pages: rootPages }, null, 2) + '\n',
236
+ 'utf-8'
237
+ );
238
+
239
+ // Return the default language's page map for file watching
240
+ const defaultConfig = { ...config, navigation: { ...config.navigation, tabs: navLanguages[0].tabs } };
241
+ return buildArtifacts(defaultConfig).pageMap;
242
+ }
243
+
244
+ // ── Mode 2: Simple multi-lang (same nav, content in docs/<lang>/) ─
245
+ const artifacts = buildArtifacts(config);
246
+
247
+ const useLangFolders = simpleLanguages.length > 1;
248
+ writeLangContent(simpleLanguages[0] || 'en', artifacts, true, useLangFolders);
249
+
250
+ if (simpleLanguages.length > 1) {
251
+ const rootMetaPath = join(contentDir, 'meta.json');
252
+ const rootPages = [`!${simpleLanguages[0] || 'en'}`];
253
+
254
+ for (const lang of simpleLanguages.slice(1)) {
255
+ writeLangContent(lang, artifacts, false, true);
256
+ rootPages.push(`!${lang}`);
257
+ }
258
+
259
+ writeFileSync(rootMetaPath, JSON.stringify({ pages: rootPages }, null, 2) + '\n', 'utf-8');
150
260
  }
151
261
 
152
- writeIndexPage(artifacts.firstPage);
153
262
  return artifacts.pageMap;
154
263
  }
155
264
 
@@ -244,7 +353,7 @@ const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : 4321;
244
353
 
245
354
  if (command === 'dev') {
246
355
  console.log('');
247
- console.log(' \x1b[36mvelu\x1b[0m fumadocs dev');
356
+ console.log(' \x1b[36mvelu\x1b[0m dev server');
248
357
  console.log('');
249
358
  console.log(' watching for file changes...');
250
359
  startWatcher();
@@ -0,0 +1,87 @@
1
+ import type { ReactNode } from 'react';
2
+ import { DocsLayout } from 'fumadocs-ui/layouts/docs';
3
+ import { baseOptions } from '@/lib/layout.shared';
4
+ import { source } from '@/lib/source';
5
+ import { getLanguages, getVersionOptions, getProductOptions, type VeluVersionOption, type VeluProductOption } from '@/lib/velu';
6
+ import { SidebarLinks } from '@/components/sidebar-links';
7
+ import { ProductSwitcher } from '@/components/product-switcher';
8
+
9
+ interface LayoutParams {
10
+ slug?: string[];
11
+ }
12
+
13
+ interface SlugLayoutProps {
14
+ children: ReactNode;
15
+ params: Promise<LayoutParams>;
16
+ }
17
+
18
+ function resolveLocale(slugInput: string[] | undefined): string {
19
+ const languages = getLanguages();
20
+ const defaultLanguage = languages[0] ?? 'en';
21
+ const slug = slugInput ?? [];
22
+ const firstSeg = slug[0];
23
+
24
+ return languages.includes(firstSeg ?? '') ? firstSeg! : defaultLanguage;
25
+ }
26
+
27
+ function resolveCurrentVersion(slugInput: string[] | undefined, versions: VeluVersionOption[]): VeluVersionOption | undefined {
28
+ if (versions.length === 0) return undefined;
29
+ const firstSeg = (slugInput ?? [])[0] ?? '';
30
+ return versions.find((v) => v.slug === firstSeg) ?? versions.find((v) => v.isDefault) ?? versions[0];
31
+ }
32
+
33
+ function filterTreeBySlugPrefix<T extends { children?: unknown[] }>(tree: T, prefix?: string): T {
34
+ if (!prefix) return tree;
35
+
36
+ const children = Array.isArray(tree.children) ? tree.children : [];
37
+
38
+ const filtered = children.filter((node) => {
39
+ if (typeof node !== 'object' || node === null) return false;
40
+ const entry = node as { url?: unknown; path?: unknown; $ref?: { metaFile?: unknown; file?: unknown } };
41
+ const candidates = [entry.url, entry.path, entry.$ref?.metaFile, entry.$ref?.file]
42
+ .filter((value): value is string => typeof value === 'string');
43
+ return candidates.some((value) => value.includes(`${prefix}/`));
44
+ });
45
+
46
+ if (filtered.length === 0) return tree;
47
+ return { ...tree, children: filtered } as T;
48
+ }
49
+
50
+ function resolveCurrentProduct(slugInput: string[] | undefined, products: VeluProductOption[]): VeluProductOption | undefined {
51
+ if (products.length === 0) return undefined;
52
+ const firstSeg = (slugInput ?? [])[0] ?? '';
53
+ return products.find((p) => p.slug === firstSeg) ?? products[0];
54
+ }
55
+
56
+ export default async function SlugLayout({ children, params }: SlugLayoutProps) {
57
+ const resolvedParams = await params;
58
+ const locale = resolveLocale(resolvedParams.slug);
59
+ const versions = getVersionOptions();
60
+ const products = getProductOptions();
61
+ const currentVersion = resolveCurrentVersion(resolvedParams.slug, versions);
62
+ const currentProduct = resolveCurrentProduct(resolvedParams.slug, products);
63
+
64
+ const activePrefix = currentVersion?.slug ?? currentProduct?.slug;
65
+ const tree = filterTreeBySlugPrefix(source.getPageTree(locale), activePrefix);
66
+
67
+ return (
68
+ <DocsLayout
69
+ tree={tree}
70
+ sidebar={{
71
+ collapsible: false,
72
+ banner: products.length > 1 ? (
73
+ <div className="velu-sidebar-banner">
74
+ <ProductSwitcher products={products} />
75
+ </div>
76
+ ) : undefined,
77
+ footer: <SidebarLinks />,
78
+ }}
79
+ {...baseOptions()}
80
+ themeSwitch={{ enabled: false }}
81
+ >
82
+ {children}
83
+ </DocsLayout>
84
+ );
85
+ }
86
+
87
+ export { generateStaticParams } from './page';
@@ -9,6 +9,7 @@ import {
9
9
  } from 'fumadocs-ui/layouts/docs/page';
10
10
  import { getMDXComponents } from '@/mdx-components';
11
11
  import { source } from '@/lib/source';
12
+ import { getLanguages, getVersionOptions, getProductOptions } from '@/lib/velu';
12
13
  import { CopyPageButton } from '@/components/copy-page';
13
14
 
14
15
  interface RouteParams {
@@ -19,17 +20,79 @@ interface PageProps {
19
20
  params: Promise<RouteParams>;
20
21
  }
21
22
 
23
+ function resolveLocaleSlug(slugInput: string[] | undefined) {
24
+ const languages = getLanguages();
25
+ const defaultLanguage = languages[0] ?? 'en';
26
+ const slug = slugInput ?? [];
27
+ const firstSeg = slug[0];
28
+ const hasLocalePrefix = languages.includes(firstSeg ?? '');
29
+
30
+ return {
31
+ defaultLanguage,
32
+ locale: hasLocalePrefix ? firstSeg! : defaultLanguage,
33
+ pageSlug: hasLocalePrefix ? slug.slice(1) : slug,
34
+ };
35
+ }
36
+
37
+ function resolveContextFromSlug(slugInput: string[] | undefined) {
38
+ const languages = getLanguages();
39
+ const versions = getVersionOptions();
40
+ const products = getProductOptions();
41
+ const slug = slugInput ?? [];
42
+
43
+ // Check for language prefix
44
+ const firstSeg = slug[0];
45
+ const hasLocalePrefix = languages.includes(firstSeg ?? '');
46
+ const locale = hasLocalePrefix ? firstSeg! : (languages[0] ?? 'en');
47
+ const remainingSlug = hasLocalePrefix ? slug.slice(1) : slug;
48
+
49
+ // Check for version/product in remaining slug
50
+ const contextSeg = remainingSlug[0] ?? '';
51
+ const version = versions.find((v) => v.slug === contextSeg);
52
+ const product = products.find((p) => p.slug === contextSeg);
53
+
54
+ return {
55
+ locale,
56
+ version: version?.slug,
57
+ product: product?.slug,
58
+ };
59
+ }
60
+
22
61
  export default async function Page({ params }: PageProps) {
23
62
  const resolvedParams = await params;
24
- const page = source.getPage(resolvedParams.slug);
63
+ const { locale, pageSlug } = resolveLocaleSlug(resolvedParams.slug);
64
+ const { locale: filterLocale, version, product } = resolveContextFromSlug(resolvedParams.slug);
65
+ const hasI18n = getLanguages().length > 1;
66
+
67
+ const page = hasI18n ? source.getPage(pageSlug, locale) : source.getPage(pageSlug);
25
68
 
26
69
  if (!page) notFound();
27
70
 
28
71
  const MDX = page.data.body;
29
72
 
73
+ // Build pagefind filter attributes
74
+ const metaAttrs: string[] = [`title:${page.data.title}`];
75
+ const filterAttrs: string[] = [];
76
+ if (hasI18n) {
77
+ metaAttrs.push(`language:${filterLocale}`);
78
+ filterAttrs.push(`language:${filterLocale}`);
79
+ }
80
+ if (version) {
81
+ metaAttrs.push(`version:${version}`);
82
+ filterAttrs.push(`version:${version}`);
83
+ }
84
+ if (product) {
85
+ metaAttrs.push(`product:${product}`);
86
+ filterAttrs.push(`product:${product}`);
87
+ }
88
+
30
89
  return (
31
90
  <DocsPage toc={page.data.toc} full={page.data.full}>
32
- <div data-pagefind-body data-pagefind-meta={`title:${page.data.title}`}>
91
+ <div
92
+ data-pagefind-body
93
+ data-pagefind-meta={metaAttrs.join(',')}
94
+ data-pagefind-filter={filterAttrs.length > 0 ? filterAttrs.join(',') : undefined}
95
+ >
33
96
  <div className="velu-title-row">
34
97
  <DocsTitle>{page.data.title}</DocsTitle>
35
98
  <CopyPageButton />
@@ -43,19 +106,36 @@ export default async function Page({ params }: PageProps) {
43
106
  />
44
107
  </DocsBody>
45
108
  </div>
109
+ <footer className="velu-footer">
110
+ Powered by <a href="https://getvelu.com" target="_blank" rel="noopener noreferrer">Velu</a>
111
+ </footer>
46
112
  </DocsPage>
47
113
  );
48
114
  }
49
115
 
50
116
  export async function generateStaticParams() {
51
- const params = source.generateParams();
52
- // Include root path for the optional catch-all [[...slug]]
53
- return [{ slug: [] }, ...params];
117
+ const generated = source.generateParams('slug') as Array<{ slug?: string[] }>;
118
+ const seen = new Set<string>();
119
+
120
+ const nonRoot = generated.filter((entry) => {
121
+ const slug = entry.slug ?? [];
122
+ if (slug.length === 0) return false;
123
+ const key = slug.join('/');
124
+ if (seen.has(key)) return false;
125
+ seen.add(key);
126
+ return true;
127
+ });
128
+
129
+ // Include root variants for optional catch-all [[...slug]] in export mode.
130
+ return [{}, { slug: undefined }, { slug: [] }, ...nonRoot];
54
131
  }
55
132
 
56
133
  export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
57
134
  const resolvedParams = await params;
58
- const page = source.getPage(resolvedParams.slug);
135
+ const { locale, pageSlug } = resolveLocaleSlug(resolvedParams.slug);
136
+ const hasI18n = getLanguages().length > 1;
137
+
138
+ const page = hasI18n ? source.getPage(pageSlug, locale) : source.getPage(pageSlug);
59
139
 
60
140
  if (!page) notFound();
61
141
 
@@ -1,16 +1,4 @@
1
1
  import type { ReactNode } from 'react';
2
- import { DocsLayout } from 'fumadocs-ui/layouts/docs';
3
- import { baseOptions } from '@/lib/layout.shared';
4
- import { source } from '@/lib/source';
5
-
6
2
  export default function DocsRootLayout({ children }: { children: ReactNode }) {
7
- return (
8
- <DocsLayout
9
- tree={source.getPageTree()}
10
- sidebar={{ collapsible: false }}
11
- {...baseOptions()}
12
- >
13
- {children}
14
- </DocsLayout>
15
- );
3
+ return children;
16
4
  }