@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,254 @@
1
+ import { buildLocalVariantPath, buildRemoteVariantPath, imageRuntimeGlobalName, matchRemotePattern, normalizeImageRuntimePayload, normalizeImageSource, resolveWidthCandidates } from './shared.js';
2
+ function safeString(value) {
3
+ if (typeof value === 'string') {
4
+ return value.trim();
5
+ }
6
+ if (typeof value === 'number' && Number.isFinite(value)) {
7
+ return String(value);
8
+ }
9
+ return '';
10
+ }
11
+ function escapeHtml(value) {
12
+ return String(value || '')
13
+ .replace(/&/g, '&')
14
+ .replace(/</g, '&lt;')
15
+ .replace(/>/g, '&gt;')
16
+ .replace(/"/g, '&quot;')
17
+ .replace(/'/g, '&#39;');
18
+ }
19
+ function buildAttr(name, value) {
20
+ if (value === null || value === undefined || value === '') {
21
+ return '';
22
+ }
23
+ return ` ${name}="${escapeHtml(value)}"`;
24
+ }
25
+ function readRuntimePayload() {
26
+ if (typeof globalThis !== 'object' || !globalThis) {
27
+ return null;
28
+ }
29
+ return normalizeImageRuntimePayload(globalThis[imageRuntimeGlobalName()]);
30
+ }
31
+ function encodeBase64(value) {
32
+ if (typeof Buffer !== 'undefined') {
33
+ return Buffer.from(value, 'utf8').toString('base64');
34
+ }
35
+ if (typeof btoa === 'function' && typeof TextEncoder === 'function') {
36
+ const bytes = new TextEncoder().encode(value);
37
+ let binary = '';
38
+ for (const byte of bytes) {
39
+ binary += String.fromCharCode(byte);
40
+ }
41
+ return btoa(binary);
42
+ }
43
+ return '';
44
+ }
45
+ function decodeBase64(value) {
46
+ if (typeof Buffer !== 'undefined') {
47
+ return Buffer.from(value, 'base64').toString('utf8');
48
+ }
49
+ if (typeof atob === 'function' && typeof TextDecoder === 'function') {
50
+ const binary = atob(value);
51
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
52
+ return new TextDecoder().decode(bytes);
53
+ }
54
+ return '';
55
+ }
56
+ function encodeMarkerPayload(value) {
57
+ return encodeBase64(JSON.stringify(value));
58
+ }
59
+ function decodeMarkerPayload(value) {
60
+ try {
61
+ const json = decodeBase64(String(value || ''));
62
+ const parsed = JSON.parse(json);
63
+ return parsed && typeof parsed === 'object' ? parsed : null;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ function localEntryFor(payload, publicPath) {
70
+ if (!payload || !payload.localImages || typeof payload.localImages !== 'object') {
71
+ return null;
72
+ }
73
+ return payload.localImages[publicPath] || null;
74
+ }
75
+ function pickNumeric(value, fallback) {
76
+ return Number.isInteger(value) && value > 0 ? value : fallback;
77
+ }
78
+ function mergeStyle(style, fit, position) {
79
+ const segments = [];
80
+ const incoming = safeString(style);
81
+ if (incoming) {
82
+ segments.push(incoming.replace(/;+\s*$/, ''));
83
+ }
84
+ if (safeString(fit)) {
85
+ segments.push(`object-fit: ${safeString(fit)}`);
86
+ }
87
+ if (safeString(position)) {
88
+ segments.push(`object-position: ${safeString(position)}`);
89
+ }
90
+ return segments.join('; ');
91
+ }
92
+ function buildSourceTags(sources) {
93
+ return sources.map((entry) => {
94
+ const attrs = [
95
+ buildAttr('type', entry.type),
96
+ buildAttr('srcset', entry.srcset),
97
+ buildAttr('sizes', entry.sizes)
98
+ ].join('');
99
+ return `<source${attrs}>`;
100
+ }).join('');
101
+ }
102
+ function mimeTypeForFormat(format) {
103
+ switch (String(format || '').toLowerCase()) {
104
+ case 'avif':
105
+ return 'image/avif';
106
+ case 'webp':
107
+ return 'image/webp';
108
+ case 'png':
109
+ return 'image/png';
110
+ case 'jpg':
111
+ case 'jpeg':
112
+ return 'image/jpeg';
113
+ default:
114
+ return '';
115
+ }
116
+ }
117
+ function buildLocalImageModel(props, payload, source) {
118
+ const config = payload?.config;
119
+ const manifestEntry = localEntryFor(payload, source.path);
120
+ const width = pickNumeric(props.width, pickNumeric(source.width, pickNumeric(manifestEntry?.width, null)));
121
+ const height = pickNumeric(props.height, pickNumeric(source.height, pickNumeric(manifestEntry?.height, null)));
122
+ const quality = pickNumeric(props.quality, config.quality);
123
+ const sizes = safeString(props.sizes);
124
+ const widths = resolveWidthCandidates(width, sizes, config, manifestEntry);
125
+ const fallbackFormat = safeString(manifestEntry?.originalFormat) || 'jpg';
126
+ const sourceFormats = (config.formats || []).filter((format) => Array.isArray(manifestEntry?.availableFormats)
127
+ ? manifestEntry.availableFormats.includes(format)
128
+ : true);
129
+ const fallbackWidth = widths.length > 0 ? widths[widths.length - 1] : width;
130
+ const imgSrc = props.unoptimized === true
131
+ ? source.path
132
+ : buildLocalVariantPath(source.path, fallbackWidth || width || manifestEntry?.width || 0, quality, fallbackFormat);
133
+ const sources = props.unoptimized === true
134
+ ? []
135
+ : sourceFormats.map((format) => ({
136
+ type: mimeTypeForFormat(format),
137
+ sizes,
138
+ srcset: widths.map((candidate) => `${buildLocalVariantPath(source.path, candidate, quality, format)} ${candidate}w`).join(', ')
139
+ })).filter((entry) => entry.type && entry.srcset);
140
+ return {
141
+ src: imgSrc,
142
+ width,
143
+ height,
144
+ sizes,
145
+ sources
146
+ };
147
+ }
148
+ function buildRemoteImageModel(props, payload, source) {
149
+ const config = payload?.config;
150
+ const width = pickNumeric(props.width, pickNumeric(source.width, null));
151
+ const height = pickNumeric(props.height, pickNumeric(source.height, null));
152
+ const quality = pickNumeric(props.quality, config.quality);
153
+ const sizes = safeString(props.sizes);
154
+ const widths = resolveWidthCandidates(width, sizes, config, null);
155
+ const allowed = matchRemotePattern(source.url, config.remotePatterns || []);
156
+ if (!allowed) {
157
+ return null;
158
+ }
159
+ if (payload.mode !== 'endpoint' || props.unoptimized === true || !width) {
160
+ return {
161
+ src: source.url,
162
+ width,
163
+ height,
164
+ sizes,
165
+ sources: []
166
+ };
167
+ }
168
+ const sources = (config.formats || []).map((format) => ({
169
+ type: mimeTypeForFormat(format),
170
+ sizes,
171
+ srcset: widths.map((candidate) => `${buildRemoteVariantPath(source.url, candidate, quality, format)} ${candidate}w`).join(', ')
172
+ })).filter((entry) => entry.type && entry.srcset);
173
+ return {
174
+ src: buildRemoteVariantPath(source.url, widths[widths.length - 1] || width, quality, ''),
175
+ width,
176
+ height,
177
+ sizes,
178
+ sources
179
+ };
180
+ }
181
+ export function renderImageHtmlWithPayload(rawProps, payload) {
182
+ const props = rawProps && typeof rawProps === 'object' ? rawProps : {};
183
+ const source = normalizeImageSource(props.src);
184
+ if (!source) {
185
+ return '';
186
+ }
187
+ const alt = safeString(props.alt) || safeString(source.alt);
188
+ if (!alt) {
189
+ return '';
190
+ }
191
+ const model = source.kind === 'local'
192
+ ? buildLocalImageModel(props, payload, source)
193
+ : buildRemoteImageModel(props, payload, source);
194
+ if (!model || !model.src) {
195
+ return '';
196
+ }
197
+ const className = safeString(props.class);
198
+ const style = mergeStyle(props.style, props.fit, props.position);
199
+ const loading = props.priority === true ? 'eager' : safeString(props.loading) || 'lazy';
200
+ const decoding = safeString(props.decoding) || 'async';
201
+ const fetchPriority = props.priority === true ? 'high' : '';
202
+ const sourcesHtml = buildSourceTags(model.sources);
203
+ const imgAttrs = [
204
+ buildAttr('src', model.src),
205
+ buildAttr('alt', alt),
206
+ buildAttr('class', className),
207
+ buildAttr('style', style),
208
+ buildAttr('loading', loading),
209
+ buildAttr('decoding', decoding),
210
+ buildAttr('fetchpriority', fetchPriority),
211
+ buildAttr('sizes', model.sizes),
212
+ buildAttr('width', model.width),
213
+ buildAttr('height', model.height)
214
+ ].join('');
215
+ if (sourcesHtml) {
216
+ return `<picture>${sourcesHtml}<img${imgAttrs} /></picture>`;
217
+ }
218
+ return `<img${imgAttrs} />`;
219
+ }
220
+ export function renderImageHtml(rawProps) {
221
+ const payload = readRuntimePayload();
222
+ if (!payload) {
223
+ return '';
224
+ }
225
+ return renderImageHtmlWithPayload(rawProps, payload);
226
+ }
227
+ export function serializeImageProps(rawProps) {
228
+ const props = rawProps && typeof rawProps === 'object' ? rawProps : {};
229
+ const source = normalizeImageSource(props.src);
230
+ const alt = safeString(props.alt) || safeString(source?.alt);
231
+ if (!source || !alt) {
232
+ return '';
233
+ }
234
+ return encodeMarkerPayload({
235
+ ...props,
236
+ src: props.src
237
+ });
238
+ }
239
+ export function replaceImageMarkers(html, payload) {
240
+ if (typeof html !== 'string' || html.length === 0) {
241
+ return html;
242
+ }
243
+ const runtimePayload = normalizeImageRuntimePayload(payload);
244
+ if (!runtimePayload) {
245
+ return html;
246
+ }
247
+ return html.replace(/(<span\b[^>]*\bdata-zenith-image=(["'])([^"']+)\2[^>]*>)([\s\S]*?)<\/span>/gi, (_match, openTag, _quote, encodedPayload) => {
248
+ const props = decodeMarkerPayload(encodedPayload);
249
+ if (!props) {
250
+ return `${openTag}</span>`;
251
+ }
252
+ return `${openTag}${renderImageHtmlWithPayload(props, runtimePayload)}</span>`;
253
+ });
254
+ }
@@ -0,0 +1,4 @@
1
+ export function buildImageArtifacts(options: any): Promise<{
2
+ manifest: {};
3
+ }>;
4
+ export function handleImageRequest(req: any, res: any, options: any): Promise<boolean>;
@@ -0,0 +1,302 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, readFile, stat, writeFile, readdir } from 'node:fs/promises';
4
+ import { dirname, extname, join, relative, resolve } from 'node:path';
5
+ import sharp from 'sharp';
6
+ import { buildLocalImageKey, buildLocalVariantPath, matchRemotePattern, normalizeImageConfig, normalizeImageFormat } from './shared.js';
7
+ const RASTER_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif']);
8
+ const MIME_BY_FORMAT = {
9
+ avif: 'image/avif',
10
+ webp: 'image/webp',
11
+ png: 'image/png',
12
+ jpg: 'image/jpeg',
13
+ jpeg: 'image/jpeg'
14
+ };
15
+ function isPrivateIp(address) {
16
+ if (!address) {
17
+ return false;
18
+ }
19
+ if (address === '::1' || address === '127.0.0.1') {
20
+ return true;
21
+ }
22
+ if (address.startsWith('10.') || address.startsWith('192.168.')) {
23
+ return true;
24
+ }
25
+ if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(address)) {
26
+ return true;
27
+ }
28
+ if (/^(fc|fd)/i.test(address.replace(/:/g, ''))) {
29
+ return true;
30
+ }
31
+ return false;
32
+ }
33
+ function isLoopbackHostname(hostname) {
34
+ const normalized = String(hostname || '').toLowerCase();
35
+ return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1';
36
+ }
37
+ function mimeTypeForFormat(format) {
38
+ return MIME_BY_FORMAT[normalizeImageFormat(format)] || 'application/octet-stream';
39
+ }
40
+ function uniqueSortedWidths(config, metadataWidth) {
41
+ const values = new Set([
42
+ ...(config.deviceSizes || []),
43
+ ...(config.imageSizes || [])
44
+ ]);
45
+ if (Number.isInteger(metadataWidth) && metadataWidth > 0) {
46
+ values.add(metadataWidth);
47
+ }
48
+ return [...values]
49
+ .filter((value) => Number.isInteger(value) && value > 0)
50
+ .filter((value) => !metadataWidth || value <= metadataWidth)
51
+ .sort((left, right) => left - right);
52
+ }
53
+ function resolvePublicRoots(projectRoot) {
54
+ const candidates = [
55
+ resolve(projectRoot, 'src', 'public'),
56
+ resolve(projectRoot, 'public')
57
+ ];
58
+ return candidates.filter((candidate, index) => existsSync(candidate) && candidates.indexOf(candidate) === index);
59
+ }
60
+ async function walkPublicImages(rootDir) {
61
+ const files = [];
62
+ async function walk(dir) {
63
+ let entries = [];
64
+ try {
65
+ entries = await readdir(dir, { withFileTypes: true });
66
+ }
67
+ catch {
68
+ return;
69
+ }
70
+ entries.sort((left, right) => left.name.localeCompare(right.name));
71
+ for (const entry of entries) {
72
+ const fullPath = join(dir, entry.name);
73
+ if (entry.isDirectory()) {
74
+ await walk(fullPath);
75
+ continue;
76
+ }
77
+ const extension = extname(entry.name).toLowerCase();
78
+ if (RASTER_EXTENSIONS.has(extension)) {
79
+ files.push(fullPath);
80
+ }
81
+ }
82
+ }
83
+ await walk(rootDir);
84
+ return files;
85
+ }
86
+ async function writeIfStale(sourcePath, targetPath, buffer) {
87
+ const sourceInfo = await stat(sourcePath);
88
+ if (existsSync(targetPath)) {
89
+ const targetInfo = await stat(targetPath);
90
+ if (targetInfo.mtimeMs >= sourceInfo.mtimeMs) {
91
+ return;
92
+ }
93
+ }
94
+ await mkdir(dirname(targetPath), { recursive: true });
95
+ await writeFile(targetPath, buffer);
96
+ }
97
+ function variantRelativePath(publicPath, width, quality, format) {
98
+ return buildLocalVariantPath(publicPath, width, quality, format).replace(/^\//, '');
99
+ }
100
+ function createRemoteCacheKey(url, width, quality, format) {
101
+ return buildLocalImageKey(`${url}|${width}|${quality}|${format || 'original'}`);
102
+ }
103
+ async function transformImageBuffer(buffer, width, quality, format) {
104
+ let pipeline = sharp(buffer, { animated: false }).rotate();
105
+ if (Number.isInteger(width) && width > 0) {
106
+ pipeline = pipeline.resize({ width, withoutEnlargement: true });
107
+ }
108
+ switch (normalizeImageFormat(format)) {
109
+ case 'avif':
110
+ return pipeline.avif({ quality }).toBuffer();
111
+ case 'webp':
112
+ return pipeline.webp({ quality }).toBuffer();
113
+ case 'png':
114
+ return pipeline.png({ quality }).toBuffer();
115
+ case 'jpg':
116
+ case 'jpeg':
117
+ return pipeline.jpeg({ quality }).toBuffer();
118
+ default:
119
+ return pipeline.toBuffer();
120
+ }
121
+ }
122
+ export async function buildImageArtifacts(options) {
123
+ const { projectRoot, outDir, config: rawConfig } = options;
124
+ const config = normalizeImageConfig(rawConfig);
125
+ const manifest = {};
126
+ const publicRoots = resolvePublicRoots(projectRoot);
127
+ if (publicRoots.length === 0) {
128
+ return { manifest };
129
+ }
130
+ for (const publicRoot of publicRoots) {
131
+ const files = await walkPublicImages(publicRoot);
132
+ for (const filePath of files) {
133
+ const publicPath = `/${relative(publicRoot, filePath).replaceAll('\\', '/')}`;
134
+ if (manifest[publicPath]) {
135
+ continue;
136
+ }
137
+ const image = sharp(filePath, { animated: false });
138
+ const metadata = await image.metadata();
139
+ if (!Number.isInteger(metadata.width) || !Number.isInteger(metadata.height) || !metadata.format) {
140
+ continue;
141
+ }
142
+ const widths = uniqueSortedWidths(config, metadata.width);
143
+ const originalFormat = normalizeImageFormat(metadata.format);
144
+ const formats = [...new Set([originalFormat, ...config.formats])];
145
+ const quality = config.quality;
146
+ const key = buildLocalImageKey(publicPath);
147
+ manifest[publicPath] = {
148
+ key,
149
+ width: metadata.width,
150
+ height: metadata.height,
151
+ originalFormat,
152
+ availableWidths: widths,
153
+ availableFormats: formats
154
+ };
155
+ const sourceBuffer = await readFile(filePath);
156
+ for (const width of widths) {
157
+ for (const format of formats) {
158
+ const buffer = await transformImageBuffer(sourceBuffer, width, quality, format);
159
+ const outputPath = join(outDir, variantRelativePath(publicPath, width, quality, format));
160
+ await writeIfStale(filePath, outputPath, buffer);
161
+ }
162
+ }
163
+ }
164
+ }
165
+ const manifestPath = join(outDir, '_zenith', 'image', 'manifest.json');
166
+ await mkdir(dirname(manifestPath), { recursive: true });
167
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
168
+ return { manifest };
169
+ }
170
+ async function validateRemoteTarget(remoteUrl, config) {
171
+ const url = new URL(remoteUrl);
172
+ if (!matchRemotePattern(url, config.remotePatterns)) {
173
+ throw new Error('[Zenith:Image] Remote URL is not allowed by images.remotePatterns');
174
+ }
175
+ if (!config.dangerouslyAllowLocalNetwork) {
176
+ if (isLoopbackHostname(url.hostname)) {
177
+ throw new Error('[Zenith:Image] Loopback and local network image fetches are blocked');
178
+ }
179
+ const resolved = await lookup(url.hostname, { all: true });
180
+ if (resolved.some((entry) => isPrivateIp(entry.address))) {
181
+ throw new Error('[Zenith:Image] Private network image fetches are blocked');
182
+ }
183
+ }
184
+ return url;
185
+ }
186
+ async function readRemoteBuffer(response, maxBytes) {
187
+ const reader = response.body?.getReader?.();
188
+ if (!reader) {
189
+ const buffer = Buffer.from(await response.arrayBuffer());
190
+ if (buffer.length > maxBytes) {
191
+ throw new Error('[Zenith:Image] Remote image exceeds maxRemoteBytes');
192
+ }
193
+ return buffer;
194
+ }
195
+ let total = 0;
196
+ const chunks = [];
197
+ while (true) {
198
+ const { done, value } = await reader.read();
199
+ if (done) {
200
+ break;
201
+ }
202
+ total += value.byteLength;
203
+ if (total > maxBytes) {
204
+ throw new Error('[Zenith:Image] Remote image exceeds maxRemoteBytes');
205
+ }
206
+ chunks.push(Buffer.from(value));
207
+ }
208
+ return Buffer.concat(chunks);
209
+ }
210
+ function sendBuffer(res, status, contentType, buffer, cacheSeconds) {
211
+ res.writeHead(status, {
212
+ 'Content-Type': contentType,
213
+ 'Cache-Control': `public, max-age=${cacheSeconds}`
214
+ });
215
+ res.end(buffer);
216
+ }
217
+ function remoteCachePaths(cacheDir, cacheKey) {
218
+ return {
219
+ dataPath: join(cacheDir, `${cacheKey}.img`),
220
+ metaPath: join(cacheDir, `${cacheKey}.json`)
221
+ };
222
+ }
223
+ export async function handleImageRequest(req, res, options) {
224
+ const { requestUrl, projectRoot, config: rawConfig } = options;
225
+ const config = normalizeImageConfig(rawConfig);
226
+ const url = requestUrl instanceof URL ? requestUrl : new URL(String(requestUrl));
227
+ const remoteUrl = url.searchParams.get('url');
228
+ const width = Number.parseInt(url.searchParams.get('w') || '', 10);
229
+ const requestedQuality = Number.parseInt(url.searchParams.get('q') || '', 10);
230
+ const format = normalizeImageFormat(url.searchParams.get('f') || '');
231
+ const quality = Number.isInteger(requestedQuality) && requestedQuality > 0 ? requestedQuality : config.quality;
232
+ if (!remoteUrl) {
233
+ res.writeHead(400, { 'Content-Type': 'application/json' });
234
+ res.end(JSON.stringify({ error: 'missing_url' }));
235
+ return true;
236
+ }
237
+ if (!Number.isInteger(width) || width <= 0) {
238
+ res.writeHead(400, { 'Content-Type': 'application/json' });
239
+ res.end(JSON.stringify({ error: 'invalid_width' }));
240
+ return true;
241
+ }
242
+ try {
243
+ const remote = await validateRemoteTarget(remoteUrl, config);
244
+ const cacheKey = createRemoteCacheKey(remote.toString(), width, quality, format || 'original');
245
+ const cacheDir = resolve(projectRoot, '.zenith', 'image-cache');
246
+ const { dataPath, metaPath } = remoteCachePaths(cacheDir, cacheKey);
247
+ if (existsSync(dataPath) && existsSync(metaPath)) {
248
+ const [cached, cachedMeta] = await Promise.all([
249
+ readFile(dataPath),
250
+ readFile(metaPath, 'utf8')
251
+ ]);
252
+ const parsedMeta = JSON.parse(cachedMeta);
253
+ const contentType = typeof parsedMeta?.contentType === 'string'
254
+ ? parsedMeta.contentType
255
+ : mimeTypeForFormat(format || 'jpg');
256
+ sendBuffer(res, 200, contentType, cached, config.minimumCacheTTL);
257
+ return true;
258
+ }
259
+ const response = await fetch(remote, {
260
+ headers: {
261
+ 'Accept': 'image/avif,image/webp,image/png,image/jpeg,image/*;q=0.8,*/*;q=0.1'
262
+ },
263
+ redirect: 'follow'
264
+ });
265
+ if (!response.ok) {
266
+ throw new Error(`[Zenith:Image] Remote image fetch failed with status ${response.status}`);
267
+ }
268
+ const contentType = String(response.headers.get('content-type') || '').toLowerCase();
269
+ if (!contentType.startsWith('image/')) {
270
+ throw new Error('[Zenith:Image] Remote response was not an image');
271
+ }
272
+ if (contentType.includes('svg') && !config.allowSvg) {
273
+ throw new Error('[Zenith:Image] SVG images are blocked unless images.allowSvg is enabled');
274
+ }
275
+ const buffer = await readRemoteBuffer(response, config.maxRemoteBytes);
276
+ const metadata = await sharp(buffer, { animated: false }).metadata();
277
+ if ((metadata.width || 0) * (metadata.height || 0) > config.maxPixels) {
278
+ throw new Error('[Zenith:Image] Remote image exceeds maxPixels');
279
+ }
280
+ const sourceFormat = normalizeImageFormat(metadata.format);
281
+ const targetFormat = format || (sourceFormat === 'gif' ? 'png' : sourceFormat || 'jpg');
282
+ const output = await transformImageBuffer(buffer, width, quality, targetFormat);
283
+ await mkdir(cacheDir, { recursive: true });
284
+ await Promise.all([
285
+ writeFile(dataPath, output),
286
+ writeFile(metaPath, `${JSON.stringify({
287
+ contentType: mimeTypeForFormat(targetFormat),
288
+ format: targetFormat
289
+ }, null, 2)}\n`, 'utf8')
290
+ ]);
291
+ sendBuffer(res, 200, mimeTypeForFormat(targetFormat), output, config.minimumCacheTTL);
292
+ return true;
293
+ }
294
+ catch (error) {
295
+ res.writeHead(400, { 'Content-Type': 'application/json' });
296
+ res.end(JSON.stringify({
297
+ error: 'image_request_failed',
298
+ message: error instanceof Error ? error.message : String(error)
299
+ }));
300
+ return true;
301
+ }
302
+ }
@@ -0,0 +1,58 @@
1
+ export function normalizeImageConfig(input: any): {
2
+ formats: string[];
3
+ deviceSizes: number[];
4
+ imageSizes: number[];
5
+ remotePatterns: any[];
6
+ quality: number;
7
+ allowSvg: boolean;
8
+ maxRemoteBytes: number;
9
+ maxPixels: number;
10
+ minimumCacheTTL: number;
11
+ dangerouslyAllowLocalNetwork: boolean;
12
+ };
13
+ export function matchRemotePattern(inputUrl: any, patterns: any): boolean;
14
+ export function isRemoteImageUrl(value: any): boolean;
15
+ export function normalizeImageSource(input: any): any;
16
+ export function normalizeImageFormat(value: any): string;
17
+ export function buildLocalImageKey(publicPath: any): string;
18
+ export function buildLocalVariantPath(publicPath: any, width: any, quality: any, format: any): string;
19
+ export function buildRemoteVariantPath(remoteUrl: any, width: any, quality: any, format: any): string;
20
+ export function resolveWidthCandidates(width: any, sizes: any, config: any, manifestEntry: any): any[];
21
+ export function imageRuntimeGlobalName(): string;
22
+ export function normalizeImageRuntimePayload(payload: any): {
23
+ mode: string;
24
+ config: {
25
+ formats: string[];
26
+ deviceSizes: number[];
27
+ imageSizes: number[];
28
+ remotePatterns: any[];
29
+ quality: number;
30
+ allowSvg: boolean;
31
+ maxRemoteBytes: number;
32
+ maxPixels: number;
33
+ minimumCacheTTL: number;
34
+ dangerouslyAllowLocalNetwork: boolean;
35
+ };
36
+ localImages: any;
37
+ } | null;
38
+ export namespace DEFAULT_IMAGE_CONFIG {
39
+ export { DEFAULT_FORMATS as formats };
40
+ export { DEFAULT_QUALITY as quality };
41
+ export { DEFAULT_DEVICE_SIZES as deviceSizes };
42
+ export { DEFAULT_IMAGE_SIZES as imageSizes };
43
+ export { DEFAULT_REMOTE_PATTERNS as remotePatterns };
44
+ export let allowSvg: boolean;
45
+ export { DEFAULT_MAX_REMOTE_BYTES as maxRemoteBytes };
46
+ export { DEFAULT_MAX_PIXELS as maxPixels };
47
+ export { DEFAULT_MINIMUM_CACHE_TTL as minimumCacheTTL };
48
+ export let dangerouslyAllowLocalNetwork: boolean;
49
+ }
50
+ declare const DEFAULT_FORMATS: string[];
51
+ declare const DEFAULT_QUALITY: 75;
52
+ declare const DEFAULT_DEVICE_SIZES: number[];
53
+ declare const DEFAULT_IMAGE_SIZES: number[];
54
+ declare const DEFAULT_REMOTE_PATTERNS: any[];
55
+ declare const DEFAULT_MAX_REMOTE_BYTES: number;
56
+ declare const DEFAULT_MAX_PIXELS: 40000000;
57
+ declare const DEFAULT_MINIMUM_CACHE_TTL: 60;
58
+ export {};