@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 +114 -0
- package/bin/velu.mjs +19 -0
- package/package.json +33 -0
- package/schema/velu.schema.json +137 -0
- package/src/build.ts +637 -0
- package/src/cli.ts +132 -0
- package/src/validate.ts +125 -0
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
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -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 };
|