@ccheever/exact-renderer 0.1.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 (80) hide show
  1. package/package.json +118 -0
  2. package/src/__tests__/adapter-window-state.test.tsx +190 -0
  3. package/src/__tests__/attrs.test.ts +157 -0
  4. package/src/__tests__/classname.test.ts +332 -0
  5. package/src/__tests__/color.test.ts +169 -0
  6. package/src/__tests__/dom-mirror.test.ts +682 -0
  7. package/src/__tests__/dom-shim.test.ts +274 -0
  8. package/src/__tests__/fixtures/SvelteCounter.svelte +7 -0
  9. package/src/__tests__/fixtures/SvelteInput.svelte +8 -0
  10. package/src/__tests__/host-config.test.ts +51 -0
  11. package/src/__tests__/host-ops.test.ts +2234 -0
  12. package/src/__tests__/image-source.test.ts +135 -0
  13. package/src/__tests__/liquid-glass.test.ts +72 -0
  14. package/src/__tests__/multi-root.test.ts +118 -0
  15. package/src/__tests__/native-view-events.test.ts +102 -0
  16. package/src/__tests__/nodes.test.ts +399 -0
  17. package/src/__tests__/normalize.test.ts +576 -0
  18. package/src/__tests__/paragraph-lowering.test.tsx +144 -0
  19. package/src/__tests__/props.test.ts +518 -0
  20. package/src/__tests__/protocol-encoder.test.ts +732 -0
  21. package/src/__tests__/protocol-fixture-bytes.test.ts +41 -0
  22. package/src/__tests__/reconciler.test.tsx +241 -0
  23. package/src/__tests__/svelte-adapter.test.ts +166 -0
  24. package/src/__tests__/svg-source.test.ts +71 -0
  25. package/src/__tests__/tags.test.ts +354 -0
  26. package/src/__tests__/toggle.test.ts +441 -0
  27. package/src/__tests__/transitions.test.ts +106 -0
  28. package/src/__tests__/web-primitives.test.tsx +454 -0
  29. package/src/__tests__/window-hooks.test.tsx +447 -0
  30. package/src/adapter-contract.ts +68 -0
  31. package/src/attrs.ts +596 -0
  32. package/src/classname-contract.ts +87 -0
  33. package/src/classname-resolve.ts +553 -0
  34. package/src/classname-runtime.ts +29 -0
  35. package/src/components.ts +214 -0
  36. package/src/css-variable-context.ts +83 -0
  37. package/src/dom-hydration.ts +160 -0
  38. package/src/dom-mirror.ts +1459 -0
  39. package/src/dom-shim.ts +1736 -0
  40. package/src/group-context.ts +69 -0
  41. package/src/host-config.ts +431 -0
  42. package/src/host-ops.ts +3167 -0
  43. package/src/image-source.native.ts +703 -0
  44. package/src/image-source.ts +554 -0
  45. package/src/index.ts +278 -0
  46. package/src/inspector-runtime.ts +244 -0
  47. package/src/inspector.ts +3570 -0
  48. package/src/jsx-augmentations.ts +54 -0
  49. package/src/keyboard-avoidance.ts +217 -0
  50. package/src/native-primitives.ts +43 -0
  51. package/src/native-view-events.ts +322 -0
  52. package/src/native-view.ts +60 -0
  53. package/src/nodes/index.ts +41 -0
  54. package/src/nodes/node.ts +531 -0
  55. package/src/peer-context.ts +100 -0
  56. package/src/primitives.native.ts +8 -0
  57. package/src/primitives.ts +8 -0
  58. package/src/props/index.ts +14 -0
  59. package/src/props/normalize.ts +816 -0
  60. package/src/protocol/encoder.ts +940 -0
  61. package/src/protocol/index.ts +33 -0
  62. package/src/reconciler.ts +581 -0
  63. package/src/runtime.ts +11 -0
  64. package/src/safe-area.ts +543 -0
  65. package/src/solid.ts +490 -0
  66. package/src/style/color.js +1 -0
  67. package/src/style/color.ts +15 -0
  68. package/src/style/index.js +1 -0
  69. package/src/style/index.ts +22 -0
  70. package/src/style/normalize.js +1 -0
  71. package/src/style/normalize.ts +1426 -0
  72. package/src/svelte.ts +349 -0
  73. package/src/svg-source.ts +222 -0
  74. package/src/tags/index.ts +21 -0
  75. package/src/tags/tag-map.ts +289 -0
  76. package/src/text/paragraph-lowering.ts +310 -0
  77. package/src/types.ts +1175 -0
  78. package/src/vue.ts +535 -0
  79. package/src/web-host.ts +19 -0
  80. package/src/web-primitives.ts +1654 -0
@@ -0,0 +1,703 @@
1
+ import { isAssetRef } from '@exact/core/assets-fonts-state';
2
+
3
+ import type {
4
+ ColorSchemeImageSource,
5
+ ImageColorScheme,
6
+ ImageCandidate,
7
+ ImageLoading,
8
+ ImagePlaceholder,
9
+ ImageSource,
10
+ } from './types.js';
11
+
12
+ export interface ImageWarningInput {
13
+ readonly alt?: string;
14
+ readonly decorative?: boolean;
15
+ readonly source?: ImageSource;
16
+ readonly testID?: string;
17
+ }
18
+
19
+ const warnedMissingAlt = new Set<string>();
20
+
21
+ function isRecord(value: unknown): value is Record<string, unknown> {
22
+ return value !== null && typeof value === 'object';
23
+ }
24
+
25
+ function hasNonEmptyStringProperty<Key extends string>(
26
+ value: unknown,
27
+ key: Key,
28
+ ): value is Record<Key, string> & Record<string, unknown> {
29
+ return isRecord(value) && typeof value[key] === 'string' && value[key].length > 0;
30
+ }
31
+
32
+ function getDevicePixelRatio(): number {
33
+ const value = (globalThis as typeof globalThis & { devicePixelRatio?: unknown }).devicePixelRatio;
34
+ if (typeof value === 'number' && isFinite(value) && value > 0) {
35
+ return value;
36
+ }
37
+
38
+ const exact = (globalThis as { exact?: { screenScale?: unknown } }).exact;
39
+ return typeof exact?.screenScale === 'number' && isFinite(exact.screenScale) && exact.screenScale > 0
40
+ ? exact.screenScale
41
+ : 1;
42
+ }
43
+
44
+ function isImageCandidate(value: unknown): value is ImageCandidate {
45
+ return isRecord(value) && typeof value.uri === 'string' && value.uri.length > 0;
46
+ }
47
+
48
+ function isImageCandidateArray(source: unknown): source is readonly ImageCandidate[] {
49
+ return Array.isArray(source) && source.length > 0 && source.every(isImageCandidate);
50
+ }
51
+
52
+ function isThumbhashSource(source: unknown): source is { thumbhash: string } {
53
+ return hasNonEmptyStringProperty(source, 'thumbhash');
54
+ }
55
+
56
+ function isUriSource(
57
+ source: unknown,
58
+ ): source is { uri: string; headers?: Record<string, string>; cacheKey?: string } {
59
+ return hasNonEmptyStringProperty(source, 'uri');
60
+ }
61
+
62
+ export function isColorSchemeImageSource(source: unknown): source is ColorSchemeImageSource {
63
+ if (!isRecord(source) || isUriSource(source) || isThumbhashSource(source)) {
64
+ return false;
65
+ }
66
+
67
+ return (
68
+ 'light' in source ||
69
+ 'dark' in source ||
70
+ 'fallback' in source
71
+ );
72
+ }
73
+
74
+ function describeImageSource(source: ImageSource | undefined): string {
75
+ if (source == null) {
76
+ return 'unknown';
77
+ }
78
+
79
+ if (typeof source === 'string') {
80
+ return source;
81
+ }
82
+
83
+ if (typeof source === 'number') {
84
+ return `numeric:${source}`;
85
+ }
86
+
87
+ if (isAssetRef(source)) {
88
+ return source.url;
89
+ }
90
+
91
+ if (isImageCandidateArray(source)) {
92
+ return source.map((candidate) => candidate.uri).join('|');
93
+ }
94
+
95
+ if (isColorSchemeImageSource(source)) {
96
+ return [
97
+ source.light ? `light:${describeImageSource(source.light)}` : '',
98
+ source.dark ? `dark:${describeImageSource(source.dark)}` : '',
99
+ source.fallback ? `fallback:${describeImageSource(source.fallback)}` : '',
100
+ ].filter(Boolean).join('|') || 'color-scheme';
101
+ }
102
+
103
+ if (isThumbhashSource(source)) {
104
+ return 'thumbhash';
105
+ }
106
+
107
+ if (isUriSource(source)) {
108
+ return source.uri;
109
+ }
110
+
111
+ return 'unknown';
112
+ }
113
+
114
+ function formatPreferenceScore(type: string | undefined): number {
115
+ switch ((type ?? '').toLowerCase()) {
116
+ case 'image/avif':
117
+ return 0;
118
+ case 'image/webp':
119
+ return 1;
120
+ case 'image/png':
121
+ return 2;
122
+ case 'image/jpeg':
123
+ case 'image/jpg':
124
+ return 3;
125
+ default:
126
+ return 4;
127
+ }
128
+ }
129
+
130
+ function isWidthBucketCandidateList(candidates: readonly ImageCandidate[]): boolean {
131
+ if (candidates.length === 0) {
132
+ return false;
133
+ }
134
+
135
+ for (const candidate of candidates) {
136
+ if (typeof candidate.pixelDensity === 'number' && isFinite(candidate.pixelDensity)) {
137
+ return false;
138
+ }
139
+
140
+ if (!(typeof candidate.width === 'number' && isFinite(candidate.width) && candidate.width > 0)) {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ return true;
146
+ }
147
+
148
+ function estimateViewportContentWidthPoints(): number {
149
+ const exact = (globalThis as { exact?: { screenWidth?: number } }).exact;
150
+ if (typeof exact?.screenWidth === 'number' && isFinite(exact.screenWidth) && exact.screenWidth > 0) {
151
+ return Math.max(120, exact.screenWidth - 96);
152
+ }
153
+
154
+ return 390;
155
+ }
156
+
157
+ function pickBestWidthBucketCandidate(
158
+ candidates: readonly ImageCandidate[],
159
+ targetPixelWidth: number,
160
+ ): ImageCandidate {
161
+ const sorted = [...candidates].sort((a, b) => (a.width ?? 0) - (b.width ?? 0));
162
+ for (const candidate of sorted) {
163
+ const width = candidate.width ?? 0;
164
+ if (width >= targetPixelWidth) {
165
+ return candidate;
166
+ }
167
+ }
168
+
169
+ return sorted[sorted.length - 1] ?? candidates[0];
170
+ }
171
+
172
+ function pickBestImageCandidate(
173
+ candidates: readonly ImageCandidate[],
174
+ pixelRatio: number = getDevicePixelRatio(),
175
+ ): ImageCandidate {
176
+ if (isWidthBucketCandidateList(candidates)) {
177
+ const targetPixels = Math.ceil(estimateViewportContentWidthPoints() * pixelRatio);
178
+ return pickBestWidthBucketCandidate(candidates, targetPixels);
179
+ }
180
+
181
+ let best = candidates[0];
182
+ let bestScore = Number.POSITIVE_INFINITY;
183
+
184
+ for (const candidate of candidates) {
185
+ const density = typeof candidate.pixelDensity === 'number' && isFinite(candidate.pixelDensity)
186
+ ? candidate.pixelDensity
187
+ : 1;
188
+ const densityScore = Math.abs(density - pixelRatio);
189
+ const typeScore = formatPreferenceScore(candidate.type);
190
+ const widthScore =
191
+ typeof candidate.width === 'number' && isFinite(candidate.width)
192
+ ? -candidate.width / 100000
193
+ : 0;
194
+ const score = densityScore * 10 + typeScore + widthScore;
195
+
196
+ if (score < bestScore) {
197
+ best = candidate;
198
+ bestScore = score;
199
+ }
200
+ }
201
+
202
+ return best;
203
+ }
204
+
205
+ function decodeThumbHashBase64ToBytes(encoded: string): Uint8Array {
206
+ const normalized = encoded.trim();
207
+ if (normalized.length === 0) {
208
+ throw new Error('empty thumbhash');
209
+ }
210
+
211
+ if (typeof Buffer !== 'undefined') {
212
+ return Uint8Array.from(Buffer.from(normalized, 'base64'));
213
+ }
214
+
215
+ const binary = atob(normalized);
216
+ const out = new Uint8Array(binary.length);
217
+ for (let index = 0; index < binary.length; index++) {
218
+ out[index] = binary.charCodeAt(index);
219
+ }
220
+ return out;
221
+ }
222
+
223
+ export function thumbhashStringToDataUrl(hash: string): string | undefined {
224
+ try {
225
+ return thumbHashBytesToDataUrl(decodeThumbHashBase64ToBytes(hash));
226
+ } catch {
227
+ return undefined;
228
+ }
229
+ }
230
+
231
+ // Adapted from thumbhash 0.1.1's MIT-licensed decoder so native startup does
232
+ // not need the full package module, which also includes the encoder.
233
+ function thumbHashBytesToDataUrl(hash: Uint8Array): string {
234
+ const image = thumbHashToRgba(hash);
235
+ return rgbaToDataUrl(image.width, image.height, image.rgba);
236
+ }
237
+
238
+ function thumbHashToRgba(hash: Uint8Array): {
239
+ readonly height: number;
240
+ readonly rgba: Uint8Array;
241
+ readonly width: number;
242
+ } {
243
+ const { PI, min, max, cos, round } = Math;
244
+ const header24 = hash[0] | (hash[1] << 8) | (hash[2] << 16);
245
+ const header16 = hash[3] | (hash[4] << 8);
246
+ const lDc = (header24 & 63) / 63;
247
+ const pDc = ((header24 >> 6) & 63) / 31.5 - 1;
248
+ const qDc = ((header24 >> 12) & 63) / 31.5 - 1;
249
+ const lScale = ((header24 >> 18) & 31) / 31;
250
+ const hasAlpha = header24 >> 23;
251
+ const pScale = ((header16 >> 3) & 63) / 63;
252
+ const qScale = ((header16 >> 9) & 63) / 63;
253
+ const isLandscape = header16 >> 15;
254
+ const lx = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7);
255
+ const ly = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7);
256
+ const aDc = hasAlpha ? (hash[5] & 15) / 15 : 1;
257
+ const aScale = (hash[5] >> 4) / 15;
258
+ const acStart = hasAlpha ? 6 : 5;
259
+ let acIndex = 0;
260
+
261
+ const decodeChannel = (nx: number, ny: number, scale: number): number[] => {
262
+ const ac: number[] = [];
263
+ for (let cy = 0; cy < ny; cy++) {
264
+ for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) {
265
+ const nibble = (hash[acStart + (acIndex >> 1)] >> ((acIndex++ & 1) << 2)) & 15;
266
+ ac.push((nibble / 7.5 - 1) * scale);
267
+ }
268
+ }
269
+ return ac;
270
+ };
271
+
272
+ const lAc = decodeChannel(lx, ly, lScale);
273
+ const pAc = decodeChannel(3, 3, pScale * 1.25);
274
+ const qAc = decodeChannel(3, 3, qScale * 1.25);
275
+ const aAc = hasAlpha ? decodeChannel(5, 5, aScale) : [];
276
+ const ratio = thumbHashApproximateAspectRatio(hash);
277
+ const width = round(ratio > 1 ? 32 : 32 * ratio);
278
+ const height = round(ratio > 1 ? 32 / ratio : 32);
279
+ const rgba = new Uint8Array(width * height * 4);
280
+ const fx: number[] = [];
281
+ const fy: number[] = [];
282
+
283
+ for (let y = 0, i = 0; y < height; y++) {
284
+ for (let x = 0; x < width; x++, i += 4) {
285
+ let l = lDc;
286
+ let p = pDc;
287
+ let q = qDc;
288
+ let a = aDc;
289
+
290
+ for (let cx = 0, count = max(lx, hasAlpha ? 5 : 3); cx < count; cx++) {
291
+ fx[cx] = cos(PI / width * (x + 0.5) * cx);
292
+ }
293
+ for (let cy = 0, count = max(ly, hasAlpha ? 5 : 3); cy < count; cy++) {
294
+ fy[cy] = cos(PI / height * (y + 0.5) * cy);
295
+ }
296
+
297
+ for (let cy = 0, j = 0; cy < ly; cy++) {
298
+ for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx * ly < lx * (ly - cy); cx++, j++) {
299
+ l += lAc[j] * fx[cx] * fy2;
300
+ }
301
+ }
302
+
303
+ for (let cy = 0, j = 0; cy < 3; cy++) {
304
+ for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 3 - cy; cx++, j++) {
305
+ const f = fx[cx] * fy2;
306
+ p += pAc[j] * f;
307
+ q += qAc[j] * f;
308
+ }
309
+ }
310
+
311
+ if (hasAlpha) {
312
+ for (let cy = 0, j = 0; cy < 5; cy++) {
313
+ for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 5 - cy; cx++, j++) {
314
+ a += aAc[j] * fx[cx] * fy2;
315
+ }
316
+ }
317
+ }
318
+
319
+ const b = l - 2 / 3 * p;
320
+ const r = (3 * l - b + q) / 2;
321
+ const g = r - q;
322
+ rgba[i] = max(0, 255 * min(1, r));
323
+ rgba[i + 1] = max(0, 255 * min(1, g));
324
+ rgba[i + 2] = max(0, 255 * min(1, b));
325
+ rgba[i + 3] = max(0, 255 * min(1, a));
326
+ }
327
+ }
328
+
329
+ return { width, height, rgba };
330
+ }
331
+
332
+ function thumbHashApproximateAspectRatio(hash: Uint8Array): number {
333
+ const header = hash[3];
334
+ const hasAlpha = hash[2] & 0x80;
335
+ const isLandscape = hash[4] & 0x80;
336
+ const lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7;
337
+ const ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7;
338
+ return lx / ly;
339
+ }
340
+
341
+ function rgbaToDataUrl(width: number, height: number, rgba: Uint8Array): string {
342
+ const row = width * 4 + 1;
343
+ const idat = 6 + height * (5 + row);
344
+ const bytes = [
345
+ 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0,
346
+ width >> 8, width & 255, 0, 0, height >> 8, height & 255, 8, 6, 0, 0, 0, 0, 0, 0, 0,
347
+ idat >>> 24, (idat >> 16) & 255, (idat >> 8) & 255, idat & 255,
348
+ 73, 68, 65, 84, 120, 1,
349
+ ];
350
+ const crcTable = [
351
+ 0, 498536548, 997073096, 651767980, 1994146192, 1802195444, 1303535960,
352
+ 1342533948, -306674912, -267414716, -690576408, -882789492, -1687895376,
353
+ -2032938284, -1609899400, -1111625188,
354
+ ];
355
+ let a = 1;
356
+ let b = 0;
357
+
358
+ for (let y = 0, i = 0, end = row - 1; y < height; y++, end += row - 1) {
359
+ bytes.push(y + 1 < height ? 0 : 1, row & 255, row >> 8, ~row & 255, (row >> 8) ^ 255, 0);
360
+ for (b = (b + a) % 65521; i < end; i++) {
361
+ const value = rgba[i] & 255;
362
+ bytes.push(value);
363
+ a = (a + value) % 65521;
364
+ b = (b + a) % 65521;
365
+ }
366
+ }
367
+
368
+ bytes.push(
369
+ b >> 8, b & 255, a >> 8, a & 255, 0, 0, 0, 0,
370
+ 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
371
+ );
372
+
373
+ for (const [start, end] of [[12, 29], [37, 41 + idat]]) {
374
+ let crc = ~0;
375
+ for (let i = start; i < end; i++) {
376
+ crc ^= bytes[i];
377
+ crc = (crc >>> 4) ^ crcTable[crc & 15];
378
+ crc = (crc >>> 4) ^ crcTable[crc & 15];
379
+ }
380
+ crc = ~crc;
381
+ let outputIndex = end;
382
+ bytes[outputIndex++] = crc >>> 24;
383
+ bytes[outputIndex++] = (crc >> 16) & 255;
384
+ bytes[outputIndex++] = (crc >> 8) & 255;
385
+ bytes[outputIndex] = crc & 255;
386
+ }
387
+
388
+ return `data:image/png;base64,${encodeBase64Bytes(bytes)}`;
389
+ }
390
+
391
+ function encodeBase64Bytes(bytes: readonly number[]): string {
392
+ if (typeof Buffer !== 'undefined') {
393
+ return Buffer.from(bytes).toString('base64');
394
+ }
395
+
396
+ if (typeof btoa === 'function') {
397
+ let binary = '';
398
+ for (const byte of bytes) {
399
+ binary += String.fromCharCode(byte & 255);
400
+ }
401
+ return btoa(binary);
402
+ }
403
+
404
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
405
+ let output = '';
406
+ for (let index = 0; index < bytes.length; index += 3) {
407
+ const first = bytes[index] & 255;
408
+ const hasSecond = index + 1 < bytes.length;
409
+ const hasThird = index + 2 < bytes.length;
410
+ const second = hasSecond ? bytes[index + 1] & 255 : 0;
411
+ const third = hasThird ? bytes[index + 2] & 255 : 0;
412
+ const triplet = (first << 16) | (second << 8) | third;
413
+ output += alphabet[(triplet >> 18) & 63];
414
+ output += alphabet[(triplet >> 12) & 63];
415
+ output += hasSecond ? alphabet[(triplet >> 6) & 63] : '=';
416
+ output += hasThird ? alphabet[triplet & 63] : '=';
417
+ }
418
+ return output;
419
+ }
420
+
421
+ function resolveUrlLikeImageSource(source: ImageSource): string | undefined {
422
+ if (typeof source === 'string' && source.length > 0) {
423
+ return source;
424
+ }
425
+
426
+ if (isAssetRef(source)) {
427
+ return source.url;
428
+ }
429
+
430
+ if (isUriSource(source)) {
431
+ return source.uri;
432
+ }
433
+
434
+ return undefined;
435
+ }
436
+
437
+ export function getRuntimeImageColorScheme(): ImageColorScheme {
438
+ const state = (globalThis as {
439
+ __exactExperienceRegistryState?: {
440
+ theme?: {
441
+ scheme?: unknown;
442
+ } | null;
443
+ };
444
+ }).__exactExperienceRegistryState;
445
+ const scheme = state?.theme?.scheme;
446
+ return scheme === 'light' || scheme === 'dark' ? scheme : 'light';
447
+ }
448
+
449
+ export function resolveThemeAwareImageSource(
450
+ source: ImageSource | undefined,
451
+ scheme: ImageColorScheme = getRuntimeImageColorScheme(),
452
+ ): ImageSource | undefined {
453
+ if (!isColorSchemeImageSource(source)) {
454
+ return source;
455
+ }
456
+
457
+ const selected = source[scheme] ?? source.fallback ?? source.light ?? source.dark;
458
+ return resolveThemeAwareImageSource(selected, scheme);
459
+ }
460
+
461
+ export function resolveImageSourceForNative(
462
+ source: ImageSource | undefined,
463
+ scheme: ImageColorScheme = getRuntimeImageColorScheme(),
464
+ ): string | undefined {
465
+ if (source == null) {
466
+ return undefined;
467
+ }
468
+
469
+ if (isColorSchemeImageSource(source)) {
470
+ return resolveImageSourceForNative(resolveThemeAwareImageSource(source, scheme), scheme);
471
+ }
472
+
473
+ if (typeof source === 'number') {
474
+ if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') {
475
+ console.warn('[Exact] Numeric image sources are not supported. Use asset(...) instead.');
476
+ }
477
+ return undefined;
478
+ }
479
+
480
+ if (isImageCandidateArray(source)) {
481
+ return resolveImageSourceForNative(pickBestImageCandidate(source), scheme);
482
+ }
483
+
484
+ if (isThumbhashSource(source)) {
485
+ return thumbhashStringToDataUrl(source.thumbhash);
486
+ }
487
+
488
+ return resolveUrlLikeImageSource(source);
489
+ }
490
+
491
+ export function resolveImagePlaceholderForNative(
492
+ placeholder: ImagePlaceholder | undefined,
493
+ ): string | undefined {
494
+ if (placeholder == null) {
495
+ return undefined;
496
+ }
497
+
498
+ if (hasNonEmptyStringProperty(placeholder, 'color')) {
499
+ return `color:${placeholder.color}`;
500
+ }
501
+
502
+ return resolveImageSourceForNative(placeholder as ImageSource);
503
+ }
504
+
505
+ export function warnIfImageMissingAlt({
506
+ alt,
507
+ decorative,
508
+ source,
509
+ testID,
510
+ }: ImageWarningInput): void {
511
+ if (
512
+ process.env.NODE_ENV === 'production' ||
513
+ process.env.NODE_ENV === 'test' ||
514
+ decorative ||
515
+ alt !== undefined
516
+ ) {
517
+ return;
518
+ }
519
+
520
+ const signature = `${testID ?? 'no-testid'}:${describeImageSource(source)}`;
521
+ if (warnedMissingAlt.has(signature)) {
522
+ return;
523
+ }
524
+
525
+ warnedMissingAlt.add(signature);
526
+ console.warn('[Exact] <Image> is missing alt text. Provide alt="..." or set decorative.');
527
+ }
528
+
529
+ // LLP 0154 P2: web-primitives.ts is shared runtime code (it branches on the
530
+ // render environment at runtime), so every platform variant of this module
531
+ // must export the same public surface. The DOM resolvers below mirror
532
+ // image-source.ts using this file's own helpers; the DOM render branch is
533
+ // unreachable on native, but the implementations stay faithful so the
534
+ // contract holds if that ever changes.
535
+
536
+ const DEFAULT_PLACEHOLDER_COLOR = '#e5e7eb';
537
+
538
+ export interface DOMResolvedImageSource {
539
+ readonly pictureSources: ReadonlyArray<{
540
+ readonly srcSet: string;
541
+ readonly type?: string;
542
+ }>;
543
+ readonly src?: string;
544
+ readonly srcSet?: string;
545
+ readonly themeSources?: {
546
+ readonly light?: string;
547
+ readonly dark?: string;
548
+ readonly fallback?: string;
549
+ };
550
+ }
551
+
552
+ export interface DOMResolvedImagePlaceholder {
553
+ readonly backgroundColor?: string;
554
+ readonly backgroundImage?: string;
555
+ }
556
+
557
+ function formatSrcSetDescriptor(candidate: ImageCandidate): string {
558
+ if (typeof candidate.width === 'number' && isFinite(candidate.width) && candidate.width > 0) {
559
+ return `${candidate.width}w`;
560
+ }
561
+
562
+ if (
563
+ typeof candidate.pixelDensity === 'number' &&
564
+ isFinite(candidate.pixelDensity) &&
565
+ candidate.pixelDensity > 0
566
+ ) {
567
+ return `${candidate.pixelDensity}x`;
568
+ }
569
+
570
+ return '';
571
+ }
572
+
573
+ function buildSrcSet(candidates: readonly ImageCandidate[]): string {
574
+ return candidates
575
+ .map((candidate) => {
576
+ const descriptor = formatSrcSetDescriptor(candidate);
577
+ return descriptor.length > 0 ? `${candidate.uri} ${descriptor}` : candidate.uri;
578
+ })
579
+ .join(', ');
580
+ }
581
+
582
+ export function resolveImageSourceForDOM(
583
+ source: ImageSource | undefined,
584
+ scheme: ImageColorScheme = getRuntimeImageColorScheme(),
585
+ ): DOMResolvedImageSource | undefined {
586
+ if (source == null || typeof source === 'number') {
587
+ return undefined;
588
+ }
589
+
590
+ if (isColorSchemeImageSource(source)) {
591
+ const selected = resolveThemeAwareImageSource(source, scheme);
592
+ const resolved = resolveImageSourceForDOM(selected, scheme);
593
+ const light = source.light ? resolveImageSourceForNative(source.light, 'light') : undefined;
594
+ const dark = source.dark ? resolveImageSourceForNative(source.dark, 'dark') : undefined;
595
+ const fallback = source.fallback ? resolveImageSourceForNative(source.fallback, scheme) : undefined;
596
+ if (!resolved) {
597
+ return undefined;
598
+ }
599
+ return {
600
+ ...resolved,
601
+ themeSources: {
602
+ ...(light ? { light } : {}),
603
+ ...(dark ? { dark } : {}),
604
+ ...(fallback ? { fallback } : {}),
605
+ },
606
+ };
607
+ }
608
+
609
+ if (isThumbhashSource(source)) {
610
+ const dataUrl = thumbhashStringToDataUrl(source.thumbhash);
611
+ if (dataUrl == null || dataUrl.length === 0) {
612
+ return undefined;
613
+ }
614
+
615
+ return {
616
+ pictureSources: [],
617
+ src: dataUrl,
618
+ };
619
+ }
620
+
621
+ if (isImageCandidateArray(source)) {
622
+ const groupedByType = new Map<string, ImageCandidate[]>();
623
+ const fallbackCandidates: ImageCandidate[] = [];
624
+
625
+ for (const candidate of source) {
626
+ if (typeof candidate.type === 'string' && candidate.type.length > 0) {
627
+ const existing = groupedByType.get(candidate.type) ?? [];
628
+ existing.push(candidate);
629
+ groupedByType.set(candidate.type, existing);
630
+ } else {
631
+ fallbackCandidates.push(candidate);
632
+ }
633
+ }
634
+
635
+ const pictureSources = Array.from(groupedByType.entries())
636
+ .sort(([typeA], [typeB]) => formatPreferenceScore(typeA) - formatPreferenceScore(typeB))
637
+ .map(([type, candidates]) => ({
638
+ type,
639
+ srcSet: buildSrcSet(candidates),
640
+ }));
641
+ const fallback = fallbackCandidates.length > 0 ? fallbackCandidates : source;
642
+
643
+ return {
644
+ pictureSources,
645
+ src: pickBestImageCandidate(source).uri,
646
+ srcSet: fallback.length > 1 ? buildSrcSet(fallback) : undefined,
647
+ };
648
+ }
649
+
650
+ const src = resolveUrlLikeImageSource(source);
651
+ if (!src) {
652
+ return undefined;
653
+ }
654
+
655
+ return {
656
+ pictureSources: [],
657
+ src,
658
+ };
659
+ }
660
+
661
+ export function resolveImagePlaceholderForDOM(
662
+ placeholder: ImagePlaceholder | undefined,
663
+ ): DOMResolvedImagePlaceholder | undefined {
664
+ if (placeholder == null) {
665
+ return undefined;
666
+ }
667
+
668
+ if (hasNonEmptyStringProperty(placeholder, 'color')) {
669
+ return {
670
+ backgroundColor: placeholder.color,
671
+ };
672
+ }
673
+
674
+ if (isThumbhashSource(placeholder)) {
675
+ const dataUrl = thumbhashStringToDataUrl(placeholder.thumbhash);
676
+ if (dataUrl != null && dataUrl.length > 0) {
677
+ return {
678
+ backgroundImage: `url("${dataUrl.replace(/"/g, '\\"')}")`,
679
+ };
680
+ }
681
+
682
+ return {
683
+ backgroundColor: DEFAULT_PLACEHOLDER_COLOR,
684
+ };
685
+ }
686
+
687
+ const resolved = resolveImageSourceForDOM(placeholder as ImageSource);
688
+ if (!resolved?.src) {
689
+ return undefined;
690
+ }
691
+
692
+ return {
693
+ backgroundImage: `url("${resolved.src.replace(/"/g, '\\"')}")`,
694
+ };
695
+ }
696
+
697
+ export function normalizeImageLoading(loading: ImageLoading | undefined): 'eager' | 'lazy' | undefined {
698
+ if (loading === 'eager' || loading === 'lazy') {
699
+ return loading;
700
+ }
701
+
702
+ return undefined;
703
+ }