@zenithbuild/cli 0.5.0-beta.2.6 → 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.
package/README.md CHANGED
@@ -5,6 +5,11 @@
5
5
 
6
6
  The command-line interface for developing and building Zenith applications.
7
7
 
8
+ ## Canonical Docs
9
+
10
+ - CLI contract: `../zenith-docs/documentation/cli-contract.md`
11
+ - Script server/data contract: `../zenith-docs/documentation/contracts/server-data.md`
12
+
8
13
  ## Overview
9
14
 
10
15
  `@zenithbuild/cli` provides the toolchain needed to manage Zenith projects. While `create-zenith` is for scaffolding, this CLI is for the daily development loop: serving apps, building for production, and managing plugins.
package/dist/build.js CHANGED
@@ -15,7 +15,7 @@ import { spawn, spawnSync } from 'node:child_process';
15
15
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
16
16
  import { mkdir, readdir, rm, stat } from 'node:fs/promises';
17
17
  import { createRequire } from 'node:module';
18
- import { basename, dirname, join, relative, resolve } from 'node:path';
18
+ import { basename, dirname, extname, join, relative, resolve } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { generateManifest } from './manifest.js';
21
21
  import { buildComponentRegistry, expandComponents, extractTemplate, isDocumentMode } from './resolve-components.js';
@@ -63,10 +63,34 @@ const COMPILER_BIN = resolveBinary([
63
63
  resolve(CLI_ROOT, '../zenith-compiler/target/release/zenith-compiler')
64
64
  ]);
65
65
 
66
- const BUNDLER_BIN = resolveBinary([
67
- resolve(CLI_ROOT, '../bundler/target/release/zenith-bundler'),
68
- resolve(CLI_ROOT, '../zenith-bundler/target/release/zenith-bundler')
69
- ]);
66
+ function getBundlerBin() {
67
+ const envBin = process.env.ZENITH_BUNDLER_BIN;
68
+ if (envBin && typeof envBin === 'string' && existsSync(envBin)) {
69
+ return envBin;
70
+ }
71
+ return resolveBinary([
72
+ resolve(CLI_ROOT, '../bundler/target/release/zenith-bundler'),
73
+ resolve(CLI_ROOT, '../zenith-bundler/target/release/zenith-bundler')
74
+ ]);
75
+ }
76
+
77
+ /**
78
+ * Build a per-build warning emitter that deduplicates repeated compiler lines.
79
+ *
80
+ * @param {(line: string) => void} sink
81
+ * @returns {(line: string) => void}
82
+ */
83
+ export function createCompilerWarningEmitter(sink = (line) => console.warn(line)) {
84
+ const emitted = new Set();
85
+ return (line) => {
86
+ const text = String(line || '').trim();
87
+ if (!text || emitted.has(text)) {
88
+ return;
89
+ }
90
+ emitted.add(text);
91
+ sink(text);
92
+ };
93
+ }
70
94
 
71
95
  /**
72
96
  * Run the compiler process and parse its JSON stdout.
@@ -77,15 +101,21 @@ const BUNDLER_BIN = resolveBinary([
77
101
  *
78
102
  * @param {string} filePath — path for diagnostics (and file reading when no stdinSource)
79
103
  * @param {string} [stdinSource] — if provided, piped to compiler via stdin
104
+ * @param {object} compilerRunOptions
105
+ * @param {(warning: string) => void} [compilerRunOptions.onWarning]
106
+ * @param {boolean} [compilerRunOptions.suppressWarnings]
80
107
  * @returns {object}
81
108
  */
82
- function runCompiler(filePath, stdinSource, compilerOpts = {}) {
109
+ function runCompiler(filePath, stdinSource, compilerOpts = {}, compilerRunOptions = {}) {
83
110
  const args = stdinSource !== undefined
84
111
  ? ['--stdin', filePath]
85
112
  : [filePath];
86
113
  if (compilerOpts?.experimentalEmbeddedMarkup) {
87
114
  args.push('--embedded-markup-expressions');
88
115
  }
116
+ if (compilerOpts?.strictDomLints) {
117
+ args.push('--strict-dom-lints');
118
+ }
89
119
  const opts = { encoding: 'utf8' };
90
120
  if (stdinSource !== undefined) {
91
121
  opts.input = stdinSource;
@@ -102,6 +132,20 @@ function runCompiler(filePath, stdinSource, compilerOpts = {}) {
102
132
  );
103
133
  }
104
134
 
135
+ if (result.stderr && result.stderr.trim().length > 0 && compilerRunOptions.suppressWarnings !== true) {
136
+ const lines = String(result.stderr)
137
+ .split('\n')
138
+ .map((line) => line.trim())
139
+ .filter((line) => line.length > 0);
140
+ for (const line of lines) {
141
+ if (typeof compilerRunOptions.onWarning === 'function') {
142
+ compilerRunOptions.onWarning(line);
143
+ } else {
144
+ console.warn(line);
145
+ }
146
+ }
147
+ }
148
+
105
149
  try {
106
150
  return JSON.parse(result.stdout);
107
151
  } catch (err) {
@@ -143,7 +187,7 @@ function buildComponentExpressionRewrite(compPath, componentSource, compIr, comp
143
187
 
144
188
  let templateIr;
145
189
  try {
146
- templateIr = runCompiler(compPath, templateOnly, compilerOpts);
190
+ templateIr = runCompiler(compPath, templateOnly, compilerOpts, { suppressWarnings: true });
147
191
  } catch {
148
192
  return out;
149
193
  }
@@ -232,6 +276,9 @@ function applyExpressionRewrites(pageIr, expressionMap, ambiguous) {
232
276
  bindings[index].literal === current
233
277
  ) {
234
278
  bindings[index].literal = rewritten;
279
+ if (bindings[index].compiled_expr === current) {
280
+ bindings[index].compiled_expr = rewritten;
281
+ }
235
282
  }
236
283
  }
237
284
  }
@@ -271,6 +318,15 @@ function rewriteLegacyMarkupIdentifiers(pageIr) {
271
318
  _LEGACY_MARKUP_RE.lastIndex = 0;
272
319
  bindings[i].literal = bindings[i].literal.replace(_LEGACY_MARKUP_RE, '__ZENITH_INTERNAL_ZENHTML');
273
320
  }
321
+ if (
322
+ bindings[i] &&
323
+ typeof bindings[i] === 'object' &&
324
+ typeof bindings[i].compiled_expr === 'string' &&
325
+ bindings[i].compiled_expr.includes(_LEGACY_MARKUP_IDENT)
326
+ ) {
327
+ _LEGACY_MARKUP_RE.lastIndex = 0;
328
+ bindings[i].compiled_expr = bindings[i].compiled_expr.replace(_LEGACY_MARKUP_RE, '__ZENITH_INTERNAL_ZENHTML');
329
+ }
274
330
  }
275
331
  }
276
332
 
@@ -452,7 +508,7 @@ function extractServerScript(source, sourceFile, compilerOpts = {}) {
452
508
  const scriptRe = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
453
509
  const serverMatches = [];
454
510
  const reservedServerExportRe =
455
- /\bexport\s+const\s+(?:data|prerender)\b|\bexport\s+(?:async\s+)?function\s+load\s*\(|\bexport\s+const\s+load\s*=/;
511
+ /\bexport\s+const\s+(?:data|prerender|guard|load)\b|\bexport\s+(?:async\s+)?function\s+(?:load|guard)\s*\(|\bexport\s+const\s+(?:load|guard)\s*=/;
456
512
 
457
513
  for (const match of source.matchAll(scriptRe)) {
458
514
  const attrs = String(match[1] || '');
@@ -463,8 +519,8 @@ function extractServerScript(source, sourceFile, compilerOpts = {}) {
463
519
  throw new Error(
464
520
  `Zenith server script contract violation:\n` +
465
521
  ` File: ${sourceFile}\n` +
466
- ` Reason: data/load/prerender exports are only allowed in <script server lang="ts">\n` +
467
- ` Example: move export const data or export const load into <script server lang="ts">`
522
+ ` Reason: guard/load/data exports are only allowed in <script server lang="ts"> or adjacent .guard.ts / .load.ts files\n` +
523
+ ` Example: move the export into <script server lang="ts">`
468
524
  );
469
525
  }
470
526
 
@@ -535,6 +591,25 @@ function extractServerScript(source, sourceFile, compilerOpts = {}) {
535
591
  );
536
592
  }
537
593
 
594
+ const guardFnMatch = serverSource.match(/\bexport\s+(?:async\s+)?function\s+guard\s*\(([^)]*)\)/);
595
+ const guardConstParenMatch = serverSource.match(/\bexport\s+const\s+guard\s*=\s*(?:async\s*)?\(([^)]*)\)\s*=>/);
596
+ const guardConstSingleArgMatch = serverSource.match(
597
+ /\bexport\s+const\s+guard\s*=\s*(?:async\s*)?([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=>/
598
+ );
599
+ const hasGuard = Boolean(guardFnMatch || guardConstParenMatch || guardConstSingleArgMatch);
600
+ const guardMatchCount =
601
+ Number(Boolean(guardFnMatch)) +
602
+ Number(Boolean(guardConstParenMatch)) +
603
+ Number(Boolean(guardConstSingleArgMatch));
604
+ if (guardMatchCount > 1) {
605
+ throw new Error(
606
+ `Zenith server script contract violation:\n` +
607
+ ` File: ${sourceFile}\n` +
608
+ ` Reason: multiple guard exports detected\n` +
609
+ ` Example: keep exactly one export const guard = async (ctx) => ({ ... })`
610
+ );
611
+ }
612
+
538
613
  const hasData = /\bexport\s+const\s+data\b/.test(serverSource);
539
614
  const hasSsrData = /\bexport\s+const\s+ssr_data\b/.test(serverSource);
540
615
  const hasSsr = /\bexport\s+const\s+ssr\b/.test(serverSource);
@@ -575,6 +650,24 @@ function extractServerScript(source, sourceFile, compilerOpts = {}) {
575
650
  }
576
651
  }
577
652
 
653
+ if (hasGuard) {
654
+ const singleArg = String(guardConstSingleArgMatch?.[1] || '').trim();
655
+ const paramsText = String((guardFnMatch || guardConstParenMatch)?.[1] || '').trim();
656
+ const arity = singleArg
657
+ ? 1
658
+ : paramsText.length === 0
659
+ ? 0
660
+ : paramsText.split(',').length;
661
+ if (arity !== 1) {
662
+ throw new Error(
663
+ `Zenith server script contract violation:\n` +
664
+ ` File: ${sourceFile}\n` +
665
+ ` Reason: guard(ctx) must accept exactly one argument\n` +
666
+ ` Example: export const guard = async (ctx) => ({ ... })`
667
+ );
668
+ }
669
+ }
670
+
578
671
  const prerenderMatch = serverSource.match(/\bexport\s+const\s+prerender\s*=\s*([^\n;]+)/);
579
672
  let prerender = false;
580
673
  if (prerenderMatch) {
@@ -596,6 +689,8 @@ function extractServerScript(source, sourceFile, compilerOpts = {}) {
596
689
  serverScript: {
597
690
  source: serverSource,
598
691
  prerender,
692
+ has_guard: hasGuard,
693
+ has_load: hasLoad,
599
694
  source_path: sourceFile
600
695
  }
601
696
  };
@@ -609,6 +704,8 @@ function extractServerScript(source, sourceFile, compilerOpts = {}) {
609
704
  serverScript: {
610
705
  source: serverSource,
611
706
  prerender,
707
+ has_guard: hasGuard,
708
+ has_load: hasLoad,
612
709
  source_path: sourceFile
613
710
  }
614
711
  };
@@ -741,11 +838,7 @@ function mergeComponentIr(pageIr, compIr, compPath, pageFile, options, seenStati
741
838
  : rebased;
742
839
  const withPropsPrelude = injectPropsPrelude(filteredImports, options.componentAttrs || '');
743
840
  const transpiled = transpileTypeScriptToJs(withPropsPrelude, compPath);
744
- const withRefFallbacks = injectRefFallbacksInZenMount(
745
- transpiled,
746
- options.refFallbacks || []
747
- );
748
- const deduped = dedupeStaticImportsInSource(withRefFallbacks, seenStaticImports);
841
+ const deduped = dedupeStaticImportsInSource(transpiled, seenStaticImports);
749
842
  const deferred = deferComponentRuntimeBlock(deduped);
750
843
  if (deferred.trim().length > 0 && !pageIr.hoisted.code.includes(deferred)) {
751
844
  pageIr.hoisted.code.push(deferred);
@@ -868,7 +961,7 @@ function transpileTypeScriptToJs(source, sourceFile) {
868
961
  fileName: sourceFile,
869
962
  compilerOptions: {
870
963
  module: ts.ModuleKind.ESNext,
871
- target: ts.ScriptTarget.ES2022,
964
+ target: ts.ScriptTarget.ES5,
872
965
  importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Preserve,
873
966
  verbatimModuleSyntax: true,
874
967
  newLine: ts.NewLineKind.LineFeed,
@@ -1103,7 +1196,7 @@ function injectPropsPrelude(source, attrs) {
1103
1196
  }
1104
1197
 
1105
1198
  const propsLiteral = renderPropsLiteralFromAttrs(attrs);
1106
- return `const props = ${propsLiteral};\n${source}`;
1199
+ return `var props = ${propsLiteral};\n${source}`;
1107
1200
  }
1108
1201
 
1109
1202
  /**
@@ -1155,96 +1248,6 @@ function deferComponentRuntimeBlock(source) {
1155
1248
  return wrapped;
1156
1249
  }
1157
1250
 
1158
- /**
1159
- * @param {string} componentSource
1160
- * @returns {Array<{ identifier: string, selector: string }>}
1161
- */
1162
- function extractRefFallbackAssignments(componentSource) {
1163
- const template = extractTemplate(componentSource);
1164
- const tagRe = /<[^>]*ref=\{([A-Za-z_$][A-Za-z0-9_$]*)\}[^>]*>/g;
1165
- const out = [];
1166
- const seen = new Set();
1167
-
1168
- let match;
1169
- while ((match = tagRe.exec(template)) !== null) {
1170
- const tag = match[0];
1171
- const identifier = match[1];
1172
- const attrMatch = tag.match(/\b(data-[a-z0-9-]+-runtime)\b/i)
1173
- || tag.match(/\b(data-[a-z0-9-]+)\b/i);
1174
- if (!attrMatch) {
1175
- continue;
1176
- }
1177
-
1178
- const selector = `[${attrMatch[1]}]`;
1179
- const key = `${identifier}:${selector}`;
1180
- if (seen.has(key)) {
1181
- continue;
1182
- }
1183
- seen.add(key);
1184
- out.push({ identifier, selector });
1185
- }
1186
-
1187
- return out;
1188
- }
1189
-
1190
- /**
1191
- * @param {string} source
1192
- * @param {string} originalIdentifier
1193
- * @returns {string | null}
1194
- */
1195
- function resolveRenamedRefIdentifier(source, originalIdentifier) {
1196
- const re = /const\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*ref\s*\(/g;
1197
- let match;
1198
- while ((match = re.exec(source)) !== null) {
1199
- const candidate = match[1];
1200
- if (candidate === originalIdentifier || candidate.endsWith(`_${originalIdentifier}`)) {
1201
- return candidate;
1202
- }
1203
- }
1204
- return null;
1205
- }
1206
-
1207
- /**
1208
- * @param {string} source
1209
- * @param {Array<{ identifier: string, selector: string }>} refFallbacks
1210
- * @returns {string}
1211
- */
1212
- function injectRefFallbacksInZenMount(source, refFallbacks) {
1213
- if (!Array.isArray(refFallbacks) || refFallbacks.length === 0) {
1214
- return source;
1215
- }
1216
-
1217
- const lines = [];
1218
- for (let i = 0; i < refFallbacks.length; i++) {
1219
- const fallback = refFallbacks[i];
1220
- const refIdentifier = resolveRenamedRefIdentifier(source, fallback.identifier);
1221
- if (!refIdentifier) {
1222
- continue;
1223
- }
1224
- const selector = JSON.stringify(fallback.selector);
1225
- const nodeVar = `__zenith_ref_node_${i}`;
1226
- lines.push(
1227
- ` if (typeof document !== 'undefined' && ${refIdentifier} && !${refIdentifier}.current) {`,
1228
- ` const ${nodeVar} = document.querySelector(${selector});`,
1229
- ` if (${nodeVar}) {`,
1230
- ` ${refIdentifier}.current = ${nodeVar};`,
1231
- ' }',
1232
- ' }'
1233
- );
1234
- }
1235
-
1236
- if (lines.length === 0) {
1237
- return source;
1238
- }
1239
-
1240
- const mountRe = /zenMount\s*\(\s*\([^)]*\)\s*=>\s*\{/;
1241
- if (!mountRe.test(source)) {
1242
- return source;
1243
- }
1244
-
1245
- return source.replace(mountRe, (match) => `${match}\n${lines.join('\n')}\n`);
1246
- }
1247
-
1248
1251
  /**
1249
1252
  * Run bundler process for one page envelope.
1250
1253
  *
@@ -1255,7 +1258,7 @@ function injectRefFallbacksInZenMount(source, refFallbacks) {
1255
1258
  function runBundler(envelope, outDir) {
1256
1259
  return new Promise((resolvePromise, rejectPromise) => {
1257
1260
  const child = spawn(
1258
- BUNDLER_BIN,
1261
+ getBundlerBin(),
1259
1262
  ['--out-dir', outDir],
1260
1263
  { stdio: ['pipe', 'inherit', 'inherit'] }
1261
1264
  );
@@ -1333,7 +1336,8 @@ export async function build(options) {
1333
1336
  const softNavigationEnabled = config.softNavigation === true || config.router === true;
1334
1337
  const compilerOpts = {
1335
1338
  typescriptDefault: config.typescriptDefault === true,
1336
- experimentalEmbeddedMarkup: config.embeddedMarkupExpressions === true || config.experimental?.embeddedMarkupExpressions === true
1339
+ experimentalEmbeddedMarkup: config.embeddedMarkupExpressions === true || config.experimental?.embeddedMarkupExpressions === true,
1340
+ strictDomLints: config.strictDomLints === true
1337
1341
  };
1338
1342
 
1339
1343
  await rm(outDir, { recursive: true, force: true });
@@ -1356,10 +1360,9 @@ export async function build(options) {
1356
1360
  const componentIrCache = new Map();
1357
1361
  /** @type {Map<string, boolean>} */
1358
1362
  const componentDocumentModeCache = new Map();
1359
- /** @type {Map<string, Array<{ identifier: string, selector: string }>>} */
1360
- const componentRefFallbackCache = new Map();
1361
1363
  /** @type {Map<string, { map: Map<string, string>, ambiguous: Set<string> }>} */
1362
1364
  const componentExpressionRewriteCache = new Map();
1365
+ const emitCompilerWarning = createCompilerWarningEmitter((line) => console.warn(line));
1363
1366
 
1364
1367
  const envelopes = [];
1365
1368
  for (const entry of manifest) {
@@ -1367,6 +1370,14 @@ export async function build(options) {
1367
1370
  const rawSource = readFileSync(sourceFile, 'utf8');
1368
1371
  const componentUsageAttrs = collectComponentUsageAttrs(rawSource, registry);
1369
1372
 
1373
+ const baseName = sourceFile.slice(0, -extname(sourceFile).length);
1374
+ let adjacentGuard = null;
1375
+ let adjacentLoad = null;
1376
+ for (const ext of ['.ts', '.js']) {
1377
+ if (!adjacentGuard && existsSync(`${baseName}.guard${ext}`)) adjacentGuard = `${baseName}.guard${ext}`;
1378
+ if (!adjacentLoad && existsSync(`${baseName}.load${ext}`)) adjacentLoad = `${baseName}.load${ext}`;
1379
+ }
1380
+
1370
1381
  // 2a. Expand PascalCase component tags
1371
1382
  const { expandedSource, usedComponents } = expandComponents(
1372
1383
  rawSource, registry, sourceFile
@@ -1375,7 +1386,16 @@ export async function build(options) {
1375
1386
  const compileSource = extractedServer.source;
1376
1387
 
1377
1388
  // 2b. Compile expanded page source via --stdin
1378
- const pageIr = runCompiler(sourceFile, compileSource, compilerOpts);
1389
+ const pageIr = runCompiler(
1390
+ sourceFile,
1391
+ compileSource,
1392
+ compilerOpts,
1393
+ { onWarning: emitCompilerWarning }
1394
+ );
1395
+
1396
+ const hasGuard = (extractedServer.serverScript && extractedServer.serverScript.has_guard) || adjacentGuard !== null;
1397
+ const hasLoad = (extractedServer.serverScript && extractedServer.serverScript.has_load) || adjacentLoad !== null;
1398
+
1379
1399
  if (extractedServer.serverScript) {
1380
1400
  pageIr.server_script = extractedServer.serverScript;
1381
1401
  pageIr.prerender = extractedServer.serverScript.prerender === true;
@@ -1384,6 +1404,20 @@ export async function build(options) {
1384
1404
  }
1385
1405
  }
1386
1406
 
1407
+ // Static Build Route Protection Policy
1408
+ if (pageIr.prerender === true && (hasGuard || hasLoad)) {
1409
+ throw new Error(
1410
+ `[zenith] Build failed for ${entry.file}: protected routes require SSR/runtime. ` +
1411
+ `Cannot prerender a static route with a \`guard\` or \`load\` function.`
1412
+ );
1413
+ }
1414
+
1415
+ // Apply metadata to IR
1416
+ pageIr.has_guard = hasGuard;
1417
+ pageIr.has_load = hasLoad;
1418
+ pageIr.guard_module_ref = adjacentGuard ? relative(srcDir, adjacentGuard).replaceAll('\\', '/') : null;
1419
+ pageIr.load_module_ref = adjacentLoad ? relative(srcDir, adjacentLoad).replaceAll('\\', '/') : null;
1420
+
1387
1421
  // Ensure IR has required array fields for merging
1388
1422
  pageIr.components_scripts = pageIr.components_scripts || {};
1389
1423
  pageIr.component_instances = pageIr.component_instances || [];
@@ -1409,17 +1443,19 @@ export async function build(options) {
1409
1443
  compIr = componentIrCache.get(compPath);
1410
1444
  } else {
1411
1445
  const componentCompileSource = stripStyleBlocks(componentSource);
1412
- compIr = runCompiler(compPath, componentCompileSource, compilerOpts);
1446
+ compIr = runCompiler(
1447
+ compPath,
1448
+ componentCompileSource,
1449
+ compilerOpts,
1450
+ { onWarning: emitCompilerWarning }
1451
+ );
1413
1452
  componentIrCache.set(compPath, compIr);
1414
1453
  }
1415
1454
 
1416
1455
  let isDocMode = componentDocumentModeCache.get(compPath);
1417
- let refFallbacks = componentRefFallbackCache.get(compPath);
1418
1456
  if (isDocMode === undefined) {
1419
1457
  isDocMode = isDocumentMode(extractTemplate(componentSource));
1420
- refFallbacks = extractRefFallbackAssignments(componentSource);
1421
1458
  componentDocumentModeCache.set(compPath, isDocMode);
1422
- componentRefFallbackCache.set(compPath, refFallbacks);
1423
1459
  }
1424
1460
 
1425
1461
  let expressionRewrite = componentExpressionRewriteCache.get(compPath);
@@ -1443,8 +1479,7 @@ export async function build(options) {
1443
1479
  includeCode: true,
1444
1480
  cssImportsOnly: isDocMode,
1445
1481
  documentMode: isDocMode,
1446
- componentAttrs: (componentUsageAttrs.get(compName) || [])[0] || '',
1447
- refFallbacks: refFallbacks || []
1482
+ componentAttrs: (componentUsageAttrs.get(compName) || [])[0] || ''
1448
1483
  },
1449
1484
  seenStaticImports
1450
1485
  );