@zenithbuild/cli 0.4.11 → 0.5.0-beta.2.3

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.
@@ -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,118 @@
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 } from 'node:path';
14
+ import { existsSync } from 'node:fs';
15
+ import { createLogger } from './ui/logger.js';
16
+
17
+ const COMMANDS = ['dev', 'build', 'preview'];
18
+
19
+ /**
20
+ * Load zenith.config.js from project root.
21
+ *
22
+ * @param {string} projectRoot
23
+ * @returns {Promise<object>}
24
+ */
25
+ async function loadConfig(projectRoot) {
26
+ const configPath = join(projectRoot, 'zenith.config.js');
27
+ try {
28
+ const mod = await import(configPath);
29
+ return mod.default || {};
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ /**
36
+ * CLI entry point.
37
+ *
38
+ * @param {string[]} args - Process arguments (without node and script paths)
39
+ * @param {string} [cwd] - Working directory override
40
+ */
41
+ export async function cli(args, cwd) {
42
+ const logger = createLogger(process);
43
+ const command = args[0];
44
+
45
+ if (!command || !COMMANDS.includes(command)) {
46
+ logger.heading('V0');
47
+ logger.print('Usage:');
48
+ logger.print(' zenith dev Start development server');
49
+ logger.print(' zenith build Build static site to /dist');
50
+ logger.print(' zenith preview Preview /dist statically');
51
+ logger.print('');
52
+ process.exit(command ? 1 : 0);
53
+ }
54
+
55
+ const projectRoot = resolve(cwd || process.cwd());
56
+ const rootPagesDir = join(projectRoot, 'pages');
57
+ const srcPagesDir = join(projectRoot, 'src', 'pages');
58
+ const pagesDir = existsSync(rootPagesDir) ? rootPagesDir : srcPagesDir;
59
+ const outDir = join(projectRoot, 'dist');
60
+ const config = await loadConfig(projectRoot);
61
+
62
+ if (command === 'build') {
63
+ const { build } = await import('./build.js');
64
+ logger.info('Building...');
65
+ const result = await build({ pagesDir, outDir, config });
66
+ logger.success(`Built ${result.pages} page(s), ${result.assets.length} asset(s)`);
67
+ logger.summary([{ label: 'Output', value: './dist' }]);
68
+ }
69
+
70
+ if (command === 'dev') {
71
+ const { createDevServer } = await import('./dev-server.js');
72
+ const port = parseInt(args[1]) || 3000;
73
+ logger.info('Starting dev server...');
74
+ const dev = await createDevServer({ pagesDir, outDir, port, config });
75
+ logger.success(`Dev server running at http://localhost:${dev.port}`);
76
+
77
+ // Graceful shutdown
78
+ process.on('SIGINT', () => {
79
+ dev.close();
80
+ process.exit(0);
81
+ });
82
+ process.on('SIGTERM', () => {
83
+ dev.close();
84
+ process.exit(0);
85
+ });
86
+ }
87
+
88
+ if (command === 'preview') {
89
+ const { createPreviewServer } = await import('./preview.js');
90
+ const port = parseInt(args[1]) || 4000;
91
+ logger.info('Starting preview server...');
92
+ const preview = await createPreviewServer({ distDir: outDir, port });
93
+ logger.success(`Preview server running at http://localhost:${preview.port}`);
94
+
95
+ process.on('SIGINT', () => {
96
+ preview.close();
97
+ process.exit(0);
98
+ });
99
+ process.on('SIGTERM', () => {
100
+ preview.close();
101
+ process.exit(0);
102
+ });
103
+ }
104
+ }
105
+
106
+ // Auto-run if called directly
107
+ const isDirectRun = process.argv[1] && (
108
+ process.argv[1].endsWith('/index.js') ||
109
+ process.argv[1].endsWith('/zenith')
110
+ );
111
+
112
+ if (isDirectRun) {
113
+ cli(process.argv.slice(2)).catch((error) => {
114
+ const logger = createLogger(process);
115
+ logger.error(error);
116
+ process.exit(1);
117
+ });
118
+ }
@@ -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
+ }