@zenithbuild/cli 0.4.11 → 0.5.0-beta.2.15

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,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
+ }