@aravindc26/velu 0.8.0 → 0.9.1

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/src/cli.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { resolve, join, dirname } from "node:path";
1
+ import { resolve, join, dirname, delimiter } from "node:path";
2
2
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
3
  import { spawn } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
@@ -6,6 +6,17 @@ import { fileURLToPath } from "node:url";
6
6
  const __filename = fileURLToPath(import.meta.url);
7
7
  const PACKAGE_ROOT = resolve(dirname(__filename), "..");
8
8
  const SCHEMA_PATH = join(PACKAGE_ROOT, "schema", "velu.schema.json");
9
+ const NODE_MODULES_PATH = join(PACKAGE_ROOT, "node_modules");
10
+
11
+ /** Build env that lets spawned processes resolve deps from the CLI's own node_modules */
12
+ function engineEnv(docsDir?: string): NodeJS.ProcessEnv {
13
+ const existing = process.env.NODE_PATH || "";
14
+ return {
15
+ ...process.env,
16
+ NODE_PATH: existing ? `${NODE_MODULES_PATH}${delimiter}${existing}` : NODE_MODULES_PATH,
17
+ ...(docsDir ? { VELU_DOCS_DIR: docsDir } : {}),
18
+ };
19
+ }
9
20
 
10
21
  // ── Help ────────────────────────────────────────────────────────────────────────
11
22
 
@@ -65,7 +76,7 @@ function init(targetDir: string) {
65
76
 
66
77
  // Example pages
67
78
  const pages: Record<string, string> = {
68
- "quickstart.md": `# Quickstart\n\nWelcome to your new documentation site!\n\n## Prerequisites\n\n- Node.js 18+\n- npm\n\n## Getting Started\n\n1. Edit the markdown files in this directory\n2. Update \`velu.json\` to configure navigation\n3. Run \`velu run\` to start the dev server\n\n\`\`\`bash\nvelu run\n\`\`\`\n\nYour site is live at \`http://localhost:4321\`.\n`,
79
+ "quickstart.md": `# Quickstart\n\nWelcome to your new documentation site!\n\n## Prerequisites\n\n- Node.js 20.9+\n- npm\n\n## Getting Started\n\n1. Edit the markdown files in this directory\n2. Update \`velu.json\` to configure navigation\n3. Run \`velu run\` to start the dev server\n\n\`\`\`bash\nvelu run\n\`\`\`\n\nYour site is live at \`http://localhost:4321\`.\n`,
69
80
  "installation.md": `# Installation\n\nInstall Velu globally:\n\n\`\`\`bash\nnpm install -g @aravindc26/velu\n\`\`\`\n\nOr run directly with npx:\n\n\`\`\`bash\nnpx @aravindc26/velu run\n\`\`\`\n`,
70
81
  "guides/configuration.md": `# Configuration\n\nVelu uses a \`velu.json\` file to define your site's navigation.\n\n## Navigation Structure\n\n- **Tabs** — Top-level horizontal navigation\n- **Groups** — Collapsible sidebar sections within a tab\n- **Pages** — Individual markdown documents\n\n## Example\n\n\`\`\`json\n{\n "navigation": {\n "tabs": [\n {\n "tab": "Getting Started",\n "slug": "getting-started",\n "groups": [\n {\n "group": "Basics",\n "slug": "getting-started",\n "pages": ["quickstart"]\n }\n ]\n }\n ]\n }\n}\n\`\`\`\n`,
71
82
  "guides/deployment.md": `# Deployment\n\nBuild your site for production:\n\n\`\`\`bash\nvelu build\n\`\`\`\n\nThe output is a static site you can deploy anywhere — Netlify, Vercel, GitHub Pages, etc.\n`,
@@ -115,16 +126,19 @@ async function lint(docsDir: string) {
115
126
 
116
127
  async function generateProject(docsDir: string): Promise<string> {
117
128
  const { build } = await import("./build.js");
118
- const outDir = join(docsDir, ".velu-out");
129
+ // Place .velu-out inside CLI package dir so node_modules resolves
130
+ // naturally by walking up — avoids symlinks that Turbopack rejects.
131
+ const outDir = join(PACKAGE_ROOT, ".velu-out");
119
132
  build(docsDir, outDir);
120
133
  return outDir;
121
134
  }
122
135
 
123
- async function buildStatic(outDir: string) {
136
+ async function buildStatic(outDir: string, docsDir: string) {
124
137
  await new Promise<void>((res, rej) => {
125
138
  const child = spawn("node", ["_server.mjs", "build"], {
126
139
  cwd: outDir,
127
140
  stdio: "inherit",
141
+ env: engineEnv(docsDir),
128
142
  });
129
143
  child.on("exit", (code) => (code === 0 ? res() : rej(new Error(`Build exited with ${code}`))));
130
144
  });
@@ -132,32 +146,18 @@ async function buildStatic(outDir: string) {
132
146
 
133
147
  async function buildSite(docsDir: string) {
134
148
  const outDir = await generateProject(docsDir);
135
- await installDeps(outDir);
136
- await buildStatic(outDir);
137
- const distDir = join(outDir, "dist");
138
- console.log(`\n📁 Static site output: ${distDir}`);
149
+ await buildStatic(outDir, docsDir);
150
+ const staticOutDir = join(outDir, "dist");
151
+ console.log(`\n📁 Static site output: ${staticOutDir}`);
139
152
  }
140
153
 
141
154
  // ── run ──────────────────────────────────────────────────────────────────────────
142
155
 
143
- async function installDeps(outDir: string) {
144
- if (!existsSync(join(outDir, "node_modules"))) {
145
- console.log("\n📦 Installing dependencies...\n");
146
- await new Promise<void>((res, rej) => {
147
- const child = spawn("npm", ["install", "--silent"], {
148
- cwd: outDir,
149
- stdio: "inherit",
150
- shell: true,
151
- });
152
- child.on("exit", (code) => (code === 0 ? res() : rej(new Error(`npm install exited with ${code}`))));
153
- });
154
- }
155
- }
156
-
157
- function spawnServer(outDir: string, command: string, port: number) {
156
+ function spawnServer(outDir: string, command: string, port: number, docsDir: string) {
158
157
  const child = spawn("node", ["_server.mjs", command, "--port", String(port)], {
159
158
  cwd: outDir,
160
159
  stdio: "inherit",
160
+ env: engineEnv(docsDir),
161
161
  });
162
162
 
163
163
  child.on("exit", (code) => process.exit(code ?? 0));
@@ -169,8 +169,7 @@ function spawnServer(outDir: string, command: string, port: number) {
169
169
 
170
170
  async function run(docsDir: string, port: number) {
171
171
  const outDir = await generateProject(docsDir);
172
- await installDeps(outDir);
173
- spawnServer(outDir, "dev", port);
172
+ spawnServer(outDir, "dev", port, docsDir);
174
173
  }
175
174
 
176
175
  // ── Parse args ───────────────────────────────────────────────────────────────────
@@ -0,0 +1,271 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ import { watch } from 'node:fs';
4
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { dirname, extname, join, resolve } from 'node:path';
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const nextBinPath = require.resolve('next/dist/bin/next');
9
+
10
+ // ── Docs directory (passed via env var from CLI) ────────────────────────────
11
+ const docsDir = process.env.VELU_DOCS_DIR || resolve('..');
12
+ const contentDir = resolve('content', 'docs');
13
+
14
+ function loadConfig() {
15
+ const raw = readFileSync(join(docsDir, 'velu.json'), 'utf-8');
16
+ return JSON.parse(raw);
17
+ }
18
+
19
+ function pageBasename(page) {
20
+ return page.split('/').pop();
21
+ }
22
+
23
+ function pageLabelFromSlug(slug) {
24
+ const last = slug.split('/').pop() || slug;
25
+ return last.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
26
+ }
27
+
28
+ function buildArtifacts(config) {
29
+ const pageMap = [];
30
+ const metaFiles = [];
31
+ const rootTabs = (config.navigation?.tabs || []).filter((tab) => !tab.href);
32
+ const rootPages = rootTabs.map((tab) => tab.slug);
33
+ let firstPage = 'quickstart';
34
+ let hasFirstPage = false;
35
+
36
+ function trackFirstPage(dest) {
37
+ if (!hasFirstPage) {
38
+ firstPage = dest;
39
+ hasFirstPage = true;
40
+ }
41
+ }
42
+
43
+ function addGroup(group, parentDir) {
44
+ const groupDir = `${parentDir}/${group.slug}`;
45
+ const pages = [];
46
+
47
+ for (const item of group.pages || []) {
48
+ if (typeof item === 'string') {
49
+ const basename = pageBasename(item);
50
+ const dest = `${groupDir}/${basename}`;
51
+ pageMap.push({ src: item, dest });
52
+ pages.push(basename);
53
+ trackFirstPage(dest);
54
+ } else {
55
+ addGroup(item, groupDir);
56
+ pages.push(item.slug);
57
+ }
58
+ }
59
+
60
+ const groupMeta = {
61
+ title: group.group,
62
+ pages,
63
+ defaultOpen: group.expanded !== false,
64
+ };
65
+
66
+ if (group.icon) groupMeta.icon = group.icon;
67
+
68
+ metaFiles.push({ dir: groupDir, data: groupMeta });
69
+ }
70
+
71
+ for (const tab of rootTabs) {
72
+ const tabPages = [];
73
+
74
+ for (const group of tab.groups || []) {
75
+ addGroup(group, tab.slug);
76
+ tabPages.push(group.slug);
77
+ }
78
+
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);
85
+ }
86
+
87
+ const tabMeta = {
88
+ title: tab.tab,
89
+ root: true,
90
+ pages: tabPages,
91
+ };
92
+
93
+ if (tab.icon) tabMeta.icon = tab.icon;
94
+
95
+ metaFiles.push({ dir: tab.slug, data: tabMeta });
96
+ }
97
+
98
+ if (rootPages.length > 0) {
99
+ metaFiles.push({ dir: '', data: { pages: rootPages } });
100
+ }
101
+
102
+ return { pageMap, metaFiles, firstPage };
103
+ }
104
+
105
+ function processPage(srcPath, destPath, slug) {
106
+ let content = readFileSync(srcPath, 'utf-8');
107
+ if (!content.startsWith('---')) {
108
+ const titleMatch = content.match(/^#\s+(.+)$/m);
109
+ const title = titleMatch ? titleMatch[1] : pageLabelFromSlug(slug);
110
+ if (titleMatch) {
111
+ content = content.replace(/^#\s+.+$/m, '').trimStart();
112
+ }
113
+ content = `---\ntitle: "${title}"\n---\n\n${content}`;
114
+ }
115
+
116
+ mkdirSync(dirname(destPath), { recursive: true });
117
+ writeFileSync(destPath, content, 'utf-8');
118
+ }
119
+
120
+ function writeMetaFiles(metaFiles) {
121
+ for (const meta of metaFiles) {
122
+ const metaPath = join(contentDir, meta.dir, 'meta.json');
123
+ mkdirSync(dirname(metaPath), { recursive: true });
124
+ writeFileSync(metaPath, JSON.stringify(meta.data, null, 2) + '\n', 'utf-8');
125
+ }
126
+ }
127
+
128
+ function writeIndexPage(firstPage) {
129
+ writeFileSync(
130
+ 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`,
132
+ 'utf-8'
133
+ );
134
+ }
135
+
136
+ function rebuildFromConfig() {
137
+ const config = loadConfig();
138
+ const artifacts = buildArtifacts(config);
139
+
140
+ rmSync(contentDir, { recursive: true, force: true });
141
+ mkdirSync(contentDir, { recursive: true });
142
+
143
+ writeMetaFiles(artifacts.metaFiles);
144
+
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);
150
+ }
151
+
152
+ writeIndexPage(artifacts.firstPage);
153
+ return artifacts.pageMap;
154
+ }
155
+
156
+ let pageMap = rebuildFromConfig();
157
+
158
+ function syncMarkdownFile(filename) {
159
+ const srcSlug = filename.replace(/\\/g, '/').replace(/\.md$/, '');
160
+ const srcPath = join(docsDir, `${srcSlug}.md`);
161
+
162
+ if (!existsSync(srcPath)) {
163
+ pageMap = rebuildFromConfig();
164
+ return;
165
+ }
166
+
167
+ const matches = pageMap.filter((entry) => entry.src === srcSlug);
168
+ if (matches.length === 0) return;
169
+
170
+ for (const match of matches) {
171
+ const destPath = join(contentDir, `${match.dest}.mdx`);
172
+ processPage(srcPath, destPath, srcSlug);
173
+ }
174
+
175
+ console.log(' \x1b[32m↻\x1b[0m ' + srcSlug);
176
+ }
177
+
178
+ function syncConfig() {
179
+ const srcPath = join(docsDir, 'velu.json');
180
+ copyFileSync(srcPath, resolve('velu.json'));
181
+ pageMap = rebuildFromConfig();
182
+ console.log(' \x1b[32m↻\x1b[0m velu.json updated (navigation/content synced)');
183
+ }
184
+
185
+ function startWatcher() {
186
+ const debounce = new Map();
187
+
188
+ watch(docsDir, { recursive: true }, (_, rawFilename) => {
189
+ if (!rawFilename) return;
190
+ const filename = rawFilename.replace(/\\/g, '/');
191
+
192
+ if (filename.startsWith('.velu-out/')) return;
193
+ if (filename.includes('node_modules')) return;
194
+ if (filename.startsWith('.')) return;
195
+
196
+ if (debounce.has(filename)) clearTimeout(debounce.get(filename));
197
+ debounce.set(
198
+ filename,
199
+ setTimeout(() => {
200
+ debounce.delete(filename);
201
+
202
+ try {
203
+ if (filename === 'velu.json') {
204
+ syncConfig();
205
+ return;
206
+ }
207
+
208
+ if (extname(filename) === '.md') {
209
+ syncMarkdownFile(filename);
210
+ }
211
+ } catch (error) {
212
+ console.error(' \x1b[31m✗\x1b[0m Failed to sync ' + filename + ': ' + error.message);
213
+ }
214
+ }, 120)
215
+ );
216
+ });
217
+ }
218
+
219
+ function runNext(command, port) {
220
+ return new Promise((resolvePromise, rejectPromise) => {
221
+ const args = [nextBinPath, command];
222
+ if (command === 'dev' || command === 'start') {
223
+ args.push('--port', String(port));
224
+ }
225
+
226
+ const child = spawn(process.execPath, args, {
227
+ cwd: '.',
228
+ stdio: 'inherit',
229
+ env: process.env,
230
+ });
231
+
232
+ child.on('exit', (code) => {
233
+ if (code === 0) resolvePromise();
234
+ else rejectPromise(new Error(`${command} exited with ${code}`));
235
+ });
236
+ });
237
+ }
238
+
239
+ // ── CLI ──────────────────────────────────────────────────────────────────────
240
+ const args = process.argv.slice(2);
241
+ const command = args[0] || 'dev';
242
+ const portIdx = args.indexOf('--port');
243
+ const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : 4321;
244
+
245
+ if (command === 'dev') {
246
+ console.log('');
247
+ console.log(' \x1b[36mvelu\x1b[0m fumadocs dev');
248
+ console.log('');
249
+ console.log(' watching for file changes...');
250
+ startWatcher();
251
+ await runNext('dev', port);
252
+ } else if (command === 'build') {
253
+ console.log('\n Building site...\n');
254
+ await runNext('build', port);
255
+
256
+ // Run Pagefind to index the static output for search
257
+ console.log(' Indexing for search...');
258
+ const pagefindBin = join(dirname(require.resolve('next/package.json')), '..', 'pagefind', 'lib', 'runner', 'bin.cjs');
259
+ await new Promise((res, rej) => {
260
+ const pf = spawn(process.execPath, [pagefindBin, '--site', 'dist', '--output-path', 'dist/pagefind'], {
261
+ cwd: '.',
262
+ stdio: 'inherit',
263
+ });
264
+ pf.on('exit', (code) => (code === 0 ? res() : rej(new Error(`pagefind exited with ${code}`))));
265
+ });
266
+
267
+ console.log('\n ✅ Site built successfully.\n');
268
+ } else {
269
+ console.error(`Unknown server command: ${command}`);
270
+ process.exit(1);
271
+ }
@@ -0,0 +1,66 @@
1
+ import type { Metadata } from 'next';
2
+ import { notFound } from 'next/navigation';
3
+ import { createRelativeLink } from 'fumadocs-ui/mdx';
4
+ import {
5
+ DocsBody,
6
+ DocsDescription,
7
+ DocsPage,
8
+ DocsTitle,
9
+ } from 'fumadocs-ui/layouts/docs/page';
10
+ import { getMDXComponents } from '@/mdx-components';
11
+ import { source } from '@/lib/source';
12
+ import { CopyPageButton } from '@/components/copy-page';
13
+
14
+ interface RouteParams {
15
+ slug?: string[];
16
+ }
17
+
18
+ interface PageProps {
19
+ params: Promise<RouteParams>;
20
+ }
21
+
22
+ export default async function Page({ params }: PageProps) {
23
+ const resolvedParams = await params;
24
+ const page = source.getPage(resolvedParams.slug);
25
+
26
+ if (!page) notFound();
27
+
28
+ const MDX = page.data.body;
29
+
30
+ return (
31
+ <DocsPage toc={page.data.toc} full={page.data.full}>
32
+ <div data-pagefind-body data-pagefind-meta={`title:${page.data.title}`}>
33
+ <div className="velu-title-row">
34
+ <DocsTitle>{page.data.title}</DocsTitle>
35
+ <CopyPageButton />
36
+ </div>
37
+ {page.data.description ? <DocsDescription>{page.data.description}</DocsDescription> : null}
38
+ <DocsBody>
39
+ <MDX
40
+ components={getMDXComponents({
41
+ a: createRelativeLink(source, page),
42
+ })}
43
+ />
44
+ </DocsBody>
45
+ </div>
46
+ </DocsPage>
47
+ );
48
+ }
49
+
50
+ export async function generateStaticParams() {
51
+ const params = source.generateParams();
52
+ // Include root path for the optional catch-all [[...slug]]
53
+ return [{ slug: [] }, ...params];
54
+ }
55
+
56
+ export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
57
+ const resolvedParams = await params;
58
+ const page = source.getPage(resolvedParams.slug);
59
+
60
+ if (!page) notFound();
61
+
62
+ return {
63
+ title: page.data.title,
64
+ description: page.data.description,
65
+ };
66
+ }
@@ -0,0 +1,16 @@
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
+ 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
+ );
16
+ }