@aravindc26/velu 0.1.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.
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # Velu
2
+
3
+ A modern documentation site generator. Write Markdown, configure with JSON, ship a beautiful docs site.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g github:YOUR_USERNAME/velu
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ 1. Create a directory with your docs:
14
+
15
+ ```
16
+ my-docs/
17
+ velu.json
18
+ quickstart.md
19
+ guides/
20
+ installation.md
21
+ editor.md
22
+ ```
23
+
24
+ 2. Define your navigation in `velu.json`:
25
+
26
+ ```json
27
+ {
28
+ "$schema": "https://raw.githubusercontent.com/YOUR_USERNAME/velu/main/schema/velu.schema.json",
29
+ "navigation": {
30
+ "tabs": [
31
+ {
32
+ "tab": "API Reference",
33
+ "pages": ["api-reference/get", "api-reference/post"]
34
+ }
35
+ ],
36
+ "groups": [
37
+ {
38
+ "group": "Getting Started",
39
+ "pages": ["quickstart", "guides/installation"]
40
+ }
41
+ ]
42
+ }
43
+ }
44
+ ```
45
+
46
+ 3. Run the dev server:
47
+
48
+ ```bash
49
+ cd my-docs
50
+ velu run
51
+ ```
52
+
53
+ Your site is live at `http://localhost:4321`.
54
+
55
+ ## CLI Commands
56
+
57
+ | Command | Description |
58
+ | -------------------- | ------------------------------------------------ |
59
+ | `velu lint` | Validate `velu.json` and check referenced pages |
60
+ | `velu run` | Build and start the dev server (default port 4321)|
61
+ | `velu run --port N` | Start on a custom port |
62
+ | `velu build` | Build the site without starting a server |
63
+
64
+ ## Navigation
65
+
66
+ Velu supports three levels of navigation hierarchy:
67
+
68
+ ### Tabs
69
+
70
+ Top-level horizontal navigation rendered in the header.
71
+
72
+ ```json
73
+ {
74
+ "tab": "SDKs",
75
+ "pages": ["sdk/fetch", "sdk/create"]
76
+ }
77
+ ```
78
+
79
+ External link tabs:
80
+
81
+ ```json
82
+ {
83
+ "tab": "Blog",
84
+ "href": "https://blog.example.com"
85
+ }
86
+ ```
87
+
88
+ ### Groups
89
+
90
+ Collapsible sidebar groups containing pages or nested groups.
91
+
92
+ ```json
93
+ {
94
+ "group": "Getting Started",
95
+ "pages": ["quickstart", "installation"]
96
+ }
97
+ ```
98
+
99
+ ### Pages
100
+
101
+ Reference markdown files by their path relative to the docs directory, without the `.md` extension:
102
+
103
+ ```
104
+ "quickstart" → quickstart.md
105
+ "guides/installation" → guides/installation.md
106
+ ```
107
+
108
+ ## File Watching
109
+
110
+ During `velu run`, changes to `.md` files and `velu.json` in the docs directory are automatically synced and hot-reloaded — no restart needed.
111
+
112
+ ## License
113
+
114
+ MIT
package/bin/velu.mjs ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from "node:child_process";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const cliPath = join(__dirname, "..", "src", "cli.ts");
9
+
10
+ const child = spawn(
11
+ process.execPath,
12
+ ["--import", "tsx", cliPath, ...process.argv.slice(2)],
13
+ { stdio: "inherit", cwd: process.cwd() }
14
+ );
15
+
16
+ child.on("exit", (code) => process.exit(code ?? 1));
17
+
18
+ process.on("SIGINT", () => child.kill("SIGINT"));
19
+ process.on("SIGTERM", () => child.kill("SIGTERM"));
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@aravindc26/velu",
3
+ "version": "0.1.0",
4
+ "description": "A modern documentation site generator powered by Markdown and JSON configuration",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": ["docs", "documentation", "markdown", "static-site", "cli"],
8
+ "engines": {
9
+ "node": ">=18.0.0"
10
+ },
11
+ "bin": {
12
+ "velu": "./bin/velu.mjs"
13
+ },
14
+ "files": [
15
+ "bin/",
16
+ "src/",
17
+ "schema/"
18
+ ],
19
+ "scripts": {
20
+ "lint": "tsx src/cli.ts lint",
21
+ "build": "tsx src/cli.ts build",
22
+ "dev": "tsx src/cli.ts run"
23
+ },
24
+ "dependencies": {
25
+ "ajv": "^8.17.1",
26
+ "ajv-formats": "^3.0.1",
27
+ "tsx": "^4.19.0"
28
+ },
29
+ "devDependencies": {
30
+ "typescript": "^5.7.0",
31
+ "@types/node": "^22.0.0"
32
+ }
33
+ }
@@ -0,0 +1,137 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://velu.dev/schema/velu.schema.json",
4
+ "title": "Velu Configuration",
5
+ "description": "Configuration schema for velu.json — the core config file for Velu documentation sites.",
6
+ "type": "object",
7
+ "required": ["navigation"],
8
+ "properties": {
9
+ "$schema": {
10
+ "type": "string",
11
+ "description": "Path or URL to the JSON schema for editor validation."
12
+ },
13
+ "navigation": {
14
+ "type": "object",
15
+ "description": "Defines the site navigation hierarchy: tabs → groups → pages.",
16
+ "properties": {
17
+ "tabs": {
18
+ "type": "array",
19
+ "description": "Top-level navigation tabs.",
20
+ "items": {
21
+ "$ref": "#/definitions/tab"
22
+ }
23
+ },
24
+ "groups": {
25
+ "type": "array",
26
+ "description": "Top-level navigation groups (used when there are no tabs, or as default content).",
27
+ "items": {
28
+ "$ref": "#/definitions/group"
29
+ }
30
+ },
31
+ "pages": {
32
+ "type": "array",
33
+ "description": "Top-level standalone pages.",
34
+ "items": {
35
+ "$ref": "#/definitions/page"
36
+ }
37
+ }
38
+ },
39
+ "additionalProperties": false
40
+ }
41
+ },
42
+ "additionalProperties": false,
43
+ "definitions": {
44
+ "page": {
45
+ "type": "string",
46
+ "description": "Reference to a markdown file relative to the docs directory (without .md extension). E.g. 'quickstart' → docs/quickstart.md, 'writing-content/page' → docs/writing-content/page.md."
47
+ },
48
+ "group": {
49
+ "type": "object",
50
+ "description": "A group of pages, optionally nested.",
51
+ "required": ["group", "pages"],
52
+ "properties": {
53
+ "group": {
54
+ "type": "string",
55
+ "description": "Display name for the group."
56
+ },
57
+ "icon": {
58
+ "type": "string",
59
+ "description": "Icon identifier for the group."
60
+ },
61
+ "tag": {
62
+ "type": "string",
63
+ "description": "Optional badge/tag label displayed next to the group name."
64
+ },
65
+ "expanded": {
66
+ "type": "boolean",
67
+ "description": "Whether the group is expanded by default. Defaults to true.",
68
+ "default": true
69
+ },
70
+ "pages": {
71
+ "type": "array",
72
+ "description": "Pages or nested groups within this group.",
73
+ "items": {
74
+ "oneOf": [
75
+ { "$ref": "#/definitions/page" },
76
+ { "$ref": "#/definitions/group" }
77
+ ]
78
+ }
79
+ }
80
+ },
81
+ "additionalProperties": false
82
+ },
83
+ "tab": {
84
+ "type": "object",
85
+ "description": "A top-level navigation tab.",
86
+ "required": ["tab"],
87
+ "properties": {
88
+ "tab": {
89
+ "type": "string",
90
+ "description": "Display name for the tab."
91
+ },
92
+ "icon": {
93
+ "type": "string",
94
+ "description": "Icon identifier for the tab."
95
+ },
96
+ "href": {
97
+ "type": "string",
98
+ "description": "External link URL. When set, the tab links externally instead of showing pages.",
99
+ "format": "uri"
100
+ },
101
+ "pages": {
102
+ "type": "array",
103
+ "description": "Standalone pages directly under this tab.",
104
+ "items": {
105
+ "$ref": "#/definitions/page"
106
+ }
107
+ },
108
+ "groups": {
109
+ "type": "array",
110
+ "description": "Groups of pages under this tab.",
111
+ "items": {
112
+ "$ref": "#/definitions/group"
113
+ }
114
+ }
115
+ },
116
+ "additionalProperties": false,
117
+ "oneOf": [
118
+ {
119
+ "required": ["href"],
120
+ "not": {
121
+ "anyOf": [
122
+ { "required": ["pages"] },
123
+ { "required": ["groups"] }
124
+ ]
125
+ }
126
+ },
127
+ {
128
+ "not": { "required": ["href"] },
129
+ "anyOf": [
130
+ { "required": ["pages"] },
131
+ { "required": ["groups"] }
132
+ ]
133
+ }
134
+ ]
135
+ }
136
+ }
137
+ }
package/src/build.ts ADDED
@@ -0,0 +1,637 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, rmSync } from "node:fs";
2
+ import { resolve, join, dirname } from "node:path";
3
+
4
+ // ── Types (used only by build.ts for page copying) ─────────────────────────────
5
+
6
+ interface VeluGroup {
7
+ group: string;
8
+ pages: (string | VeluGroup)[];
9
+ }
10
+
11
+ interface VeluTab {
12
+ tab: string;
13
+ href?: string;
14
+ pages?: string[];
15
+ groups?: VeluGroup[];
16
+ }
17
+
18
+ interface VeluConfig {
19
+ $schema?: string;
20
+ navigation: {
21
+ tabs?: VeluTab[];
22
+ groups?: VeluGroup[];
23
+ pages?: string[];
24
+ };
25
+ }
26
+
27
+ // ── Helpers ────────────────────────────────────────────────────────────────────
28
+
29
+ function loadConfig(docsDir: string): VeluConfig {
30
+ const raw = readFileSync(join(docsDir, "velu.json"), "utf-8");
31
+ return JSON.parse(raw);
32
+ }
33
+
34
+ function pageLabelFromSlug(slug: string): string {
35
+ const last = slug.split("/").pop()!;
36
+ return last.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
37
+ }
38
+
39
+ function collectPagesFromGroup(group: VeluGroup): string[] {
40
+ const pages: string[] = [];
41
+ for (const item of group.pages) {
42
+ if (typeof item === "string") pages.push(item);
43
+ else pages.push(...collectPagesFromGroup(item));
44
+ }
45
+ return pages;
46
+ }
47
+
48
+ function collectAllPages(config: VeluConfig): string[] {
49
+ const pages: string[] = [];
50
+ const nav = config.navigation;
51
+ if (nav.pages) pages.push(...nav.pages);
52
+ if (nav.groups) for (const g of nav.groups) pages.push(...collectPagesFromGroup(g));
53
+ if (nav.tabs) {
54
+ for (const tab of nav.tabs) {
55
+ if (tab.pages) pages.push(...tab.pages);
56
+ if (tab.groups) for (const g of tab.groups) pages.push(...collectPagesFromGroup(g));
57
+ }
58
+ }
59
+ return pages;
60
+ }
61
+
62
+ // ── Build ──────────────────────────────────────────────────────────────────────
63
+
64
+ function build(docsDir: string, outDir: string) {
65
+ console.log(`📖 Loading velu.json from: ${docsDir}`);
66
+ const config = loadConfig(docsDir);
67
+
68
+ if (existsSync(outDir)) {
69
+ rmSync(outDir, { recursive: true, force: true });
70
+ }
71
+
72
+ // Create directories
73
+ mkdirSync(join(outDir, "src", "content", "docs"), { recursive: true });
74
+ mkdirSync(join(outDir, "src", "components"), { recursive: true });
75
+ mkdirSync(join(outDir, "src", "lib"), { recursive: true });
76
+ mkdirSync(join(outDir, "src", "styles"), { recursive: true });
77
+ mkdirSync(join(outDir, "public"), { recursive: true });
78
+
79
+ // ── 1. Copy velu.json into the Astro project ─────────────────────────────
80
+ copyFileSync(join(docsDir, "velu.json"), join(outDir, "velu.json"));
81
+ console.log("📋 Copied velu.json");
82
+
83
+ // ── 2. Copy all referenced .md files ──────────────────────────────────────
84
+ const allPages = collectAllPages(config);
85
+ for (const page of allPages) {
86
+ const srcPath = join(docsDir, `${page}.md`);
87
+ const destPath = join(outDir, "src", "content", "docs", `${page}.md`);
88
+
89
+ if (!existsSync(srcPath)) {
90
+ console.warn(`⚠️ Missing: ${srcPath}`);
91
+ continue;
92
+ }
93
+
94
+ mkdirSync(dirname(destPath), { recursive: true });
95
+
96
+ let content = readFileSync(srcPath, "utf-8");
97
+ if (!content.startsWith("---")) {
98
+ const titleMatch = content.match(/^#\s+(.+)$/m);
99
+ const title = titleMatch ? titleMatch[1] : pageLabelFromSlug(page);
100
+ if (titleMatch) {
101
+ content = content.replace(/^#\s+.+$/m, "").trimStart();
102
+ }
103
+ content = `---\ntitle: "${title}"\n---\n\n${content}`;
104
+ }
105
+
106
+ writeFileSync(destPath, content, "utf-8");
107
+ }
108
+ console.log(`📄 Copied ${allPages.length} pages`);
109
+
110
+ // ── 3. Generate src/lib/velu.ts — the single source of truth ──────────────
111
+ // This module reads velu.json at Astro build/render time. No hardcoded data.
112
+ const veluLib = `import { readFileSync } from 'node:fs';
113
+ import { resolve } from 'node:path';
114
+
115
+ // ── Types ───────────────────────────────────────────────────────────────────
116
+
117
+ export interface VeluGroup {
118
+ group: string;
119
+ icon?: string;
120
+ tag?: string;
121
+ expanded?: boolean;
122
+ pages: (string | VeluGroup)[];
123
+ }
124
+
125
+ export interface VeluTab {
126
+ tab: string;
127
+ icon?: string;
128
+ href?: string;
129
+ pages?: string[];
130
+ groups?: VeluGroup[];
131
+ }
132
+
133
+ export interface VeluConfig {
134
+ $schema?: string;
135
+ navigation: {
136
+ tabs?: VeluTab[];
137
+ groups?: VeluGroup[];
138
+ pages?: string[];
139
+ };
140
+ }
141
+
142
+ export interface TabMeta {
143
+ label: string;
144
+ icon?: string;
145
+ href?: string;
146
+ pathPrefix: string;
147
+ firstPage?: string;
148
+ }
149
+
150
+ // ── Load config ─────────────────────────────────────────────────────────────
151
+
152
+ let _cachedConfig: VeluConfig | null = null;
153
+
154
+ export function loadVeluConfig(): VeluConfig {
155
+ if (_cachedConfig) return _cachedConfig;
156
+ const configPath = resolve(process.cwd(), 'velu.json');
157
+ const raw = readFileSync(configPath, 'utf-8');
158
+ _cachedConfig = JSON.parse(raw);
159
+ return _cachedConfig!;
160
+ }
161
+
162
+ // ── Helpers ─────────────────────────────────────────────────────────────────
163
+
164
+ function collectPagesFromGroup(group: VeluGroup): string[] {
165
+ const pages: string[] = [];
166
+ for (const item of group.pages) {
167
+ if (typeof item === 'string') pages.push(item);
168
+ else pages.push(...collectPagesFromGroup(item));
169
+ }
170
+ return pages;
171
+ }
172
+
173
+ function collectTabPages(tab: VeluTab): string[] {
174
+ const pages: string[] = [];
175
+ if (tab.pages) pages.push(...tab.pages);
176
+ if (tab.groups) for (const g of tab.groups) pages.push(...collectPagesFromGroup(g));
177
+ return pages;
178
+ }
179
+
180
+ function detectPathPrefix(slugs: string[]): string {
181
+ if (slugs.length === 0) return '';
182
+ const first = slugs[0];
183
+ const idx = first.indexOf('/');
184
+ if (idx === -1) return '';
185
+ const prefix = first.substring(0, idx);
186
+ if (slugs.every((s) => s.startsWith(prefix + '/'))) return prefix;
187
+ return '';
188
+ }
189
+
190
+ function veluGroupToSidebar(group: VeluGroup): any {
191
+ const items: any[] = [];
192
+ for (const item of group.pages) {
193
+ if (typeof item === 'string') items.push(item);
194
+ else items.push(veluGroupToSidebar(item));
195
+ }
196
+ const result: any = { label: group.group, items };
197
+ if (group.tag) result.badge = group.tag;
198
+ if (group.expanded === false) result.collapsed = true;
199
+ return result;
200
+ }
201
+
202
+ // ── Public API ──────────────────────────────────────────────────────────────
203
+
204
+ /** Build the full Starlight sidebar array from velu.json */
205
+ export function getSidebar(): any[] {
206
+ const config = loadVeluConfig();
207
+ const nav = config.navigation;
208
+ const sidebar: any[] = [];
209
+
210
+ // Default groups
211
+ if (nav.groups) {
212
+ for (const group of nav.groups) sidebar.push(veluGroupToSidebar(group));
213
+ }
214
+
215
+ // Default standalone pages
216
+ if (nav.pages) {
217
+ for (const page of nav.pages) sidebar.push(page);
218
+ }
219
+
220
+ // Tab content as top-level groups
221
+ if (nav.tabs) {
222
+ for (const tab of nav.tabs) {
223
+ if (tab.href) continue;
224
+ const items: any[] = [];
225
+ if (tab.groups) for (const g of tab.groups) items.push(veluGroupToSidebar(g));
226
+ if (tab.pages) for (const p of tab.pages) items.push(p);
227
+ sidebar.push({ label: tab.tab, items });
228
+ }
229
+ }
230
+
231
+ return sidebar;
232
+ }
233
+
234
+ /** Get tab metadata for the header navigation */
235
+ export function getTabs(): TabMeta[] {
236
+ const config = loadVeluConfig();
237
+ const nav = config.navigation;
238
+ const tabs: TabMeta[] = [];
239
+
240
+ // Default "Docs" tab from groups/pages
241
+ const defaultPages: string[] = [];
242
+ if (nav.groups) for (const g of nav.groups) defaultPages.push(...collectPagesFromGroup(g));
243
+ if (nav.pages) defaultPages.push(...nav.pages);
244
+
245
+ if (defaultPages.length > 0) {
246
+ tabs.push({
247
+ label: 'Docs',
248
+ icon: 'book-open',
249
+ pathPrefix: detectPathPrefix(defaultPages) || '__default__',
250
+ firstPage: defaultPages[0],
251
+ });
252
+ }
253
+
254
+ if (nav.tabs) {
255
+ for (const tab of nav.tabs) {
256
+ if (tab.href) {
257
+ tabs.push({ label: tab.tab, icon: tab.icon, href: tab.href, pathPrefix: '' });
258
+ } else {
259
+ const tabPages = collectTabPages(tab);
260
+ tabs.push({
261
+ label: tab.tab,
262
+ icon: tab.icon,
263
+ pathPrefix: detectPathPrefix(tabPages) || tabPages[0]?.split('/')[0] || '',
264
+ firstPage: tabPages[0],
265
+ });
266
+ }
267
+ }
268
+ }
269
+
270
+ return tabs;
271
+ }
272
+
273
+ /** Get the mapping of path prefix → sidebar group labels for filtering */
274
+ export function getTabSidebarMap(): Record<string, string[]> {
275
+ const config = loadVeluConfig();
276
+ const nav = config.navigation;
277
+ const map: Record<string, string[]> = {};
278
+
279
+ // Default tab owns top-level groups
280
+ const defaultLabels: string[] = [];
281
+ if (nav.groups) for (const g of nav.groups) defaultLabels.push(g.group);
282
+ map['__default__'] = defaultLabels;
283
+
284
+ if (nav.tabs) {
285
+ for (const tab of nav.tabs) {
286
+ if (tab.href) continue;
287
+ const tabPages = collectTabPages(tab);
288
+ const prefix = detectPathPrefix(tabPages) || tabPages[0]?.split('/')[0] || '';
289
+ map[prefix] = [tab.tab];
290
+ }
291
+ }
292
+
293
+ return map;
294
+ }
295
+ `;
296
+ writeFileSync(join(outDir, "src", "lib", "velu.ts"), veluLib, "utf-8");
297
+ console.log("📚 Generated config module");
298
+
299
+ // ── 4. Generate site config ────────────────────────────────────────────────
300
+ const astroConfig = `import { defineConfig } from 'astro/config';
301
+ import starlight from '@astrojs/starlight';
302
+ import { getSidebar } from './src/lib/velu.ts';
303
+
304
+ export default defineConfig({
305
+ devToolbar: { enabled: false },
306
+ integrations: [
307
+ starlight({
308
+ title: 'Velu Docs',
309
+ components: {
310
+ Header: './src/components/Header.astro',
311
+ Sidebar: './src/components/Sidebar.astro',
312
+ },
313
+ customCss: ['./src/styles/tabs.css'],
314
+ sidebar: getSidebar(),
315
+ }),
316
+ ],
317
+ });
318
+ `;
319
+ writeFileSync(join(outDir, "_config.mjs"), astroConfig, "utf-8");
320
+ console.log("⚙️ Generated site config");
321
+
322
+ // ── 5. Generate Header.astro — reads tabs from velu.ts ────────────────────
323
+ const headerComponent = `---
324
+ import Default from '@astrojs/starlight/components/Header.astro';
325
+ import { getTabs } from '../lib/velu.ts';
326
+
327
+ const tabs = getTabs();
328
+ const currentPath = Astro.url.pathname;
329
+
330
+ function isTabActive(tab: any, path: string): boolean {
331
+ if (tab.href) return false;
332
+ if (tab.pathPrefix === '__default__') {
333
+ const otherPrefixes = tabs
334
+ .filter((t) => t.pathPrefix && t.pathPrefix !== '__default__' && !t.href)
335
+ .map((t) => t.pathPrefix);
336
+ return !otherPrefixes.some((p) => path.startsWith('/' + p + '/'));
337
+ }
338
+ return path.startsWith('/' + tab.pathPrefix + '/');
339
+ }
340
+ ---
341
+
342
+ <Default {...Astro.props}>
343
+ <slot />
344
+ </Default>
345
+
346
+ <nav class="velu-tabs">
347
+ <div class="velu-tabs-inner">
348
+ {tabs.map((tab) => {
349
+ if (tab.href) {
350
+ return (
351
+ <a href={tab.href} class="velu-tab" target="_blank" rel="noopener noreferrer">
352
+ {tab.label}
353
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 17L17 7M17 7H7M17 7V17"/></svg>
354
+ </a>
355
+ );
356
+ }
357
+ const active = isTabActive(tab, currentPath);
358
+ const href = tab.firstPage ? '/' + tab.firstPage + '/' : '/';
359
+ return (
360
+ <a href={href} class:list={['velu-tab', { active }]}>
361
+ {tab.label}
362
+ </a>
363
+ );
364
+ })}
365
+ </div>
366
+ </nav>
367
+ `;
368
+ writeFileSync(join(outDir, "src", "components", "Header.astro"), headerComponent, "utf-8");
369
+ console.log("🧩 Generated header component");
370
+
371
+ // ── 6. Generate Sidebar.astro — reads filter map from velu.ts ─────────────
372
+ const sidebarComponent = `---
373
+ import MobileMenuFooter from 'virtual:starlight/components/MobileMenuFooter';
374
+ import SidebarPersister from '@astrojs/starlight/components/SidebarPersister.astro';
375
+ import SidebarSublist from '@astrojs/starlight/components/SidebarSublist.astro';
376
+ import { getTabSidebarMap } from '../lib/velu.ts';
377
+
378
+ const tabSidebarMap = getTabSidebarMap();
379
+ const currentPath = Astro.url.pathname;
380
+
381
+ function getActivePrefix(path: string): string {
382
+ const prefixes = Object.keys(tabSidebarMap).filter(p => p !== '__default__');
383
+ for (const prefix of prefixes) {
384
+ if (path.startsWith('/' + prefix + '/')) return prefix;
385
+ }
386
+ return '__default__';
387
+ }
388
+
389
+ const activePrefix = getActivePrefix(currentPath);
390
+ const visibleLabels = new Set(tabSidebarMap[activePrefix] || []);
391
+
392
+ const { sidebar } = Astro.locals.starlightRoute;
393
+ const filteredSidebar = sidebar.filter((entry: any) => {
394
+ if (entry.type === 'group') return visibleLabels.has(entry.label);
395
+ return activePrefix === '__default__';
396
+ });
397
+ ---
398
+
399
+ <SidebarPersister>
400
+ <SidebarSublist sublist={filteredSidebar} />
401
+ </SidebarPersister>
402
+
403
+ <div class="md:sl-hidden">
404
+ <MobileMenuFooter />
405
+ </div>
406
+ `;
407
+ writeFileSync(join(outDir, "src", "components", "Sidebar.astro"), sidebarComponent, "utf-8");
408
+ console.log("📋 Generated sidebar component");
409
+
410
+ // ── 7. Generate tabs.css ──────────────────────────────────────────────────
411
+ const tabsCss = `/* ── Velu layout overrides ──────────────────────────────────────────────── */
412
+
413
+ :root {
414
+ --sl-nav-height: 6rem;
415
+ }
416
+
417
+ /* Fixed header: flex column, no bottom padding — tab bar sits at the bottom */
418
+ .page > header.header {
419
+ display: flex;
420
+ flex-direction: column;
421
+ padding-bottom: 0;
422
+ }
423
+
424
+ /* Standard nav content fills the top */
425
+ .page > header.header > .header.sl-flex {
426
+ height: auto;
427
+ flex: 1;
428
+ }
429
+
430
+ /* ── Tab bar ───────────────────────────────────────────────────────────── */
431
+
432
+ .velu-tabs {
433
+ flex-shrink: 0;
434
+ /* Stretch to full header width past its padding */
435
+ margin-inline: calc(-1 * var(--sl-nav-pad-x));
436
+ padding-inline: var(--sl-nav-pad-x);
437
+ background: var(--sl-color-bg-nav);
438
+ }
439
+
440
+ .velu-tabs-inner {
441
+ display: flex;
442
+ gap: 0.25rem;
443
+ overflow-x: auto;
444
+ }
445
+
446
+ .velu-tab {
447
+ display: inline-flex;
448
+ align-items: center;
449
+ gap: 0.35rem;
450
+ padding: 0.55rem 0.85rem;
451
+ font-size: var(--sl-text-sm);
452
+ font-weight: 500;
453
+ color: var(--sl-color-gray-3);
454
+ text-decoration: none;
455
+ border-radius: 0.375rem;
456
+ transition: color 0.15s, background-color 0.15s;
457
+ white-space: nowrap;
458
+ }
459
+
460
+ .velu-tab:hover {
461
+ color: var(--sl-color-gray-1);
462
+ background-color: var(--sl-color-gray-6);
463
+ }
464
+
465
+ .velu-tab.active {
466
+ color: var(--sl-color-white);
467
+ background-color: var(--sl-color-gray-5);
468
+ }
469
+
470
+ .velu-tab svg {
471
+ opacity: 0.5;
472
+ flex-shrink: 0;
473
+ }
474
+ `;
475
+ writeFileSync(join(outDir, "src", "styles", "tabs.css"), tabsCss, "utf-8");
476
+ console.log("🎨 Generated tabs.css");
477
+
478
+ // ── 8. Static boilerplate ─────────────────────────────────────────────────
479
+ const astroPkg = {
480
+ name: "velu-docs-site",
481
+ version: "0.0.1",
482
+ private: true,
483
+ type: "module",
484
+ scripts: {
485
+ dev: "astro dev",
486
+ build: "astro build",
487
+ preview: "astro preview",
488
+ },
489
+ dependencies: {
490
+ astro: "^5.1.0",
491
+ "@astrojs/starlight": "^0.32.0",
492
+ sharp: "^0.33.0",
493
+ },
494
+ };
495
+ writeFileSync(join(outDir, "package.json"), JSON.stringify(astroPkg, null, 2) + "\n", "utf-8");
496
+
497
+ writeFileSync(
498
+ join(outDir, "tsconfig.json"),
499
+ JSON.stringify({ extends: "astro/tsconfigs/strict" }, null, 2) + "\n",
500
+ "utf-8"
501
+ );
502
+
503
+ const firstPage = allPages[0] || "quickstart";
504
+ writeFileSync(
505
+ join(outDir, "src", "content", "docs", "index.mdx"),
506
+ `---\ntitle: "Welcome to Velu Docs"\ndescription: Documentation powered by Velu\n---\n\nWelcome to the documentation. Head over to the [Quickstart](/${firstPage}/) to get started.\n`,
507
+ "utf-8"
508
+ );
509
+
510
+ writeFileSync(
511
+ join(outDir, "src", "content.config.ts"),
512
+ `import { defineCollection } from 'astro:content';\nimport { docsSchema } from '@astrojs/starlight/schema';\n\nexport const collections = {\n docs: defineCollection({ schema: docsSchema() }),\n};\n`,
513
+ "utf-8"
514
+ );
515
+
516
+ // ── 9. Generate _server.mjs — programmatic dev/build/preview ────────────────
517
+ const serverScript = `import { dev, build, preview } from 'astro';
518
+ import { watch } from 'node:fs';
519
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from 'node:fs';
520
+ import { resolve, dirname, relative, extname, join } from 'node:path';
521
+
522
+ // ── Docs directory (parent of .velu-out) ────────────────────────────────────
523
+ const docsDir = resolve('..');
524
+ const contentDir = resolve('src', 'content', 'docs');
525
+
526
+ // ── Page processing (mirrors build.ts logic) ────────────────────────────────
527
+ function pageLabelFromSlug(slug) {
528
+ const last = slug.split('/').pop() || slug;
529
+ return last.replace(/[-_]/g, ' ').replace(/\\b\\w/g, c => c.toUpperCase());
530
+ }
531
+
532
+ function processPage(srcPath, destPath, slug) {
533
+ let content = readFileSync(srcPath, 'utf-8');
534
+ if (!content.startsWith('---')) {
535
+ const titleMatch = content.match(/^#\\s+(.+)$/m);
536
+ const title = titleMatch ? titleMatch[1] : pageLabelFromSlug(slug);
537
+ if (titleMatch) {
538
+ content = content.replace(/^#\\s+.+$/m, '').trimStart();
539
+ }
540
+ content = '---\\ntitle: "' + title + '"\\n---\\n\\n' + content;
541
+ }
542
+ mkdirSync(dirname(destPath), { recursive: true });
543
+ writeFileSync(destPath, content, 'utf-8');
544
+ }
545
+
546
+ function startWatcher() {
547
+ const debounce = new Map();
548
+
549
+ watch(docsDir, { recursive: true }, (eventType, filename) => {
550
+ if (!filename) return;
551
+ // Ignore changes inside .velu-out itself
552
+ if (filename.startsWith('.velu-out')) return;
553
+ // Ignore node_modules, hidden dirs
554
+ if (filename.includes('node_modules') || filename.startsWith('.')) return;
555
+
556
+ // Debounce — avoid duplicate events
557
+ if (debounce.has(filename)) clearTimeout(debounce.get(filename));
558
+ debounce.set(filename, setTimeout(() => {
559
+ debounce.delete(filename);
560
+ const srcPath = join(docsDir, filename);
561
+ if (!existsSync(srcPath)) return;
562
+
563
+ if (filename === 'velu.json') {
564
+ copyFileSync(srcPath, resolve('velu.json'));
565
+ console.log(' \\x1b[32m↻\\x1b[0m velu.json updated');
566
+ return;
567
+ }
568
+
569
+ if (extname(filename) === '.md') {
570
+ const slug = filename.replace(/\\\\/g, '/').replace(/\\.md$/, '');
571
+ const destPath = join(contentDir, slug + '.md');
572
+ try {
573
+ processPage(srcPath, destPath, slug);
574
+ console.log(' \\x1b[32m↻\\x1b[0m ' + slug);
575
+ } catch (e) {
576
+ console.error(' \\x1b[31m✗\\x1b[0m Failed to sync ' + filename + ': ' + e.message);
577
+ }
578
+ }
579
+ }, 100));
580
+ });
581
+ }
582
+
583
+ // ── CLI ──────────────────────────────────────────────────────────────────────
584
+ const args = process.argv.slice(2);
585
+ const command = args[0] || 'dev';
586
+ const portIdx = args.indexOf('--port');
587
+ const port = portIdx !== -1 ? parseInt(args[portIdx + 1]) : 4321;
588
+
589
+ if (command === 'dev') {
590
+ const server = await dev({
591
+ root: '.',
592
+ configFile: './_config.mjs',
593
+ server: { port },
594
+ logLevel: 'silent',
595
+ });
596
+ const addr = server.address;
597
+ console.log('');
598
+ console.log(' \\x1b[36mvelu\\x1b[0m v0.1.0 ready');
599
+ console.log('');
600
+ console.log(' ┃ Local \\x1b[36mhttp://localhost:' + addr.port + '/\\x1b[0m');
601
+ console.log(' ┃ Network use --host to expose');
602
+ console.log('');
603
+ console.log(' watching for file changes...');
604
+ startWatcher();
605
+ } else if (command === 'build') {
606
+ console.log('\\n Building site...\\n');
607
+ await build({ root: '.', configFile: './_config.mjs', logLevel: 'warn' });
608
+ console.log('\\n ✅ Site built successfully.\\n');
609
+ } else if (command === 'preview') {
610
+ const server = await preview({
611
+ root: '.',
612
+ configFile: './_config.mjs',
613
+ server: { port },
614
+ logLevel: 'silent',
615
+ });
616
+ const addr = server.address;
617
+ console.log('');
618
+ console.log(' \\x1b[36mvelu\\x1b[0m preview');
619
+ console.log('');
620
+ console.log(' ┃ Local \\x1b[36mhttp://localhost:' + addr.port + '/\\x1b[0m');
621
+ console.log('');
622
+ }
623
+ `;
624
+ writeFileSync(join(outDir, "_server.mjs"), serverScript, "utf-8");
625
+
626
+ // ── 10. Generate .gitignore ──────────────────────────────────────────────
627
+ writeFileSync(
628
+ join(outDir, ".gitignore"),
629
+ `.astro/\nnode_modules/\ndist/\n`,
630
+ "utf-8"
631
+ );
632
+
633
+ console.log("📦 Generated boilerplate");
634
+ console.log(`\n✅ Site generated at: ${outDir}`);
635
+ }
636
+
637
+ export { build };
package/src/cli.ts ADDED
@@ -0,0 +1,132 @@
1
+ import { resolve, join, dirname } from "node:path";
2
+ import { existsSync } from "node:fs";
3
+ import { spawn } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const PACKAGE_ROOT = resolve(dirname(__filename), "..");
8
+ const SCHEMA_PATH = join(PACKAGE_ROOT, "schema", "velu.schema.json");
9
+
10
+ // ── Help ────────────────────────────────────────────────────────────────────────
11
+
12
+ function printHelp() {
13
+ console.log(`
14
+ velu — documentation site generator
15
+
16
+ Usage:
17
+ velu lint Validate velu.json and check referenced pages
18
+ velu run [--port N] Build site and start dev server (default: 4321)
19
+ velu build Build site without starting the dev server
20
+
21
+ Options:
22
+ --port <number> Port for the dev server (default: 4321)
23
+ --help Show this help message
24
+
25
+ Run these commands from a directory containing velu.json.
26
+ `);
27
+ }
28
+
29
+ // ── lint ─────────────────────────────────────────────────────────────────────────
30
+
31
+ async function lint(docsDir: string) {
32
+ const { validateVeluConfig } = await import("./validate.js");
33
+ const result = validateVeluConfig(docsDir, SCHEMA_PATH);
34
+
35
+ if (result.valid) {
36
+ console.log("✅ velu.json is valid. All referenced pages exist.");
37
+ } else {
38
+ console.error("❌ Validation failed:\n");
39
+ for (const err of result.errors) {
40
+ console.error(` • ${err}`);
41
+ }
42
+ process.exit(1);
43
+ }
44
+ }
45
+
46
+ // ── build ────────────────────────────────────────────────────────────────────────
47
+
48
+ async function buildSite(docsDir: string): Promise<string> {
49
+ const { build } = await import("./build.js");
50
+ const outDir = join(docsDir, ".velu-out");
51
+ build(docsDir, outDir);
52
+ return outDir;
53
+ }
54
+
55
+ // ── run ──────────────────────────────────────────────────────────────────────────
56
+
57
+ async function installDeps(outDir: string) {
58
+ if (!existsSync(join(outDir, "node_modules"))) {
59
+ console.log("\n📦 Installing dependencies...\n");
60
+ await new Promise<void>((res, rej) => {
61
+ const child = spawn("npm", ["install", "--silent"], {
62
+ cwd: outDir,
63
+ stdio: "inherit",
64
+ shell: true,
65
+ });
66
+ child.on("exit", (code) => (code === 0 ? res() : rej(new Error(`npm install exited with ${code}`))));
67
+ });
68
+ }
69
+ }
70
+
71
+ function spawnServer(outDir: string, command: string, port: number) {
72
+ const child = spawn("node", ["_server.mjs", command, "--port", String(port)], {
73
+ cwd: outDir,
74
+ stdio: "inherit",
75
+ });
76
+
77
+ child.on("exit", (code) => process.exit(code ?? 0));
78
+
79
+ const cleanup = () => child.kill("SIGTERM");
80
+ process.on("SIGINT", cleanup);
81
+ process.on("SIGTERM", cleanup);
82
+ }
83
+
84
+ async function run(docsDir: string, port: number) {
85
+ const outDir = await buildSite(docsDir);
86
+ await installDeps(outDir);
87
+ spawnServer(outDir, "dev", port);
88
+ }
89
+
90
+ // ── Parse args ───────────────────────────────────────────────────────────────────
91
+
92
+ const args = process.argv.slice(2);
93
+ const command = args[0];
94
+
95
+ if (!command || command === "--help" || command === "-h") {
96
+ printHelp();
97
+ process.exit(0);
98
+ }
99
+
100
+ const docsDir = process.cwd();
101
+
102
+ if (!existsSync(join(docsDir, "velu.json"))) {
103
+ console.error("❌ No velu.json found in the current directory.");
104
+ console.error(" Run this command from a directory containing velu.json.");
105
+ process.exit(1);
106
+ }
107
+
108
+ switch (command) {
109
+ case "lint":
110
+ await lint(docsDir);
111
+ break;
112
+
113
+ case "build":
114
+ await buildSite(docsDir);
115
+ break;
116
+
117
+ case "run": {
118
+ const portIdx = args.indexOf("--port");
119
+ const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : 4321;
120
+ if (isNaN(port)) {
121
+ console.error("❌ Invalid port number.");
122
+ process.exit(1);
123
+ }
124
+ await run(docsDir, port);
125
+ break;
126
+ }
127
+
128
+ default:
129
+ console.error(`Unknown command: ${command}\n`);
130
+ printHelp();
131
+ process.exit(1);
132
+ }
@@ -0,0 +1,125 @@
1
+ import Ajv, { type AnySchema } from "ajv";
2
+ import addFormats from "ajv-formats";
3
+ import { readFileSync, existsSync } from "node:fs";
4
+ import { resolve, join } from "node:path";
5
+
6
+ interface VeluGroup {
7
+ group: string;
8
+ icon?: string;
9
+ tag?: string;
10
+ expanded?: boolean;
11
+ pages: (string | VeluGroup)[];
12
+ }
13
+
14
+ interface VeluTab {
15
+ tab: string;
16
+ icon?: string;
17
+ href?: string;
18
+ pages?: string[];
19
+ groups?: VeluGroup[];
20
+ }
21
+
22
+ interface VeluConfig {
23
+ $schema?: string;
24
+ navigation: {
25
+ tabs?: VeluTab[];
26
+ groups?: VeluGroup[];
27
+ pages?: string[];
28
+ };
29
+ }
30
+
31
+ function loadJson(filePath: string): unknown {
32
+ const raw = readFileSync(filePath, "utf-8");
33
+ return JSON.parse(raw);
34
+ }
35
+
36
+ function collectPages(config: VeluConfig): string[] {
37
+ const pages: string[] = [];
38
+
39
+ function collectFromGroup(group: VeluGroup) {
40
+ for (const item of group.pages) {
41
+ if (typeof item === "string") {
42
+ pages.push(item);
43
+ } else {
44
+ collectFromGroup(item);
45
+ }
46
+ }
47
+ }
48
+
49
+ const nav = config.navigation;
50
+
51
+ if (nav.pages) {
52
+ pages.push(...nav.pages);
53
+ }
54
+
55
+ if (nav.groups) {
56
+ for (const group of nav.groups) {
57
+ collectFromGroup(group);
58
+ }
59
+ }
60
+
61
+ if (nav.tabs) {
62
+ for (const tab of nav.tabs) {
63
+ if (tab.pages) {
64
+ pages.push(...tab.pages);
65
+ }
66
+ if (tab.groups) {
67
+ for (const group of tab.groups) {
68
+ collectFromGroup(group);
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ return pages;
75
+ }
76
+
77
+ function validateVeluConfig(docsDir: string, schemaPath: string): { valid: boolean; errors: string[] } {
78
+ const errors: string[] = [];
79
+
80
+ const configPath = join(docsDir, "velu.json");
81
+ if (!existsSync(configPath)) {
82
+ return { valid: false, errors: [`velu.json not found at ${configPath}`] };
83
+ }
84
+
85
+ if (!existsSync(schemaPath)) {
86
+ return { valid: false, errors: [`Schema not found at ${schemaPath}`] };
87
+ }
88
+
89
+ const schema = loadJson(schemaPath) as AnySchema;
90
+ const config = loadJson(configPath) as VeluConfig;
91
+
92
+ // Validate against JSON schema
93
+ const ajv = new Ajv({ allErrors: true, strict: false });
94
+ addFormats(ajv);
95
+ const validate = ajv.compile(schema);
96
+ const schemaValid = validate(config);
97
+
98
+ if (!schemaValid && validate.errors) {
99
+ for (const err of validate.errors) {
100
+ errors.push(`Schema: ${err.instancePath || "/"} ${err.message}`);
101
+ }
102
+ }
103
+
104
+ // Validate that all referenced .md files exist
105
+ const pages = collectPages(config);
106
+ for (const page of pages) {
107
+ const mdPath = join(docsDir, `${page}.md`);
108
+ if (!existsSync(mdPath)) {
109
+ errors.push(`Missing page: ${page}.md (expected at ${mdPath})`);
110
+ }
111
+ }
112
+
113
+ // Check for duplicate page references
114
+ const seen = new Set<string>();
115
+ for (const page of pages) {
116
+ if (seen.has(page)) {
117
+ errors.push(`Duplicate page reference: ${page}`);
118
+ }
119
+ seen.add(page);
120
+ }
121
+
122
+ return { valid: errors.length === 0, errors };
123
+ }
124
+
125
+ export { validateVeluConfig, collectPages, VeluConfig, VeluGroup, VeluTab };