@zenithbuild/cli 0.5.0-beta.2.6 → 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/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,63 @@ 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
+ }
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
+ }
70
123
 
71
124
  /**
72
125
  * Run the compiler process and parse its JSON stdout.
@@ -77,15 +130,21 @@ const BUNDLER_BIN = resolveBinary([
77
130
  *
78
131
  * @param {string} filePath — path for diagnostics (and file reading when no stdinSource)
79
132
  * @param {string} [stdinSource] — if provided, piped to compiler via stdin
133
+ * @param {object} compilerRunOptions
134
+ * @param {(warning: string) => void} [compilerRunOptions.onWarning]
135
+ * @param {boolean} [compilerRunOptions.suppressWarnings]
80
136
  * @returns {object}
81
137
  */
82
- function runCompiler(filePath, stdinSource, compilerOpts = {}) {
138
+ function runCompiler(filePath, stdinSource, compilerOpts = {}, compilerRunOptions = {}) {
83
139
  const args = stdinSource !== undefined
84
140
  ? ['--stdin', filePath]
85
141
  : [filePath];
86
142
  if (compilerOpts?.experimentalEmbeddedMarkup) {
87
143
  args.push('--embedded-markup-expressions');
88
144
  }
145
+ if (compilerOpts?.strictDomLints) {
146
+ args.push('--strict-dom-lints');
147
+ }
89
148
  const opts = { encoding: 'utf8' };
90
149
  if (stdinSource !== undefined) {
91
150
  opts.input = stdinSource;
@@ -102,6 +161,20 @@ function runCompiler(filePath, stdinSource, compilerOpts = {}) {
102
161
  );
103
162
  }
104
163
 
164
+ if (result.stderr && result.stderr.trim().length > 0 && compilerRunOptions.suppressWarnings !== true) {
165
+ const lines = String(result.stderr)
166
+ .split('\n')
167
+ .map((line) => line.trim())
168
+ .filter((line) => line.length > 0);
169
+ for (const line of lines) {
170
+ if (typeof compilerRunOptions.onWarning === 'function') {
171
+ compilerRunOptions.onWarning(line);
172
+ } else {
173
+ console.warn(line);
174
+ }
175
+ }
176
+ }
177
+
105
178
  try {
106
179
  return JSON.parse(result.stdout);
107
180
  } catch (err) {
@@ -143,7 +216,7 @@ function buildComponentExpressionRewrite(compPath, componentSource, compIr, comp
143
216
 
144
217
  let templateIr;
145
218
  try {
146
- templateIr = runCompiler(compPath, templateOnly, compilerOpts);
219
+ templateIr = runCompiler(compPath, templateOnly, compilerOpts, { suppressWarnings: true });
147
220
  } catch {
148
221
  return out;
149
222
  }
@@ -200,6 +273,57 @@ function mergeExpressionRewriteMaps(pageMap, pageAmbiguous, componentRewrite) {
200
273
  }
201
274
  }
202
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
+
203
327
  /**
204
328
  * Rewrite unresolved page expressions using component script-aware mappings.
205
329
  *
@@ -232,6 +356,9 @@ function applyExpressionRewrites(pageIr, expressionMap, ambiguous) {
232
356
  bindings[index].literal === current
233
357
  ) {
234
358
  bindings[index].literal = rewritten;
359
+ if (bindings[index].compiled_expr === current) {
360
+ bindings[index].compiled_expr = rewritten;
361
+ }
235
362
  }
236
363
  }
237
364
  }
@@ -271,6 +398,15 @@ function rewriteLegacyMarkupIdentifiers(pageIr) {
271
398
  _LEGACY_MARKUP_RE.lastIndex = 0;
272
399
  bindings[i].literal = bindings[i].literal.replace(_LEGACY_MARKUP_RE, '__ZENITH_INTERNAL_ZENHTML');
273
400
  }
401
+ if (
402
+ bindings[i] &&
403
+ typeof bindings[i] === 'object' &&
404
+ typeof bindings[i].compiled_expr === 'string' &&
405
+ bindings[i].compiled_expr.includes(_LEGACY_MARKUP_IDENT)
406
+ ) {
407
+ _LEGACY_MARKUP_RE.lastIndex = 0;
408
+ bindings[i].compiled_expr = bindings[i].compiled_expr.replace(_LEGACY_MARKUP_RE, '__ZENITH_INTERNAL_ZENHTML');
409
+ }
274
410
  }
275
411
  }
276
412
 
@@ -452,7 +588,7 @@ function extractServerScript(source, sourceFile, compilerOpts = {}) {
452
588
  const scriptRe = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
453
589
  const serverMatches = [];
454
590
  const reservedServerExportRe =
455
- /\bexport\s+const\s+(?:data|prerender)\b|\bexport\s+(?:async\s+)?function\s+load\s*\(|\bexport\s+const\s+load\s*=/;
591
+ /\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
592
 
457
593
  for (const match of source.matchAll(scriptRe)) {
458
594
  const attrs = String(match[1] || '');
@@ -463,8 +599,8 @@ function extractServerScript(source, sourceFile, compilerOpts = {}) {
463
599
  throw new Error(
464
600
  `Zenith server script contract violation:\n` +
465
601
  ` 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">`
602
+ ` Reason: guard/load/data exports are only allowed in <script server lang="ts"> or adjacent .guard.ts / .load.ts files\n` +
603
+ ` Example: move the export into <script server lang="ts">`
468
604
  );
469
605
  }
470
606
 
@@ -535,6 +671,25 @@ function extractServerScript(source, sourceFile, compilerOpts = {}) {
535
671
  );
536
672
  }
537
673
 
674
+ const guardFnMatch = serverSource.match(/\bexport\s+(?:async\s+)?function\s+guard\s*\(([^)]*)\)/);
675
+ const guardConstParenMatch = serverSource.match(/\bexport\s+const\s+guard\s*=\s*(?:async\s*)?\(([^)]*)\)\s*=>/);
676
+ const guardConstSingleArgMatch = serverSource.match(
677
+ /\bexport\s+const\s+guard\s*=\s*(?:async\s*)?([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=>/
678
+ );
679
+ const hasGuard = Boolean(guardFnMatch || guardConstParenMatch || guardConstSingleArgMatch);
680
+ const guardMatchCount =
681
+ Number(Boolean(guardFnMatch)) +
682
+ Number(Boolean(guardConstParenMatch)) +
683
+ Number(Boolean(guardConstSingleArgMatch));
684
+ if (guardMatchCount > 1) {
685
+ throw new Error(
686
+ `Zenith server script contract violation:\n` +
687
+ ` File: ${sourceFile}\n` +
688
+ ` Reason: multiple guard exports detected\n` +
689
+ ` Example: keep exactly one export const guard = async (ctx) => ({ ... })`
690
+ );
691
+ }
692
+
538
693
  const hasData = /\bexport\s+const\s+data\b/.test(serverSource);
539
694
  const hasSsrData = /\bexport\s+const\s+ssr_data\b/.test(serverSource);
540
695
  const hasSsr = /\bexport\s+const\s+ssr\b/.test(serverSource);
@@ -575,6 +730,24 @@ function extractServerScript(source, sourceFile, compilerOpts = {}) {
575
730
  }
576
731
  }
577
732
 
733
+ if (hasGuard) {
734
+ const singleArg = String(guardConstSingleArgMatch?.[1] || '').trim();
735
+ const paramsText = String((guardFnMatch || guardConstParenMatch)?.[1] || '').trim();
736
+ const arity = singleArg
737
+ ? 1
738
+ : paramsText.length === 0
739
+ ? 0
740
+ : paramsText.split(',').length;
741
+ if (arity !== 1) {
742
+ throw new Error(
743
+ `Zenith server script contract violation:\n` +
744
+ ` File: ${sourceFile}\n` +
745
+ ` Reason: guard(ctx) must accept exactly one argument\n` +
746
+ ` Example: export const guard = async (ctx) => ({ ... })`
747
+ );
748
+ }
749
+ }
750
+
578
751
  const prerenderMatch = serverSource.match(/\bexport\s+const\s+prerender\s*=\s*([^\n;]+)/);
579
752
  let prerender = false;
580
753
  if (prerenderMatch) {
@@ -596,6 +769,8 @@ function extractServerScript(source, sourceFile, compilerOpts = {}) {
596
769
  serverScript: {
597
770
  source: serverSource,
598
771
  prerender,
772
+ has_guard: hasGuard,
773
+ has_load: hasLoad,
599
774
  source_path: sourceFile
600
775
  }
601
776
  };
@@ -609,6 +784,8 @@ function extractServerScript(source, sourceFile, compilerOpts = {}) {
609
784
  serverScript: {
610
785
  source: serverSource,
611
786
  prerender,
787
+ has_guard: hasGuard,
788
+ has_load: hasLoad,
612
789
  source_path: sourceFile
613
790
  }
614
791
  };
@@ -654,7 +831,7 @@ function collectComponentUsageAttrs(source, registry) {
654
831
  * @param {{ includeCode: boolean, cssImportsOnly: boolean, documentMode?: boolean, componentAttrs?: string }} options
655
832
  * @param {Set<string>} seenStaticImports
656
833
  */
657
- function mergeComponentIr(pageIr, compIr, compPath, pageFile, options, seenStaticImports) {
834
+ function mergeComponentIr(pageIr, compIr, compPath, pageFile, options, seenStaticImports, knownRefKeys = null) {
658
835
  // Merge components_scripts
659
836
  if (compIr.components_scripts) {
660
837
  for (const [hoistId, script] of Object.entries(compIr.components_scripts)) {
@@ -669,6 +846,17 @@ function mergeComponentIr(pageIr, compIr, compPath, pageFile, options, seenStati
669
846
  pageIr.component_instances.push(...compIr.component_instances);
670
847
  }
671
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
+
672
860
  // Merge hoisted imports (deduplicated, rebased to the page file path)
673
861
  if (compIr.hoisted?.imports?.length) {
674
862
  for (const imp of compIr.hoisted.imports) {
@@ -741,11 +929,7 @@ function mergeComponentIr(pageIr, compIr, compPath, pageFile, options, seenStati
741
929
  : rebased;
742
930
  const withPropsPrelude = injectPropsPrelude(filteredImports, options.componentAttrs || '');
743
931
  const transpiled = transpileTypeScriptToJs(withPropsPrelude, compPath);
744
- const withRefFallbacks = injectRefFallbacksInZenMount(
745
- transpiled,
746
- options.refFallbacks || []
747
- );
748
- const deduped = dedupeStaticImportsInSource(withRefFallbacks, seenStaticImports);
932
+ const deduped = dedupeStaticImportsInSource(transpiled, seenStaticImports);
749
933
  const deferred = deferComponentRuntimeBlock(deduped);
750
934
  if (deferred.trim().length > 0 && !pageIr.hoisted.code.includes(deferred)) {
751
935
  pageIr.hoisted.code.push(deferred);
@@ -868,7 +1052,7 @@ function transpileTypeScriptToJs(source, sourceFile) {
868
1052
  fileName: sourceFile,
869
1053
  compilerOptions: {
870
1054
  module: ts.ModuleKind.ESNext,
871
- target: ts.ScriptTarget.ES2022,
1055
+ target: ts.ScriptTarget.ES5,
872
1056
  importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Preserve,
873
1057
  verbatimModuleSyntax: true,
874
1058
  newLine: ts.NewLineKind.LineFeed,
@@ -1103,7 +1287,7 @@ function injectPropsPrelude(source, attrs) {
1103
1287
  }
1104
1288
 
1105
1289
  const propsLiteral = renderPropsLiteralFromAttrs(attrs);
1106
- return `const props = ${propsLiteral};\n${source}`;
1290
+ return `var props = ${propsLiteral};\n${source}`;
1107
1291
  }
1108
1292
 
1109
1293
  /**
@@ -1155,111 +1339,37 @@ function deferComponentRuntimeBlock(source) {
1155
1339
  return wrapped;
1156
1340
  }
1157
1341
 
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
1342
  /**
1249
1343
  * Run bundler process for one page envelope.
1250
1344
  *
1251
1345
  * @param {object|object[]} envelope
1252
1346
  * @param {string} outDir
1347
+ * @param {string} projectRoot
1348
+ * @param {object | null} [logger]
1349
+ * @param {boolean} [showInfo]
1253
1350
  * @returns {Promise<void>}
1254
1351
  */
1255
- function runBundler(envelope, outDir) {
1352
+ function runBundler(envelope, outDir, projectRoot, logger = null, showInfo = true) {
1256
1353
  return new Promise((resolvePromise, rejectPromise) => {
1354
+ const useStructuredLogger = Boolean(logger && typeof logger.childLine === 'function');
1257
1355
  const child = spawn(
1258
- BUNDLER_BIN,
1356
+ getBundlerBin(),
1259
1357
  ['--out-dir', outDir],
1260
- { stdio: ['pipe', 'inherit', 'inherit'] }
1358
+ {
1359
+ cwd: projectRoot,
1360
+ stdio: useStructuredLogger ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'inherit', 'inherit']
1361
+ }
1261
1362
  );
1262
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
+
1263
1373
  child.on('error', (err) => {
1264
1374
  rejectPromise(new Error(`Bundler spawn failed: ${err.message}`));
1265
1375
  });
@@ -1325,15 +1435,17 @@ async function collectAssets(rootDir) {
1325
1435
  * d. Merge component IRs into page IR
1326
1436
  * 3. Send all envelopes to bundler
1327
1437
  *
1328
- * @param {{ pagesDir: string, outDir: string, config?: object }} options
1438
+ * @param {{ pagesDir: string, outDir: string, config?: object, logger?: object | null, showBundlerInfo?: boolean }} options
1329
1439
  * @returns {Promise<{ pages: number, assets: string[] }>}
1330
1440
  */
1331
1441
  export async function build(options) {
1332
- const { pagesDir, outDir, config = {} } = options;
1442
+ const { pagesDir, outDir, config = {}, logger = null, showBundlerInfo = true } = options;
1443
+ const projectRoot = deriveProjectRootFromPagesDir(pagesDir);
1333
1444
  const softNavigationEnabled = config.softNavigation === true || config.router === true;
1334
1445
  const compilerOpts = {
1335
1446
  typescriptDefault: config.typescriptDefault === true,
1336
- experimentalEmbeddedMarkup: config.embeddedMarkupExpressions === true || config.experimental?.embeddedMarkupExpressions === true
1447
+ experimentalEmbeddedMarkup: config.embeddedMarkupExpressions === true || config.experimental?.embeddedMarkupExpressions === true,
1448
+ strictDomLints: config.strictDomLints === true
1337
1449
  };
1338
1450
 
1339
1451
  await rm(outDir, { recursive: true, force: true });
@@ -1345,7 +1457,13 @@ export async function build(options) {
1345
1457
  // 1. Build component registry
1346
1458
  const registry = buildComponentRegistry(srcDir);
1347
1459
  if (registry.size > 0) {
1348
- 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
+ }
1349
1467
  }
1350
1468
 
1351
1469
  const manifest = await generateManifest(pagesDir);
@@ -1356,10 +1474,15 @@ export async function build(options) {
1356
1474
  const componentIrCache = new Map();
1357
1475
  /** @type {Map<string, boolean>} */
1358
1476
  const componentDocumentModeCache = new Map();
1359
- /** @type {Map<string, Array<{ identifier: string, selector: string }>>} */
1360
- const componentRefFallbackCache = new Map();
1361
1477
  /** @type {Map<string, { map: Map<string, string>, ambiguous: Set<string> }>} */
1362
1478
  const componentExpressionRewriteCache = new Map();
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
+ });
1363
1486
 
1364
1487
  const envelopes = [];
1365
1488
  for (const entry of manifest) {
@@ -1367,6 +1490,14 @@ export async function build(options) {
1367
1490
  const rawSource = readFileSync(sourceFile, 'utf8');
1368
1491
  const componentUsageAttrs = collectComponentUsageAttrs(rawSource, registry);
1369
1492
 
1493
+ const baseName = sourceFile.slice(0, -extname(sourceFile).length);
1494
+ let adjacentGuard = null;
1495
+ let adjacentLoad = null;
1496
+ for (const ext of ['.ts', '.js']) {
1497
+ if (!adjacentGuard && existsSync(`${baseName}.guard${ext}`)) adjacentGuard = `${baseName}.guard${ext}`;
1498
+ if (!adjacentLoad && existsSync(`${baseName}.load${ext}`)) adjacentLoad = `${baseName}.load${ext}`;
1499
+ }
1500
+
1370
1501
  // 2a. Expand PascalCase component tags
1371
1502
  const { expandedSource, usedComponents } = expandComponents(
1372
1503
  rawSource, registry, sourceFile
@@ -1375,7 +1506,16 @@ export async function build(options) {
1375
1506
  const compileSource = extractedServer.source;
1376
1507
 
1377
1508
  // 2b. Compile expanded page source via --stdin
1378
- const pageIr = runCompiler(sourceFile, compileSource, compilerOpts);
1509
+ const pageIr = runCompiler(
1510
+ sourceFile,
1511
+ compileSource,
1512
+ compilerOpts,
1513
+ { onWarning: emitCompilerWarning }
1514
+ );
1515
+
1516
+ const hasGuard = (extractedServer.serverScript && extractedServer.serverScript.has_guard) || adjacentGuard !== null;
1517
+ const hasLoad = (extractedServer.serverScript && extractedServer.serverScript.has_load) || adjacentLoad !== null;
1518
+
1379
1519
  if (extractedServer.serverScript) {
1380
1520
  pageIr.server_script = extractedServer.serverScript;
1381
1521
  pageIr.prerender = extractedServer.serverScript.prerender === true;
@@ -1384,6 +1524,20 @@ export async function build(options) {
1384
1524
  }
1385
1525
  }
1386
1526
 
1527
+ // Static Build Route Protection Policy
1528
+ if (pageIr.prerender === true && (hasGuard || hasLoad)) {
1529
+ throw new Error(
1530
+ `[zenith] Build failed for ${entry.file}: protected routes require SSR/runtime. ` +
1531
+ `Cannot prerender a static route with a \`guard\` or \`load\` function.`
1532
+ );
1533
+ }
1534
+
1535
+ // Apply metadata to IR
1536
+ pageIr.has_guard = hasGuard;
1537
+ pageIr.has_load = hasLoad;
1538
+ pageIr.guard_module_ref = adjacentGuard ? relative(srcDir, adjacentGuard).replaceAll('\\', '/') : null;
1539
+ pageIr.load_module_ref = adjacentLoad ? relative(srcDir, adjacentLoad).replaceAll('\\', '/') : null;
1540
+
1387
1541
  // Ensure IR has required array fields for merging
1388
1542
  pageIr.components_scripts = pageIr.components_scripts || {};
1389
1543
  pageIr.component_instances = pageIr.component_instances || [];
@@ -1397,6 +1551,7 @@ export async function build(options) {
1397
1551
  const seenStaticImports = new Set();
1398
1552
  const pageExpressionRewriteMap = new Map();
1399
1553
  const pageAmbiguousExpressionMap = new Set();
1554
+ const knownRefKeys = new Set();
1400
1555
 
1401
1556
  // 2c. Compile each used component separately for its script IR
1402
1557
  for (const compName of usedComponents) {
@@ -1409,17 +1564,19 @@ export async function build(options) {
1409
1564
  compIr = componentIrCache.get(compPath);
1410
1565
  } else {
1411
1566
  const componentCompileSource = stripStyleBlocks(componentSource);
1412
- compIr = runCompiler(compPath, componentCompileSource, compilerOpts);
1567
+ compIr = runCompiler(
1568
+ compPath,
1569
+ componentCompileSource,
1570
+ compilerOpts,
1571
+ { onWarning: emitCompilerWarning }
1572
+ );
1413
1573
  componentIrCache.set(compPath, compIr);
1414
1574
  }
1415
1575
 
1416
1576
  let isDocMode = componentDocumentModeCache.get(compPath);
1417
- let refFallbacks = componentRefFallbackCache.get(compPath);
1418
1577
  if (isDocMode === undefined) {
1419
1578
  isDocMode = isDocumentMode(extractTemplate(componentSource));
1420
- refFallbacks = extractRefFallbackAssignments(componentSource);
1421
1579
  componentDocumentModeCache.set(compPath, isDocMode);
1422
- componentRefFallbackCache.set(compPath, refFallbacks);
1423
1580
  }
1424
1581
 
1425
1582
  let expressionRewrite = componentExpressionRewriteCache.get(compPath);
@@ -1443,10 +1600,10 @@ export async function build(options) {
1443
1600
  includeCode: true,
1444
1601
  cssImportsOnly: isDocMode,
1445
1602
  documentMode: isDocMode,
1446
- componentAttrs: (componentUsageAttrs.get(compName) || [])[0] || '',
1447
- refFallbacks: refFallbacks || []
1603
+ componentAttrs: (componentUsageAttrs.get(compName) || [])[0] || ''
1448
1604
  },
1449
- seenStaticImports
1605
+ seenStaticImports,
1606
+ knownRefKeys
1450
1607
  );
1451
1608
  }
1452
1609
 
@@ -1457,6 +1614,7 @@ export async function build(options) {
1457
1614
  );
1458
1615
 
1459
1616
  rewriteLegacyMarkupIdentifiers(pageIr);
1617
+ rewriteRefBindingIdentifiers(pageIr, knownRefKeys);
1460
1618
 
1461
1619
  envelopes.push({
1462
1620
  route: entry.path,
@@ -1467,7 +1625,7 @@ export async function build(options) {
1467
1625
  }
1468
1626
 
1469
1627
  if (envelopes.length > 0) {
1470
- await runBundler(envelopes, outDir);
1628
+ await runBundler(envelopes, outDir, projectRoot, logger, showBundlerInfo);
1471
1629
  }
1472
1630
 
1473
1631
  const assets = await collectAssets(outDir);