@zenithbuild/cli 0.5.0-beta.2.5 → 0.6.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.
@@ -245,6 +245,9 @@ function findNextKnownTag(source, registry, startIndex) {
245
245
  if (!registry.has(name)) {
246
246
  continue;
247
247
  }
248
+ if (isInsideExpressionScope(source, match.index)) {
249
+ continue;
250
+ }
248
251
  return {
249
252
  name,
250
253
  start: match.index,
@@ -256,6 +259,111 @@ function findNextKnownTag(source, registry, startIndex) {
256
259
  return null;
257
260
  }
258
261
 
262
+ /**
263
+ * Detect whether `index` is inside a `{ ... }` expression scope.
264
+ *
265
+ * This prevents component macro expansion inside embedded markup expressions,
266
+ * which must remain expression-local so the compiler can lower them safely.
267
+ *
268
+ * @param {string} source
269
+ * @param {number} index
270
+ * @returns {boolean}
271
+ */
272
+ function isInsideExpressionScope(source, index) {
273
+ let depth = 0;
274
+ let mode = 'code';
275
+ let escaped = false;
276
+ const lower = source.toLowerCase();
277
+
278
+ for (let i = 0; i < index; i++) {
279
+ if (mode === 'code') {
280
+ if (lower.startsWith('<script', i)) {
281
+ const close = lower.indexOf('</script>', i + 7);
282
+ if (close < 0 || close >= index) {
283
+ return false;
284
+ }
285
+ i = close + '</script>'.length - 1;
286
+ continue;
287
+ }
288
+ if (lower.startsWith('<style', i)) {
289
+ const close = lower.indexOf('</style>', i + 6);
290
+ if (close < 0 || close >= index) {
291
+ return false;
292
+ }
293
+ i = close + '</style>'.length - 1;
294
+ continue;
295
+ }
296
+ }
297
+
298
+ const ch = source[i];
299
+ const next = i + 1 < index ? source[i + 1] : '';
300
+
301
+ if (mode === 'line-comment') {
302
+ if (ch === '\n') {
303
+ mode = 'code';
304
+ }
305
+ continue;
306
+ }
307
+ if (mode === 'block-comment') {
308
+ if (ch === '*' && next === '/') {
309
+ mode = 'code';
310
+ i += 1;
311
+ }
312
+ continue;
313
+ }
314
+ if (mode === 'single-quote' || mode === 'double-quote' || mode === 'template') {
315
+ if (escaped) {
316
+ escaped = false;
317
+ continue;
318
+ }
319
+ if (ch === '\\') {
320
+ escaped = true;
321
+ continue;
322
+ }
323
+ if (
324
+ (mode === 'single-quote' && ch === "'") ||
325
+ (mode === 'double-quote' && ch === '"') ||
326
+ (mode === 'template' && ch === '`')
327
+ ) {
328
+ mode = 'code';
329
+ }
330
+ continue;
331
+ }
332
+
333
+ if (ch === '/' && next === '/') {
334
+ mode = 'line-comment';
335
+ i += 1;
336
+ continue;
337
+ }
338
+ if (ch === '/' && next === '*') {
339
+ mode = 'block-comment';
340
+ i += 1;
341
+ continue;
342
+ }
343
+ if (ch === "'") {
344
+ mode = 'single-quote';
345
+ continue;
346
+ }
347
+ if (ch === '"') {
348
+ mode = 'double-quote';
349
+ continue;
350
+ }
351
+ if (ch === '`') {
352
+ mode = 'template';
353
+ continue;
354
+ }
355
+ if (ch === '{') {
356
+ depth += 1;
357
+ continue;
358
+ }
359
+ if (ch === '}') {
360
+ depth = Math.max(0, depth - 1);
361
+ }
362
+ }
363
+
364
+ return depth > 0;
365
+ }
366
+
259
367
  /**
260
368
  * Find the matching </Name> for an opening tag, accounting for nested
261
369
  * tags with the same name.
@@ -2,9 +2,73 @@
2
2
  // ---------------------------------------------------------------------------
3
3
  // Shared validation and payload resolution logic for <script server> blocks.
4
4
 
5
- const NEW_KEYS = new Set(['data', 'load', 'prerender']);
5
+ const NEW_KEYS = new Set(['data', 'load', 'guard', 'prerender']);
6
6
  const LEGACY_KEYS = new Set(['ssr_data', 'props', 'ssr', 'prerender']);
7
- const ALLOWED_KEYS = new Set(['data', 'load', 'prerender', 'ssr_data', 'props', 'ssr']);
7
+ const ALLOWED_KEYS = new Set(['data', 'load', 'guard', 'prerender', 'ssr_data', 'props', 'ssr']);
8
+
9
+ const ROUTE_RESULT_KINDS = new Set(['allow', 'redirect', 'deny', 'data']);
10
+
11
+ export function allow() {
12
+ return { kind: 'allow' };
13
+ }
14
+
15
+ export function redirect(location, status = 302) {
16
+ return {
17
+ kind: 'redirect',
18
+ location: String(location || ''),
19
+ status: Number.isInteger(status) ? status : 302
20
+ };
21
+ }
22
+
23
+ export function deny(status = 403, message = undefined) {
24
+ return {
25
+ kind: 'deny',
26
+ status: Number.isInteger(status) ? status : 403,
27
+ message: typeof message === 'string' ? message : undefined
28
+ };
29
+ }
30
+
31
+ export function data(payload) {
32
+ return { kind: 'data', data: payload };
33
+ }
34
+
35
+ function isRouteResultLike(value) {
36
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
37
+ return false;
38
+ }
39
+ const kind = value.kind;
40
+ return typeof kind === 'string' && ROUTE_RESULT_KINDS.has(kind);
41
+ }
42
+
43
+ function assertValidRouteResultShape(value, where, allowedKinds) {
44
+ if (!isRouteResultLike(value)) {
45
+ throw new Error(`[Zenith] ${where}: invalid route result. Expected object with kind.`);
46
+ }
47
+ const kind = value.kind;
48
+ if (!allowedKinds.has(kind)) {
49
+ throw new Error(
50
+ `[Zenith] ${where}: kind "${kind}" is not allowed here (allowed: ${Array.from(allowedKinds).join(', ')}).`
51
+ );
52
+ }
53
+
54
+ if (kind === 'redirect') {
55
+ if (typeof value.location !== 'string' || value.location.length === 0) {
56
+ throw new Error(`[Zenith] ${where}: redirect requires non-empty string location.`);
57
+ }
58
+ if (value.status !== undefined && (!Number.isInteger(value.status) || value.status < 300 || value.status > 399)) {
59
+ throw new Error(`[Zenith] ${where}: redirect status must be an integer 3xx.`);
60
+ }
61
+ }
62
+
63
+ if (kind === 'deny') {
64
+ if (!Number.isInteger(value.status) || (value.status !== 401 && value.status !== 403)) {
65
+ throw new Error(`[Zenith] ${where}: deny status must be 401 or 403.`);
66
+ }
67
+ if (value.message !== undefined && typeof value.message !== 'string') {
68
+ throw new Error(`[Zenith] ${where}: deny message must be a string when provided.`);
69
+ }
70
+ }
71
+ }
8
72
 
9
73
  export function validateServerExports({ exports, filePath }) {
10
74
  const exportKeys = Object.keys(exports);
@@ -16,6 +80,7 @@ export function validateServerExports({ exports, filePath }) {
16
80
 
17
81
  const hasData = 'data' in exports;
18
82
  const hasLoad = 'load' in exports;
83
+ const hasGuard = 'guard' in exports;
19
84
 
20
85
  const hasNew = hasData || hasLoad;
21
86
  const hasLegacy = ('ssr_data' in exports) || ('props' in exports) || ('ssr' in exports);
@@ -47,6 +112,20 @@ export function validateServerExports({ exports, filePath }) {
47
112
  throw new Error(`[Zenith] ${filePath}: "load(ctx)" must not contain rest parameters.`);
48
113
  }
49
114
  }
115
+
116
+ if (hasGuard && typeof exports.guard !== 'function') {
117
+ throw new Error(`[Zenith] ${filePath}: "guard" must be a function.`);
118
+ }
119
+ if (hasGuard) {
120
+ if (exports.guard.length !== 1) {
121
+ throw new Error(`[Zenith] ${filePath}: "guard(ctx)" must take exactly 1 argument.`);
122
+ }
123
+ const fnStr = exports.guard.toString();
124
+ const paramsMatch = fnStr.match(/^[^{=]+\(([^)]*)\)/);
125
+ if (paramsMatch && paramsMatch[1].includes('...')) {
126
+ throw new Error(`[Zenith] ${filePath}: "guard(ctx)" must not contain rest parameters.`);
127
+ }
128
+ }
50
129
  }
51
130
 
52
131
  export function assertJsonSerializable(value, where = 'payload') {
@@ -110,37 +189,97 @@ export function assertJsonSerializable(value, where = 'payload') {
110
189
  walk(value, '$');
111
190
  }
112
191
 
113
- export async function resolveServerPayload({ exports, ctx, filePath }) {
192
+ export async function resolveRouteResult({ exports, ctx, filePath, guardOnly = false }) {
114
193
  validateServerExports({ exports, filePath });
115
194
 
195
+ const trace = {
196
+ guard: 'none',
197
+ load: 'none'
198
+ };
199
+
200
+ if ('guard' in exports) {
201
+ const guardRaw = await exports.guard(ctx);
202
+ const guardResult = guardRaw == null ? allow() : guardRaw;
203
+ if (guardResult.kind === 'data') {
204
+ throw new Error(`[Zenith] ${filePath}: guard(ctx) returned data(payload) which is a critical invariant violation. guard() can only return allow(), redirect(), or deny(). Use load(ctx) for data injection.`);
205
+ }
206
+ assertValidRouteResultShape(
207
+ guardResult,
208
+ `${filePath}: guard(ctx) return`,
209
+ new Set(['allow', 'redirect', 'deny'])
210
+ );
211
+ trace.guard = guardResult.kind;
212
+ if (guardResult.kind === 'redirect' || guardResult.kind === 'deny') {
213
+ return { result: guardResult, trace };
214
+ }
215
+ }
216
+
217
+ if (guardOnly) {
218
+ return { result: allow(), trace };
219
+ }
220
+
116
221
  let payload;
117
222
  if ('load' in exports) {
118
- payload = await exports.load(ctx);
119
- assertJsonSerializable(payload, `${filePath}: load(ctx) return`);
120
- return payload;
223
+ const loadRaw = await exports.load(ctx);
224
+ let loadResult = null;
225
+ if (isRouteResultLike(loadRaw)) {
226
+ loadResult = loadRaw;
227
+ assertValidRouteResultShape(
228
+ loadResult,
229
+ `${filePath}: load(ctx) return`,
230
+ new Set(['data', 'redirect', 'deny'])
231
+ );
232
+ } else {
233
+ assertJsonSerializable(loadRaw, `${filePath}: load(ctx) return`);
234
+ loadResult = data(loadRaw);
235
+ }
236
+ trace.load = loadResult.kind;
237
+ return { result: loadResult, trace };
121
238
  }
122
239
  if ('data' in exports) {
123
240
  payload = exports.data;
124
241
  assertJsonSerializable(payload, `${filePath}: data export`);
125
- return payload;
242
+ trace.load = 'data';
243
+ return { result: data(payload), trace };
126
244
  }
127
245
 
128
246
  // legacy fallback
129
247
  if ('ssr_data' in exports) {
130
248
  payload = exports.ssr_data;
131
249
  assertJsonSerializable(payload, `${filePath}: ssr_data export`);
132
- return payload;
250
+ trace.load = 'data';
251
+ return { result: data(payload), trace };
133
252
  }
134
253
  if ('props' in exports) {
135
254
  payload = exports.props;
136
255
  assertJsonSerializable(payload, `${filePath}: props export`);
137
- return payload;
256
+ trace.load = 'data';
257
+ return { result: data(payload), trace };
138
258
  }
139
259
  if ('ssr' in exports) {
140
260
  payload = exports.ssr;
141
261
  assertJsonSerializable(payload, `${filePath}: ssr export`);
142
- return payload;
262
+ trace.load = 'data';
263
+ return { result: data(payload), trace };
264
+ }
265
+
266
+ return { result: data({}), trace };
267
+ }
268
+
269
+ export async function resolveServerPayload({ exports, ctx, filePath }) {
270
+ const resolved = await resolveRouteResult({ exports, ctx, filePath });
271
+ if (!resolved || !resolved.result || typeof resolved.result !== 'object') {
272
+ return {};
273
+ }
274
+
275
+ if (resolved.result.kind === 'data') {
276
+ return resolved.result.data;
277
+ }
278
+ if (resolved.result.kind === 'allow') {
279
+ return {};
143
280
  }
144
281
 
145
- return {};
282
+ throw new Error(
283
+ `[Zenith] ${filePath}: resolveServerPayload() expected data but received ${resolved.result.kind}. Use resolveRouteResult() for guard/load flows.`
284
+ );
146
285
  }
package/dist/ui/format.js CHANGED
@@ -13,6 +13,9 @@ const ANSI = {
13
13
  green: '\x1b[32m',
14
14
  cyan: '\x1b[36m'
15
15
  };
16
+ const DEFAULT_PHASE = 'cli';
17
+ const DEFAULT_FILE = '.';
18
+ const DEFAULT_HINT_BASE = 'https://github.com/zenithbuild/zenith/blob/main/zenith-cli/CLI_CONTRACT.md';
16
19
 
17
20
  function colorize(mode, token, text) {
18
21
  if (!mode.color) {
@@ -62,21 +65,53 @@ function normalizeFileLinePath(line) {
62
65
 
63
66
  const prefix = match[1];
64
67
  const filePath = match[2].trim();
65
- if (!filePath.startsWith('/') && !/^[A-Za-z]:\\/.test(filePath)) {
66
- return line;
68
+ const normalized = normalizePathForDisplay(filePath);
69
+ return `${prefix}${normalized}`;
70
+ }
71
+
72
+ function normalizePathForDisplay(filePath) {
73
+ const value = String(filePath || '').trim();
74
+ if (!value) {
75
+ return DEFAULT_FILE;
76
+ }
77
+ if (!value.startsWith('/') && !/^[A-Za-z]:\\/.test(value)) {
78
+ return value;
67
79
  }
68
80
 
69
81
  const cwd = process.cwd();
70
82
  const cwdWithSep = cwd.endsWith(sep) ? cwd : `${cwd}${sep}`;
71
- if (filePath === cwd) {
72
- return `${prefix}.`;
83
+ if (value === cwd) {
84
+ return DEFAULT_FILE;
73
85
  }
74
- if (filePath.startsWith(cwdWithSep)) {
75
- const relativePath = relative(cwd, filePath).replaceAll('\\', '/');
76
- return `${prefix}${relativePath || '.'}`;
86
+ if (value.startsWith(cwdWithSep)) {
87
+ const relativePath = relative(cwd, value).replaceAll('\\', '/');
88
+ return relativePath || DEFAULT_FILE;
77
89
  }
78
90
 
79
- return line;
91
+ return value;
92
+ }
93
+
94
+ function inferPhaseFromArgv() {
95
+ const knownPhases = new Set(['build', 'dev', 'preview']);
96
+ for (const arg of process.argv.slice(2)) {
97
+ if (knownPhases.has(arg)) {
98
+ return arg;
99
+ }
100
+ }
101
+ return DEFAULT_PHASE;
102
+ }
103
+
104
+ function extractFileFromMessage(message) {
105
+ const match = String(message || '').match(/\bFile:\s+([^\n]+)/);
106
+ return match ? match[1].trim() : '';
107
+ }
108
+
109
+ function formatHintUrl(code) {
110
+ const slug = String(code || 'CLI_ERROR')
111
+ .toLowerCase()
112
+ .replace(/[^a-z0-9]+/g, '-')
113
+ .replace(/^-+|-+$/g, '');
114
+ return `${DEFAULT_HINT_BASE}#${slug || 'cli-error'}`;
80
115
  }
81
116
 
82
117
  export function normalizeErrorMessagePaths(message) {
@@ -102,22 +137,26 @@ export function normalizeError(err) {
102
137
  */
103
138
  export function formatErrorBlock(err, mode) {
104
139
  const normalized = normalizeError(err);
105
- const maybe = /** @type {{ code?: unknown, phase?: unknown, kind?: unknown }} */ (normalized);
140
+ const maybe = /** @type {{ code?: unknown, phase?: unknown, kind?: unknown, file?: unknown, hint?: unknown }} */ (normalized);
106
141
  const kind = sanitizeErrorMessage(maybe.kind || maybe.code || 'CLI_ERROR');
107
- const phase = maybe.phase ? sanitizeErrorMessage(maybe.phase) : '';
108
- const code = maybe.code ? sanitizeErrorMessage(maybe.code) : '';
142
+ const phase = maybe.phase ? sanitizeErrorMessage(maybe.phase) : inferPhaseFromArgv();
143
+ const code = maybe.code
144
+ ? sanitizeErrorMessage(maybe.code)
145
+ : `${phase.toUpperCase().replace(/[^A-Z0-9]+/g, '_') || 'CLI'}_FAILED`;
109
146
  const rawMessage = sanitizeErrorMessage(normalized.message || String(normalized));
110
147
  const message = normalizeErrorMessagePaths(rawMessage);
148
+ const file = normalizePathForDisplay(
149
+ sanitizeErrorMessage(maybe.file || extractFileFromMessage(message) || DEFAULT_FILE)
150
+ );
151
+ const hint = sanitizeErrorMessage(maybe.hint || formatHintUrl(code));
111
152
 
112
153
  const lines = [];
113
154
  lines.push('[zenith] ERROR: Command failed');
114
155
  lines.push(`[zenith] Error Kind: ${kind}`);
115
- if (phase) {
116
- lines.push(`[zenith] Phase: ${phase}`);
117
- }
118
- if (code) {
119
- lines.push(`[zenith] Code: ${code}`);
120
- }
156
+ lines.push(`[zenith] Phase: ${phase || DEFAULT_PHASE}`);
157
+ lines.push(`[zenith] Code: ${code || 'CLI_FAILED'}`);
158
+ lines.push(`[zenith] File: ${file || DEFAULT_FILE}`);
159
+ lines.push(`[zenith] Hint: ${hint || formatHintUrl(code)}`);
121
160
  lines.push(`[zenith] Message: ${message}`);
122
161
 
123
162
  if (mode.debug && normalized.stack) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenithbuild/cli",
3
- "version": "0.5.0-beta.2.5",
3
+ "version": "0.6.0",
4
4
  "description": "Deterministic project orchestrator for Zenith framework",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,7 +24,7 @@
24
24
  "prepublishOnly": "npm run build"
25
25
  },
26
26
  "dependencies": {
27
- "@zenithbuild/compiler": "0.5.0-beta.2.5"
27
+ "@zenithbuild/compiler": "0.6.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@jest/globals": "^30.2.0",