@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,554 @@
1
+ import { thumbHashToDataURL } from 'thumbhash';
2
+
3
+ import { isAssetRef } from '@exact/core/assets-fonts-state';
4
+
5
+ import type {
6
+ ColorSchemeImageSource,
7
+ ImageColorScheme,
8
+ ImageCandidate,
9
+ ImageLoading,
10
+ ImagePlaceholder,
11
+ ImageSource,
12
+ } from './types.js';
13
+
14
+ const DEFAULT_PLACEHOLDER_COLOR = '#e5e7eb';
15
+
16
+ export interface DOMResolvedImageSource {
17
+ readonly pictureSources: ReadonlyArray<{
18
+ readonly srcSet: string;
19
+ readonly type?: string;
20
+ }>;
21
+ readonly src?: string;
22
+ readonly srcSet?: string;
23
+ readonly themeSources?: {
24
+ readonly light?: string;
25
+ readonly dark?: string;
26
+ readonly fallback?: string;
27
+ };
28
+ }
29
+
30
+ export interface DOMResolvedImagePlaceholder {
31
+ readonly backgroundColor?: string;
32
+ readonly backgroundImage?: string;
33
+ }
34
+
35
+ export interface ImageWarningInput {
36
+ readonly alt?: string;
37
+ readonly decorative?: boolean;
38
+ readonly source?: ImageSource;
39
+ readonly testID?: string;
40
+ }
41
+
42
+ const warnedMissingAlt = new Set<string>();
43
+
44
+ function isRecord(value: unknown): value is Record<string, unknown> {
45
+ return value !== null && typeof value === 'object';
46
+ }
47
+
48
+ function hasNonEmptyStringProperty<Key extends string>(
49
+ value: unknown,
50
+ key: Key,
51
+ ): value is Record<Key, string> & Record<string, unknown> {
52
+ return isRecord(value) && typeof value[key] === 'string' && value[key].length > 0;
53
+ }
54
+
55
+ function getDevicePixelRatio(): number {
56
+ const value = (globalThis as typeof globalThis & { devicePixelRatio?: unknown }).devicePixelRatio;
57
+ if (typeof value === 'number' && isFinite(value) && value > 0) {
58
+ return value;
59
+ }
60
+
61
+ const windowValue = (
62
+ globalThis as typeof globalThis & { window?: { devicePixelRatio?: unknown } }
63
+ ).window?.devicePixelRatio;
64
+ if (typeof windowValue === 'number' && isFinite(windowValue) && windowValue > 0) {
65
+ return windowValue;
66
+ }
67
+
68
+ const exact = (globalThis as { exact?: { screenScale?: unknown } }).exact;
69
+ return typeof exact?.screenScale === 'number' && isFinite(exact.screenScale) && exact.screenScale > 0
70
+ ? exact.screenScale
71
+ : 1;
72
+ }
73
+
74
+ function isImageCandidate(value: unknown): value is ImageCandidate {
75
+ return isRecord(value) && typeof value.uri === 'string' && value.uri.length > 0;
76
+ }
77
+
78
+ function isImageCandidateArray(source: unknown): source is readonly ImageCandidate[] {
79
+ return Array.isArray(source) && source.length > 0 && source.every(isImageCandidate);
80
+ }
81
+
82
+ function isThumbhashSource(source: unknown): source is { thumbhash: string } {
83
+ return hasNonEmptyStringProperty(source, 'thumbhash');
84
+ }
85
+
86
+ function isUriSource(
87
+ source: unknown,
88
+ ): source is { uri: string; headers?: Record<string, string>; cacheKey?: string } {
89
+ return hasNonEmptyStringProperty(source, 'uri');
90
+ }
91
+
92
+ export function isColorSchemeImageSource(source: unknown): source is ColorSchemeImageSource {
93
+ if (!isRecord(source) || isUriSource(source) || isThumbhashSource(source)) {
94
+ return false;
95
+ }
96
+
97
+ return (
98
+ 'light' in source ||
99
+ 'dark' in source ||
100
+ 'fallback' in source
101
+ );
102
+ }
103
+
104
+ function describeImageSource(source: ImageSource | undefined): string {
105
+ if (source == null) {
106
+ return 'unknown';
107
+ }
108
+
109
+ if (typeof source === 'string') {
110
+ return source;
111
+ }
112
+
113
+ if (typeof source === 'number') {
114
+ return `numeric:${source}`;
115
+ }
116
+
117
+ if (isAssetRef(source)) {
118
+ return source.url;
119
+ }
120
+
121
+ if (isImageCandidateArray(source)) {
122
+ return source.map((candidate) => candidate.uri).join('|');
123
+ }
124
+
125
+ if (isColorSchemeImageSource(source)) {
126
+ return [
127
+ source.light ? `light:${describeImageSource(source.light)}` : '',
128
+ source.dark ? `dark:${describeImageSource(source.dark)}` : '',
129
+ source.fallback ? `fallback:${describeImageSource(source.fallback)}` : '',
130
+ ].filter(Boolean).join('|') || 'color-scheme';
131
+ }
132
+
133
+ if (isThumbhashSource(source)) {
134
+ return 'thumbhash';
135
+ }
136
+
137
+ if (isUriSource(source)) {
138
+ return source.uri;
139
+ }
140
+
141
+ return 'unknown';
142
+ }
143
+
144
+ function formatSrcSetDescriptor(candidate: ImageCandidate): string {
145
+ if (typeof candidate.width === 'number' && isFinite(candidate.width) && candidate.width > 0) {
146
+ return `${candidate.width}w`;
147
+ }
148
+
149
+ if (
150
+ typeof candidate.pixelDensity === 'number' &&
151
+ isFinite(candidate.pixelDensity) &&
152
+ candidate.pixelDensity > 0
153
+ ) {
154
+ return `${candidate.pixelDensity}x`;
155
+ }
156
+
157
+ return '';
158
+ }
159
+
160
+ function buildSrcSet(candidates: readonly ImageCandidate[]): string {
161
+ return candidates
162
+ .map((candidate) => {
163
+ const descriptor = formatSrcSetDescriptor(candidate);
164
+ return descriptor.length > 0 ? `${candidate.uri} ${descriptor}` : candidate.uri;
165
+ })
166
+ .join(', ');
167
+ }
168
+
169
+ function formatPreferenceScore(type: string | undefined): number {
170
+ switch ((type ?? '').toLowerCase()) {
171
+ case 'image/avif':
172
+ return 0;
173
+ case 'image/webp':
174
+ return 1;
175
+ case 'image/png':
176
+ return 2;
177
+ case 'image/jpeg':
178
+ case 'image/jpg':
179
+ return 3;
180
+ default:
181
+ return 4;
182
+ }
183
+ }
184
+
185
+ /** True when every candidate declares `width` and none declare `pixelDensity` (width-bucket lists). */
186
+ function isWidthBucketCandidateList(candidates: readonly ImageCandidate[]): boolean {
187
+ if (candidates.length === 0) {
188
+ return false;
189
+ }
190
+
191
+ for (const candidate of candidates) {
192
+ if (typeof candidate.pixelDensity === 'number' && isFinite(candidate.pixelDensity)) {
193
+ return false;
194
+ }
195
+
196
+ if (!(typeof candidate.width === 'number' && isFinite(candidate.width) && candidate.width > 0)) {
197
+ return false;
198
+ }
199
+ }
200
+
201
+ return true;
202
+ }
203
+
204
+ /**
205
+ * Rough content width in CSS points for responsive image picks on native (full-width column heuristic).
206
+ */
207
+ function estimateViewportContentWidthPoints(): number {
208
+ const exact = (globalThis as { exact?: { screenWidth?: number } }).exact;
209
+ if (typeof exact?.screenWidth === 'number' && isFinite(exact.screenWidth) && exact.screenWidth > 0) {
210
+ return Math.max(120, exact.screenWidth - 96);
211
+ }
212
+
213
+ return 390;
214
+ }
215
+
216
+ /**
217
+ * Pick the smallest variant whose pixel width still covers the requested decode width, else the largest.
218
+ */
219
+ function pickBestWidthBucketCandidate(
220
+ candidates: readonly ImageCandidate[],
221
+ targetPixelWidth: number,
222
+ ): ImageCandidate {
223
+ const sorted = [...candidates].sort((a, b) => (a.width ?? 0) - (b.width ?? 0));
224
+ for (const c of sorted) {
225
+ const w = c.width ?? 0;
226
+ if (w >= targetPixelWidth) {
227
+ return c;
228
+ }
229
+ }
230
+
231
+ return sorted[sorted.length - 1] ?? candidates[0];
232
+ }
233
+
234
+ function pickBestImageCandidate(
235
+ candidates: readonly ImageCandidate[],
236
+ pixelRatio: number = getDevicePixelRatio(),
237
+ ): ImageCandidate {
238
+ if (isWidthBucketCandidateList(candidates)) {
239
+ const targetPx = Math.ceil(estimateViewportContentWidthPoints() * pixelRatio);
240
+ return pickBestWidthBucketCandidate(candidates, targetPx);
241
+ }
242
+
243
+ let best = candidates[0];
244
+ let bestScore = Number.POSITIVE_INFINITY;
245
+
246
+ for (const candidate of candidates) {
247
+ const density = typeof candidate.pixelDensity === 'number' && isFinite(candidate.pixelDensity)
248
+ ? candidate.pixelDensity
249
+ : 1;
250
+ const densityScore = Math.abs(density - pixelRatio);
251
+ const typeScore = formatPreferenceScore(candidate.type);
252
+ const widthScore =
253
+ typeof candidate.width === 'number' && isFinite(candidate.width)
254
+ ? -candidate.width / 100000
255
+ : 0;
256
+ const score = densityScore * 10 + typeScore + widthScore;
257
+
258
+ if (score < bestScore) {
259
+ best = candidate;
260
+ bestScore = score;
261
+ }
262
+ }
263
+
264
+ return best;
265
+ }
266
+
267
+ function decodeThumbHashBase64ToBytes(encoded: string): Uint8Array {
268
+ const normalized = encoded.trim();
269
+ if (normalized.length === 0) {
270
+ throw new Error('empty thumbhash');
271
+ }
272
+
273
+ if (typeof Buffer !== 'undefined') {
274
+ return Uint8Array.from(Buffer.from(normalized, 'base64'));
275
+ }
276
+
277
+ const binary = atob(normalized);
278
+ const out = new Uint8Array(binary.length);
279
+ for (let i = 0; i < binary.length; i++) {
280
+ out[i] = binary.charCodeAt(i);
281
+ }
282
+ return out;
283
+ }
284
+
285
+ /** Decodes a standard base64-encoded ThumbHash string to a PNG data URL (web / SSR). */
286
+ export function thumbhashStringToDataUrl(hash: string): string | undefined {
287
+ try {
288
+ return thumbHashToDataURL(decodeThumbHashBase64ToBytes(hash));
289
+ } catch {
290
+ return undefined;
291
+ }
292
+ }
293
+
294
+ function resolveUrlLikeImageSource(source: ImageSource): string | undefined {
295
+ if (typeof source === 'string' && source.length > 0) {
296
+ return source;
297
+ }
298
+
299
+ if (isAssetRef(source)) {
300
+ return source.url;
301
+ }
302
+
303
+ if (isUriSource(source)) {
304
+ return source.uri;
305
+ }
306
+
307
+ return undefined;
308
+ }
309
+
310
+ export function getRuntimeImageColorScheme(): ImageColorScheme {
311
+ const documentValue = readDocumentColorScheme();
312
+ if (documentValue) {
313
+ return documentValue;
314
+ }
315
+
316
+ const themeValue = readExactThemeColorScheme();
317
+ if (themeValue) {
318
+ return themeValue;
319
+ }
320
+
321
+ return 'light';
322
+ }
323
+
324
+ function readDocumentColorScheme(): ImageColorScheme | undefined {
325
+ const doc = (globalThis as { document?: { documentElement?: { getAttribute?: (name: string) => string | null } } }).document;
326
+ const root = doc?.documentElement;
327
+ if (!root || typeof root.getAttribute !== 'function') {
328
+ return undefined;
329
+ }
330
+
331
+ for (const attr of ['data-exact-color-scheme', 'data-blog-appearance']) {
332
+ const value = root.getAttribute(attr);
333
+ if (value === 'light' || value === 'dark') {
334
+ return value;
335
+ }
336
+ }
337
+
338
+ return undefined;
339
+ }
340
+
341
+ function readExactThemeColorScheme(): ImageColorScheme | undefined {
342
+ const state = (globalThis as {
343
+ __exactExperienceRegistryState?: {
344
+ theme?: {
345
+ scheme?: unknown;
346
+ } | null;
347
+ };
348
+ }).__exactExperienceRegistryState;
349
+ const scheme = state?.theme?.scheme;
350
+ return scheme === 'light' || scheme === 'dark' ? scheme : undefined;
351
+ }
352
+
353
+ export function resolveThemeAwareImageSource(
354
+ source: ImageSource | undefined,
355
+ scheme: ImageColorScheme = getRuntimeImageColorScheme(),
356
+ ): ImageSource | undefined {
357
+ if (!isColorSchemeImageSource(source)) {
358
+ return source;
359
+ }
360
+
361
+ const selected = source[scheme] ?? source.fallback ?? source.light ?? source.dark;
362
+ return resolveThemeAwareImageSource(selected, scheme);
363
+ }
364
+
365
+ export function resolveImageSourceForNative(
366
+ source: ImageSource | undefined,
367
+ scheme: ImageColorScheme = getRuntimeImageColorScheme(),
368
+ ): string | undefined {
369
+ if (source == null) {
370
+ return undefined;
371
+ }
372
+
373
+ if (isColorSchemeImageSource(source)) {
374
+ return resolveImageSourceForNative(resolveThemeAwareImageSource(source, scheme), scheme);
375
+ }
376
+
377
+ if (typeof source === 'number') {
378
+ if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') {
379
+ console.warn('[Exact] Numeric image sources are not supported. Use asset(...) instead.');
380
+ }
381
+ return undefined;
382
+ }
383
+
384
+ if (isImageCandidateArray(source)) {
385
+ return resolveImageSourceForNative(pickBestImageCandidate(source), scheme);
386
+ }
387
+
388
+ if (isThumbhashSource(source)) {
389
+ return thumbhashStringToDataUrl(source.thumbhash);
390
+ }
391
+
392
+ return resolveUrlLikeImageSource(source);
393
+ }
394
+
395
+ export function resolveImagePlaceholderForNative(
396
+ placeholder: ImagePlaceholder | undefined,
397
+ ): string | undefined {
398
+ if (placeholder == null) {
399
+ return undefined;
400
+ }
401
+
402
+ if (hasNonEmptyStringProperty(placeholder, 'color')) {
403
+ return `color:${placeholder.color}`;
404
+ }
405
+
406
+ return resolveImageSourceForNative(placeholder as ImageSource);
407
+ }
408
+
409
+ export function resolveImageSourceForDOM(
410
+ source: ImageSource | undefined,
411
+ scheme: ImageColorScheme = getRuntimeImageColorScheme(),
412
+ ): DOMResolvedImageSource | undefined {
413
+ if (source == null || typeof source === 'number') {
414
+ return undefined;
415
+ }
416
+
417
+ if (isColorSchemeImageSource(source)) {
418
+ const selected = resolveThemeAwareImageSource(source, scheme);
419
+ const resolved = resolveImageSourceForDOM(selected, scheme);
420
+ const light = source.light ? resolveImageSourceForNative(source.light, 'light') : undefined;
421
+ const dark = source.dark ? resolveImageSourceForNative(source.dark, 'dark') : undefined;
422
+ const fallback = source.fallback ? resolveImageSourceForNative(source.fallback, scheme) : undefined;
423
+ if (!resolved) {
424
+ return undefined;
425
+ }
426
+ return {
427
+ ...resolved,
428
+ themeSources: {
429
+ ...(light ? { light } : {}),
430
+ ...(dark ? { dark } : {}),
431
+ ...(fallback ? { fallback } : {}),
432
+ },
433
+ };
434
+ }
435
+
436
+ if (isThumbhashSource(source)) {
437
+ const dataUrl = thumbhashStringToDataUrl(source.thumbhash);
438
+ if (dataUrl == null || dataUrl.length === 0) {
439
+ return undefined;
440
+ }
441
+
442
+ return {
443
+ pictureSources: [],
444
+ src: dataUrl,
445
+ };
446
+ }
447
+
448
+ if (isImageCandidateArray(source)) {
449
+ const groupedByType = new Map<string, ImageCandidate[]>();
450
+ const fallbackCandidates: ImageCandidate[] = [];
451
+
452
+ for (const candidate of source) {
453
+ if (typeof candidate.type === 'string' && candidate.type.length > 0) {
454
+ const existing = groupedByType.get(candidate.type) ?? [];
455
+ existing.push(candidate);
456
+ groupedByType.set(candidate.type, existing);
457
+ } else {
458
+ fallbackCandidates.push(candidate);
459
+ }
460
+ }
461
+
462
+ const pictureSources = Array.from(groupedByType.entries())
463
+ .sort(([typeA], [typeB]) => formatPreferenceScore(typeA) - formatPreferenceScore(typeB))
464
+ .map(([type, candidates]) => ({
465
+ type,
466
+ srcSet: buildSrcSet(candidates),
467
+ }));
468
+ const fallback = fallbackCandidates.length > 0 ? fallbackCandidates : source;
469
+
470
+ return {
471
+ pictureSources,
472
+ src: pickBestImageCandidate(source).uri,
473
+ srcSet: fallback.length > 1 ? buildSrcSet(fallback) : undefined,
474
+ };
475
+ }
476
+
477
+ const src = resolveUrlLikeImageSource(source);
478
+ if (!src) {
479
+ return undefined;
480
+ }
481
+
482
+ return {
483
+ pictureSources: [],
484
+ src,
485
+ };
486
+ }
487
+
488
+ export function resolveImagePlaceholderForDOM(
489
+ placeholder: ImagePlaceholder | undefined,
490
+ ): DOMResolvedImagePlaceholder | undefined {
491
+ if (placeholder == null) {
492
+ return undefined;
493
+ }
494
+
495
+ if (hasNonEmptyStringProperty(placeholder, 'color')) {
496
+ return {
497
+ backgroundColor: placeholder.color,
498
+ };
499
+ }
500
+
501
+ if (isThumbhashSource(placeholder)) {
502
+ const dataUrl = thumbhashStringToDataUrl(placeholder.thumbhash);
503
+ if (dataUrl != null && dataUrl.length > 0) {
504
+ return {
505
+ backgroundImage: `url("${dataUrl.replace(/"/g, '\\"')}")`,
506
+ };
507
+ }
508
+
509
+ return {
510
+ backgroundColor: DEFAULT_PLACEHOLDER_COLOR,
511
+ };
512
+ }
513
+
514
+ const resolved = resolveImageSourceForDOM(placeholder as ImageSource);
515
+ if (!resolved?.src) {
516
+ return undefined;
517
+ }
518
+
519
+ return {
520
+ backgroundImage: `url("${resolved.src.replace(/"/g, '\\"')}")`,
521
+ };
522
+ }
523
+
524
+ export function normalizeImageLoading(loading: ImageLoading | undefined): 'eager' | 'lazy' | undefined {
525
+ if (loading === 'eager' || loading === 'lazy') {
526
+ return loading;
527
+ }
528
+
529
+ return undefined;
530
+ }
531
+
532
+ export function warnIfImageMissingAlt({
533
+ alt,
534
+ decorative,
535
+ source,
536
+ testID,
537
+ }: ImageWarningInput): void {
538
+ if (
539
+ process.env.NODE_ENV === 'production' ||
540
+ process.env.NODE_ENV === 'test' ||
541
+ decorative ||
542
+ alt !== undefined
543
+ ) {
544
+ return;
545
+ }
546
+
547
+ const signature = `${testID ?? 'no-testid'}:${describeImageSource(source)}`;
548
+ if (warnedMissingAlt.has(signature)) {
549
+ return;
550
+ }
551
+
552
+ warnedMissingAlt.add(signature);
553
+ console.warn('[Exact] <Image> is missing alt text. Provide alt="..." or set decorative.');
554
+ }