@zenithbuild/cli 0.6.0 → 0.6.2

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/dist/build.js CHANGED
@@ -92,6 +92,35 @@ export function createCompilerWarningEmitter(sink = (line) => console.warn(line)
92
92
  };
93
93
  }
94
94
 
95
+ /**
96
+ * Forward child-process output line-by-line through the structured logger.
97
+ *
98
+ * @param {import('node:stream').Readable | null | undefined} stream
99
+ * @param {(line: string) => void} onLine
100
+ */
101
+ function forwardStreamLines(stream, onLine) {
102
+ if (!stream || typeof stream.on !== 'function') {
103
+ return;
104
+ }
105
+ let pending = '';
106
+ stream.setEncoding?.('utf8');
107
+ stream.on('data', (chunk) => {
108
+ pending += String(chunk || '');
109
+ const lines = pending.split(/\r?\n/);
110
+ pending = lines.pop() || '';
111
+ for (const line of lines) {
112
+ if (line.trim().length > 0) {
113
+ onLine(line);
114
+ }
115
+ }
116
+ });
117
+ stream.on('end', () => {
118
+ if (pending.trim().length > 0) {
119
+ onLine(pending);
120
+ }
121
+ });
122
+ }
123
+
95
124
  /**
96
125
  * Run the compiler process and parse its JSON stdout.
97
126
  *
@@ -244,6 +273,57 @@ function mergeExpressionRewriteMaps(pageMap, pageAmbiguous, componentRewrite) {
244
273
  }
245
274
  }
246
275
 
276
+ function resolveStateKeyFromBindings(identifier, stateBindings, preferredKeys = null) {
277
+ const ident = String(identifier || '').trim();
278
+ if (!ident) {
279
+ return null;
280
+ }
281
+
282
+ const exact = stateBindings.find((entry) => String(entry?.key || '') === ident);
283
+ if (exact && typeof exact.key === 'string') {
284
+ return exact.key;
285
+ }
286
+
287
+ const suffix = `_${ident}`;
288
+ const matches = stateBindings
289
+ .map((entry) => String(entry?.key || ''))
290
+ .filter((key) => key.endsWith(suffix));
291
+
292
+ if (preferredKeys instanceof Set && preferredKeys.size > 0) {
293
+ const preferredMatches = matches.filter((key) => preferredKeys.has(key));
294
+ if (preferredMatches.length === 1) {
295
+ return preferredMatches[0];
296
+ }
297
+ }
298
+
299
+ if (matches.length === 1) {
300
+ return matches[0];
301
+ }
302
+
303
+ return null;
304
+ }
305
+
306
+ function rewriteRefBindingIdentifiers(pageIr, preferredKeys = null) {
307
+ if (!Array.isArray(pageIr?.ref_bindings) || pageIr.ref_bindings.length === 0) {
308
+ return;
309
+ }
310
+
311
+ const stateBindings = Array.isArray(pageIr?.hoisted?.state) ? pageIr.hoisted.state : [];
312
+ if (stateBindings.length === 0) {
313
+ return;
314
+ }
315
+
316
+ for (const binding of pageIr.ref_bindings) {
317
+ if (!binding || typeof binding !== 'object' || typeof binding.identifier !== 'string') {
318
+ continue;
319
+ }
320
+ const resolved = resolveStateKeyFromBindings(binding.identifier, stateBindings, preferredKeys);
321
+ if (resolved) {
322
+ binding.identifier = resolved;
323
+ }
324
+ }
325
+ }
326
+
247
327
  /**
248
328
  * Rewrite unresolved page expressions using component script-aware mappings.
249
329
  *
@@ -751,7 +831,7 @@ function collectComponentUsageAttrs(source, registry) {
751
831
  * @param {{ includeCode: boolean, cssImportsOnly: boolean, documentMode?: boolean, componentAttrs?: string }} options
752
832
  * @param {Set<string>} seenStaticImports
753
833
  */
754
- function mergeComponentIr(pageIr, compIr, compPath, pageFile, options, seenStaticImports) {
834
+ function mergeComponentIr(pageIr, compIr, compPath, pageFile, options, seenStaticImports, knownRefKeys = null) {
755
835
  // Merge components_scripts
756
836
  if (compIr.components_scripts) {
757
837
  for (const [hoistId, script] of Object.entries(compIr.components_scripts)) {
@@ -766,6 +846,17 @@ function mergeComponentIr(pageIr, compIr, compPath, pageFile, options, seenStati
766
846
  pageIr.component_instances.push(...compIr.component_instances);
767
847
  }
768
848
 
849
+ if (knownRefKeys instanceof Set && Array.isArray(compIr.ref_bindings)) {
850
+ const componentStateBindings = Array.isArray(compIr?.hoisted?.state) ? compIr.hoisted.state : [];
851
+ for (const binding of compIr.ref_bindings) {
852
+ if (!binding || typeof binding.identifier !== 'string' || binding.identifier.length === 0) {
853
+ continue;
854
+ }
855
+ const resolved = resolveStateKeyFromBindings(binding.identifier, componentStateBindings);
856
+ knownRefKeys.add(resolved || binding.identifier);
857
+ }
858
+ }
859
+
769
860
  // Merge hoisted imports (deduplicated, rebased to the page file path)
770
861
  if (compIr.hoisted?.imports?.length) {
771
862
  for (const imp of compIr.hoisted.imports) {
@@ -1253,16 +1344,32 @@ function deferComponentRuntimeBlock(source) {
1253
1344
  *
1254
1345
  * @param {object|object[]} envelope
1255
1346
  * @param {string} outDir
1347
+ * @param {string} projectRoot
1348
+ * @param {object | null} [logger]
1349
+ * @param {boolean} [showInfo]
1256
1350
  * @returns {Promise<void>}
1257
1351
  */
1258
- function runBundler(envelope, outDir) {
1352
+ function runBundler(envelope, outDir, projectRoot, logger = null, showInfo = true) {
1259
1353
  return new Promise((resolvePromise, rejectPromise) => {
1354
+ const useStructuredLogger = Boolean(logger && typeof logger.childLine === 'function');
1260
1355
  const child = spawn(
1261
1356
  getBundlerBin(),
1262
1357
  ['--out-dir', outDir],
1263
- { stdio: ['pipe', 'inherit', 'inherit'] }
1358
+ {
1359
+ cwd: projectRoot,
1360
+ stdio: useStructuredLogger ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'inherit', 'inherit']
1361
+ }
1264
1362
  );
1265
1363
 
1364
+ if (useStructuredLogger) {
1365
+ forwardStreamLines(child.stdout, (line) => {
1366
+ logger.childLine('bundler', line, { stream: 'stdout', showInfo });
1367
+ });
1368
+ forwardStreamLines(child.stderr, (line) => {
1369
+ logger.childLine('bundler', line, { stream: 'stderr', showInfo: true });
1370
+ });
1371
+ }
1372
+
1266
1373
  child.on('error', (err) => {
1267
1374
  rejectPromise(new Error(`Bundler spawn failed: ${err.message}`));
1268
1375
  });
@@ -1328,11 +1435,12 @@ async function collectAssets(rootDir) {
1328
1435
  * d. Merge component IRs into page IR
1329
1436
  * 3. Send all envelopes to bundler
1330
1437
  *
1331
- * @param {{ pagesDir: string, outDir: string, config?: object }} options
1438
+ * @param {{ pagesDir: string, outDir: string, config?: object, logger?: object | null, showBundlerInfo?: boolean }} options
1332
1439
  * @returns {Promise<{ pages: number, assets: string[] }>}
1333
1440
  */
1334
1441
  export async function build(options) {
1335
- const { pagesDir, outDir, config = {} } = options;
1442
+ const { pagesDir, outDir, config = {}, logger = null, showBundlerInfo = true } = options;
1443
+ const projectRoot = deriveProjectRootFromPagesDir(pagesDir);
1336
1444
  const softNavigationEnabled = config.softNavigation === true || config.router === true;
1337
1445
  const compilerOpts = {
1338
1446
  typescriptDefault: config.typescriptDefault === true,
@@ -1349,7 +1457,13 @@ export async function build(options) {
1349
1457
  // 1. Build component registry
1350
1458
  const registry = buildComponentRegistry(srcDir);
1351
1459
  if (registry.size > 0) {
1352
- console.log(`[zenith] Component registry: ${registry.size} components`);
1460
+ if (logger && typeof logger.build === 'function') {
1461
+ logger.build(`registry=${registry.size} components`, {
1462
+ onceKey: `component-registry:${registry.size}`
1463
+ });
1464
+ } else {
1465
+ console.log(`[zenith] Component registry: ${registry.size} components`);
1466
+ }
1353
1467
  }
1354
1468
 
1355
1469
  const manifest = await generateManifest(pagesDir);
@@ -1362,7 +1476,13 @@ export async function build(options) {
1362
1476
  const componentDocumentModeCache = new Map();
1363
1477
  /** @type {Map<string, { map: Map<string, string>, ambiguous: Set<string> }>} */
1364
1478
  const componentExpressionRewriteCache = new Map();
1365
- const emitCompilerWarning = createCompilerWarningEmitter((line) => console.warn(line));
1479
+ const emitCompilerWarning = createCompilerWarningEmitter((line) => {
1480
+ if (logger && typeof logger.warn === 'function') {
1481
+ logger.warn(line, { onceKey: `compiler-warning:${line}` });
1482
+ return;
1483
+ }
1484
+ console.warn(line);
1485
+ });
1366
1486
 
1367
1487
  const envelopes = [];
1368
1488
  for (const entry of manifest) {
@@ -1431,6 +1551,7 @@ export async function build(options) {
1431
1551
  const seenStaticImports = new Set();
1432
1552
  const pageExpressionRewriteMap = new Map();
1433
1553
  const pageAmbiguousExpressionMap = new Set();
1554
+ const knownRefKeys = new Set();
1434
1555
 
1435
1556
  // 2c. Compile each used component separately for its script IR
1436
1557
  for (const compName of usedComponents) {
@@ -1481,7 +1602,8 @@ export async function build(options) {
1481
1602
  documentMode: isDocMode,
1482
1603
  componentAttrs: (componentUsageAttrs.get(compName) || [])[0] || ''
1483
1604
  },
1484
- seenStaticImports
1605
+ seenStaticImports,
1606
+ knownRefKeys
1485
1607
  );
1486
1608
  }
1487
1609
 
@@ -1492,6 +1614,7 @@ export async function build(options) {
1492
1614
  );
1493
1615
 
1494
1616
  rewriteLegacyMarkupIdentifiers(pageIr);
1617
+ rewriteRefBindingIdentifiers(pageIr, knownRefKeys);
1495
1618
 
1496
1619
  envelopes.push({
1497
1620
  route: entry.path,
@@ -1502,7 +1625,7 @@ export async function build(options) {
1502
1625
  }
1503
1626
 
1504
1627
  if (envelopes.length > 0) {
1505
- await runBundler(envelopes, outDir);
1628
+ await runBundler(envelopes, outDir, projectRoot, logger, showBundlerInfo);
1506
1629
  }
1507
1630
 
1508
1631
  const assets = await collectAssets(outDir);
@@ -16,6 +16,7 @@ import { existsSync, watch } from 'node:fs';
16
16
  import { readFile, stat } from 'node:fs/promises';
17
17
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
18
18
  import { build } from './build.js';
19
+ import { createSilentLogger } from './ui/logger.js';
19
20
  import {
20
21
  executeServerRoute,
21
22
  injectSsrPayload,
@@ -41,7 +42,7 @@ const MIME_TYPES = {
41
42
  /**
42
43
  * Create and start a development server.
43
44
  *
44
- * @param {{ pagesDir: string, outDir: string, port?: number, host?: string, config?: object }} options
45
+ * @param {{ pagesDir: string, outDir: string, port?: number, host?: string, config?: object, logger?: object | null }} options
45
46
  * @returns {Promise<{ server: import('http').Server, port: number, close: () => void }>}
46
47
  */
47
48
  export async function createDevServer(options) {
@@ -50,8 +51,10 @@ export async function createDevServer(options) {
50
51
  outDir,
51
52
  port = 3000,
52
53
  host = '127.0.0.1',
53
- config = {}
54
+ config = {},
55
+ logger: providedLogger = null
54
56
  } = options;
57
+ const logger = providedLogger || createSilentLogger();
55
58
 
56
59
  const resolvedPagesDir = resolve(pagesDir);
57
60
  const resolvedOutDir = resolve(outDir);
@@ -83,6 +86,7 @@ export async function createDevServer(options) {
83
86
  let durationMs = 0;
84
87
  let buildError = null;
85
88
  const traceEnabled = config.devTrace === true || process.env.ZENITH_DEV_TRACE === '1';
89
+ const verboseLogging = traceEnabled || logger.mode?.logLevel === 'verbose';
86
90
 
87
91
  // Stable dev CSS endpoint points to this backing asset.
88
92
  let currentCssAssetPath = '';
@@ -104,8 +108,10 @@ export async function createDevServer(options) {
104
108
  function _trace(event, payload = {}) {
105
109
  if (!traceEnabled) return;
106
110
  try {
107
- const timestamp = new Date().toISOString();
108
- console.log(`[zenith-dev][${timestamp}] ${event} ${JSON.stringify(payload)}`);
111
+ const detail = Object.keys(payload).length > 0
112
+ ? `${event} ${JSON.stringify(payload)}`
113
+ : event;
114
+ logger.verbose('BUILD', detail);
109
115
  } catch {
110
116
  // tracing must never break the dev server
111
117
  }
@@ -243,11 +249,19 @@ export async function createDevServer(options) {
243
249
 
244
250
  // Initial build
245
251
  try {
246
- const initialBuild = await build({ pagesDir, outDir, config });
252
+ logger.build('Initial build (id=0)', { onceKey: 'dev-initial-build' });
253
+ const initialBuild = await build({ pagesDir, outDir, config, logger });
247
254
  await _syncCssStateFromBuild(initialBuild, buildId);
255
+ if (currentCssHref.length > 0) {
256
+ logger.css(`ready (${currentCssHref})`, { onceKey: `css-ready:${buildId}:${currentCssHref}` });
257
+ }
248
258
  } catch (err) {
249
259
  buildStatus = 'error';
250
260
  buildError = { message: err instanceof Error ? err.message : String(err) };
261
+ logger.error('initial build failed', {
262
+ hint: 'fix the error and restart dev',
263
+ error: err
264
+ });
251
265
  }
252
266
 
253
267
  const server = createServer(async (req, res) => {
@@ -265,7 +279,10 @@ export async function createDevServer(options) {
265
279
  'Connection': 'keep-alive',
266
280
  'X-Zenith-Deprecated': 'true'
267
281
  });
268
- console.warn('[zenith] Warning: /__zenith_hmr is legacy; use /__zenith_dev/events');
282
+ logger.warn('legacy HMR endpoint in use', {
283
+ hint: 'use /__zenith_dev/events',
284
+ onceKey: 'legacy-hmr-endpoint'
285
+ });
269
286
  res.write(': connected\n\n');
270
287
  hmrClients.push(res);
271
288
  req.on('close', () => {
@@ -448,7 +465,11 @@ export async function createDevServer(options) {
448
465
  let filePath = null;
449
466
 
450
467
  if (resolved.matched && resolved.route) {
451
- console.log(`[zenith] Request: ${pathname} | Route: ${resolved.route.path} | Params: ${JSON.stringify(resolved.params)}`);
468
+ if (verboseLogging) {
469
+ logger.router(
470
+ `${req.method || 'GET'} ${pathname} -> ${resolved.route.path} params=${JSON.stringify(resolved.params)}`
471
+ );
472
+ }
452
473
  const output = resolved.route.output.startsWith('/')
453
474
  ? resolved.route.output.slice(1)
454
475
  : resolved.route.output;
@@ -490,8 +511,11 @@ export async function createDevServer(options) {
490
511
 
491
512
  const trace = routeExecution?.trace || { guard: 'none', load: 'none' };
492
513
  const routeId = resolved.route.route_id || '';
493
- console.log(`[Zenith] guard(${routeId || resolved.route.path}) -> ${trace.guard}`);
494
- console.log(`[Zenith] load(${routeId || resolved.route.path}) -> ${trace.load}`);
514
+ if (verboseLogging) {
515
+ logger.router(
516
+ `${routeId || resolved.route.path} guard=${trace.guard} load=${trace.load}`
517
+ );
518
+ }
495
519
 
496
520
  const result = routeExecution?.result;
497
521
  if (result && result.kind === 'redirect') {
@@ -609,17 +633,25 @@ export async function createDevServer(options) {
609
633
  const cycleBuildId = pendingBuildId + 1;
610
634
  pendingBuildId = cycleBuildId;
611
635
  buildStatus = 'building';
636
+ logger.build(`Rebuild (id=${cycleBuildId})`);
612
637
  _broadcastEvent('build_start', { buildId: cycleBuildId, changedFiles: changed });
613
638
 
614
639
  const startTime = Date.now();
640
+ const previousCssAssetPath = currentCssAssetPath;
641
+ const previousCssContent = currentCssContent;
615
642
  try {
616
- const buildResult = await build({ pagesDir, outDir, config });
643
+ const buildResult = await build({ pagesDir, outDir, config, logger });
617
644
  const cssReady = await _syncCssStateFromBuild(buildResult, cycleBuildId);
645
+ const cssChanged = cssReady && (
646
+ currentCssAssetPath !== previousCssAssetPath ||
647
+ currentCssContent !== previousCssContent
648
+ );
618
649
  buildId = cycleBuildId;
619
650
  buildStatus = 'ok';
620
651
  buildError = null;
621
652
  lastBuildMs = Date.now();
622
653
  durationMs = lastBuildMs - startTime;
654
+ logger.build(`Complete (id=${cycleBuildId}, ${durationMs}ms)`);
623
655
 
624
656
  _broadcastEvent('build_complete', {
625
657
  buildId: cycleBuildId,
@@ -637,12 +669,23 @@ export async function createDevServer(options) {
637
669
  changedFiles: changed
638
670
  });
639
671
 
640
- const onlyCss = changed.length > 0 && changed.every((f) => f.endsWith('.css'));
641
- if (onlyCss && cssReady && currentCssHref.length > 0) {
642
- // Let the client fetch the updated CSS automatically
672
+ if (cssChanged && currentCssHref.length > 0) {
673
+ logger.css(`ready (${currentCssHref})`);
674
+ logger.hmr(`css_update (buildId=${cycleBuildId})`);
643
675
  _broadcastEvent('css_update', { href: currentCssHref, changedFiles: changed });
644
- } else {
676
+ }
677
+
678
+ const onlyCss = changed.length > 0 && changed.every((f) => f.endsWith('.css'));
679
+ if (!onlyCss) {
680
+ logger.hmr(`reload (buildId=${cycleBuildId})`);
645
681
  _broadcastEvent('reload', { changedFiles: changed });
682
+ } else {
683
+ _trace('css_only_update', {
684
+ buildId: cycleBuildId,
685
+ cssHref: currentCssHref,
686
+ cssChanged,
687
+ changedFiles: changed
688
+ });
646
689
  }
647
690
  } catch (err) {
648
691
  const fullError = err instanceof Error ? err.message : String(err);
@@ -650,6 +693,10 @@ export async function createDevServer(options) {
650
693
  buildError = { message: fullError.length > 10000 ? fullError.slice(0, 10000) + '... (truncated)' : fullError };
651
694
  lastBuildMs = Date.now();
652
695
  durationMs = lastBuildMs - startTime;
696
+ logger.error('rebuild failed', {
697
+ hint: 'fix the error and save again',
698
+ error: err
699
+ });
653
700
 
654
701
  _broadcastEvent('build_error', { buildId: cycleBuildId, ...buildError, changedFiles: changed });
655
702
  _trace('state_snapshot', {
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@
13
13
  import { resolve, join, dirname } from 'node:path';
14
14
  import { existsSync, readFileSync } from 'node:fs';
15
15
  import { fileURLToPath } from 'node:url';
16
- import { createLogger } from './ui/logger.js';
16
+ import { createZenithLogger } from './ui/logger.js';
17
17
 
18
18
  const COMMANDS = ['dev', 'build', 'preview'];
19
19
  const DEFAULT_VERSION = '0.0.0';
@@ -90,7 +90,7 @@ async function loadConfig(projectRoot) {
90
90
  * @param {string} [cwd] - Working directory override
91
91
  */
92
92
  export async function cli(args, cwd) {
93
- const logger = createLogger(process);
93
+ const logger = createZenithLogger(process);
94
94
  const command = args[0];
95
95
  const cliVersion = getCliVersion();
96
96
 
@@ -118,10 +118,10 @@ export async function cli(args, cwd) {
118
118
 
119
119
  if (command === 'build') {
120
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' }]);
121
+ logger.build('Building');
122
+ const result = await build({ pagesDir, outDir, config, logger, showBundlerInfo: false });
123
+ logger.ok(`Built ${result.pages} page(s), ${result.assets.length} asset(s)`);
124
+ logger.summary([{ label: 'Output', value: './dist' }], 'BUILD');
125
125
  }
126
126
 
127
127
  if (command === 'dev') {
@@ -130,9 +130,9 @@ export async function cli(args, cwd) {
130
130
  ? Number.parseInt(process.env.ZENITH_DEV_PORT, 10)
131
131
  : resolvePort(args.slice(1), 3000);
132
132
  const host = process.env.ZENITH_DEV_HOST || '127.0.0.1';
133
- logger.info('Starting dev server...');
134
- const dev = await createDevServer({ pagesDir, outDir, port, host, config });
135
- logger.success(`Dev server running at http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${dev.port}`);
133
+ logger.dev('Starting dev server');
134
+ const dev = await createDevServer({ pagesDir, outDir, port, host, config, logger });
135
+ logger.ok(`http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${dev.port}`);
136
136
 
137
137
  // Graceful shutdown
138
138
  process.on('SIGINT', () => {
@@ -149,9 +149,9 @@ export async function cli(args, cwd) {
149
149
  const { createPreviewServer } = await import('./preview.js');
150
150
  const port = resolvePort(args.slice(1), 4000);
151
151
  const host = process.env.ZENITH_PREVIEW_HOST || '127.0.0.1';
152
- logger.info('Starting preview server...');
153
- const preview = await createPreviewServer({ distDir: outDir, port, host });
154
- logger.success(`Preview server running at http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${preview.port}`);
152
+ logger.dev('Starting preview server');
153
+ const preview = await createPreviewServer({ distDir: outDir, port, host, logger });
154
+ logger.ok(`http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${preview.port}`);
155
155
 
156
156
  process.on('SIGINT', () => {
157
157
  preview.close();
@@ -172,7 +172,7 @@ const isDirectRun = process.argv[1] && (
172
172
 
173
173
  if (isDirectRun) {
174
174
  cli(process.argv.slice(2)).catch((error) => {
175
- const logger = createLogger(process);
175
+ const logger = createZenithLogger(process);
176
176
  logger.error(error);
177
177
  process.exit(1);
178
178
  });
package/dist/preview.js CHANGED
@@ -14,6 +14,7 @@ import { createServer } from 'node:http';
14
14
  import { access, readFile } from 'node:fs/promises';
15
15
  import { extname, join, normalize, resolve, sep, dirname } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
+ import { createSilentLogger } from './ui/logger.js';
17
18
  import {
18
19
  compareRouteSpecificity,
19
20
  matchRoute as matchManifestRoute,
@@ -386,11 +387,13 @@ try {
386
387
  /**
387
388
  * Create and start a preview server.
388
389
  *
389
- * @param {{ distDir: string, port?: number, host?: string }} options
390
+ * @param {{ distDir: string, port?: number, host?: string, logger?: object | null }} options
390
391
  * @returns {Promise<{ server: import('http').Server, port: number, close: () => void }>}
391
392
  */
392
393
  export async function createPreviewServer(options) {
393
- const { distDir, port = 4000, host = '127.0.0.1' } = options;
394
+ const { distDir, port = 4000, host = '127.0.0.1', logger: providedLogger = null } = options;
395
+ const logger = providedLogger || createSilentLogger();
396
+ const verboseLogging = logger.mode?.logLevel === 'verbose';
394
397
  let actualPort = port;
395
398
 
396
399
  function publicHost() {
@@ -502,7 +505,11 @@ export async function createPreviewServer(options) {
502
505
  let htmlPath = null;
503
506
 
504
507
  if (resolved.matched && resolved.route) {
505
- console.log(`[zenith] Request: ${url.pathname} | Route: ${resolved.route.path} | Params: ${JSON.stringify(resolved.params)}`);
508
+ if (verboseLogging) {
509
+ logger.router(
510
+ `${req.method || 'GET'} ${url.pathname} -> ${resolved.route.path} params=${JSON.stringify(resolved.params)}`
511
+ );
512
+ }
506
513
  const output = resolved.route.output.startsWith('/')
507
514
  ? resolved.route.output.slice(1)
508
515
  : resolved.route.output;
@@ -541,8 +548,9 @@ export async function createPreviewServer(options) {
541
548
 
542
549
  const trace = routeExecution?.trace || { guard: 'none', load: 'none' };
543
550
  const routeId = resolved.route.route_id || routeIdFromSourcePath(resolved.route.server_script_path || '');
544
- console.log(`[Zenith] guard(${routeId}) -> ${trace.guard}`);
545
- console.log(`[Zenith] load(${routeId}) -> ${trace.load}`);
551
+ if (verboseLogging) {
552
+ logger.router(`${routeId} guard=${trace.guard} load=${trace.load}`);
553
+ }
546
554
 
547
555
  const result = routeExecution?.result;
548
556
  if (result && result.kind === 'redirect') {
package/dist/ui/env.js CHANGED
@@ -10,6 +10,14 @@ function flagEnabled(value) {
10
10
  return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
11
11
  }
12
12
 
13
+ function parseLogLevel(value) {
14
+ const normalized = String(value || '').trim().toLowerCase();
15
+ if (normalized === 'quiet' || normalized === 'verbose') {
16
+ return normalized;
17
+ }
18
+ return 'normal';
19
+ }
20
+
13
21
  /**
14
22
  * @param {{ env?: Record<string, string | undefined>, stdout?: { isTTY?: boolean } }} runtime
15
23
  */
@@ -21,10 +29,17 @@ export function getUiMode(runtime = process) {
21
29
  const noColor = env.NO_COLOR !== undefined && String(env.NO_COLOR).length >= 0;
22
30
  const forceColor = flagEnabled(env.FORCE_COLOR);
23
31
  const debug = flagEnabled(env.ZENITH_DEBUG);
32
+ let logLevel = parseLogLevel(env.ZENITH_LOG_LEVEL);
24
33
 
25
34
  const plain = noUi || ci || !tty;
26
35
  const color = !plain && !noColor && (forceColor || tty);
27
36
  const spinner = tty && !plain && !ci;
37
+ if (flagEnabled(env.ZENITH_DEV_TRACE)) {
38
+ logLevel = 'verbose';
39
+ }
40
+ if (debug && logLevel !== 'quiet') {
41
+ logLevel = 'verbose';
42
+ }
28
43
 
29
44
  return {
30
45
  plain,
@@ -32,7 +47,8 @@ export function getUiMode(runtime = process) {
32
47
  tty,
33
48
  ci,
34
49
  spinner,
35
- debug
50
+ debug,
51
+ logLevel
36
52
  };
37
53
  }
38
54
 
package/dist/ui/format.js CHANGED
@@ -1,53 +1,78 @@
1
- /**
2
- * Deterministic text formatters for CLI UX.
3
- */
4
-
1
+ import pc from 'picocolors';
5
2
  import { relative, sep } from 'node:path';
6
3
 
7
- const ANSI = {
8
- reset: '\x1b[0m',
9
- bold: '\x1b[1m',
10
- dim: '\x1b[2m',
11
- red: '\x1b[31m',
12
- yellow: '\x1b[33m',
13
- green: '\x1b[32m',
14
- cyan: '\x1b[36m'
15
- };
16
4
  const DEFAULT_PHASE = 'cli';
17
5
  const DEFAULT_FILE = '.';
18
6
  const DEFAULT_HINT_BASE = 'https://github.com/zenithbuild/zenith/blob/main/zenith-cli/CLI_CONTRACT.md';
7
+ const PREFIX = '[zenith]';
8
+ const TAG_WIDTH = 6;
9
+
10
+ const TAG_COLORS = {
11
+ DEV: (colors, value) => colors.cyan(value),
12
+ BUILD: (colors, value) => colors.blue(value),
13
+ HMR: (colors, value) => colors.magenta(value),
14
+ ROUTER: (colors, value) => colors.cyan(value),
15
+ CSS: (colors, value) => colors.yellow(value),
16
+ OK: (colors, value) => colors.green(value),
17
+ WARN: (colors, value) => colors.bold(colors.yellow(value)),
18
+ ERR: (colors, value) => colors.bold(colors.red(value))
19
+ };
20
+
21
+ function getColors(mode) {
22
+ return pc.createColors(Boolean(mode?.color));
23
+ }
19
24
 
20
- function colorize(mode, token, text) {
25
+ export function formatPrefix(mode) {
26
+ return mode.color ? getColors(mode).dim(PREFIX) : PREFIX;
27
+ }
28
+
29
+ function colorizeTag(mode, tag) {
30
+ const padded = String(tag || '').padEnd(TAG_WIDTH, ' ');
21
31
  if (!mode.color) {
22
- return text;
32
+ return padded;
23
33
  }
24
- return `${ANSI[token]}${text}${ANSI.reset}`;
34
+ const colors = getColors(mode);
35
+ const colorizer = TAG_COLORS[tag] || ((_colors, value) => colors.white(value));
36
+ return colorizer(colors, padded);
25
37
  }
26
38
 
27
- export function formatHeading(mode, text) {
28
- const label = mode.plain ? 'ZENITH CLI' : colorize(mode, 'bold', 'Zenith CLI');
29
- return `${label} ${text}`.trim();
39
+ function colorizeGlyph(mode, glyph, tag) {
40
+ if (!mode.color) {
41
+ return glyph;
42
+ }
43
+ const colors = getColors(mode);
44
+ const colorizer = TAG_COLORS[tag] || ((_colors, value) => value);
45
+ return colorizer(colors, glyph);
30
46
  }
31
47
 
32
- export function formatStep(mode, text) {
33
- if (mode.plain) {
34
- return `[zenith] INFO: ${text}`;
35
- }
36
- const bullet = colorize(mode, 'cyan', '•');
37
- return `[zenith] ${bullet} ${text}`;
48
+ export function formatLine(mode, { glyph = '•', tag = 'DEV', text = '' }) {
49
+ return `${formatPrefix(mode)} ${colorizeGlyph(mode, glyph, tag)} ${colorizeTag(mode, tag)} ${String(text || '')}`;
38
50
  }
39
51
 
40
- export function formatSummaryTable(mode, rows) {
52
+ export function formatStep(mode, text, tag = 'BUILD') {
53
+ return formatLine(mode, { glyph: '•', tag, text });
54
+ }
55
+
56
+ export function formatHint(mode, text) {
57
+ const body = ` hint: ${String(text || '').trim()}`;
58
+ return mode.color ? getColors(mode).dim(body) : body;
59
+ }
60
+
61
+ export function formatHeading(mode, text) {
62
+ const label = mode.color ? getColors(mode).bold('Zenith CLI') : 'Zenith CLI';
63
+ return `${label} ${String(text || '').trim()}`.trim();
64
+ }
65
+
66
+ export function formatSummaryTable(mode, rows, tag = 'BUILD') {
41
67
  if (!Array.isArray(rows) || rows.length === 0) {
42
68
  return '';
43
69
  }
44
- const maxLabel = rows.reduce((acc, row) => Math.max(acc, String(row.label || '').length), 0);
45
70
  return rows
46
- .map((row) => {
47
- const label = String(row.label || '').padEnd(maxLabel, ' ');
48
- const value = String(row.value || '');
49
- return `[zenith] ${label} : ${value}`;
50
- })
71
+ .map((row) => formatLine(mode, {
72
+ glyph: '',
73
+ tag,
74
+ text: `${String(row.label || '')}: ${String(row.value || '')}`
75
+ }))
51
76
  .join('\n');
52
77
  }
53
78
 
@@ -131,36 +156,49 @@ export function normalizeError(err) {
131
156
  return new Error(sanitizeErrorMessage(err));
132
157
  }
133
158
 
159
+ function firstMeaningfulLine(text) {
160
+ return String(text || '')
161
+ .split('\n')
162
+ .map((line) => line.trim())
163
+ .find((line) => line.length > 0) || '';
164
+ }
165
+
134
166
  /**
135
167
  * @param {unknown} err
136
- * @param {{ plain: boolean, color: boolean, debug: boolean }} mode
168
+ * @param {{ plain: boolean, color: boolean, debug?: boolean, logLevel?: string }} mode
137
169
  */
138
170
  export function formatErrorBlock(err, mode) {
139
171
  const normalized = normalizeError(err);
140
172
  const maybe = /** @type {{ code?: unknown, phase?: unknown, kind?: unknown, file?: unknown, hint?: unknown }} */ (normalized);
141
- const kind = sanitizeErrorMessage(maybe.kind || maybe.code || 'CLI_ERROR');
142
173
  const phase = maybe.phase ? sanitizeErrorMessage(maybe.phase) : inferPhaseFromArgv();
143
174
  const code = maybe.code
144
175
  ? sanitizeErrorMessage(maybe.code)
145
176
  : `${phase.toUpperCase().replace(/[^A-Z0-9]+/g, '_') || 'CLI'}_FAILED`;
146
177
  const rawMessage = sanitizeErrorMessage(normalized.message || String(normalized));
147
178
  const message = normalizeErrorMessagePaths(rawMessage);
179
+ const compactMessage = firstMeaningfulLine(message) || 'Command failed';
148
180
  const file = normalizePathForDisplay(
149
181
  sanitizeErrorMessage(maybe.file || extractFileFromMessage(message) || DEFAULT_FILE)
150
182
  );
151
183
  const hint = sanitizeErrorMessage(maybe.hint || formatHintUrl(code));
152
184
 
185
+ if (mode.logLevel !== 'verbose' && !mode.debug) {
186
+ return [
187
+ formatLine(mode, { glyph: '✖', tag: 'ERR', text: compactMessage }),
188
+ formatHint(mode, hint)
189
+ ].join('\n');
190
+ }
191
+
153
192
  const lines = [];
154
- lines.push('[zenith] ERROR: Command failed');
155
- lines.push(`[zenith] Error Kind: ${kind}`);
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)}`);
160
- lines.push(`[zenith] Message: ${message}`);
193
+ lines.push(formatLine(mode, { glyph: '✖', tag: 'ERR', text: compactMessage }));
194
+ lines.push(formatHint(mode, hint || formatHintUrl(code)));
195
+ lines.push(`${formatPrefix(mode)} code: ${code || 'CLI_FAILED'}`);
196
+ lines.push(`${formatPrefix(mode)} phase: ${phase || DEFAULT_PHASE}`);
197
+ lines.push(`${formatPrefix(mode)} file: ${file || DEFAULT_FILE}`);
198
+ lines.push(`${formatPrefix(mode)} detail: ${message}`);
161
199
 
162
200
  if (mode.debug && normalized.stack) {
163
- lines.push('[zenith] Stack:');
201
+ lines.push(`${formatPrefix(mode)} stack:`);
164
202
  lines.push(...String(normalized.stack).split('\n').slice(0, 20));
165
203
  }
166
204
 
package/dist/ui/logger.js CHANGED
@@ -1,105 +1,270 @@
1
- import { formatErrorBlock, formatHeading, formatStep, formatSummaryTable } from './format.js';
1
+ import {
2
+ formatErrorBlock,
3
+ formatHeading,
4
+ formatHint,
5
+ formatLine,
6
+ formatSummaryTable
7
+ } from './format.js';
2
8
  import { getUiMode } from './env.js';
3
9
 
4
- const SPINNER_FRAMES = ['-', '\\', '|', '/'];
5
-
6
10
  function write(out, text) {
7
11
  out.write(`${text}\n`);
8
12
  }
9
13
 
10
- function createSpinner(mode, stderr) {
11
- if (!mode.spinner) {
14
+ const SILENT_MODE = {
15
+ plain: true,
16
+ color: false,
17
+ tty: false,
18
+ ci: true,
19
+ spinner: false,
20
+ debug: false,
21
+ logLevel: 'quiet'
22
+ };
23
+
24
+ function createNoopSpinner() {
25
+ return {
26
+ start() { },
27
+ update() { },
28
+ stop() { },
29
+ succeed() { },
30
+ fail() { }
31
+ };
32
+ }
33
+
34
+ function normalizeLevel(level) {
35
+ return level === 'quiet' || level === 'verbose' ? level : 'normal';
36
+ }
37
+
38
+ function shouldEmit(mode, tag) {
39
+ const level = normalizeLevel(mode.logLevel);
40
+ if (level === 'quiet') {
41
+ return tag === 'OK' || tag === 'WARN' || tag === 'ERR';
42
+ }
43
+ return true;
44
+ }
45
+
46
+ function createWriter(runtime, mode, sink = null) {
47
+ if (typeof sink === 'function') {
48
+ return sink;
49
+ }
50
+ return (stream, text) => {
51
+ const out = stream === 'stderr' ? runtime.stderr : runtime.stdout;
52
+ write(out, text);
53
+ };
54
+ }
55
+
56
+ function classifyChildLine(line) {
57
+ const trimmed = String(line || '').trim();
58
+ if (!trimmed) {
59
+ return null;
60
+ }
61
+
62
+ const vendorCache = trimmed.match(/^\[zenith\]\s+Vendor cache (hit|miss):\s+(.+)$/);
63
+ if (vendorCache) {
64
+ return {
65
+ tag: 'BUILD',
66
+ glyph: '•',
67
+ message: `vendor cache ${vendorCache[1]} (${vendorCache[2]})`,
68
+ onceKey: `vendor-cache:${vendorCache[1]}:${vendorCache[2]}`
69
+ };
70
+ }
71
+
72
+ const vendorBundle = trimmed.match(/^\[zenith\]\s+Vendor bundle:\s+(.+)$/);
73
+ if (vendorBundle) {
12
74
  return {
13
- start() { },
14
- update() { },
15
- stop() { },
16
- succeed() { },
17
- fail() { }
75
+ tag: 'BUILD',
76
+ glyph: '•',
77
+ message: `vendor bundle ${vendorBundle[1]}`,
78
+ onceKey: `vendor-bundle:${vendorBundle[1]}`
18
79
  };
19
80
  }
20
81
 
21
- let interval = null;
22
- let frame = 0;
23
- let message = '';
82
+ const bundler = trimmed.match(/^\[zenith-bundler\]\s*(.+)$/);
83
+ if (bundler) {
84
+ const message = bundler[1].trim();
85
+ const lower = message.toLowerCase();
86
+ if (lower.includes('warning')) {
87
+ return {
88
+ tag: 'WARN',
89
+ glyph: '⚠',
90
+ message,
91
+ onceKey: `bundler-warning:${message}`
92
+ };
93
+ }
94
+ if (lower.includes('error') || lower.includes('failed')) {
95
+ return {
96
+ tag: 'ERR',
97
+ glyph: '✖',
98
+ message
99
+ };
100
+ }
101
+ return {
102
+ tag: 'BUILD',
103
+ glyph: '•',
104
+ message
105
+ };
106
+ }
24
107
 
25
- const paint = () => {
26
- const current = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
27
- stderr.write(`\r[zenith] ${current} ${message}`);
28
- frame += 1;
29
- };
108
+ const zenith = trimmed.match(/^\[zenith\]\s+(.+)$/);
109
+ if (zenith) {
110
+ return {
111
+ tag: 'BUILD',
112
+ glyph: '•',
113
+ message: zenith[1].trim(),
114
+ onceKey: `zenith-child:${zenith[1].trim()}`
115
+ };
116
+ }
30
117
 
31
- const clear = () => {
32
- stderr.write('\r');
33
- stderr.write(' '.repeat(message.length + 12));
34
- stderr.write('\r');
35
- };
118
+ const compilerWarning = trimmed.match(/warning\[[^\]]+\]/i);
119
+ if (compilerWarning) {
120
+ return {
121
+ tag: 'WARN',
122
+ glyph: '⚠',
123
+ message: trimmed,
124
+ onceKey: `compiler-warning:${trimmed}`
125
+ };
126
+ }
36
127
 
37
128
  return {
38
- start(nextMessage) {
39
- message = String(nextMessage || '');
40
- clearInterval(interval);
41
- frame = 0;
42
- paint();
43
- interval = setInterval(paint, 80);
44
- },
45
- update(nextMessage) {
46
- message = String(nextMessage || '');
47
- },
48
- stop() {
49
- clearInterval(interval);
50
- interval = null;
51
- clear();
52
- },
53
- succeed(nextMessage) {
54
- this.stop();
55
- write(stderr, `[zenith] OK: ${nextMessage}`);
56
- },
57
- fail(nextMessage) {
58
- this.stop();
59
- write(stderr, `[zenith] ERROR: ${nextMessage}`);
60
- }
129
+ tag: 'BUILD',
130
+ glyph: '',
131
+ message: trimmed
61
132
  };
62
133
  }
63
134
 
64
- /**
65
- * @param {NodeJS.Process} runtime
66
- */
67
- export function createLogger(runtime = process) {
68
- const mode = getUiMode(runtime);
69
- const stdout = runtime.stdout;
70
- const stderr = runtime.stderr;
71
- const spinner = createSpinner(mode, stderr);
135
+ function createBaseLogger({ runtime = process, mode, sink = null, silent = false } = {}) {
136
+ const resolvedMode = mode || (silent ? SILENT_MODE : getUiMode(runtime));
137
+ const once = new Set();
138
+ const writeLine = createWriter(runtime, resolvedMode, sink);
139
+
140
+ function emit(tag, glyph, message, options = {}) {
141
+ if (options.onceKey) {
142
+ if (once.has(options.onceKey)) {
143
+ return false;
144
+ }
145
+ once.add(options.onceKey);
146
+ }
147
+
148
+ if (!shouldEmit(resolvedMode, tag)) {
149
+ return false;
150
+ }
151
+
152
+ const stream = tag === 'WARN' || tag === 'ERR' ? 'stderr' : 'stdout';
153
+ writeLine(stream, formatLine(resolvedMode, { glyph, tag, text: message }));
154
+ if (options.hint) {
155
+ writeLine(stream, formatHint(resolvedMode, options.hint));
156
+ }
157
+ return true;
158
+ }
72
159
 
73
160
  return {
74
- mode,
75
- spinner,
161
+ mode: resolvedMode,
162
+ spinner: createNoopSpinner(),
76
163
  heading(text) {
77
- write(stdout, formatHeading(mode, text));
164
+ writeLine('stdout', formatHeading(resolvedMode, text));
165
+ },
166
+ print(text) {
167
+ writeLine('stdout', String(text));
78
168
  },
79
- info(text) {
80
- if (mode.plain) {
81
- write(stdout, `[zenith] INFO: ${text}`);
82
- return;
169
+ summary(rows, tag = 'BUILD') {
170
+ const table = formatSummaryTable(resolvedMode, rows, tag);
171
+ if (table) {
172
+ writeLine('stdout', table);
83
173
  }
84
- write(stdout, formatStep(mode, text));
85
174
  },
86
- success(text) {
87
- write(stdout, `[zenith] OK: ${text}`);
175
+ dev(message, options = {}) {
176
+ return emit('DEV', '•', message, options);
88
177
  },
89
- warn(text) {
90
- write(stderr, `[zenith] WARN: ${text}`);
178
+ build(message, options = {}) {
179
+ return emit('BUILD', '•', message, options);
91
180
  },
92
- error(err) {
93
- write(stderr, formatErrorBlock(err, mode));
181
+ hmr(message, options = {}) {
182
+ return emit('HMR', '•', message, options);
94
183
  },
95
- summary(rows) {
96
- const table = formatSummaryTable(mode, rows);
97
- if (table) {
98
- write(stdout, table);
184
+ router(message, options = {}) {
185
+ return emit('ROUTER', '•', message, options);
186
+ },
187
+ css(message, options = {}) {
188
+ return emit('CSS', '•', message, options);
189
+ },
190
+ ok(message, options = {}) {
191
+ return emit('OK', '✓', message, options);
192
+ },
193
+ warn(message, options = {}) {
194
+ return emit('WARN', '⚠', message, options);
195
+ },
196
+ error(messageOrError, options = {}) {
197
+ const hasStructuredError = messageOrError instanceof Error || typeof messageOrError === 'object';
198
+ if (hasStructuredError && !options.hint && !options.onceKey && !options.error) {
199
+ writeLine('stderr', formatErrorBlock(messageOrError, resolvedMode));
200
+ return true;
201
+ }
202
+ const detail = options.error || messageOrError;
203
+ const formatted = detail instanceof Error || typeof detail === 'object'
204
+ ? formatErrorBlock(detail, resolvedMode)
205
+ : null;
206
+ if (formatted && (resolvedMode.logLevel === 'verbose' || resolvedMode.debug)) {
207
+ writeLine('stderr', formatted);
208
+ return true;
99
209
  }
210
+ const text = typeof messageOrError === 'string'
211
+ ? messageOrError
212
+ : (detail instanceof Error ? detail.message : String(detail || 'Command failed'));
213
+ return emit('ERR', '✖', text, options);
100
214
  },
101
- print(text) {
102
- write(stdout, String(text));
215
+ verbose(tag, message, options = {}) {
216
+ if (resolvedMode.logLevel !== 'verbose') {
217
+ return false;
218
+ }
219
+ return emit(tag, '•', message, options);
220
+ },
221
+ childLine(source, line, options = {}) {
222
+ const entry = classifyChildLine(line);
223
+ if (!entry) {
224
+ return false;
225
+ }
226
+ const streamHint = options.stream === 'stderr';
227
+ const isVerbose = resolvedMode.logLevel === 'verbose';
228
+ const isSeverity = entry.tag === 'WARN' || entry.tag === 'ERR';
229
+ if (!isVerbose && !isSeverity && options.showInfo === false) {
230
+ return false;
231
+ }
232
+ const onceKey = options.onceKey || entry.onceKey;
233
+ const message = options.prefix
234
+ ? `${options.prefix}${entry.message}`
235
+ : entry.message;
236
+ return emit(entry.tag, entry.glyph, message, {
237
+ ...options,
238
+ onceKey,
239
+ hint: options.hint,
240
+ stream: streamHint ? 'stderr' : undefined
241
+ });
242
+ },
243
+ info(message) {
244
+ return emit('DEV', '•', message);
245
+ },
246
+ success(message) {
247
+ return emit('OK', '✓', message);
103
248
  }
104
249
  };
105
250
  }
251
+
252
+ export function createZenithLogger(runtime = process, options = {}) {
253
+ const mode = getUiMode(runtime);
254
+ if (options.logLevel) {
255
+ mode.logLevel = normalizeLevel(options.logLevel);
256
+ }
257
+ return createBaseLogger({ runtime, mode });
258
+ }
259
+
260
+ export function createSilentLogger() {
261
+ return createBaseLogger({
262
+ mode: SILENT_MODE,
263
+ sink: () => { },
264
+ silent: true
265
+ });
266
+ }
267
+
268
+ export function createLogger(runtime = process, options = {}) {
269
+ return createZenithLogger(runtime, options);
270
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenithbuild/cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Deterministic project orchestrator for Zenith framework",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,7 +24,8 @@
24
24
  "prepublishOnly": "npm run build"
25
25
  },
26
26
  "dependencies": {
27
- "@zenithbuild/compiler": "0.6.0"
27
+ "@zenithbuild/compiler": "0.6.2",
28
+ "picocolors": "^1.1.1"
28
29
  },
29
30
  "devDependencies": {
30
31
  "@jest/globals": "^30.2.0",