@zenithbuild/cli 0.6.13 → 0.7.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.
Files changed (64) hide show
  1. package/dist/build/compiler-runtime.d.ts +59 -0
  2. package/dist/build/compiler-runtime.js +277 -0
  3. package/dist/build/expression-rewrites.d.ts +88 -0
  4. package/dist/build/expression-rewrites.js +372 -0
  5. package/dist/build/hoisted-code-transforms.d.ts +44 -0
  6. package/dist/build/hoisted-code-transforms.js +316 -0
  7. package/dist/build/merge-component-ir.d.ts +16 -0
  8. package/dist/build/merge-component-ir.js +257 -0
  9. package/dist/build/page-component-loop.d.ts +92 -0
  10. package/dist/build/page-component-loop.js +257 -0
  11. package/dist/build/page-ir-normalization.d.ts +23 -0
  12. package/dist/build/page-ir-normalization.js +370 -0
  13. package/dist/build/page-loop-metrics.d.ts +100 -0
  14. package/dist/build/page-loop-metrics.js +131 -0
  15. package/dist/build/page-loop-state.d.ts +261 -0
  16. package/dist/build/page-loop-state.js +92 -0
  17. package/dist/build/page-loop.d.ts +33 -0
  18. package/dist/build/page-loop.js +217 -0
  19. package/dist/build/scoped-identifier-rewrite.d.ts +112 -0
  20. package/dist/build/scoped-identifier-rewrite.js +245 -0
  21. package/dist/build/server-script.d.ts +41 -0
  22. package/dist/build/server-script.js +210 -0
  23. package/dist/build/type-declarations.d.ts +16 -0
  24. package/dist/build/type-declarations.js +158 -0
  25. package/dist/build/typescript-expression-utils.d.ts +23 -0
  26. package/dist/build/typescript-expression-utils.js +272 -0
  27. package/dist/build.d.ts +10 -18
  28. package/dist/build.js +74 -2261
  29. package/dist/component-instance-ir.d.ts +2 -2
  30. package/dist/component-instance-ir.js +146 -39
  31. package/dist/component-occurrences.js +63 -15
  32. package/dist/config.d.ts +66 -0
  33. package/dist/config.js +86 -0
  34. package/dist/debug-script.d.ts +1 -0
  35. package/dist/debug-script.js +8 -0
  36. package/dist/dev-build-session.d.ts +23 -0
  37. package/dist/dev-build-session.js +421 -0
  38. package/dist/dev-server.js +405 -58
  39. package/dist/framework-components/Image.zen +316 -0
  40. package/dist/images/materialize.d.ts +17 -0
  41. package/dist/images/materialize.js +200 -0
  42. package/dist/images/payload.d.ts +18 -0
  43. package/dist/images/payload.js +65 -0
  44. package/dist/images/runtime.d.ts +4 -0
  45. package/dist/images/runtime.js +254 -0
  46. package/dist/images/service.d.ts +4 -0
  47. package/dist/images/service.js +302 -0
  48. package/dist/images/shared.d.ts +58 -0
  49. package/dist/images/shared.js +306 -0
  50. package/dist/index.js +2 -17
  51. package/dist/manifest.js +45 -0
  52. package/dist/preview.d.ts +4 -1
  53. package/dist/preview.js +59 -6
  54. package/dist/resolve-components.js +20 -3
  55. package/dist/server-contract.js +3 -2
  56. package/dist/server-script-composition.d.ts +39 -0
  57. package/dist/server-script-composition.js +133 -0
  58. package/dist/startup-profile.d.ts +10 -0
  59. package/dist/startup-profile.js +62 -0
  60. package/dist/toolchain-paths.d.ts +1 -0
  61. package/dist/toolchain-paths.js +31 -0
  62. package/dist/version-check.d.ts +2 -1
  63. package/dist/version-check.js +12 -5
  64. package/package.json +5 -4
@@ -0,0 +1,306 @@
1
+ const DEFAULT_DEVICE_SIZES = [640, 750, 828, 1080, 1200, 1920, 2048, 3840];
2
+ const DEFAULT_IMAGE_SIZES = [16, 32, 48, 64, 96, 128, 256, 384];
3
+ const DEFAULT_FORMATS = ['webp', 'avif'];
4
+ const DEFAULT_REMOTE_PATTERNS = [];
5
+ const DEFAULT_MAX_REMOTE_BYTES = 10 * 1024 * 1024;
6
+ const DEFAULT_MAX_PIXELS = 40_000_000;
7
+ const DEFAULT_MINIMUM_CACHE_TTL = 60;
8
+ const DEFAULT_QUALITY = 75;
9
+ const IMAGE_RUNTIME_GLOBAL = '__zenith_image_runtime';
10
+ export const DEFAULT_IMAGE_CONFIG = {
11
+ formats: DEFAULT_FORMATS,
12
+ quality: DEFAULT_QUALITY,
13
+ deviceSizes: DEFAULT_DEVICE_SIZES,
14
+ imageSizes: DEFAULT_IMAGE_SIZES,
15
+ remotePatterns: DEFAULT_REMOTE_PATTERNS,
16
+ allowSvg: false,
17
+ maxRemoteBytes: DEFAULT_MAX_REMOTE_BYTES,
18
+ maxPixels: DEFAULT_MAX_PIXELS,
19
+ minimumCacheTTL: DEFAULT_MINIMUM_CACHE_TTL,
20
+ dangerouslyAllowLocalNetwork: false
21
+ };
22
+ const TOP_LEVEL_KEYS = new Set([
23
+ 'formats',
24
+ 'quality',
25
+ 'deviceSizes',
26
+ 'imageSizes',
27
+ 'remotePatterns',
28
+ 'allowSvg',
29
+ 'maxRemoteBytes',
30
+ 'maxPixels',
31
+ 'minimumCacheTTL',
32
+ 'dangerouslyAllowLocalNetwork'
33
+ ]);
34
+ function isPlainObject(value) {
35
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
36
+ }
37
+ function asPositiveInt(value, key) {
38
+ if (!Number.isInteger(value) || value <= 0) {
39
+ throw new Error(`[Zenith:Config] images.${key} must be a positive integer`);
40
+ }
41
+ return value;
42
+ }
43
+ function normalizeStringArray(value, key) {
44
+ if (!Array.isArray(value) || value.length === 0) {
45
+ throw new Error(`[Zenith:Config] images.${key} must be a non-empty array`);
46
+ }
47
+ const out = [];
48
+ for (const entry of value) {
49
+ if (typeof entry !== 'string' || entry.trim().length === 0) {
50
+ throw new Error(`[Zenith:Config] images.${key} must contain non-empty strings`);
51
+ }
52
+ out.push(entry.trim().toLowerCase());
53
+ }
54
+ return [...new Set(out)];
55
+ }
56
+ function normalizePositiveIntArray(value, key) {
57
+ if (!Array.isArray(value) || value.length === 0) {
58
+ throw new Error(`[Zenith:Config] images.${key} must be a non-empty array`);
59
+ }
60
+ const out = [];
61
+ for (const entry of value) {
62
+ out.push(asPositiveInt(entry, key));
63
+ }
64
+ return [...new Set(out)].sort((left, right) => left - right);
65
+ }
66
+ function normalizeRemotePattern(pattern) {
67
+ if (!isPlainObject(pattern)) {
68
+ throw new Error('[Zenith:Config] images.remotePatterns must contain plain objects');
69
+ }
70
+ const protocol = typeof pattern.protocol === 'string' && pattern.protocol.trim().length > 0
71
+ ? pattern.protocol.trim().replace(/:$/, '').toLowerCase()
72
+ : 'https';
73
+ const hostname = typeof pattern.hostname === 'string' ? pattern.hostname.trim().toLowerCase() : '';
74
+ if (!hostname) {
75
+ throw new Error('[Zenith:Config] images.remotePatterns[].hostname is required');
76
+ }
77
+ const port = typeof pattern.port === 'string' ? pattern.port.trim() : '';
78
+ const pathname = typeof pattern.pathname === 'string' && pattern.pathname.trim().length > 0
79
+ ? pattern.pathname.trim()
80
+ : '/**';
81
+ const search = typeof pattern.search === 'string' && pattern.search.length > 0
82
+ ? pattern.search
83
+ : '';
84
+ return {
85
+ protocol,
86
+ hostname,
87
+ port,
88
+ pathname,
89
+ search
90
+ };
91
+ }
92
+ function cloneImageConfig(config = DEFAULT_IMAGE_CONFIG) {
93
+ return {
94
+ ...config,
95
+ formats: [...(config.formats || [])],
96
+ deviceSizes: [...(config.deviceSizes || [])],
97
+ imageSizes: [...(config.imageSizes || [])],
98
+ remotePatterns: Array.isArray(config.remotePatterns)
99
+ ? config.remotePatterns.map((pattern) => ({ ...pattern }))
100
+ : []
101
+ };
102
+ }
103
+ export function normalizeImageConfig(input) {
104
+ if (input === undefined || input === null) {
105
+ return cloneImageConfig(DEFAULT_IMAGE_CONFIG);
106
+ }
107
+ if (!isPlainObject(input)) {
108
+ throw new Error('[Zenith:Config] images must be a plain object');
109
+ }
110
+ for (const key of Object.keys(input)) {
111
+ if (!TOP_LEVEL_KEYS.has(key)) {
112
+ throw new Error(`[Zenith:Config] Unknown key: "images.${key}"`);
113
+ }
114
+ }
115
+ const config = cloneImageConfig(DEFAULT_IMAGE_CONFIG);
116
+ if ('formats' in input) {
117
+ config.formats = normalizeStringArray(input.formats, 'formats');
118
+ }
119
+ if ('quality' in input) {
120
+ config.quality = asPositiveInt(input.quality, 'quality');
121
+ }
122
+ if ('deviceSizes' in input) {
123
+ config.deviceSizes = normalizePositiveIntArray(input.deviceSizes, 'deviceSizes');
124
+ }
125
+ if ('imageSizes' in input) {
126
+ config.imageSizes = normalizePositiveIntArray(input.imageSizes, 'imageSizes');
127
+ }
128
+ if ('remotePatterns' in input) {
129
+ if (!Array.isArray(input.remotePatterns)) {
130
+ throw new Error('[Zenith:Config] images.remotePatterns must be an array');
131
+ }
132
+ config.remotePatterns = input.remotePatterns.map(normalizeRemotePattern);
133
+ }
134
+ if ('allowSvg' in input) {
135
+ if (typeof input.allowSvg !== 'boolean') {
136
+ throw new Error('[Zenith:Config] images.allowSvg must be boolean');
137
+ }
138
+ config.allowSvg = input.allowSvg;
139
+ }
140
+ if ('maxRemoteBytes' in input) {
141
+ config.maxRemoteBytes = asPositiveInt(input.maxRemoteBytes, 'maxRemoteBytes');
142
+ }
143
+ if ('maxPixels' in input) {
144
+ config.maxPixels = asPositiveInt(input.maxPixels, 'maxPixels');
145
+ }
146
+ if ('minimumCacheTTL' in input) {
147
+ config.minimumCacheTTL = asPositiveInt(input.minimumCacheTTL, 'minimumCacheTTL');
148
+ }
149
+ if ('dangerouslyAllowLocalNetwork' in input) {
150
+ if (typeof input.dangerouslyAllowLocalNetwork !== 'boolean') {
151
+ throw new Error('[Zenith:Config] images.dangerouslyAllowLocalNetwork must be boolean');
152
+ }
153
+ config.dangerouslyAllowLocalNetwork = input.dangerouslyAllowLocalNetwork;
154
+ }
155
+ return config;
156
+ }
157
+ function escapeRegex(value) {
158
+ return String(value).replace(/[|\\{}()[\]^$+?.*]/g, '\\$&');
159
+ }
160
+ function globToRegExp(glob, isHostname = false) {
161
+ let pattern = escapeRegex(glob);
162
+ pattern = pattern.replaceAll('\\*\\*', '__DOUBLE_STAR__');
163
+ pattern = pattern.replaceAll('\\*', isHostname ? '[^.]*' : '[^/]*');
164
+ pattern = pattern.replaceAll('__DOUBLE_STAR__', '.*');
165
+ return new RegExp(`^${pattern}$`, 'i');
166
+ }
167
+ function hostnameMatches(hostname, pattern) {
168
+ if (pattern.startsWith('*.')) {
169
+ const suffix = pattern.slice(1);
170
+ return hostname.endsWith(suffix) && hostname.length > suffix.length;
171
+ }
172
+ return globToRegExp(pattern, true).test(hostname);
173
+ }
174
+ export function matchRemotePattern(inputUrl, patterns) {
175
+ if (!inputUrl || !Array.isArray(patterns) || patterns.length === 0) {
176
+ return false;
177
+ }
178
+ const url = inputUrl instanceof URL ? inputUrl : new URL(String(inputUrl));
179
+ const protocol = url.protocol.replace(/:$/, '').toLowerCase();
180
+ const hostname = url.hostname.toLowerCase();
181
+ const port = url.port || '';
182
+ const pathname = url.pathname || '/';
183
+ const search = url.search || '';
184
+ return patterns.some((pattern) => {
185
+ if (pattern.protocol && pattern.protocol !== protocol) {
186
+ return false;
187
+ }
188
+ if (!hostnameMatches(hostname, pattern.hostname || '')) {
189
+ return false;
190
+ }
191
+ if (pattern.port && pattern.port !== port) {
192
+ return false;
193
+ }
194
+ if (pattern.search && pattern.search !== search) {
195
+ return false;
196
+ }
197
+ return globToRegExp(pattern.pathname || '/**').test(pathname);
198
+ });
199
+ }
200
+ export function isRemoteImageUrl(value) {
201
+ if (typeof value !== 'string') {
202
+ return false;
203
+ }
204
+ return /^https?:\/\//i.test(value.trim());
205
+ }
206
+ export function normalizeImageSource(input) {
207
+ if (typeof input === 'string') {
208
+ const trimmed = input.trim();
209
+ if (!trimmed) {
210
+ return null;
211
+ }
212
+ if (isRemoteImageUrl(trimmed)) {
213
+ return { kind: 'remote', url: trimmed, width: null, height: null, alt: '' };
214
+ }
215
+ if (trimmed.startsWith('/')) {
216
+ return { kind: 'local', path: trimmed, width: null, height: null, alt: '' };
217
+ }
218
+ return null;
219
+ }
220
+ if (!isPlainObject(input)) {
221
+ return null;
222
+ }
223
+ const rawUrl = typeof input.url === 'string'
224
+ ? input.url
225
+ : typeof input.src === 'string'
226
+ ? input.src
227
+ : typeof input.path === 'string'
228
+ ? input.path
229
+ : '';
230
+ const normalized = normalizeImageSource(rawUrl);
231
+ if (!normalized) {
232
+ return null;
233
+ }
234
+ const width = Number.isInteger(input.width) && input.width > 0 ? input.width : null;
235
+ const height = Number.isInteger(input.height) && input.height > 0 ? input.height : null;
236
+ const alt = typeof input.alt === 'string' ? input.alt : '';
237
+ return {
238
+ ...normalized,
239
+ width,
240
+ height,
241
+ alt
242
+ };
243
+ }
244
+ export function normalizeImageFormat(value) {
245
+ return String(value || '').trim().toLowerCase().replace(/^\./, '');
246
+ }
247
+ export function buildLocalImageKey(publicPath) {
248
+ const input = String(publicPath || '');
249
+ let hash = 2166136261;
250
+ for (let index = 0; index < input.length; index += 1) {
251
+ hash ^= input.charCodeAt(index);
252
+ hash = Math.imul(hash, 16777619);
253
+ }
254
+ return (hash >>> 0).toString(16).padStart(8, '0');
255
+ }
256
+ export function buildLocalVariantPath(publicPath, width, quality, format) {
257
+ const key = buildLocalImageKey(publicPath);
258
+ return `/_zenith/image/local/${key}/w${width}-q${quality}.${normalizeImageFormat(format)}`;
259
+ }
260
+ export function buildRemoteVariantPath(remoteUrl, width, quality, format) {
261
+ const query = new URLSearchParams();
262
+ query.set('url', String(remoteUrl || ''));
263
+ query.set('w', String(width));
264
+ query.set('q', String(quality));
265
+ if (format) {
266
+ query.set('f', normalizeImageFormat(format));
267
+ }
268
+ return `/_zenith/image?${query.toString()}`;
269
+ }
270
+ export function resolveWidthCandidates(width, sizes, config, manifestEntry) {
271
+ const base = new Set([
272
+ ...(config?.deviceSizes || DEFAULT_DEVICE_SIZES),
273
+ ...(config?.imageSizes || DEFAULT_IMAGE_SIZES)
274
+ ]);
275
+ if (Number.isInteger(width) && width > 0) {
276
+ base.add(width);
277
+ base.add(width * 2);
278
+ }
279
+ if (!width && typeof sizes === 'string' && sizes.trim().length > 0) {
280
+ for (const candidate of config?.deviceSizes || DEFAULT_DEVICE_SIZES) {
281
+ base.add(candidate);
282
+ }
283
+ }
284
+ let widths = [...base].filter((entry) => Number.isInteger(entry) && entry > 0).sort((left, right) => left - right);
285
+ const available = Array.isArray(manifestEntry?.availableWidths) ? manifestEntry.availableWidths : null;
286
+ if (available && available.length > 0) {
287
+ widths = widths.filter((entry) => available.includes(entry));
288
+ if (widths.length === 0) {
289
+ widths = [...available];
290
+ }
291
+ }
292
+ return widths;
293
+ }
294
+ export function imageRuntimeGlobalName() {
295
+ return IMAGE_RUNTIME_GLOBAL;
296
+ }
297
+ export function normalizeImageRuntimePayload(payload) {
298
+ if (!isPlainObject(payload)) {
299
+ return null;
300
+ }
301
+ return {
302
+ mode: payload.mode === 'endpoint' ? 'endpoint' : 'passthrough',
303
+ config: normalizeImageConfig(payload.config || {}),
304
+ localImages: isPlainObject(payload.localImages) ? payload.localImages : {}
305
+ };
306
+ }
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { resolve, join, dirname } from 'node:path';
13
13
  import { existsSync, readFileSync } from 'node:fs';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { createZenithLogger } from './ui/logger.js';
16
+ import { loadConfig } from './config.js';
16
17
  const COMMANDS = ['dev', 'build', 'preview'];
17
18
  const DEFAULT_VERSION = '0.0.0';
18
19
  const __filename = fileURLToPath(import.meta.url);
@@ -59,22 +60,6 @@ function resolvePort(args, fallback) {
59
60
  }
60
61
  return fallback;
61
62
  }
62
- /**
63
- * Load zenith.config.js from project root.
64
- *
65
- * @param {string} projectRoot
66
- * @returns {Promise<object>}
67
- */
68
- async function loadConfig(projectRoot) {
69
- const configPath = join(projectRoot, 'zenith.config.js');
70
- try {
71
- const mod = await import(configPath);
72
- return mod.default || {};
73
- }
74
- catch {
75
- return {};
76
- }
77
- }
78
63
  /**
79
64
  * CLI entry point.
80
65
  *
@@ -142,7 +127,7 @@ export async function cli(args, cwd) {
142
127
  const port = resolvePort(args.slice(1), 4000);
143
128
  const host = process.env.ZENITH_PREVIEW_HOST || '127.0.0.1';
144
129
  logger.dev('Starting preview server…');
145
- const preview = await createPreviewServer({ distDir: outDir, port, host, logger });
130
+ const preview = await createPreviewServer({ distDir: outDir, port, host, logger, config, projectRoot });
146
131
  logger.ok(`http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${preview.port}`);
147
132
  process.on('SIGINT', () => {
148
133
  preview.close();
package/dist/manifest.js CHANGED
@@ -32,6 +32,7 @@ export async function generateManifest(pagesDir, extension = '.zen') {
32
32
  for (const entry of entries) {
33
33
  _validateParams(entry.path);
34
34
  }
35
+ _validateManifestConflicts(entries);
35
36
  // Sort: static first, dynamic after, alpha within each category
36
37
  return _sortEntries(entries);
37
38
  }
@@ -142,6 +143,32 @@ function _validateParams(routePath) {
142
143
  }
143
144
  }
144
145
  }
146
+ /**
147
+ * Reject duplicate or structurally ambiguous routes across different files.
148
+ *
149
+ * @param {ManifestEntry[]} entries
150
+ */
151
+ function _validateManifestConflicts(entries) {
152
+ /** @type {Map<string, ManifestEntry>} */
153
+ const exactPaths = new Map();
154
+ /** @type {Map<string, ManifestEntry>} */
155
+ const structural = new Map();
156
+ for (const entry of entries) {
157
+ const existingExact = exactPaths.get(entry.path);
158
+ if (existingExact) {
159
+ throw new Error(`[Zenith CLI] Duplicate route path '${entry.path}' generated by '${existingExact.file}' and '${entry.file}'`);
160
+ }
161
+ exactPaths.set(entry.path, entry);
162
+ const signature = _routeConflictSignature(entry.path);
163
+ const existingStructural = structural.get(signature);
164
+ if (existingStructural && existingStructural.path !== entry.path) {
165
+ throw new Error(`[Zenith CLI] Ambiguous route patterns '${existingStructural.path}' (${existingStructural.file}) and '${entry.path}' (${entry.file}) match the same URL shape`);
166
+ }
167
+ if (!existingStructural) {
168
+ structural.set(signature, entry);
169
+ }
170
+ }
171
+ }
145
172
  /**
146
173
  * Check if a route contains any dynamic segments.
147
174
  *
@@ -160,6 +187,24 @@ function _isDynamic(routePath) {
160
187
  function _sortEntries(entries) {
161
188
  return [...entries].sort((a, b) => compareRouteSpecificity(a.path, b.path));
162
189
  }
190
+ /**
191
+ * Normalize a route path so structurally equivalent param names conflict.
192
+ *
193
+ * @param {string} routePath
194
+ * @returns {string}
195
+ */
196
+ function _routeConflictSignature(routePath) {
197
+ const segments = routePath.split('/').filter(Boolean).map((segment) => {
198
+ if (segment.startsWith('*')) {
199
+ return segment.endsWith('?') ? '*?' : '*';
200
+ }
201
+ if (segment.startsWith(':')) {
202
+ return ':';
203
+ }
204
+ return segment;
205
+ });
206
+ return '/' + segments.join('/');
207
+ }
163
208
  /**
164
209
  * Deterministic route precedence:
165
210
  * static segment > param segment > catch-all segment.
package/dist/preview.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Create and start a preview server.
3
3
  *
4
- * @param {{ distDir: string, port?: number, host?: string, logger?: object | null }} options
4
+ * @param {{ distDir: string, port?: number, host?: string, logger?: object | null, config?: object, projectRoot?: string }} options
5
5
  * @returns {Promise<{ server: import('http').Server, port: number, close: () => void }>}
6
6
  */
7
7
  export function createPreviewServer(options: {
@@ -9,6 +9,8 @@ export function createPreviewServer(options: {
9
9
  port?: number;
10
10
  host?: string;
11
11
  logger?: object | null;
12
+ config?: object;
13
+ projectRoot?: string;
12
14
  }): Promise<{
13
15
  server: import("http").Server;
14
16
  port: number;
@@ -59,6 +61,7 @@ export function executeServerRoute({ source, sourcePath, params, requestUrl, req
59
61
  load: string;
60
62
  };
61
63
  }>;
64
+ export function defaultRouteDenyMessage(status: any): "Unauthorized" | "Forbidden" | "Not Found" | "Internal Server Error";
62
65
  /**
63
66
  * @param {{ source: string, sourcePath: string, params: Record<string, string>, requestUrl?: string, requestMethod?: string, requestHeaders?: Record<string, string | string[] | undefined>, routePattern?: string, routeFile?: string, routeId?: string }} input
64
67
  * @returns {Promise<Record<string, unknown> | null>}
package/dist/preview.js CHANGED
@@ -13,6 +13,10 @@ import { createServer } from 'node:http';
13
13
  import { access, readFile } from 'node:fs/promises';
14
14
  import { extname, join, normalize, resolve, sep, dirname } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
+ import { loadConfig } from './config.js';
17
+ import { materializeImageMarkup } from './images/materialize.js';
18
+ import { createImageRuntimePayload, injectImageRuntimePayload } from './images/payload.js';
19
+ import { handleImageRequest } from './images/service.js';
16
20
  import { createSilentLogger } from './ui/logger.js';
17
21
  import { compareRouteSpecificity, matchRoute as matchManifestRoute, resolveRequestRoute } from './server/resolve-request-route.js';
18
22
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -22,8 +26,12 @@ const MIME_TYPES = {
22
26
  '.css': 'text/css',
23
27
  '.json': 'application/json',
24
28
  '.png': 'image/png',
29
+ '.jpeg': 'image/jpeg',
25
30
  '.jpg': 'image/jpeg',
26
- '.svg': 'image/svg+xml'
31
+ '.svg': 'image/svg+xml',
32
+ '.webp': 'image/webp',
33
+ '.avif': 'image/avif',
34
+ '.gif': 'image/gif'
27
35
  };
28
36
  const SERVER_SCRIPT_RUNNER = String.raw `
29
37
  import vm from 'node:vm';
@@ -378,14 +386,30 @@ try {
378
386
  /**
379
387
  * Create and start a preview server.
380
388
  *
381
- * @param {{ distDir: string, port?: number, host?: string, logger?: object | null }} options
389
+ * @param {{ distDir: string, port?: number, host?: string, logger?: object | null, config?: object, projectRoot?: string }} options
382
390
  * @returns {Promise<{ server: import('http').Server, port: number, close: () => void }>}
383
391
  */
384
392
  export async function createPreviewServer(options) {
393
+ const resolvedProjectRoot = options?.projectRoot ? resolve(options.projectRoot) : resolve(options.distDir, '..');
394
+ const resolvedConfig = options?.config && typeof options.config === 'object'
395
+ ? options.config
396
+ : await loadConfig(resolvedProjectRoot);
385
397
  const { distDir, port = 4000, host = '127.0.0.1', logger: providedLogger = null } = options;
398
+ const projectRoot = resolvedProjectRoot;
399
+ const config = resolvedConfig;
386
400
  const logger = providedLogger || createSilentLogger();
387
401
  const verboseLogging = logger.mode?.logLevel === 'verbose';
388
402
  let actualPort = port;
403
+ async function loadImageManifest() {
404
+ try {
405
+ const manifestRaw = await readFile(join(distDir, '_zenith', 'image', 'manifest.json'), 'utf8');
406
+ const parsed = JSON.parse(manifestRaw);
407
+ return parsed && typeof parsed === 'object' ? parsed : {};
408
+ }
409
+ catch {
410
+ return {};
411
+ }
412
+ }
389
413
  function publicHost() {
390
414
  if (host === '0.0.0.0' || host === '::') {
391
415
  return '127.0.0.1';
@@ -469,6 +493,14 @@ export async function createPreviewServer(options) {
469
493
  }));
470
494
  return;
471
495
  }
496
+ if (url.pathname === '/_zenith/image') {
497
+ await handleImageRequest(req, res, {
498
+ requestUrl: url,
499
+ projectRoot,
500
+ config: config.images
501
+ });
502
+ return;
503
+ }
472
504
  if (extname(url.pathname) && extname(url.pathname) !== '.html') {
473
505
  const staticPath = resolveWithinDist(distDir, url.pathname);
474
506
  if (!staticPath || !(await fileExists(staticPath))) {
@@ -540,7 +572,7 @@ export async function createPreviewServer(options) {
540
572
  if (result && result.kind === 'deny') {
541
573
  const status = Number.isInteger(result.status) ? result.status : 403;
542
574
  res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
543
- res.end(result.message || (status === 401 ? 'Unauthorized' : 'Forbidden'));
575
+ res.end(result.message || defaultRouteDenyMessage(status));
544
576
  return;
545
577
  }
546
578
  if (result && result.kind === 'data' && result.data && typeof result.data === 'object' && !Array.isArray(result.data)) {
@@ -548,9 +580,20 @@ export async function createPreviewServer(options) {
548
580
  }
549
581
  }
550
582
  let html = await readFile(htmlPath, 'utf8');
583
+ if (resolved.matched && resolved.route?.page_asset) {
584
+ const pageAssetPath = resolveWithinDist(distDir, resolved.route.page_asset);
585
+ html = await materializeImageMarkup({
586
+ html,
587
+ pageAssetPath,
588
+ payload: createImageRuntimePayload(config.images, await loadImageManifest(), 'endpoint'),
589
+ ssrData: ssrPayload,
590
+ routePathname: resolved.route.path || url.pathname
591
+ });
592
+ }
551
593
  if (ssrPayload) {
552
594
  html = injectSsrPayload(html, ssrPayload);
553
595
  }
596
+ html = injectImageRuntimePayload(html, createImageRuntimePayload(config.images, await loadImageManifest(), 'endpoint'));
554
597
  res.writeHead(200, { 'Content-Type': 'text/html' });
555
598
  res.end(html);
556
599
  }
@@ -674,6 +717,15 @@ export async function executeServerRoute({ source, sourcePath, params, requestUr
674
717
  trace: { guard: 'none', load: 'data' }
675
718
  };
676
719
  }
720
+ export function defaultRouteDenyMessage(status) {
721
+ if (status === 401)
722
+ return 'Unauthorized';
723
+ if (status === 403)
724
+ return 'Forbidden';
725
+ if (status === 404)
726
+ return 'Not Found';
727
+ return 'Internal Server Error';
728
+ }
677
729
  /**
678
730
  * @param {{ source: string, sourcePath: string, params: Record<string, string>, requestUrl?: string, requestMethod?: string, requestHeaders?: Record<string, string | string[] | undefined>, routePattern?: string, routeFile?: string, routeId?: string }} input
679
731
  * @returns {Promise<Record<string, unknown> | null>}
@@ -697,11 +749,12 @@ export async function executeServerScript(input) {
697
749
  };
698
750
  }
699
751
  if (result.kind === 'deny') {
752
+ const status = Number.isInteger(result.status) ? result.status : 403;
700
753
  return {
701
754
  __zenith_error: {
702
- status: Number.isInteger(result.status) ? result.status : 403,
703
- code: 'ACCESS_DENIED',
704
- message: String(result.message || 'Access denied')
755
+ status,
756
+ code: status >= 500 ? 'LOAD_FAILED' : (status === 404 ? 'NOT_FOUND' : 'ACCESS_DENIED'),
757
+ message: String(result.message || defaultRouteDenyMessage(status))
705
758
  }
706
759
  };
707
760
  }
@@ -8,9 +8,15 @@
8
8
  // Pipeline:
9
9
  // buildComponentRegistry() → expandComponents() → expanded source string
10
10
  // ---------------------------------------------------------------------------
11
- import { readdirSync, readFileSync, statSync } from 'node:fs';
12
- import { basename, extname, join } from 'node:path';
11
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
12
+ import { basename, dirname, extname, join } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
13
14
  import { findMatchingComponentClose, findNextKnownComponentTag } from './component-tag-parser.js';
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+ const FRAMEWORK_COMPONENTS = [
18
+ ['Image', join(__dirname, 'framework-components', 'Image.zen')]
19
+ ];
14
20
  // ---------------------------------------------------------------------------
15
21
  // Registry: Map<PascalCaseName, absolutePath>
16
22
  // ---------------------------------------------------------------------------
@@ -37,6 +43,18 @@ export function buildComponentRegistry(srcDir) {
37
43
  }
38
44
  walkDir(dir, registry);
39
45
  }
46
+ for (const [componentName, componentPath] of FRAMEWORK_COMPONENTS) {
47
+ if (!existsSync(componentPath)) {
48
+ continue;
49
+ }
50
+ if (registry.has(componentName)) {
51
+ throw new Error(`Duplicate component name "${componentName}":\n` +
52
+ ` 1) ${registry.get(componentName)}\n` +
53
+ ` 2) ${componentPath}\n` +
54
+ 'Rename the local component to avoid shadowing the Zenith framework component.');
55
+ }
56
+ registry.set(componentName, componentPath);
57
+ }
40
58
  return registry;
41
59
  }
42
60
  /**
@@ -207,7 +225,6 @@ function expandTag(name, children, registry, sourceFile, chain, usedComponents)
207
225
  }
208
226
  const compSource = readFileSync(compPath, 'utf8');
209
227
  let template = extractTemplate(compSource);
210
- // Check Document Mode
211
228
  const docMode = isDocumentMode(template);
212
229
  if (docMode) {
213
230
  // Document Mode: must contain exactly one <slot />
@@ -49,8 +49,9 @@ function assertValidRouteResultShape(value, where, allowedKinds) {
49
49
  }
50
50
  }
51
51
  if (kind === 'deny') {
52
- if (!Number.isInteger(value.status) || (value.status !== 401 && value.status !== 403)) {
53
- throw new Error(`[Zenith] ${where}: deny status must be 401 or 403.`);
52
+ if (!Number.isInteger(value.status) ||
53
+ (value.status !== 401 && value.status !== 403 && value.status !== 404)) {
54
+ throw new Error(`[Zenith] ${where}: deny status must be 401, 403, or 404.`);
54
55
  }
55
56
  if (value.message !== undefined && typeof value.message !== 'string') {
56
57
  throw new Error(`[Zenith] ${where}: deny message must be a string when provided.`);
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @param {{
3
+ * sourceFile: string,
4
+ * inlineServerScript?: { source: string, prerender: boolean, has_guard: boolean, has_load: boolean, source_path: string } | null,
5
+ * adjacentGuardPath?: string | null,
6
+ * adjacentLoadPath?: string | null
7
+ * }} input
8
+ * @returns {{ serverScript: { source: string, prerender: boolean, has_guard: boolean, has_load: boolean, source_path: string } | null, guardPath: string | null, loadPath: string | null }}
9
+ */
10
+ export function composeServerScriptEnvelope({ sourceFile, inlineServerScript, adjacentGuardPath, adjacentLoadPath }: {
11
+ sourceFile: string;
12
+ inlineServerScript?: {
13
+ source: string;
14
+ prerender: boolean;
15
+ has_guard: boolean;
16
+ has_load: boolean;
17
+ source_path: string;
18
+ } | null;
19
+ adjacentGuardPath?: string | null;
20
+ adjacentLoadPath?: string | null;
21
+ }): {
22
+ serverScript: {
23
+ source: string;
24
+ prerender: boolean;
25
+ has_guard: boolean;
26
+ has_load: boolean;
27
+ source_path: string;
28
+ } | null;
29
+ guardPath: string | null;
30
+ loadPath: string | null;
31
+ };
32
+ /**
33
+ * @param {string} sourceFile
34
+ * @returns {{ guardPath: string | null, loadPath: string | null }}
35
+ */
36
+ export function resolveAdjacentServerModules(sourceFile: string): {
37
+ guardPath: string | null;
38
+ loadPath: string | null;
39
+ };