@meginhufr/brua-web 0.1.0 → 0.2.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/LICENSE +21 -0
- package/README.md +1 -1
- package/dist/BruaPicture.svelte +5 -3
- package/dist/brua.d.ts +15 -4
- package/dist/brua.js +54 -25
- package/dist/react.d.ts +2 -2
- package/dist/react.js +6 -4
- package/dist/svelte.d.ts +2 -2
- package/dist/svelte.js +4 -4
- package/dist/vue.d.ts +2 -2
- package/dist/vue.js +4 -3
- package/package.json +2 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Meginhufr AS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Your backend passes asset ids on, and the page reads each asset's current releas
|
|
|
10
10
|
|
|
11
11
|
## From an id
|
|
12
12
|
|
|
13
|
-
Configure once where your organization's assets live, then render by id. The asset is fetched from `{base}/{id}.json` with no key,
|
|
13
|
+
Configure once where your organization's assets live, then render by id. The asset is fetched from `{base}/{id}.json` with no key, and remembered by everyone who asks: answered from memory for the edge's minute, read again after that, and kept when the edge can't be reached. A merge in Brua reaches the page within that minute with no deploy of yours; `brua.cachedAsset(id)` gives the last release read at once, and `brua.prefetch(ids)` reads a list's ids before it renders.
|
|
14
14
|
|
|
15
15
|
```ts
|
|
16
16
|
import { createBrua } from "@meginhufr/brua-web";
|
package/dist/BruaPicture.svelte
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
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 children
|
|
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. -->
|
|
5
5
|
<script>
|
|
6
6
|
import { getContext } from "svelte";
|
|
7
7
|
import { pictureModel } from "./picture.ts";
|
|
8
8
|
|
|
9
9
|
/** @type {{ asset?: import("./types.ts").AssetResponse, id?: string, brua?: import("./brua.ts").Brua, sizes?: string, loading?: "lazy" | "eager", class?: string, colorScheme?: "light" | "dark", children?: import("svelte").Snippet }} */
|
|
10
10
|
let { asset: given, id, brua = getContext("brua"), sizes, loading, class: imgClass, colorScheme, children } = $props();
|
|
11
|
-
|
|
11
|
+
// The last release the Brua read renders at once, so a remounted picture doesn't flash its children.
|
|
12
|
+
let fetched = $state(id === undefined ? undefined : brua?.cachedAsset(id));
|
|
12
13
|
|
|
13
14
|
$effect(() => {
|
|
14
15
|
if (given || id === undefined) return;
|
|
15
16
|
if (!brua) throw new Error("BruaPicture with an id needs setBrua() in a parent, or a brua prop.");
|
|
16
17
|
let live = true;
|
|
17
|
-
brua.
|
|
18
|
+
fetched = brua.cachedAsset(id);
|
|
19
|
+
brua.asset(id).then((loaded) => { if (live) fetched = loaded; }, () => {});
|
|
18
20
|
return () => { live = false; };
|
|
19
21
|
});
|
|
20
22
|
|
package/dist/brua.d.ts
CHANGED
|
@@ -2,18 +2,29 @@ import type { AssetResponse } 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;
|
|
5
|
-
/** The language for alt text and language-specific files; left out, the browser's
|
|
5
|
+
/** The language for alt text and language-specific files; left out, the browser's languages decide. */
|
|
6
6
|
lang?: string;
|
|
7
|
-
/** How long a fetched asset is reused before it is asked for again, in milliseconds
|
|
7
|
+
/** How long a fetched asset is reused before it is asked for again, in milliseconds; by default the edge's max-age. */
|
|
8
8
|
maxAge?: number;
|
|
9
9
|
/** The fetch to use; the global one by default. */
|
|
10
10
|
fetch?: typeof fetch;
|
|
11
11
|
}
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Assets by id from the edge, with no key and no backend. Every release read is remembered: answered from memory for
|
|
14
|
+
* the edge's minute, asked for again after that, and kept when the edge can't be reached, so a merge lands within
|
|
15
|
+
* the minute and a page that loses its connection keeps its images.
|
|
16
|
+
*/
|
|
13
17
|
export interface Brua {
|
|
14
18
|
/** The JSON URL of the asset's current release. */
|
|
15
19
|
url(id: string): string;
|
|
16
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* The asset's current release, or undefined when the id is unknown or nothing is released. Reads of one id at the
|
|
22
|
+
* same time share a request. Rejects only when the edge can't be reached and no release of the id is known.
|
|
23
|
+
*/
|
|
17
24
|
asset(id: string): Promise<AssetResponse | undefined>;
|
|
25
|
+
/** The last release of the id this Brua has read, however old, or undefined: for a component's first render. */
|
|
26
|
+
cachedAsset(id: string): AssetResponse | undefined;
|
|
27
|
+
/** Reads these ids at the same time, so a list's pictures render at once when it appears. */
|
|
28
|
+
prefetch(ids: string[]): Promise<void>;
|
|
18
29
|
}
|
|
19
30
|
export declare function createBrua(options: BruaOptions): Brua;
|
package/dist/brua.js
CHANGED
|
@@ -1,31 +1,60 @@
|
|
|
1
|
+
/** "nb-NO, en;q=0.9": the browser's languages in order, with falling quality values, as the edge reads ?lang=. */
|
|
2
|
+
function acceptLanguage(languages) {
|
|
3
|
+
return languages.slice(0, 5).map((language, index) => (index === 0 ? language : `${language};q=${(1 - index * 0.1).toFixed(1)}`)).join(", ");
|
|
4
|
+
}
|
|
5
|
+
/** The edge's max-age in milliseconds, or undefined when the response doesn't say. */
|
|
6
|
+
function maxAgeOf(response) {
|
|
7
|
+
const match = /(?:^|,)\s*max-age\s*=\s*(\d+)/i.exec(response.headers.get("Cache-Control") ?? "");
|
|
8
|
+
return match ? Number(match[1]) * 1000 : undefined;
|
|
9
|
+
}
|
|
1
10
|
export function createBrua(options) {
|
|
2
11
|
const base = options.base.replace(/\/+$/, "");
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
12
|
+
const entries = new Map();
|
|
13
|
+
const reads = new Map();
|
|
14
|
+
// Without a language, the browser's languages go in the query (in a browser only, not on a server), so each has
|
|
15
|
+
// its own entry here and in the HTTP cache, the same as the other SDKs.
|
|
16
|
+
const languages = () => options.lang ?? (typeof window !== "undefined" && typeof navigator !== "undefined" ? acceptLanguage(navigator.languages ?? []) : "");
|
|
17
|
+
const url = (id) => {
|
|
18
|
+
const lang = languages();
|
|
19
|
+
return `${base}/${encodeURIComponent(id)}.json${lang ? `?lang=${encodeURIComponent(lang)}` : ""}`;
|
|
20
|
+
};
|
|
21
|
+
const read = async (id, at) => {
|
|
22
|
+
const response = await (options.fetch ?? fetch)(at);
|
|
23
|
+
const freshFor = options.maxAge ?? maxAgeOf(response) ?? 60_000;
|
|
24
|
+
if (response.status === 404)
|
|
25
|
+
return { asset: undefined, freshUntil: Date.now() + freshFor };
|
|
26
|
+
if (!response.ok)
|
|
27
|
+
throw new Error(`Brua: ${id} answered ${response.status}`);
|
|
28
|
+
return { asset: (await response.json()), freshUntil: Date.now() + freshFor };
|
|
29
|
+
};
|
|
30
|
+
const brua = {
|
|
8
31
|
url,
|
|
9
|
-
asset(id) {
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
if (
|
|
13
|
-
return
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return (await
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
32
|
+
async asset(id) {
|
|
33
|
+
const at = url(id);
|
|
34
|
+
const known = entries.get(at);
|
|
35
|
+
if (known && Date.now() < known.freshUntil)
|
|
36
|
+
return known.asset;
|
|
37
|
+
let running = reads.get(at);
|
|
38
|
+
if (!running) {
|
|
39
|
+
running = read(id, at).then((entry) => { entries.set(at, entry); return entry; }).finally(() => reads.delete(at));
|
|
40
|
+
reads.set(at, running);
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return (await running).asset;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
const kept = entries.get(at);
|
|
47
|
+
if (kept)
|
|
48
|
+
return kept.asset;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
cachedAsset(id) {
|
|
53
|
+
return entries.get(url(id))?.asset;
|
|
54
|
+
},
|
|
55
|
+
async prefetch(ids) {
|
|
56
|
+
await Promise.all([...new Set(ids)].map((id) => brua.asset(id).catch(() => undefined)));
|
|
29
57
|
},
|
|
30
58
|
};
|
|
59
|
+
return brua;
|
|
31
60
|
}
|
package/dist/react.d.ts
CHANGED
|
@@ -11,8 +11,8 @@ export type BruaProviderProps = {
|
|
|
11
11
|
/** Where the app's assets are read from by id: wrap the app once, then <BruaPicture id="…"> anywhere below. */
|
|
12
12
|
export declare function BruaProvider(props: BruaProviderProps): ReactElement;
|
|
13
13
|
/**
|
|
14
|
-
* The asset's current release for an id, from the provider's Brua or the one given:
|
|
15
|
-
* and when nothing is released under the id. Loads only in the browser; a server render shows the fallback.
|
|
14
|
+
* The asset's current release for an id, from the provider's Brua or the one given: the last release it read at once, else
|
|
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
18
|
export interface BruaPictureProps extends PictureOptions {
|
package/dist/react.js
CHANGED
|
@@ -13,21 +13,23 @@ export function BruaProvider(props) {
|
|
|
13
13
|
return createElement(BruaContext.Provider, { value: brua }, props.children);
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
* The asset's current release for an id, from the provider's Brua or the one given:
|
|
17
|
-
* and when nothing is released under the id. Loads only in the browser; a server render shows the fallback.
|
|
16
|
+
* The asset's current release for an id, from the provider's Brua or the one given: the last release it read at once, else
|
|
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
20
|
const fromContext = useContext(BruaContext);
|
|
21
21
|
const client = brua ?? fromContext;
|
|
22
|
-
|
|
22
|
+
// 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)));
|
|
23
24
|
if (id !== undefined && !client)
|
|
24
25
|
throw new Error("useBruaAsset needs a <BruaProvider> above it, or a Brua passed in.");
|
|
25
26
|
useEffect(() => {
|
|
26
27
|
if (id === undefined || !client)
|
|
27
28
|
return;
|
|
28
29
|
let live = true;
|
|
30
|
+
setAsset(client.cachedAsset(id));
|
|
29
31
|
client.asset(id).then((loaded) => { if (live)
|
|
30
|
-
setAsset(loaded); });
|
|
32
|
+
setAsset(loaded); }, () => { });
|
|
31
33
|
return () => { live = false; };
|
|
32
34
|
}, [client, id]);
|
|
33
35
|
return id === undefined ? undefined : asset;
|
package/dist/svelte.d.ts
CHANGED
|
@@ -9,8 +9,8 @@ export declare const bruaContext = "brua";
|
|
|
9
9
|
export declare function setBrua(options: Brua | BruaOptions): Brua;
|
|
10
10
|
export declare function getBrua(): Brua | undefined;
|
|
11
11
|
/**
|
|
12
|
-
* A store of the asset's current release for an id:
|
|
13
|
-
* under the id. Reads through the context's Brua unless one is given; call it during component setup.
|
|
12
|
+
* A store of the asset's current release for an id: the last release the Brua read at once, else undefined until it
|
|
13
|
+
* has loaded, and when nothing is released under the id. Reads through the context's Brua unless one is given; call it during component setup.
|
|
14
14
|
*/
|
|
15
15
|
export declare function bruaAsset(id: string, brua?: Brua | undefined): Readable<AssetResponse | undefined>;
|
|
16
16
|
/**
|
package/dist/svelte.js
CHANGED
|
@@ -17,16 +17,16 @@ export function getBrua() {
|
|
|
17
17
|
return getContext(bruaContext);
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
|
-
* A store of the asset's current release for an id:
|
|
21
|
-
* under the id. Reads through the context's Brua unless one is given; call it during component setup.
|
|
20
|
+
* A store of the asset's current release for an id: the last release the Brua read at once, else undefined until it
|
|
21
|
+
* has loaded, and when nothing is released under the id. Reads through the context's Brua unless one is given; call it during component setup.
|
|
22
22
|
*/
|
|
23
23
|
export function bruaAsset(id, brua = getBrua()) {
|
|
24
24
|
if (!brua)
|
|
25
25
|
throw new Error("bruaAsset needs setBrua() in a parent, or a Brua passed in.");
|
|
26
|
-
return readable(
|
|
26
|
+
return readable(brua.cachedAsset(id), (set) => {
|
|
27
27
|
let live = true;
|
|
28
28
|
brua.asset(id).then((loaded) => { if (live)
|
|
29
|
-
set(loaded); });
|
|
29
|
+
set(loaded); }, () => { });
|
|
30
30
|
return () => { live = false; };
|
|
31
31
|
});
|
|
32
32
|
}
|
package/dist/vue.d.ts
CHANGED
|
@@ -6,8 +6,8 @@ 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;
|
|
8
8
|
/**
|
|
9
|
-
* The asset's current release for an id, from the provided Brua or the one given:
|
|
10
|
-
* and when nothing is released under the id. A server render shows the fallback.
|
|
9
|
+
* The asset's current release for an id, from the provided Brua or the one given: the last release it read at once, else
|
|
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
13
|
/**
|
package/dist/vue.js
CHANGED
|
@@ -12,8 +12,8 @@ export function provideBrua(options) {
|
|
|
12
12
|
return brua;
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
|
-
* The asset's current release for an id, from the provided Brua or the one given:
|
|
16
|
-
* and when nothing is released under the id. A server render shows the fallback.
|
|
15
|
+
* The asset's current release for an id, from the provided Brua or the one given: the last release it read at once, else
|
|
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
19
|
const client = brua ?? inject(bruaKey, undefined);
|
|
@@ -28,8 +28,9 @@ export function useBruaAsset(id, brua) {
|
|
|
28
28
|
throw new Error("useBruaAsset needs provideBrua() in a parent, or a Brua passed in.");
|
|
29
29
|
let live = true;
|
|
30
30
|
onCleanup(() => { live = false; });
|
|
31
|
+
asset.value = client.cachedAsset(wanted);
|
|
31
32
|
client.asset(wanted).then((loaded) => { if (live)
|
|
32
|
-
asset.value = loaded; });
|
|
33
|
+
asset.value = loaded; }, () => { });
|
|
33
34
|
});
|
|
34
35
|
return asset;
|
|
35
36
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meginhufr/brua-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
],
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/BruaPicture.svelte dist/",
|
|
39
|
-
"test": "node --test test
|
|
39
|
+
"test": "node --test \"test/**/*.test.ts\""
|
|
40
40
|
},
|
|
41
41
|
"publishConfig": {
|
|
42
42
|
"access": "public",
|