@meginhufr/brua-web 0.3.0 → 0.4.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.
@@ -1,30 +1,42 @@
1
1
  <!-- Brua for Svelte 5: the asset as a picture element (SPEC.md 7.2). The browser picks the format, density
2
2
  and color scheme, and follows dark mode live. Animations show their still. Import from "@meginhufr/brua-web/svelte".
3
3
  Given an id instead of an asset, the current release is read from the edge through the Brua set with
4
- setBrua(); the last release it read shows at once, else the children until it has loaded, and when nothing is released under the id. -->
4
+ setBrua(); the last release it read shows at once, else the children until it has loaded. The error snippet, given the
5
+ reason, shows when there is no picture: nothing released, the edge unreachable, or the file failed; without it, the
6
+ children show then. -->
5
7
  <script>
6
8
  import { getContext } from "svelte";
7
9
  import { fillStyle, pictureModel } from "./picture.ts";
10
+ import { assetOrError } from "./brua.ts";
8
11
 
9
- /** @type {{ asset?: import("./types.ts").AssetResponse, id?: string, brua?: import("./brua.ts").Brua, sizes?: string, loading?: "lazy" | "eager", class?: string, colorScheme?: "light" | "dark", objectFit?: "contain" | "cover" | "none", objectPosition?: string, children?: import("svelte").Snippet }} */
10
- let { asset: given, id, brua = getContext("brua"), sizes, loading, class: imgClass, colorScheme, objectFit, objectPosition, children } = $props();
12
+ /** @type {{ asset?: import("./types.ts").AssetResponse, id?: string, brua?: import("./brua.ts").Brua, sizes?: string, loading?: "lazy" | "eager", class?: string, colorScheme?: "light" | "dark", objectFit?: "contain" | "cover" | "none", objectPosition?: string, children?: import("svelte").Snippet, error?: import("svelte").Snippet<[import("./types.ts").BruaImageError]> }} */
13
+ let { asset: given, id, brua = getContext("brua"), sizes, loading, class: imgClass, colorScheme, objectFit, objectPosition, children, error } = $props();
11
14
  // The last release the Brua read renders at once, so a remounted picture doesn't flash its children.
12
15
  let fetched = $state(id === undefined ? undefined : brua?.cachedAsset(id));
16
+ /** @type {import("./types.ts").BruaImageError | undefined} */
17
+ let reason = $state();
18
+ /** @type {string | undefined} */
19
+ let failed = $state();
13
20
 
14
21
  $effect(() => {
15
22
  if (given || id === undefined) return;
16
23
  if (!brua) throw new Error("BruaPicture with an id needs setBrua() in a parent, or a brua prop.");
17
24
  let live = true;
18
25
  fetched = brua.cachedAsset(id);
19
- brua.asset(id).then((loaded) => { if (live) fetched = loaded; }, () => {});
26
+ reason = undefined;
27
+ assetOrError(brua, id).then((read) => { if (live) { fetched = read.asset; reason = read.error; } });
20
28
  return () => { live = false; };
21
29
  });
22
30
 
23
31
  let asset = $derived(given ?? fetched);
24
32
  let model = $derived(asset ? pictureModel(asset, { sizes, loading, className: imgClass, colorScheme, objectFit, objectPosition }) : undefined);
33
+ // The img's own error event: the file failed. Another file (a merge, another size) tries again.
34
+ let failure = $derived(model && failed === model.img.src ? { reason: "file", url: model.img.src } : !asset ? reason : undefined);
25
35
  </script>
26
36
 
27
- {#if model}
37
+ {#if failure}
38
+ {#if error}{@render error(failure)}{:else if children}{@render children()}{/if}
39
+ {:else if model}
28
40
  <picture>
29
41
  {#each model.sources as source (source.type + (source.media ?? ""))}
30
42
  <source type={source.type} media={source.media} srcset={source.srcset} sizes={source.sizes} />
@@ -41,6 +53,7 @@
41
53
  decoding={model.img.decoding}
42
54
  class={model.img.className}
43
55
  style={fillStyle(model.img)}
56
+ onerror={() => (failed = model.img.src)}
44
57
  />
45
58
  </picture>
46
59
  {:else if children}
package/dist/brua.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AssetResponse } from "./types.ts";
1
+ import type { AssetResponse, BruaImageError } from "./types.ts";
2
2
  export interface BruaOptions {
3
3
  /** The organization's asset prefix on the edge, such as https://cdn.brua.dev/assets/{org}. */
4
4
  base: string;
@@ -28,3 +28,11 @@ export interface Brua {
28
28
  prefetch(ids: string[]): Promise<void>;
29
29
  }
30
30
  export declare function createBrua(options: BruaOptions): Brua;
31
+ /**
32
+ * The asset with this id, or why there is none: not released, or the edge can't be reached with no release known. The
33
+ * framework components show their error view from it.
34
+ */
35
+ export declare function assetOrError(brua: Brua, id: string): Promise<{
36
+ asset?: AssetResponse;
37
+ error?: BruaImageError;
38
+ }>;
package/dist/brua.js CHANGED
@@ -58,3 +58,16 @@ export function createBrua(options) {
58
58
  };
59
59
  return brua;
60
60
  }
61
+ /**
62
+ * The asset with this id, or why there is none: not released, or the edge can't be reached with no release known. The
63
+ * framework components show their error view from it.
64
+ */
65
+ export async function assetOrError(brua, id) {
66
+ try {
67
+ const asset = await brua.asset(id);
68
+ return asset ? { asset } : { error: { reason: "notReleased" } };
69
+ }
70
+ catch (cause) {
71
+ return { error: { reason: "unreachable", cause } };
72
+ }
73
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- export type { AssetResponse, AssetVariant, ClientState } from "./types.ts";
1
+ export type { AssetResponse, AssetVariant, BruaImageError, ClientState } from "./types.ts";
2
2
  export { selectUrl, selectVariant } from "./select.ts";
3
3
  export { fillStyle, pictureHtml, pictureModel, type PictureOptions, type PictureModel, type PictureSource, type PictureImage } from "./picture.ts";
4
4
  export { objectFitOf, objectPositionOf, targetWidth, type ObjectFit } from "./sizing.ts";
5
5
  export { browserClientState, defaultClientState, watchClientState, type ClientOptions } from "./client.ts";
6
- export { createBrua, type Brua, type BruaOptions } from "./brua.ts";
6
+ export { assetOrError, createBrua, type Brua, type BruaOptions } from "./brua.ts";
package/dist/index.js CHANGED
@@ -2,4 +2,4 @@ export { selectUrl, selectVariant } from "./select.js";
2
2
  export { fillStyle, pictureHtml, pictureModel } from "./picture.js";
3
3
  export { objectFitOf, objectPositionOf, targetWidth } from "./sizing.js";
4
4
  export { browserClientState, defaultClientState, watchClientState } from "./client.js";
5
- export { createBrua } from "./brua.js";
5
+ export { assetOrError, createBrua } from "./brua.js";
package/dist/react.d.ts CHANGED
@@ -2,7 +2,7 @@ import { type ReactElement, type ReactNode } from "react";
2
2
  import { type PictureOptions } from "./picture.ts";
3
3
  import { type ClientOptions } from "./client.ts";
4
4
  import { type Brua, type BruaOptions } from "./brua.ts";
5
- import type { AssetResponse } from "./types.ts";
5
+ import type { AssetResponse, BruaImageError } from "./types.ts";
6
6
  export type BruaProviderProps = {
7
7
  children?: ReactNode;
8
8
  } & ({
@@ -15,6 +15,14 @@ export declare function BruaProvider(props: BruaProviderProps): ReactElement;
15
15
  * undefined until it has loaded, and when nothing is released under the id. Loads only in the browser; a server render shows the fallback.
16
16
  */
17
17
  export declare function useBruaAsset(id: string | undefined, brua?: Brua): AssetResponse | undefined;
18
+ /**
19
+ * The asset's current release for an id, as useBruaAsset gives it, or the error when there is none: not released, or
20
+ * the edge can't be reached with no release known. Both undefined while it loads.
21
+ */
22
+ export declare function useBruaAssetState(id: string | undefined, brua?: Brua): {
23
+ asset?: AssetResponse;
24
+ error?: BruaImageError;
25
+ };
18
26
  export interface BruaPictureProps extends PictureOptions {
19
27
  /** The asset as your backend embedded it: a snapshot of one release. */
20
28
  asset?: AssetResponse;
@@ -22,14 +30,16 @@ export interface BruaPictureProps extends PictureOptions {
22
30
  id?: string;
23
31
  /** The Brua to read the id through, instead of the provider's. */
24
32
  brua?: Brua;
25
- /** What shows until the id has loaded, and when nothing is released under it. */
33
+ /** What shows until the id has loaded, and on failure when no error view is given. */
26
34
  fallback?: ReactNode;
35
+ /** What shows when there is no picture, with the reason: nothing released, the edge unreachable, or the file failed. */
36
+ error?: ReactNode | ((error: BruaImageError) => ReactNode);
27
37
  }
28
38
  /**
29
39
  * The asset as a picture element (SPEC.md 7.2): the browser picks the format, density and color scheme,
30
40
  * and follows dark mode live. Animations show their still.
31
41
  */
32
- export declare function BruaPicture({ asset, id, brua, fallback, sizes, loading, className, colorScheme, objectFit, objectPosition }: BruaPictureProps): ReactElement | null;
42
+ export declare function BruaPicture({ asset, id, brua, fallback, error, sizes, loading, className, colorScheme, objectFit, objectPosition }: BruaPictureProps): ReactElement | null;
33
43
  /**
34
44
  * One variant URL for the asset, chosen for this browser and kept current as its appearance, motion setting
35
45
  * or pixel density changes: for a CSS background, a canvas or an animation player. Renders light at 1x on
package/dist/react.js CHANGED
@@ -3,7 +3,7 @@ import { createContext, createElement, useContext, useEffect, useMemo, useState
3
3
  import { pictureModel } from "./picture.js";
4
4
  import { selectUrl } from "./select.js";
5
5
  import { defaultClientState, watchClientState } from "./client.js";
6
- import { createBrua } from "./brua.js";
6
+ import { assetOrError, createBrua } from "./brua.js";
7
7
  const BruaContext = createContext(undefined);
8
8
  /** Where the app's assets are read from by id: wrap the app once, then <BruaPicture id="…"> anywhere below. */
9
9
  export function BruaProvider(props) {
@@ -17,33 +17,45 @@ export function BruaProvider(props) {
17
17
  * undefined until it has loaded, and when nothing is released under the id. Loads only in the browser; a server render shows the fallback.
18
18
  */
19
19
  export function useBruaAsset(id, brua) {
20
+ return useBruaAssetState(id, brua).asset;
21
+ }
22
+ /**
23
+ * The asset's current release for an id, as useBruaAsset gives it, or the error when there is none: not released, or
24
+ * the edge can't be reached with no release known. Both undefined while it loads.
25
+ */
26
+ export function useBruaAssetState(id, brua) {
20
27
  const fromContext = useContext(BruaContext);
21
28
  const client = brua ?? fromContext;
22
29
  // The last release the Brua read renders at once, so a remounted picture doesn't flash its fallback.
23
- const [asset, setAsset] = useState(() => (id === undefined ? undefined : client?.cachedAsset(id)));
30
+ const [state, setState] = useState(() => ({ asset: id === undefined ? undefined : client?.cachedAsset(id) }));
24
31
  if (id !== undefined && !client)
25
32
  throw new Error("useBruaAsset needs a <BruaProvider> above it, or a Brua passed in.");
26
33
  useEffect(() => {
27
34
  if (id === undefined || !client)
28
35
  return;
29
36
  let live = true;
30
- setAsset(client.cachedAsset(id));
31
- client.asset(id).then((loaded) => { if (live)
32
- setAsset(loaded); }, () => { });
37
+ setState({ asset: client.cachedAsset(id) });
38
+ assetOrError(client, id).then((read) => { if (live)
39
+ setState(read); });
33
40
  return () => { live = false; };
34
41
  }, [client, id]);
35
- return id === undefined ? undefined : asset;
42
+ return id === undefined ? {} : state;
36
43
  }
37
44
  /**
38
45
  * The asset as a picture element (SPEC.md 7.2): the browser picks the format, density and color scheme,
39
46
  * and follows dark mode live. Animations show their still.
40
47
  */
41
- export function BruaPicture({ asset, id, brua, fallback = null, sizes, loading, className, colorScheme, objectFit, objectPosition }) {
42
- const fetched = useBruaAsset(asset ? undefined : id, brua);
43
- const shown = asset ?? fetched;
48
+ export function BruaPicture({ asset, id, brua, fallback = null, error, sizes, loading, className, colorScheme, objectFit, objectPosition }) {
49
+ const fetched = useBruaAssetState(asset ? undefined : id, brua);
50
+ const [failed, setFailed] = useState();
51
+ const shown = asset ?? fetched.asset;
52
+ const failure = (reason) => (error === undefined ? fallback : typeof error === "function" ? error(reason) : error);
44
53
  if (!shown)
45
- return fallback;
54
+ return fetched.error ? failure(fetched.error) : fallback;
46
55
  const { sources, img } = pictureModel(shown, { sizes, loading, className, colorScheme, objectFit, objectPosition });
56
+ // The img's own error event: the file failed. Another file (a merge, another size) tries again.
57
+ if (failed === img.src)
58
+ return failure({ reason: "file", url: img.src });
47
59
  return createElement("picture", null, ...sources.map((s) => createElement("source", { key: `${s.type}${s.media ?? ""}`, type: s.type, media: s.media, srcSet: s.srcset, sizes: s.sizes })), createElement("img", {
48
60
  src: img.src,
49
61
  srcSet: img.srcset,
@@ -56,6 +68,7 @@ export function BruaPicture({ asset, id, brua, fallback = null, sizes, loading,
56
68
  decoding: img.decoding,
57
69
  className: img.className,
58
70
  style: img.objectFit === undefined ? undefined : { objectFit: img.objectFit, objectPosition: img.objectPosition },
71
+ onError: () => setFailed(img.src),
59
72
  }));
60
73
  }
61
74
  /**
package/dist/types.d.ts CHANGED
@@ -13,6 +13,19 @@ export interface AssetResponse {
13
13
  /** Where it sits in the box, as CSS object-position: "50% 35%", the point a cover crop keeps in view. */
14
14
  objectPosition?: string;
15
15
  }
16
+ /**
17
+ * Why a picture has none to show, with the same reasons on every platform's SDK: nothing is released under the id (the
18
+ * edge answered 404, usually draw nothing), the edge can't be reached and no release is known, or the file failed.
19
+ */
20
+ export type BruaImageError = {
21
+ reason: "notReleased";
22
+ } | {
23
+ reason: "unreachable";
24
+ cause: unknown;
25
+ } | {
26
+ reason: "file";
27
+ url: string;
28
+ };
16
29
  /** Value fields are plain strings, not unions, so new values from the API never break decoding (SPEC.md 14). */
17
30
  export interface AssetVariant {
18
31
  type: string;
package/dist/vue.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type ComputedRef, type InjectionKey, type MaybeRef, type MaybeRefOrGetter, type PropType, type Ref } from "vue";
2
2
  import { type ClientOptions } from "./client.ts";
3
3
  import { type Brua, type BruaOptions } from "./brua.ts";
4
- import type { AssetResponse } from "./types.ts";
4
+ import type { AssetResponse, BruaImageError } from "./types.ts";
5
5
  export declare const bruaKey: InjectionKey<Brua>;
6
6
  /** Where the app's assets are read from by id: call once in the root component, then <BruaPicture id="…"> anywhere below. */
7
7
  export declare function provideBrua(options: Brua | BruaOptions): Brua;
@@ -10,10 +10,19 @@ export declare function provideBrua(options: Brua | BruaOptions): Brua;
10
10
  * undefined until it has loaded, and when nothing is released under the id. A server render shows the fallback.
11
11
  */
12
12
  export declare function useBruaAsset(id: MaybeRefOrGetter<string | undefined>, brua?: Brua): Ref<AssetResponse | undefined>;
13
+ /**
14
+ * The asset's current release for an id, as useBruaAsset gives it, or the error when there is none: not released, or
15
+ * the edge can't be reached with no release known. Both undefined while it loads.
16
+ */
17
+ export declare function useBruaAssetState(id: MaybeRefOrGetter<string | undefined>, brua?: Brua): Ref<{
18
+ asset?: AssetResponse;
19
+ error?: BruaImageError;
20
+ }>;
13
21
  /**
14
22
  * The asset as a picture element (SPEC.md 7.2): the browser picks the format, density and color scheme,
15
23
  * and follows dark mode live. Animations show their still. Attributes such as class go on the img.
16
- * The default slot shows until an id has loaded, and when nothing is released under it.
24
+ * The default slot shows until an id has loaded; the error slot, given the reason as `error`, when there is no picture:
25
+ * nothing released, the edge unreachable, or the file failed. Without an error slot the default one shows then.
17
26
  */
18
27
  export declare const BruaPicture: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
19
28
  /** The asset as your backend embedded it: a snapshot of one release. */
package/dist/vue.js CHANGED
@@ -3,7 +3,7 @@ import { computed, defineComponent, h, inject, onBeforeUnmount, onMounted, provi
3
3
  import { fillStyle, pictureModel } from "./picture.js";
4
4
  import { selectUrl } from "./select.js";
5
5
  import { defaultClientState, watchClientState } from "./client.js";
6
- import { createBrua } from "./brua.js";
6
+ import { assetOrError, createBrua } from "./brua.js";
7
7
  export const bruaKey = Symbol("brua");
8
8
  /** Where the app's assets are read from by id: call once in the root component, then <BruaPicture id="…"> anywhere below. */
9
9
  export function provideBrua(options) {
@@ -16,28 +16,37 @@ export function provideBrua(options) {
16
16
  * undefined until it has loaded, and when nothing is released under the id. A server render shows the fallback.
17
17
  */
18
18
  export function useBruaAsset(id, brua) {
19
+ const state = useBruaAssetState(id, brua);
20
+ return computed(() => state.value.asset);
21
+ }
22
+ /**
23
+ * The asset's current release for an id, as useBruaAsset gives it, or the error when there is none: not released, or
24
+ * the edge can't be reached with no release known. Both undefined while it loads.
25
+ */
26
+ export function useBruaAssetState(id, brua) {
19
27
  const client = brua ?? inject(bruaKey, undefined);
20
- const asset = ref();
28
+ const state = ref({});
21
29
  watchEffect((onCleanup) => {
22
30
  const wanted = toValue(id);
23
31
  if (wanted === undefined) {
24
- asset.value = undefined;
32
+ state.value = {};
25
33
  return;
26
34
  }
27
35
  if (!client)
28
36
  throw new Error("useBruaAsset needs provideBrua() in a parent, or a Brua passed in.");
29
37
  let live = true;
30
38
  onCleanup(() => { live = false; });
31
- asset.value = client.cachedAsset(wanted);
32
- client.asset(wanted).then((loaded) => { if (live)
33
- asset.value = loaded; }, () => { });
39
+ state.value = { asset: client.cachedAsset(wanted) };
40
+ assetOrError(client, wanted).then((read) => { if (live)
41
+ state.value = read; });
34
42
  });
35
- return asset;
43
+ return state;
36
44
  }
37
45
  /**
38
46
  * The asset as a picture element (SPEC.md 7.2): the browser picks the format, density and color scheme,
39
47
  * and follows dark mode live. Animations show their still. Attributes such as class go on the img.
40
- * The default slot shows until an id has loaded, and when nothing is released under it.
48
+ * The default slot shows until an id has loaded; the error slot, given the reason as `error`, when there is no picture:
49
+ * nothing released, the edge unreachable, or the file failed. Without an error slot the default one shows then.
41
50
  */
42
51
  export const BruaPicture = defineComponent({
43
52
  name: "BruaPicture",
@@ -60,14 +69,19 @@ export const BruaPicture = defineComponent({
60
69
  objectPosition: { type: String, required: false },
61
70
  },
62
71
  setup(props, { attrs, slots }) {
63
- const fetched = useBruaAsset(() => (props.asset ? undefined : props.id), props.brua);
72
+ const fetched = useBruaAssetState(() => (props.asset ? undefined : props.id), props.brua);
73
+ const failed = ref();
74
+ const failure = (error) => (slots.error ?? slots.default)?.({ error }) ?? null;
64
75
  return () => {
65
- const asset = props.asset ?? fetched.value;
76
+ const asset = props.asset ?? fetched.value.asset;
66
77
  if (!asset)
67
- return slots.default?.() ?? null;
78
+ return fetched.value.error ? failure(fetched.value.error) : slots.default?.() ?? null;
68
79
  const { sources, img } = pictureModel(asset, {
69
80
  sizes: props.sizes, loading: props.loading, colorScheme: props.colorScheme, objectFit: props.objectFit, objectPosition: props.objectPosition,
70
81
  });
82
+ // The img's own error event: the file failed. Another file (a merge, another size) tries again.
83
+ if (failed.value === img.src)
84
+ return failure({ reason: "file", url: img.src });
71
85
  return h("picture", [
72
86
  ...sources.map((s) => h("source", { key: `${s.type}${s.media ?? ""}`, type: s.type, media: s.media, srcset: s.srcset, sizes: s.sizes })),
73
87
  h("img", {
@@ -83,6 +97,7 @@ export const BruaPicture = defineComponent({
83
97
  decoding: img.decoding,
84
98
  // The fill first, so a style the page gives the component still wins.
85
99
  style: [fillStyle(img), attrs.style],
100
+ onError: () => { failed.value = img.src; },
86
101
  }),
87
102
  ]);
88
103
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meginhufr/brua-web",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Brua for the web: AssetResponse types, the variant selection rule, <picture> rendering, and components for React, Vue and Svelte.",
5
5
  "author": "Meginhufr AS <hello@brua.dev> (https://brua.dev)",
6
6
  "license": "MIT",