@foir/sdk 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.
package/dist/index.js ADDED
@@ -0,0 +1,476 @@
1
+ // src/components/EditorProvider.tsx
2
+ import {
3
+ createContext,
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState
9
+ } from "react";
10
+
11
+ // src/protocol/constants.ts
12
+ var PROTOCOL_VERSION = "1.0";
13
+ var MAX_RESIZE_HEIGHT = 1e5;
14
+ var DANGEROUS_PATH_SEGMENTS = [
15
+ "__proto__",
16
+ "constructor",
17
+ "prototype"
18
+ ];
19
+
20
+ // src/protocol/validation.ts
21
+ var VALID_EDITOR_TYPES = /* @__PURE__ */ new Set([
22
+ "ready",
23
+ "update-values",
24
+ "update-field",
25
+ "request-save",
26
+ "request-publish",
27
+ "resize",
28
+ "dirty",
29
+ "request-navigate",
30
+ "request-operation"
31
+ ]);
32
+ var VALID_HOST_TYPES = /* @__PURE__ */ new Set([
33
+ "init",
34
+ "values-changed",
35
+ "variant-changed",
36
+ "save-result",
37
+ "publish-result",
38
+ "theme-changed",
39
+ "selection-changed",
40
+ "locale-changed",
41
+ "operation-result"
42
+ ]);
43
+ function isSafeFieldPath(path) {
44
+ const segments = path.split(".");
45
+ return !segments.some((s) => DANGEROUS_PATH_SEGMENTS.includes(s));
46
+ }
47
+ function isValidEditorMessage(data) {
48
+ if (!data || typeof data !== "object") return false;
49
+ const msg = data;
50
+ if (typeof msg.type !== "string") return false;
51
+ if (!VALID_EDITOR_TYPES.has(msg.type)) return false;
52
+ if (!msg.payload || typeof msg.payload !== "object") return false;
53
+ switch (msg.type) {
54
+ case "update-field": {
55
+ const payload = msg.payload;
56
+ if (typeof payload.path !== "string") return false;
57
+ if (!isSafeFieldPath(payload.path)) return false;
58
+ break;
59
+ }
60
+ case "resize": {
61
+ const payload = msg.payload;
62
+ if (typeof payload.height !== "number") return false;
63
+ if (payload.height < 0 || payload.height > MAX_RESIZE_HEIGHT)
64
+ return false;
65
+ if (!Number.isFinite(payload.height)) return false;
66
+ break;
67
+ }
68
+ case "update-values": {
69
+ const payload = msg.payload;
70
+ if (!payload.values || typeof payload.values !== "object") return false;
71
+ break;
72
+ }
73
+ case "dirty": {
74
+ const payload = msg.payload;
75
+ if (typeof payload.isDirty !== "boolean") return false;
76
+ break;
77
+ }
78
+ case "request-navigate": {
79
+ const payload = msg.payload;
80
+ if (typeof payload.path !== "string") return false;
81
+ if (!payload.path.startsWith("/")) return false;
82
+ if (payload.path.length > 500) return false;
83
+ break;
84
+ }
85
+ }
86
+ return true;
87
+ }
88
+ function isValidHostMessage(data) {
89
+ if (!data || typeof data !== "object") return false;
90
+ const msg = data;
91
+ if (typeof msg.type !== "string") return false;
92
+ if (!VALID_HOST_TYPES.has(msg.type)) return false;
93
+ if (!msg.payload || typeof msg.payload !== "object") return false;
94
+ return true;
95
+ }
96
+
97
+ // src/client/platform-client.ts
98
+ var PlatformClientError = class extends Error {
99
+ constructor(message, status, response) {
100
+ super(message);
101
+ this.status = status;
102
+ this.response = response;
103
+ this.name = "PlatformClientError";
104
+ }
105
+ status;
106
+ response;
107
+ };
108
+ function createPlatformClient(options) {
109
+ const { apiUrl, apiKey } = options;
110
+ return {
111
+ async query(query, variables) {
112
+ const res = await fetch(apiUrl, {
113
+ method: "POST",
114
+ headers: {
115
+ "Content-Type": "application/json",
116
+ "x-api-key": apiKey
117
+ },
118
+ body: JSON.stringify({ query, variables })
119
+ });
120
+ if (!res.ok) {
121
+ throw new PlatformClientError(
122
+ `GraphQL request failed: ${res.status} ${res.statusText}`,
123
+ res.status
124
+ );
125
+ }
126
+ const json = await res.json();
127
+ if (json.errors?.length) {
128
+ throw new PlatformClientError(json.errors[0].message, void 0, json);
129
+ }
130
+ return json;
131
+ }
132
+ };
133
+ }
134
+
135
+ // src/components/EditorProvider.tsx
136
+ import { jsx } from "react/jsx-runtime";
137
+ var EditorContext = createContext(null);
138
+ function getEnv(viteKey, nextKey) {
139
+ try {
140
+ const meta = import.meta?.env;
141
+ if (meta?.[viteKey]) return meta[viteKey];
142
+ } catch {
143
+ }
144
+ const proc = globalThis.process;
145
+ if (proc?.env?.[nextKey]) return proc.env[nextKey];
146
+ return void 0;
147
+ }
148
+ function EditorProvider({
149
+ children,
150
+ parentOrigin: parentOriginProp,
151
+ apiUrl: apiUrlProp,
152
+ onInit,
153
+ onValuesChanged,
154
+ onVariantChanged,
155
+ onSaveResult,
156
+ onPublishResult,
157
+ onThemeChanged,
158
+ onSelectionChanged,
159
+ onLocaleChanged
160
+ }) {
161
+ const parentOrigin = parentOriginProp ?? getEnv("VITE_PARENT_ORIGIN", "NEXT_PUBLIC_PARENT_ORIGIN") ?? "*";
162
+ const apiUrl = apiUrlProp ?? getEnv("VITE_API_URL", "NEXT_PUBLIC_API_URL") ?? "http://localhost:4001/graphql";
163
+ if (parentOrigin === "*") {
164
+ console.error(
165
+ "[EditorSDK] No parentOrigin configured \u2014 the host<->editor channel is DISABLED for safety. Pass the `parentOrigin` prop or set VITE_PARENT_ORIGIN / NEXT_PUBLIC_PARENT_ORIGIN to the embedding origin."
166
+ );
167
+ }
168
+ const [isReady, setIsReady] = useState(false);
169
+ const [init, setInit] = useState(null);
170
+ const [theme, setTheme] = useState("light");
171
+ const [client, setClient] = useState(null);
172
+ const [configMode, setConfigMode] = useState("editor");
173
+ const [selectedFieldKeys, setSelectedFieldKeys] = useState([]);
174
+ const [selectableFields, setSelectableFields] = useState([]);
175
+ const [localeContext, setLocaleContext] = useState(
176
+ null
177
+ );
178
+ const onInitRef = useRef(onInit);
179
+ const onValuesChangedRef = useRef(onValuesChanged);
180
+ const onVariantChangedRef = useRef(onVariantChanged);
181
+ const onSaveResultRef = useRef(onSaveResult);
182
+ const onPublishResultRef = useRef(onPublishResult);
183
+ const onThemeChangedRef = useRef(onThemeChanged);
184
+ const onSelectionChangedRef = useRef(onSelectionChanged);
185
+ const onLocaleChangedRef = useRef(onLocaleChanged);
186
+ useEffect(() => {
187
+ onInitRef.current = onInit;
188
+ onValuesChangedRef.current = onValuesChanged;
189
+ onVariantChangedRef.current = onVariantChanged;
190
+ onSaveResultRef.current = onSaveResult;
191
+ onPublishResultRef.current = onPublishResult;
192
+ onThemeChangedRef.current = onThemeChanged;
193
+ onSelectionChangedRef.current = onSelectionChanged;
194
+ onLocaleChangedRef.current = onLocaleChanged;
195
+ }, [
196
+ onInit,
197
+ onValuesChanged,
198
+ onVariantChanged,
199
+ onSaveResult,
200
+ onPublishResult,
201
+ onThemeChanged,
202
+ onSelectionChanged,
203
+ onLocaleChanged
204
+ ]);
205
+ const postMessage = useCallback(
206
+ (message) => {
207
+ if (typeof window === "undefined" || !window.parent) return;
208
+ if (parentOrigin === "*") return;
209
+ window.parent.postMessage(message, parentOrigin);
210
+ },
211
+ [parentOrigin]
212
+ );
213
+ const updateValues = useCallback(
214
+ (values) => {
215
+ postMessage({ type: "update-values", payload: { values } });
216
+ },
217
+ [postMessage]
218
+ );
219
+ const updateField = useCallback(
220
+ (path, value2) => {
221
+ postMessage({ type: "update-field", payload: { path, value: value2 } });
222
+ },
223
+ [postMessage]
224
+ );
225
+ const requestSave = useCallback(() => {
226
+ postMessage({ type: "request-save", payload: {} });
227
+ }, [postMessage]);
228
+ const requestPublish = useCallback(() => {
229
+ postMessage({ type: "request-publish", payload: {} });
230
+ }, [postMessage]);
231
+ const resize = useCallback(
232
+ (height) => {
233
+ postMessage({ type: "resize", payload: { height } });
234
+ },
235
+ [postMessage]
236
+ );
237
+ const setDirty = useCallback(
238
+ (isDirty) => {
239
+ postMessage({ type: "dirty", payload: { isDirty } });
240
+ },
241
+ [postMessage]
242
+ );
243
+ const requestNavigate = useCallback(
244
+ (path) => {
245
+ postMessage({ type: "request-navigate", payload: { path } });
246
+ },
247
+ [postMessage]
248
+ );
249
+ const pendingOperationsRef = useRef(/* @__PURE__ */ new Map());
250
+ const requestOperation = useCallback(
251
+ (operationKey, input, options) => {
252
+ const requestId = crypto.randomUUID();
253
+ return new Promise((resolve) => {
254
+ pendingOperationsRef.current.set(requestId, { resolve });
255
+ postMessage({
256
+ type: "request-operation",
257
+ payload: { operationKey, input, requestId, mode: options?.mode ?? "sync" }
258
+ });
259
+ setTimeout(() => {
260
+ if (pendingOperationsRef.current.has(requestId)) {
261
+ pendingOperationsRef.current.delete(requestId);
262
+ resolve({
263
+ success: false,
264
+ error: { code: "TIMEOUT", message: "Operation timed out" }
265
+ });
266
+ }
267
+ }, 6e4);
268
+ });
269
+ },
270
+ [postMessage]
271
+ );
272
+ useEffect(() => {
273
+ postMessage({ type: "ready", payload: {} });
274
+ }, [postMessage]);
275
+ useEffect(() => {
276
+ const handleMessage = (event) => {
277
+ if (parentOrigin === "*" || event.origin !== parentOrigin) return;
278
+ if (!isValidHostMessage(event.data)) return;
279
+ const message = event.data;
280
+ switch (message.type) {
281
+ case "init": {
282
+ const initData = message.payload;
283
+ const effectiveApiUrl = initData.apiUrl || apiUrl;
284
+ setIsReady(true);
285
+ setInit(initData);
286
+ setTheme(initData.theme);
287
+ setClient(
288
+ createPlatformClient({
289
+ apiUrl: effectiveApiUrl,
290
+ apiKey: initData.apiKey
291
+ })
292
+ );
293
+ if (initData.configMode) setConfigMode(initData.configMode);
294
+ if (initData.selectedFieldKeys)
295
+ setSelectedFieldKeys(initData.selectedFieldKeys);
296
+ if (initData.selectableFields)
297
+ setSelectableFields(initData.selectableFields);
298
+ if (initData.localeContext !== void 0)
299
+ setLocaleContext(initData.localeContext ?? null);
300
+ onInitRef.current?.(initData);
301
+ break;
302
+ }
303
+ case "values-changed":
304
+ onValuesChangedRef.current?.(message.payload.values);
305
+ break;
306
+ case "variant-changed":
307
+ onVariantChangedRef.current?.(
308
+ message.payload.variantKey,
309
+ message.payload.values
310
+ );
311
+ break;
312
+ case "save-result":
313
+ onSaveResultRef.current?.(
314
+ message.payload.success,
315
+ message.payload.error,
316
+ message.payload.versionId
317
+ );
318
+ break;
319
+ case "publish-result":
320
+ onPublishResultRef.current?.(
321
+ message.payload.success,
322
+ message.payload.error
323
+ );
324
+ break;
325
+ case "theme-changed":
326
+ setTheme(message.payload.theme);
327
+ onThemeChangedRef.current?.(message.payload.theme);
328
+ break;
329
+ case "selection-changed":
330
+ setSelectedFieldKeys(message.payload.selectedFieldKeys);
331
+ setSelectableFields(message.payload.fields);
332
+ onSelectionChangedRef.current?.(
333
+ message.payload.selectedFieldKeys,
334
+ message.payload.fields
335
+ );
336
+ break;
337
+ case "locale-changed":
338
+ setLocaleContext(message.payload);
339
+ onLocaleChangedRef.current?.(message.payload);
340
+ break;
341
+ case "operation-result": {
342
+ const { requestId, ...result } = message.payload;
343
+ const pending = pendingOperationsRef.current.get(requestId);
344
+ if (pending) {
345
+ pendingOperationsRef.current.delete(requestId);
346
+ pending.resolve(result);
347
+ }
348
+ break;
349
+ }
350
+ }
351
+ };
352
+ window.addEventListener("message", handleMessage);
353
+ return () => window.removeEventListener("message", handleMessage);
354
+ }, [parentOrigin, apiUrl]);
355
+ const noopClient = useMemo(
356
+ () => ({
357
+ async query() {
358
+ throw new Error(
359
+ "[EditorSDK] Client not ready \u2014 waiting for init from host."
360
+ );
361
+ }
362
+ }),
363
+ []
364
+ );
365
+ const value = useMemo(
366
+ () => ({
367
+ isReady,
368
+ init,
369
+ theme,
370
+ client: client ?? noopClient,
371
+ updateValues,
372
+ updateField,
373
+ requestSave,
374
+ requestPublish,
375
+ resize,
376
+ setDirty,
377
+ requestNavigate,
378
+ requestOperation,
379
+ configMode,
380
+ selectedFieldKeys,
381
+ selectableFields,
382
+ localeContext
383
+ }),
384
+ [
385
+ isReady,
386
+ init,
387
+ theme,
388
+ client,
389
+ noopClient,
390
+ updateValues,
391
+ updateField,
392
+ requestSave,
393
+ requestPublish,
394
+ resize,
395
+ setDirty,
396
+ requestNavigate,
397
+ requestOperation,
398
+ configMode,
399
+ selectedFieldKeys,
400
+ selectableFields,
401
+ localeContext
402
+ ]
403
+ );
404
+ return /* @__PURE__ */ jsx(EditorContext.Provider, { value, children });
405
+ }
406
+
407
+ // src/hooks/useEditor.ts
408
+ import { useContext } from "react";
409
+ function useEditor() {
410
+ const ctx = useContext(EditorContext);
411
+ if (!ctx) {
412
+ throw new Error(
413
+ "[EditorSDK] useEditor must be used inside <EditorProvider>."
414
+ );
415
+ }
416
+ return ctx;
417
+ }
418
+
419
+ // src/hooks/useAutoResize.ts
420
+ import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2 } from "react";
421
+ function useAutoResize(options = {}) {
422
+ const { minHeight = 400, debounce = 100, enabled = true } = options;
423
+ const { isReady, resize } = useEditor();
424
+ const observerRef = useRef2(null);
425
+ const timeoutRef = useRef2(
426
+ void 0
427
+ );
428
+ const elementRef = useRef2(null);
429
+ const cleanup = useCallback2(() => {
430
+ if (observerRef.current) {
431
+ observerRef.current.disconnect();
432
+ observerRef.current = null;
433
+ }
434
+ if (timeoutRef.current) {
435
+ clearTimeout(timeoutRef.current);
436
+ timeoutRef.current = void 0;
437
+ }
438
+ }, []);
439
+ const callbackRef = useCallback2(
440
+ (node) => {
441
+ cleanup();
442
+ elementRef.current = node;
443
+ if (!node || !enabled || !isReady) return;
444
+ const reportHeight = () => {
445
+ if (!elementRef.current) return;
446
+ const height = Math.max(elementRef.current.scrollHeight, minHeight);
447
+ resize(height);
448
+ };
449
+ reportHeight();
450
+ const observer = new ResizeObserver(() => {
451
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
452
+ timeoutRef.current = setTimeout(reportHeight, debounce);
453
+ });
454
+ observer.observe(node);
455
+ observerRef.current = observer;
456
+ },
457
+ [isReady, enabled, minHeight, debounce, resize, cleanup]
458
+ );
459
+ useEffect2(() => {
460
+ if (elementRef.current) {
461
+ callbackRef(elementRef.current);
462
+ }
463
+ }, [callbackRef]);
464
+ useEffect2(() => cleanup, [cleanup]);
465
+ return callbackRef;
466
+ }
467
+ export {
468
+ EditorProvider,
469
+ PROTOCOL_VERSION,
470
+ PlatformClientError,
471
+ createPlatformClient,
472
+ isValidEditorMessage,
473
+ isValidHostMessage,
474
+ useAutoResize,
475
+ useEditor
476
+ };
@@ -0,0 +1,234 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ /**
4
+ * Foir media types + adapters.
5
+ *
6
+ * Framework-agnostic plumbing for rendering Foir image/video fields with
7
+ * `<ProgressiveMedia>`: the canonical source shapes, GraphQL-value → source
8
+ * adapters (crop normalization, per-format variant sets, HLS URL
9
+ * absolutising), and crop-aware dimension maths for CLS-free layout.
10
+ *
11
+ * HLS manifest URLs are absolutised via the shared `absolutiseFoirMediaUrl`
12
+ * from the client transport — not re-implemented here.
13
+ */
14
+ /** One rendered tier: a URL + its TRUE (crop-aware) dims (for srcset `w` + CLS). */
15
+ interface VariantTier {
16
+ url: string;
17
+ width?: number | null;
18
+ height?: number | null;
19
+ }
20
+ /** Curated tier set in one format (Foir `ImageVariantSet`). */
21
+ interface VariantSet {
22
+ thumbnail?: VariantTier | null;
23
+ small?: VariantTier | null;
24
+ medium?: VariantTier | null;
25
+ large?: VariantTier | null;
26
+ xlarge?: VariantTier | null;
27
+ _2xl?: VariantTier | null;
28
+ _3xl?: VariantTier | null;
29
+ _4xl?: VariantTier | null;
30
+ }
31
+ /** A `VariantSet` tier name. */
32
+ type VariantTierKey = keyof VariantSet;
33
+ /**
34
+ * Default tier order, smallest→largest, for walking a `VariantSet`
35
+ * deterministically when building a `srcset`. Override per `<ProgressiveMedia>`
36
+ * via the `variantTiers` prop when a project curates a different tier set.
37
+ */
38
+ declare const DEFAULT_VARIANT_TIERS: readonly VariantTierKey[];
39
+ /** Image source with progressive loading data. */
40
+ interface ProgressiveImageSource {
41
+ url: string;
42
+ alt?: string;
43
+ width?: number;
44
+ height?: number;
45
+ blurhash?: string;
46
+ dominantColor?: string;
47
+ thumbnailUrl?: string;
48
+ /** Per-format tier sets (WebP universal; AVIF preferred via `<picture>`). */
49
+ webp?: VariantSet | null;
50
+ avif?: VariantSet | null;
51
+ /**
52
+ * Optional crop rectangle expressed as percentages of the source image
53
+ * (`{x, y, width, height}` in 0–100). Foir's image pipeline serves the
54
+ * cropped file at the cropped dimensions, so consumers need this to compute
55
+ * correct `width`/`height` attrs for CLS-free rendering.
56
+ */
57
+ crop?: {
58
+ x?: number;
59
+ y?: number;
60
+ width?: number;
61
+ height?: number;
62
+ };
63
+ }
64
+ /** Video source with progressive loading data + optional HLS. */
65
+ interface ProgressiveVideoSource {
66
+ url: string;
67
+ width?: number;
68
+ height?: number;
69
+ blurhash?: string;
70
+ dominantColor?: string;
71
+ thumbnailUrl?: string;
72
+ previewUrl?: string;
73
+ hlsUrl?: string;
74
+ duration?: number;
75
+ }
76
+ /**
77
+ * Foir GraphQL `ImageValue`-like input — the shape a consumer's codegen
78
+ * produces for an image field (intrinsic dims + per-format variant sets +
79
+ * crop as opaque JSON). Structurally compatible with the generated type.
80
+ */
81
+ interface FoirImageInput {
82
+ url?: string | null;
83
+ alt?: string | null;
84
+ width?: number | null;
85
+ height?: number | null;
86
+ blurhash?: string | null;
87
+ dominantColor?: string | null;
88
+ webp?: VariantSet | null;
89
+ avif?: VariantSet | null;
90
+ crop?: {
91
+ [key: string]: unknown;
92
+ } | null;
93
+ }
94
+ /** Foir GraphQL `VideoValue`-like input. */
95
+ interface FoirVideoInput {
96
+ url?: string | null;
97
+ width?: number | null;
98
+ height?: number | null;
99
+ hlsManifestUrl?: string | null;
100
+ thumbnailUrl?: string | null;
101
+ previewUrl?: string | null;
102
+ blurhash?: string | null;
103
+ dominantColor?: string | null;
104
+ }
105
+ /**
106
+ * Adapt a Foir image field to the `ProgressiveImageSource` consumed by
107
+ * `<ProgressiveMedia>`. Returns `undefined` when the field is empty (the
108
+ * caller falls back to a placeholder). Narrows the JSON-typed `crop` to the
109
+ * numeric keys; out-of-range values are filtered in `effectiveImageDimensions`.
110
+ */
111
+ declare function toProgressiveImage(img: FoirImageInput | null | undefined): ProgressiveImageSource | undefined;
112
+ /**
113
+ * Adapt a Foir video field to the `ProgressiveVideoSource` consumed by
114
+ * `<ProgressiveMedia>`. Absolutises the HLS manifest URL (Foir returns it
115
+ * relative to its media proxy). Returns `undefined` when the field is empty.
116
+ */
117
+ declare function toProgressiveVideo(v: FoirVideoInput | null | undefined): ProgressiveVideoSource | undefined;
118
+ /**
119
+ * Resolve the dimensions of the file Foir's pipeline will actually serve,
120
+ * applying any crop percentages to the source's intrinsic size. Returns null
121
+ * when the source has no width/height. Drives both the wrapper aspect-ratio
122
+ * and the `<img>` `width`/`height` attrs.
123
+ */
124
+ declare function effectiveImageDimensions(image: {
125
+ width?: number | null;
126
+ height?: number | null;
127
+ crop?: unknown;
128
+ } | null | undefined): {
129
+ width: number;
130
+ height: number;
131
+ } | null;
132
+ /**
133
+ * Compute a CSS `aspect-ratio` string from a Foir image's natural dimensions
134
+ * and optional crop. When a usable crop is set the cropped rectangle's aspect
135
+ * wins; otherwise the intrinsic width/height; otherwise `null`.
136
+ */
137
+ declare function cropAspectRatio(image: {
138
+ width?: number | null;
139
+ height?: number | null;
140
+ crop?: unknown;
141
+ } | null | undefined): string | null;
142
+
143
+ interface ProgressiveMediaProps {
144
+ image?: ProgressiveImageSource;
145
+ video?: ProgressiveVideoSource;
146
+ alt?: string;
147
+ className?: string;
148
+ /** Fill the parent container instead of using intrinsic aspect ratio. */
149
+ fill?: boolean;
150
+ /**
151
+ * Object-fit for fill mode. Defaults to 'cover'. Has no effect when `fill`
152
+ * is false (the image renders at its intrinsic aspect ratio).
153
+ */
154
+ fit?: 'cover' | 'contain';
155
+ /**
156
+ * Solid-colour placeholder mode. When neither image nor video is provided,
157
+ * render a flat coloured div. `aspectRatio` defaults to 16/10.
158
+ */
159
+ placeholder?: {
160
+ color: string;
161
+ aspectRatio?: number;
162
+ };
163
+ /** Video playback — autoplay on mount. Default true. */
164
+ autoplay?: boolean;
165
+ /** Video playback — muted. Default true (required for autoplay). */
166
+ muted?: boolean;
167
+ /** Video playback — loop on end. Default true. */
168
+ loop?: boolean;
169
+ /**
170
+ * When false, skip loading the full media layer (no `<video>`/full `<img>`
171
+ * mount, no HLS attach, no full-image preload). The lower layers — colour,
172
+ * blurhash, thumbnail — still render. Used by responsive dual-render so the
173
+ * CSS-hidden branch doesn't download its full asset. Default true.
174
+ */
175
+ active?: boolean;
176
+ /**
177
+ * `sizes` attribute applied to the full `<img>` — tells the browser how
178
+ * wide the image renders so it can pick the right `srcset` entry. Default
179
+ * `"100vw"`. Has no effect on video sources.
180
+ */
181
+ sizes?: string;
182
+ /**
183
+ * When true, treat this media as the page's LCP candidate: thumbnail and
184
+ * full `<img>` get `fetchpriority="high"` + `loading="eager"`, and any
185
+ * `<video>` mounts immediately. When false (default) below-fold behaviour:
186
+ * `loading="lazy"` on `<img>`, and `<video>` is gated by an
187
+ * IntersectionObserver so its `src`/HLS only attaches near the viewport.
188
+ */
189
+ priority?: boolean;
190
+ /**
191
+ * Tier order, smallest→largest, used to build the `srcset` from a
192
+ * `VariantSet`. Defaults to {@link DEFAULT_VARIANT_TIERS}; override when a
193
+ * project curates a different tier set.
194
+ */
195
+ variantTiers?: readonly VariantTierKey[];
196
+ }
197
+ declare function ProgressiveMedia({ image, video, alt, className, fill, fit, placeholder, autoplay, muted, loop, active, sizes, priority, variantTiers, }: ProgressiveMediaProps): react_jsx_runtime.JSX.Element | null;
198
+
199
+ interface BlurhashCanvasProps {
200
+ hash: string;
201
+ className?: string;
202
+ }
203
+ /**
204
+ * Decodes a BlurHash string to a 32×32 canvas. `blurhash` is an optional peer
205
+ * dependency loaded via dynamic import — if it isn't installed the canvas
206
+ * stays blank rather than throwing.
207
+ */
208
+ declare function BlurhashCanvas({ hash, className }: BlurhashCanvasProps): react_jsx_runtime.JSX.Element;
209
+
210
+ type LoadStage = 'color' | 'blurhash' | 'thumbnail' | 'full';
211
+ interface UseProgressiveLoadOptions {
212
+ blurhash?: string;
213
+ thumbnailUrl?: string;
214
+ fullUrl?: string;
215
+ /**
216
+ * Optional srcset + sizes — when provided to image sources, the preloader
217
+ * matches the variant the visible `<img>` will pick, so the browser dedupes
218
+ * the fetch instead of downloading two sizes.
219
+ */
220
+ fullSrcSet?: string;
221
+ fullSizes?: string;
222
+ isVideo?: boolean;
223
+ }
224
+ /**
225
+ * State machine for progressive media loading.
226
+ * Stages advance forward only: color → blurhash → thumbnail → full.
227
+ * Skips stages when data is missing. Never regresses.
228
+ */
229
+ declare function useProgressiveLoad({ blurhash, thumbnailUrl, fullUrl, fullSrcSet, fullSizes, isVideo, }: UseProgressiveLoadOptions): {
230
+ stage: LoadStage;
231
+ onFullReady: () => void;
232
+ };
233
+
234
+ export { BlurhashCanvas, type BlurhashCanvasProps, DEFAULT_VARIANT_TIERS, type FoirImageInput, type FoirVideoInput, type LoadStage, type ProgressiveImageSource, ProgressiveMedia, type ProgressiveMediaProps, type ProgressiveVideoSource, type UseProgressiveLoadOptions, type VariantSet, type VariantTier, type VariantTierKey, cropAspectRatio, effectiveImageDimensions, toProgressiveImage, toProgressiveVideo, useProgressiveLoad };