@zenithbuild/cli 0.4.10 → 0.5.0-beta.2.12
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 +3 -0
- package/dist/build.js +1475 -0
- package/dist/dev-server.js +214 -0
- package/dist/index.js +175 -0
- package/dist/manifest.js +273 -0
- package/dist/preview.js +655 -0
- package/dist/resolve-components.js +490 -0
- package/dist/server/resolve-request-route.js +169 -0
- package/dist/server-contract.js +146 -0
- package/dist/types/generate-env-dts.js +52 -0
- package/dist/types/generate-routes-dts.js +22 -0
- package/dist/types/index.js +34 -0
- package/dist/ui/env.js +41 -0
- package/dist/ui/format.js +172 -0
- package/dist/ui/logger.js +105 -0
- package/package.json +22 -50
- package/bin/zen-build.ts +0 -2
- package/bin/zen-dev.ts +0 -2
- package/bin/zen-preview.ts +0 -2
- package/bin/zenith.ts +0 -2
- package/dist/zen-build.js +0 -9622
- package/dist/zen-dev.js +0 -9622
- package/dist/zen-preview.js +0 -9622
- package/dist/zenith.js +0 -9622
- package/src/commands/add.ts +0 -37
- package/src/commands/build.ts +0 -36
- package/src/commands/create.ts +0 -702
- package/src/commands/dev.ts +0 -472
- package/src/commands/index.ts +0 -112
- package/src/commands/preview.ts +0 -62
- package/src/commands/remove.ts +0 -33
- package/src/index.ts +0 -10
- package/src/main.ts +0 -101
- package/src/utils/branding.ts +0 -178
- package/src/utils/logger.ts +0 -52
- package/src/utils/plugin-manager.ts +0 -114
- package/src/utils/project.ts +0 -77
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// dev-server.js — Zenith CLI V0
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Development server with in-memory compilation and file watching.
|
|
5
|
+
//
|
|
6
|
+
// - Compiles pages on demand
|
|
7
|
+
// - Rebuilds on file change
|
|
8
|
+
// - Injects HMR client script
|
|
9
|
+
// - Server route resolution uses manifest matching
|
|
10
|
+
//
|
|
11
|
+
// V0: Uses Node.js http module + fs.watch. No external deps.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
import { createServer } from 'node:http';
|
|
15
|
+
import { watch } from 'node:fs';
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
17
|
+
import { join, extname } from 'node:path';
|
|
18
|
+
import { build } from './build.js';
|
|
19
|
+
import {
|
|
20
|
+
executeServerScript,
|
|
21
|
+
injectSsrPayload,
|
|
22
|
+
loadRouteManifest,
|
|
23
|
+
resolveWithinDist,
|
|
24
|
+
toStaticFilePath
|
|
25
|
+
} from './preview.js';
|
|
26
|
+
import { resolveRequestRoute } from './server/resolve-request-route.js';
|
|
27
|
+
|
|
28
|
+
const MIME_TYPES = {
|
|
29
|
+
'.html': 'text/html',
|
|
30
|
+
'.js': 'application/javascript',
|
|
31
|
+
'.css': 'text/css',
|
|
32
|
+
'.json': 'application/json',
|
|
33
|
+
'.png': 'image/png',
|
|
34
|
+
'.jpg': 'image/jpeg',
|
|
35
|
+
'.svg': 'image/svg+xml'
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const HMR_CLIENT_SCRIPT = `
|
|
39
|
+
<script>
|
|
40
|
+
// Zenith HMR Client V0
|
|
41
|
+
(function() {
|
|
42
|
+
const es = new EventSource('/__zenith_hmr');
|
|
43
|
+
es.onmessage = function(event) {
|
|
44
|
+
if (event.data === 'reload') {
|
|
45
|
+
window.location.reload();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
es.onerror = function() {
|
|
49
|
+
setTimeout(function() { window.location.reload(); }, 1000);
|
|
50
|
+
};
|
|
51
|
+
})();
|
|
52
|
+
</script>`;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create and start a development server.
|
|
56
|
+
*
|
|
57
|
+
* @param {{ pagesDir: string, outDir: string, port?: number, config?: object }} options
|
|
58
|
+
* @returns {Promise<{ server: import('http').Server, port: number, close: () => void }>}
|
|
59
|
+
*/
|
|
60
|
+
export async function createDevServer(options) {
|
|
61
|
+
const {
|
|
62
|
+
pagesDir,
|
|
63
|
+
outDir,
|
|
64
|
+
port = 3000,
|
|
65
|
+
config = {}
|
|
66
|
+
} = options;
|
|
67
|
+
|
|
68
|
+
/** @type {import('http').ServerResponse[]} */
|
|
69
|
+
const hmrClients = [];
|
|
70
|
+
let _watcher = null;
|
|
71
|
+
|
|
72
|
+
// Initial build
|
|
73
|
+
await build({ pagesDir, outDir, config });
|
|
74
|
+
|
|
75
|
+
const server = createServer(async (req, res) => {
|
|
76
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
77
|
+
let pathname = url.pathname;
|
|
78
|
+
|
|
79
|
+
// HMR endpoint
|
|
80
|
+
if (pathname === '/__zenith_hmr') {
|
|
81
|
+
res.writeHead(200, {
|
|
82
|
+
'Content-Type': 'text/event-stream',
|
|
83
|
+
'Cache-Control': 'no-cache',
|
|
84
|
+
'Connection': 'keep-alive'
|
|
85
|
+
});
|
|
86
|
+
// Flush headers by sending initial comment
|
|
87
|
+
res.write(': connected\n\n');
|
|
88
|
+
hmrClients.push(res);
|
|
89
|
+
req.on('close', () => {
|
|
90
|
+
const idx = hmrClients.indexOf(res);
|
|
91
|
+
if (idx !== -1) hmrClients.splice(idx, 1);
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const requestExt = extname(pathname);
|
|
98
|
+
if (requestExt) {
|
|
99
|
+
const assetPath = join(outDir, pathname);
|
|
100
|
+
const asset = await readFile(assetPath);
|
|
101
|
+
const mime = MIME_TYPES[requestExt] || 'application/octet-stream';
|
|
102
|
+
res.writeHead(200, { 'Content-Type': mime });
|
|
103
|
+
res.end(asset);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const routes = await loadRouteManifest(outDir);
|
|
108
|
+
const resolved = resolveRequestRoute(url, routes);
|
|
109
|
+
let filePath = null;
|
|
110
|
+
|
|
111
|
+
if (resolved.matched && resolved.route) {
|
|
112
|
+
console.log(`[zenith] Request: ${pathname} | Route: ${resolved.route.path} | Params: ${JSON.stringify(resolved.params)}`);
|
|
113
|
+
const output = resolved.route.output.startsWith('/')
|
|
114
|
+
? resolved.route.output.slice(1)
|
|
115
|
+
: resolved.route.output;
|
|
116
|
+
filePath = resolveWithinDist(outDir, output);
|
|
117
|
+
} else {
|
|
118
|
+
filePath = toStaticFilePath(outDir, pathname);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!filePath) {
|
|
122
|
+
throw new Error('not found');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let content = await readFile(filePath, 'utf8');
|
|
126
|
+
if (resolved.matched && resolved.route?.server_script && resolved.route.prerender !== true) {
|
|
127
|
+
let payload = null;
|
|
128
|
+
try {
|
|
129
|
+
payload = await executeServerScript({
|
|
130
|
+
source: resolved.route.server_script,
|
|
131
|
+
sourcePath: resolved.route.server_script_path || '',
|
|
132
|
+
params: resolved.params,
|
|
133
|
+
requestUrl: url.toString(),
|
|
134
|
+
requestMethod: req.method || 'GET',
|
|
135
|
+
requestHeaders: req.headers,
|
|
136
|
+
routePattern: resolved.route.path,
|
|
137
|
+
routeFile: resolved.route.server_script_path || ''
|
|
138
|
+
});
|
|
139
|
+
} catch (error) {
|
|
140
|
+
payload = {
|
|
141
|
+
__zenith_error: {
|
|
142
|
+
status: 500,
|
|
143
|
+
code: 'LOAD_FAILED',
|
|
144
|
+
message: error instanceof Error ? error.message : String(error)
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
149
|
+
content = injectSsrPayload(content, payload);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
content = content.replace('</body>', `${HMR_CLIENT_SCRIPT}</body>`);
|
|
154
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
155
|
+
res.end(content);
|
|
156
|
+
} catch {
|
|
157
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
158
|
+
res.end('404 Not Found');
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Broadcast HMR reload to all connected clients.
|
|
164
|
+
*/
|
|
165
|
+
function _broadcastReload() {
|
|
166
|
+
for (const client of hmrClients) {
|
|
167
|
+
try {
|
|
168
|
+
client.write('data: reload\n\n');
|
|
169
|
+
} catch {
|
|
170
|
+
// client disconnected
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Start watching the pages directory for changes.
|
|
177
|
+
*/
|
|
178
|
+
function _startWatcher() {
|
|
179
|
+
try {
|
|
180
|
+
_watcher = watch(pagesDir, { recursive: true }, async (eventType, filename) => {
|
|
181
|
+
if (!filename) return;
|
|
182
|
+
|
|
183
|
+
// Rebuild
|
|
184
|
+
await build({ pagesDir, outDir, config });
|
|
185
|
+
_broadcastReload();
|
|
186
|
+
});
|
|
187
|
+
} catch {
|
|
188
|
+
// fs.watch may not support recursive on all platforms
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return new Promise((resolve) => {
|
|
193
|
+
server.listen(port, () => {
|
|
194
|
+
const actualPort = server.address().port;
|
|
195
|
+
_startWatcher();
|
|
196
|
+
|
|
197
|
+
resolve({
|
|
198
|
+
server,
|
|
199
|
+
port: actualPort,
|
|
200
|
+
close: () => {
|
|
201
|
+
if (_watcher) {
|
|
202
|
+
_watcher.close();
|
|
203
|
+
_watcher = null;
|
|
204
|
+
}
|
|
205
|
+
for (const client of hmrClients) {
|
|
206
|
+
try { client.end(); } catch { }
|
|
207
|
+
}
|
|
208
|
+
hmrClients.length = 0;
|
|
209
|
+
server.close();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// index.js — Zenith CLI V0 Entry Point
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Commands:
|
|
6
|
+
// zenith dev — Development server + HMR
|
|
7
|
+
// zenith build — Static site generation to /dist
|
|
8
|
+
// zenith preview — Serve /dist statically
|
|
9
|
+
//
|
|
10
|
+
// Minimal arg parsing. No heavy dependencies.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
import { resolve, join, dirname } from 'node:path';
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { createLogger } from './ui/logger.js';
|
|
17
|
+
|
|
18
|
+
const COMMANDS = ['dev', 'build', 'preview'];
|
|
19
|
+
const DEFAULT_VERSION = '0.0.0';
|
|
20
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
21
|
+
const __dirname = dirname(__filename);
|
|
22
|
+
|
|
23
|
+
function getCliVersion() {
|
|
24
|
+
try {
|
|
25
|
+
const pkgPath = join(__dirname, '..', 'package.json');
|
|
26
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
27
|
+
return typeof pkg.version === 'string' ? pkg.version : DEFAULT_VERSION;
|
|
28
|
+
} catch {
|
|
29
|
+
return DEFAULT_VERSION;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function printUsage(logger) {
|
|
34
|
+
logger.heading('V0');
|
|
35
|
+
logger.print('Usage:');
|
|
36
|
+
logger.print(' zenith dev [port|--port <port>] Start development server');
|
|
37
|
+
logger.print(' zenith build Build static site to /dist');
|
|
38
|
+
logger.print(' zenith preview [port|--port <port>] Preview /dist statically');
|
|
39
|
+
logger.print('');
|
|
40
|
+
logger.print('Options:');
|
|
41
|
+
logger.print(' -h, --help Show this help message');
|
|
42
|
+
logger.print(' -v, --version Print Zenith CLI version');
|
|
43
|
+
logger.print('');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolvePort(args, fallback) {
|
|
47
|
+
if (!Array.isArray(args) || args.length === 0) {
|
|
48
|
+
return fallback;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const flagIndex = args.findIndex((arg) => arg === '--port' || arg === '-p');
|
|
52
|
+
if (flagIndex >= 0 && args[flagIndex + 1]) {
|
|
53
|
+
const parsed = Number.parseInt(args[flagIndex + 1], 10);
|
|
54
|
+
if (Number.isFinite(parsed)) {
|
|
55
|
+
return parsed;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const positional = args.find((arg) => /^[0-9]+$/.test(arg));
|
|
60
|
+
if (positional) {
|
|
61
|
+
const parsed = Number.parseInt(positional, 10);
|
|
62
|
+
if (Number.isFinite(parsed)) {
|
|
63
|
+
return parsed;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return fallback;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Load zenith.config.js from project root.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} projectRoot
|
|
74
|
+
* @returns {Promise<object>}
|
|
75
|
+
*/
|
|
76
|
+
async function loadConfig(projectRoot) {
|
|
77
|
+
const configPath = join(projectRoot, 'zenith.config.js');
|
|
78
|
+
try {
|
|
79
|
+
const mod = await import(configPath);
|
|
80
|
+
return mod.default || {};
|
|
81
|
+
} catch {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* CLI entry point.
|
|
88
|
+
*
|
|
89
|
+
* @param {string[]} args - Process arguments (without node and script paths)
|
|
90
|
+
* @param {string} [cwd] - Working directory override
|
|
91
|
+
*/
|
|
92
|
+
export async function cli(args, cwd) {
|
|
93
|
+
const logger = createLogger(process);
|
|
94
|
+
const command = args[0];
|
|
95
|
+
const cliVersion = getCliVersion();
|
|
96
|
+
|
|
97
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
98
|
+
logger.print(`zenith ${cliVersion}`);
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
103
|
+
printUsage(logger);
|
|
104
|
+
process.exit(0);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!command || !COMMANDS.includes(command)) {
|
|
108
|
+
printUsage(logger);
|
|
109
|
+
process.exit(command ? 1 : 0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const projectRoot = resolve(cwd || process.cwd());
|
|
113
|
+
const rootPagesDir = join(projectRoot, 'pages');
|
|
114
|
+
const srcPagesDir = join(projectRoot, 'src', 'pages');
|
|
115
|
+
const pagesDir = existsSync(rootPagesDir) ? rootPagesDir : srcPagesDir;
|
|
116
|
+
const outDir = join(projectRoot, 'dist');
|
|
117
|
+
const config = await loadConfig(projectRoot);
|
|
118
|
+
|
|
119
|
+
if (command === 'build') {
|
|
120
|
+
const { build } = await import('./build.js');
|
|
121
|
+
logger.info('Building...');
|
|
122
|
+
const result = await build({ pagesDir, outDir, config });
|
|
123
|
+
logger.success(`Built ${result.pages} page(s), ${result.assets.length} asset(s)`);
|
|
124
|
+
logger.summary([{ label: 'Output', value: './dist' }]);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (command === 'dev') {
|
|
128
|
+
const { createDevServer } = await import('./dev-server.js');
|
|
129
|
+
const port = resolvePort(args.slice(1), 3000);
|
|
130
|
+
logger.info('Starting dev server...');
|
|
131
|
+
const dev = await createDevServer({ pagesDir, outDir, port, config });
|
|
132
|
+
logger.success(`Dev server running at http://localhost:${dev.port}`);
|
|
133
|
+
|
|
134
|
+
// Graceful shutdown
|
|
135
|
+
process.on('SIGINT', () => {
|
|
136
|
+
dev.close();
|
|
137
|
+
process.exit(0);
|
|
138
|
+
});
|
|
139
|
+
process.on('SIGTERM', () => {
|
|
140
|
+
dev.close();
|
|
141
|
+
process.exit(0);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (command === 'preview') {
|
|
146
|
+
const { createPreviewServer } = await import('./preview.js');
|
|
147
|
+
const port = resolvePort(args.slice(1), 4000);
|
|
148
|
+
logger.info('Starting preview server...');
|
|
149
|
+
const preview = await createPreviewServer({ distDir: outDir, port });
|
|
150
|
+
logger.success(`Preview server running at http://localhost:${preview.port}`);
|
|
151
|
+
|
|
152
|
+
process.on('SIGINT', () => {
|
|
153
|
+
preview.close();
|
|
154
|
+
process.exit(0);
|
|
155
|
+
});
|
|
156
|
+
process.on('SIGTERM', () => {
|
|
157
|
+
preview.close();
|
|
158
|
+
process.exit(0);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Auto-run if called directly
|
|
164
|
+
const isDirectRun = process.argv[1] && (
|
|
165
|
+
process.argv[1].endsWith('/index.js') ||
|
|
166
|
+
process.argv[1].endsWith('/zenith')
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
if (isDirectRun) {
|
|
170
|
+
cli(process.argv.slice(2)).catch((error) => {
|
|
171
|
+
const logger = createLogger(process);
|
|
172
|
+
logger.error(error);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
});
|
|
175
|
+
}
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// manifest.js — Zenith CLI V0
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// File-based manifest engine.
|
|
5
|
+
//
|
|
6
|
+
// Scans a /pages directory and produces a deterministic RouteManifest.
|
|
7
|
+
//
|
|
8
|
+
// Rules:
|
|
9
|
+
// - index.zen → parent directory path
|
|
10
|
+
// - [param].zen → :param dynamic segment
|
|
11
|
+
// - [...slug].zen → *slug catch-all segment (must be terminal, 1+ segments;
|
|
12
|
+
// root '/*slug' may match '/' in router matcher)
|
|
13
|
+
// - [[...slug]].zen → *slug? optional catch-all segment (must be terminal, 0+ segments)
|
|
14
|
+
// - Deterministic precedence: static > :param > *catchall
|
|
15
|
+
// - Tie-breaker: lexicographic route path
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
19
|
+
import { join, relative, sep, basename, extname, dirname } from 'node:path';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {{ path: string, file: string }} ManifestEntry
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Scan a pages directory and produce a deterministic RouteManifest.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} pagesDir - Absolute path to /pages directory
|
|
29
|
+
* @param {string} [extension='.zen'] - File extension to scan for
|
|
30
|
+
* @returns {Promise<ManifestEntry[]>}
|
|
31
|
+
*/
|
|
32
|
+
export async function generateManifest(pagesDir, extension = '.zen') {
|
|
33
|
+
const entries = await _scanDir(pagesDir, pagesDir, extension);
|
|
34
|
+
|
|
35
|
+
// Validate: no repeated param names in any single route
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
_validateParams(entry.path);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Sort: static first, dynamic after, alpha within each category
|
|
41
|
+
return _sortEntries(entries);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Recursively scan a directory for page files.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} dir - Current directory
|
|
48
|
+
* @param {string} root - Root pages directory
|
|
49
|
+
* @param {string} ext - Extension to match
|
|
50
|
+
* @returns {Promise<ManifestEntry[]>}
|
|
51
|
+
*/
|
|
52
|
+
async function _scanDir(dir, root, ext) {
|
|
53
|
+
/** @type {ManifestEntry[]} */
|
|
54
|
+
const entries = [];
|
|
55
|
+
|
|
56
|
+
let items;
|
|
57
|
+
try {
|
|
58
|
+
items = await readdir(dir);
|
|
59
|
+
} catch {
|
|
60
|
+
return entries;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Sort items for deterministic traversal
|
|
64
|
+
items.sort();
|
|
65
|
+
|
|
66
|
+
for (const item of items) {
|
|
67
|
+
const fullPath = join(dir, item);
|
|
68
|
+
const info = await stat(fullPath);
|
|
69
|
+
|
|
70
|
+
if (info.isDirectory()) {
|
|
71
|
+
const nested = await _scanDir(fullPath, root, ext);
|
|
72
|
+
entries.push(...nested);
|
|
73
|
+
} else if (item.endsWith(ext)) {
|
|
74
|
+
const routePath = _fileToRoute(fullPath, root, ext);
|
|
75
|
+
entries.push({ path: routePath, file: relative(root, fullPath) });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return entries;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Convert a file path to a route path.
|
|
84
|
+
*
|
|
85
|
+
* pages/index.zen → /
|
|
86
|
+
* pages/about.zen → /about
|
|
87
|
+
* pages/users/[id].zen → /users/:id
|
|
88
|
+
* pages/docs/[...slug].zen → /docs/*slug
|
|
89
|
+
* pages/[[...slug]].zen → /*slug?
|
|
90
|
+
* pages/docs/api/index.zen → /docs/api
|
|
91
|
+
*
|
|
92
|
+
* @param {string} filePath - Absolute file path
|
|
93
|
+
* @param {string} root - Root pages directory
|
|
94
|
+
* @param {string} ext - Extension
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
function _fileToRoute(filePath, root, ext) {
|
|
98
|
+
const rel = relative(root, filePath);
|
|
99
|
+
const withoutExt = rel.slice(0, -ext.length);
|
|
100
|
+
|
|
101
|
+
// Normalize path separators
|
|
102
|
+
const segments = withoutExt.split(sep).filter(Boolean);
|
|
103
|
+
|
|
104
|
+
// Convert segments
|
|
105
|
+
const routeSegments = segments.map((seg) => {
|
|
106
|
+
// [[...param]] → *param? (optional catch-all)
|
|
107
|
+
const optionalCatchAllMatch = seg.match(/^\[\[\.\.\.([a-zA-Z_][a-zA-Z0-9_]*)\]\]$/);
|
|
108
|
+
if (optionalCatchAllMatch) {
|
|
109
|
+
return '*' + optionalCatchAllMatch[1] + '?';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// [...param] → *param (required catch-all)
|
|
113
|
+
const catchAllMatch = seg.match(/^\[\.\.\.([a-zA-Z_][a-zA-Z0-9_]*)\]$/);
|
|
114
|
+
if (catchAllMatch) {
|
|
115
|
+
return '*' + catchAllMatch[1];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// [param] → :param
|
|
119
|
+
const paramMatch = seg.match(/^\[([a-zA-Z_][a-zA-Z0-9_]*)\]$/);
|
|
120
|
+
if (paramMatch) {
|
|
121
|
+
return ':' + paramMatch[1];
|
|
122
|
+
}
|
|
123
|
+
return seg;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Remove trailing 'index'
|
|
127
|
+
if (routeSegments.length > 0 && routeSegments[routeSegments.length - 1] === 'index') {
|
|
128
|
+
routeSegments.pop();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const route = '/' + routeSegments.join('/');
|
|
132
|
+
return route;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Validate that a route path has no repeated param names.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} routePath
|
|
139
|
+
* @throws {Error} If repeated params found
|
|
140
|
+
*/
|
|
141
|
+
function _validateParams(routePath) {
|
|
142
|
+
const segments = routePath.split('/').filter(Boolean);
|
|
143
|
+
const paramNames = new Set();
|
|
144
|
+
|
|
145
|
+
for (let i = 0; i < segments.length; i++) {
|
|
146
|
+
const seg = segments[i];
|
|
147
|
+
if (seg.startsWith(':') || seg.startsWith('*')) {
|
|
148
|
+
const rawName = seg.slice(1);
|
|
149
|
+
const isCatchAll = seg.startsWith('*');
|
|
150
|
+
const optionalCatchAll = isCatchAll && rawName.endsWith('?');
|
|
151
|
+
const name = optionalCatchAll ? rawName.slice(0, -1) : rawName;
|
|
152
|
+
const label = isCatchAll ? `*${rawName}` : `:${name}`;
|
|
153
|
+
if (paramNames.has(name)) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`[Zenith CLI] Repeated param name '${label}' in route '${routePath}'`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (isCatchAll && i !== segments.length - 1) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`[Zenith CLI] Catch-all segment '${label}' must be the last segment in route '${routePath}'`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
paramNames.add(name);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Check if a route contains any dynamic segments.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} routePath
|
|
172
|
+
* @returns {boolean}
|
|
173
|
+
*/
|
|
174
|
+
function _isDynamic(routePath) {
|
|
175
|
+
return routePath.split('/').some((seg) => seg.startsWith(':') || seg.startsWith('*'));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Sort manifest entries by deterministic route precedence.
|
|
180
|
+
*
|
|
181
|
+
* @param {ManifestEntry[]} entries
|
|
182
|
+
* @returns {ManifestEntry[]}
|
|
183
|
+
*/
|
|
184
|
+
function _sortEntries(entries) {
|
|
185
|
+
return [...entries].sort((a, b) => compareRouteSpecificity(a.path, b.path));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Deterministic route precedence:
|
|
190
|
+
* static segment > param segment > catch-all segment.
|
|
191
|
+
* Tie-breakers: segment count (more specific first), then lexicographic path.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} a
|
|
194
|
+
* @param {string} b
|
|
195
|
+
* @returns {number}
|
|
196
|
+
*/
|
|
197
|
+
function compareRouteSpecificity(a, b) {
|
|
198
|
+
if (a === '/' && b !== '/') return -1;
|
|
199
|
+
if (b === '/' && a !== '/') return 1;
|
|
200
|
+
|
|
201
|
+
const aSegs = a.split('/').filter(Boolean);
|
|
202
|
+
const bSegs = b.split('/').filter(Boolean);
|
|
203
|
+
const aClass = routeClass(aSegs);
|
|
204
|
+
const bClass = routeClass(bSegs);
|
|
205
|
+
if (aClass !== bClass) {
|
|
206
|
+
return bClass - aClass;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const max = Math.min(aSegs.length, bSegs.length);
|
|
210
|
+
|
|
211
|
+
for (let i = 0; i < max; i++) {
|
|
212
|
+
const aWeight = segmentWeight(aSegs[i]);
|
|
213
|
+
const bWeight = segmentWeight(bSegs[i]);
|
|
214
|
+
if (aWeight !== bWeight) {
|
|
215
|
+
return bWeight - aWeight;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (aSegs.length !== bSegs.length) {
|
|
220
|
+
return bSegs.length - aSegs.length;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return a.localeCompare(b);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @param {string[]} segments
|
|
228
|
+
* @returns {number}
|
|
229
|
+
*/
|
|
230
|
+
function routeClass(segments) {
|
|
231
|
+
let hasParam = false;
|
|
232
|
+
let hasCatchAll = false;
|
|
233
|
+
for (const segment of segments) {
|
|
234
|
+
if (segment.startsWith('*')) {
|
|
235
|
+
hasCatchAll = true;
|
|
236
|
+
} else if (segment.startsWith(':')) {
|
|
237
|
+
hasParam = true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (!hasParam && !hasCatchAll) return 3;
|
|
241
|
+
if (hasCatchAll) return 1;
|
|
242
|
+
return 2;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {string | undefined} segment
|
|
247
|
+
* @returns {number}
|
|
248
|
+
*/
|
|
249
|
+
function segmentWeight(segment) {
|
|
250
|
+
if (!segment) return 0;
|
|
251
|
+
if (segment.startsWith('*')) return 1;
|
|
252
|
+
if (segment.startsWith(':')) return 2;
|
|
253
|
+
return 3;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Generate a JavaScript module string from manifest entries.
|
|
258
|
+
* Used for writing the manifest file to disk.
|
|
259
|
+
*
|
|
260
|
+
* @param {ManifestEntry[]} entries
|
|
261
|
+
* @returns {string}
|
|
262
|
+
*/
|
|
263
|
+
export function serializeManifest(entries) {
|
|
264
|
+
const lines = entries.map((e) => {
|
|
265
|
+
const hasParams = _isDynamic(e.path);
|
|
266
|
+
const loader = hasParams
|
|
267
|
+
? `(params) => import('./pages/${e.file}')`
|
|
268
|
+
: `() => import('./pages/${e.file}')`;
|
|
269
|
+
return ` { path: '${e.path}', load: ${loader} }`;
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
return `export default [\n${lines.join(',\n')}\n];\n`;
|
|
273
|
+
}
|