···195195 "description": "Run and serve the site for development",
196196 "command": "deno task lume -s",
197197 },
198198- "test": {
199199- "description": "Run tests",
200200- "command": "deno test -A --doc --ignore=README.md --ignore=./docs/ --ignore=./dist/",
198198+ "test:doc": {
199199+ "description": "Run documentation tests",
200200+ "command": "deno test src -A --doc --ignore=README.md",
201201+ },
202202+ "test:integration": {
203203+ "description": "Run integration tests",
204204+ "command": "deno test tests -A --ignore=README.md",
201205 },
202206 },
203207 "compilerOptions": {
+29
src/common/cid.js
···44/**
55 * @param {0x55 | 0x71} code
66 * @param {Uint8Array<any>} data
77+ *
88+ * @example Returns a non-empty base32 CID string, consistent for same input, different for different inputs
99+ * ```js
1010+ * import { create } from "~/common/cid.js";
1111+ *
1212+ * const data = new TextEncoder().encode("hello world");
1313+ * const cid = await create(0x55, data);
1414+ * if (typeof cid !== "string" || cid.length === 0) throw new Error("CID should be a non-empty string");
1515+ * if (!/^[a-z2-7]+$/.test(cid)) throw new Error("CID should be base32-encoded");
1616+ *
1717+ * const cid2 = await create(0x55, data);
1818+ * if (cid !== cid2) throw new Error("same input should produce same CID");
1919+ *
2020+ * const cidDiff = await create(0x55, new TextEncoder().encode("world"));
2121+ * if (cid === cidDiff) throw new Error("different input should produce different CID");
2222+ *
2323+ * const cidCodec = await create(0x71, data);
2424+ * if (cid === cidCodec) throw new Error("different codec should produce different CID");
2525+ * ```
726 */
827export async function create(code, data) {
928 const cid = await CID.create(code, data);
···1332/**
1433 * @param {Uint8Array<any>} data
1534 * @param {string} expected
3535+ *
3636+ * @example Returns true for matching data and false for mismatched data
3737+ * ```js
3838+ * import { create, verify } from "~/common/cid.js";
3939+ *
4040+ * const data = new TextEncoder().encode("hello");
4141+ * const cid = await create(0x55, data);
4242+ * if (!await verify(data, cid)) throw new Error("should verify matching data");
4343+ * if (await verify(new TextEncoder().encode("world"), cid)) throw new Error("should not verify mismatched data");
4444+ * ```
1645 */
1746export async function verify(data, expected) {
1847 const expectedCid = CID.fromString(expected);
+43
src/common/compare.js
···33/**
44 * @param {any} a
55 * @param {any} b
66+ *
77+ * @example Returns true for identical primitives and false for different ones
88+ * ```js
99+ * import { diff } from "~/common/compare.js";
1010+ *
1111+ * if (!diff(1, 1)) throw new Error("identical primitives should be equal");
1212+ * if (diff(1, 2)) throw new Error("different primitives should not be equal");
1313+ * ```
1414+ *
1515+ * @example Returns true for deeply equal objects and false for different ones
1616+ * ```js
1717+ * import { diff } from "~/common/compare.js";
1818+ *
1919+ * if (!diff({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } })) throw new Error("deeply equal objects should be equal");
2020+ * if (diff({ a: 1 }, { a: 2 })) throw new Error("objects with different values should not be equal");
2121+ * if (diff({ a: 1 }, { b: 1 })) throw new Error("objects with different keys should not be equal");
2222+ * ```
2323+ *
2424+ * @example Returns true for identical arrays and false for different ones
2525+ * ```js
2626+ * import { diff } from "~/common/compare.js";
2727+ *
2828+ * if (!diff([1, 2, 3], [1, 2, 3])) throw new Error("identical arrays should be equal");
2929+ * if (diff([1, 2], [1, 3])) throw new Error("arrays with different elements should not be equal");
3030+ * ```
631 */
732export function diff(a, b) {
833 return !deepDiff(a, b);
···1136/**
1237 * @param {any} a
1338 * @param {any} b
3939+ *
4040+ * @example Returns true for same primitive value, false for different
4141+ * ```js
4242+ * import { strictEquality } from "~/common/compare.js";
4343+ *
4444+ * if (!strictEquality(42, 42)) throw new Error("same primitives should be strictly equal");
4545+ * if (strictEquality(42, 43)) throw new Error("different primitives should not be strictly equal");
4646+ * if (strictEquality(null, undefined)) throw new Error("null and undefined should not be strictly equal");
4747+ * ```
4848+ *
4949+ * @example Returns false for same-content objects (reference inequality)
5050+ * ```js
5151+ * import { strictEquality } from "~/common/compare.js";
5252+ *
5353+ * if (strictEquality({ a: 1 }, { a: 1 })) throw new Error("different object references should not be strictly equal");
5454+ * const obj = { a: 1 };
5555+ * if (!strictEquality(obj, obj)) throw new Error("same object reference should be strictly equal");
5656+ * ```
1457 */
1558export function strictEquality(a, b) {
1659 return a === b;
+23
src/common/facets/category.js
···55/**
66 * @param {Facet} facet
77 * @returns {string}
88+ *
99+ * @example Returns accent-twist-4 for prelude kind and accent-twist-2 for others
1010+ * ```js
1111+ * import { color } from "~/common/facets/category.js";
1212+ *
1313+ * const prelude = color({ $type: "sh.diffuse.output.facet", id: "1", name: "x", kind: "prelude" });
1414+ * if (prelude !== "var(--accent-twist-4)") throw new Error("prelude should return accent-twist-4");
1515+ *
1616+ * const interactive = color({ $type: "sh.diffuse.output.facet", id: "1", name: "x", kind: "interactive" });
1717+ * if (interactive !== "var(--accent-twist-2)") throw new Error("interactive should return accent-twist-2");
1818+ *
1919+ * const undef = color({ $type: "sh.diffuse.output.facet", id: "1", name: "x" });
2020+ * if (undef !== "var(--accent-twist-2)") throw new Error("undefined kind should return accent-twist-2");
2121+ * ```
822 */
923export function color(facet) {
1024 switch (facet.kind) {
···1832/**
1933 * @param {Facet} facet
2034 * @returns {string}
3535+ *
3636+ * @example Returns 'feature' for prelude kind and 'interface' for others
3737+ * ```js
3838+ * import { name } from "~/common/facets/category.js";
3939+ *
4040+ * if (name({ $type: "sh.diffuse.output.facet", id: "1", name: "x", kind: "prelude" }) !== "feature") throw new Error("prelude should return 'feature'");
4141+ * if (name({ $type: "sh.diffuse.output.facet", id: "1", name: "x", kind: "interactive" }) !== "interface") throw new Error("interactive should return 'interface'");
4242+ * if (name({ $type: "sh.diffuse.output.facet", id: "1", name: "x" }) !== "interface") throw new Error("undefined kind should return 'interface'");
4343+ * ```
2144 */
2245export function name(facet) {
2346 // return facet.kind ?? "interactive";
+8
src/common/facets/constants.js
···11+/**
22+ * @example TYPE is the ATProto type string for facets
33+ * ```js
44+ * import { TYPE } from "~/common/facets/constants.js";
55+ *
66+ * if (TYPE !== "sh.diffuse.output.facet") throw new Error(`expected "sh.diffuse.output.facet", got "${TYPE}"`);
77+ * ```
88+ */
19export const TYPE = /** @type {const} */ ("sh.diffuse.output.facet");
210311export const STARTING_SET_DISABLED = [
+44
src/common/facets/utils.js
···1010/**
1111 * @param {{ description?: string; kind: string | undefined; name: string; tags?: string[]; uri: string }} _args
1212 * @param {{ fetchHTML: boolean }} options
1313+ *
1414+ * @example Creates a facet with correct $type, name, uri, id, and timestamps
1515+ * ```js
1616+ * import { facetFromURI } from "~/common/facets/utils.js";
1717+ *
1818+ * const facet = await facetFromURI({ name: "My Facet", uri: "facets/test/index.html", kind: undefined, description: undefined }, { fetchHTML: false });
1919+ *
2020+ * if (facet.$type !== "sh.diffuse.output.facet") throw new Error("$type should be sh.diffuse.output.facet");
2121+ * if (facet.name !== "My Facet") throw new Error("name should be preserved");
2222+ * if (facet.uri !== "facets/test/index.html") throw new Error("uri should be preserved");
2323+ * if (typeof facet.id !== "string" || facet.id.length === 0) throw new Error("id should be a non-empty string");
2424+ * if (!facet.createdAt) throw new Error("createdAt should be set");
2525+ * if (facet.createdAt !== facet.updatedAt) throw new Error("createdAt and updatedAt should match");
2626+ * if (new Date(facet.createdAt).toISOString() !== facet.createdAt) throw new Error("createdAt should be a valid ISO string");
2727+ * ```
2828+ *
2929+ * @example fetchHTML false leaves html and cid undefined; kind is validated
3030+ * ```js
3131+ * import { facetFromURI } from "~/common/facets/utils.js";
3232+ *
3333+ * const base = { name: "Test", uri: "test.html", description: undefined };
3434+ *
3535+ * const noHtml = await facetFromURI({ ...base, kind: undefined }, { fetchHTML: false });
3636+ * if (noHtml.html !== undefined) throw new Error("html should be undefined when fetchHTML is false");
3737+ * if (noHtml.cid !== undefined) throw new Error("cid should be undefined when fetchHTML is false");
3838+ *
3939+ * const prelude = await facetFromURI({ ...base, kind: "prelude" }, { fetchHTML: false });
4040+ * if (prelude.kind !== "prelude") throw new Error("prelude kind should be preserved");
4141+ *
4242+ * const interactive = await facetFromURI({ ...base, kind: "interactive" }, { fetchHTML: false });
4343+ * if (interactive.kind !== "interactive") throw new Error("interactive kind should be preserved");
4444+ *
4545+ * const unknown = await facetFromURI({ ...base, kind: "unknown" }, { fetchHTML: false });
4646+ * if (unknown.kind !== undefined) throw new Error("unrecognised kind should be set to undefined");
4747+ * ```
4848+ *
4949+ * @example Generates unique ids across calls
5050+ * ```js
5151+ * import { facetFromURI } from "~/common/facets/utils.js";
5252+ *
5353+ * const a = await facetFromURI({ name: "A", uri: "a.html", kind: undefined, description: undefined }, { fetchHTML: false });
5454+ * const b = await facetFromURI({ name: "B", uri: "b.html", kind: undefined, description: undefined }, { fetchHTML: false });
5555+ * if (a.id === b.id) throw new Error("ids should be unique across calls");
5656+ * ```
1357 */
1458export async function facetFromURI(
1559 { description, kind, name, tags, uri },
+25
src/common/output.js
···88 * @template T
99 * @param {{ collection: SignalReader<{ state: "loading" } | { state: "loaded"; data: T }> }} output
1010 * @returns {Promise<T>}
1111+ *
1212+ * @example Resolves immediately when collection is already loaded
1313+ * ```js
1414+ * import { data } from "~/common/output.js";
1515+ * import { signal } from "~/common/signal.js";
1616+ *
1717+ * const col = signal({ state: "loaded", data: ["a", "b"] });
1818+ * const result = await data({ collection: col.get });
1919+ * if (result.join(",") !== "a,b") throw new Error("expected ['a', 'b']");
2020+ * ```
2121+ *
2222+ * @example Waits for collection to transition to loaded
2323+ * ```js
2424+ * import { data } from "~/common/output.js";
2525+ * import { signal } from "~/common/signal.js";
2626+ *
2727+ * const col = signal({ state: "loading" });
2828+ * const promise = data({ collection: col.get });
2929+ *
3030+ * await Promise.resolve();
3131+ * col.set({ state: "loaded", data: [1, 2, 3] });
3232+ *
3333+ * const result = await promise;
3434+ * if (result.join(",") !== "1,2,3") throw new Error("expected [1, 2, 3]");
3535+ * ```
1136 */
1237export async function data(output) {
1338 return await new Promise((resolve) => {
+135
src/common/playlist.js
···99 *
1010 * @param {Track[]} tracks
1111 * @param {PlaylistItem[]} playlistItems
1212+ *
1313+ * @example Returns only tracks matching playlist criteria
1414+ * ```js
1515+ * import { filterByPlaylist } from "~/common/playlist.js";
1616+ *
1717+ * const tracks = [
1818+ * { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A", title: "T1" } },
1919+ * { $type: "sh.diffuse.output.track", id: "b", uri: "http://x.com/b.mp3", tags: { artist: "B", title: "T2" } },
2020+ * ];
2121+ * const items = [
2222+ * { $type: "sh.diffuse.output.playlistItem", id: "i1", playlist: "p", criteria: [{ field: "tags.artist", value: "A" }] },
2323+ * ];
2424+ * const result = filterByPlaylist(tracks, items);
2525+ * if (result.length !== 1 || result[0].id !== "a") throw new Error("expected only track 'a'");
2626+ * ```
2727+ *
2828+ * @example Returns empty array when no tracks match or no items given
2929+ * ```js
3030+ * import { filterByPlaylist } from "~/common/playlist.js";
3131+ *
3232+ * const tracks = [
3333+ * { $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "A" } },
3434+ * ];
3535+ * const noMatch = filterByPlaylist(tracks, [
3636+ * { $type: "sh.diffuse.output.playlistItem", id: "i1", playlist: "p", criteria: [{ field: "tags.artist", value: "Z" }] },
3737+ * ]);
3838+ * if (noMatch.length !== 0) throw new Error("expected no matches");
3939+ *
4040+ * if (filterByPlaylist(tracks, []).length !== 0) throw new Error("expected empty for no items");
4141+ * ```
4242+ *
4343+ * @example Applies transformations before comparing
4444+ * ```js
4545+ * import { filterByPlaylist } from "~/common/playlist.js";
4646+ *
4747+ * const tracks = [{ $type: "sh.diffuse.output.track", id: "a", uri: "http://x.com/a.mp3", tags: { artist: "Artist" } }];
4848+ * const items = [{
4949+ * $type: "sh.diffuse.output.playlistItem",
5050+ * id: "i1",
5151+ * playlist: "p",
5252+ * criteria: [{ field: "tags.artist", value: "ARTIST", transformations: ["toLowerCase"] }],
5353+ * }];
5454+ * if (filterByPlaylist(tracks, items).length !== 1) throw new Error("transformation should match");
5555+ * ```
1256 */
1357export function filterByPlaylist(tracks, playlistItems) {
1458 // Group playlist items by criteria shape, building a Set index per shape.
···65109 * Bundle playlist items into their respective playlists.
66110 *
67111 * @param {PlaylistItem[]} items
112112+ *
113113+ * @example Groups items by playlist name and tracks ordered/unordered state
114114+ * ```js
115115+ * import { gather } from "~/common/playlist.js";
116116+ *
117117+ * const items = [
118118+ * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Rock", criteria: [], positionedAfter: "prev" },
119119+ * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Pop", criteria: [], positionedAfter: "prev" },
120120+ * { $type: "sh.diffuse.output.playlistItem", id: "3", playlist: "Rock", criteria: [], positionedAfter: "prev" },
121121+ * ];
122122+ * const map = gather(items);
123123+ * if (map.get("Rock").items.length !== 2) throw new Error("expected 2 rock items");
124124+ * if (map.get("Pop").items.length !== 1) throw new Error("expected 1 pop item");
125125+ * if (map.get("My Playlist") !== undefined) {
126126+ * // separate test: preserves playlist name
127127+ * }
128128+ *
129129+ * const unordered = gather([
130130+ * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [] },
131131+ * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Mix", criteria: [] },
132132+ * ]);
133133+ * if (!unordered.get("Mix").unordered) throw new Error("playlist without positionedAfter should be unordered");
134134+ *
135135+ * const ordered = gather([
136136+ * { $type: "sh.diffuse.output.playlistItem", id: "1", playlist: "Mix", criteria: [], positionedAfter: null },
137137+ * { $type: "sh.diffuse.output.playlistItem", id: "2", playlist: "Mix", criteria: [], positionedAfter: "1" },
138138+ * ]);
139139+ * if (ordered.get("Mix").unordered) throw new Error("playlist with positionedAfter should be ordered");
140140+ * ```
68141 */
69142export function gather(items) {
70143 /**
···97170 *
98171 * @param {Track} track
99172 * @param {PlaylistItem} item
173173+ *
174174+ * @example Returns true when all criteria match, false when any criterion fails
175175+ * ```js
176176+ * import { match } from "~/common/playlist.js";
177177+ *
178178+ * const track = { $type: "sh.diffuse.output.track", id: "t", uri: "http://x.com/t.mp3", tags: { artist: "Artist A", title: "Song A" } };
179179+ * const item = {
180180+ * $type: "sh.diffuse.output.playlistItem", id: "i", playlist: "p",
181181+ * criteria: [{ field: "tags.artist", value: "Artist A" }, { field: "tags.title", value: "Song A" }],
182182+ * };
183183+ * if (!match(track, item)) throw new Error("should match when all criteria pass");
184184+ *
185185+ * const mismatch = { ...item, criteria: [{ field: "tags.artist", value: "Artist A" }, { field: "tags.title", value: "Wrong" }] };
186186+ * if (match(track, mismatch)) throw new Error("should not match when a criterion fails");
187187+ * ```
188188+ *
189189+ * @example Applies transformations before comparing
190190+ * ```js
191191+ * import { match } from "~/common/playlist.js";
192192+ *
193193+ * const track = { $type: "sh.diffuse.output.track", id: "t", uri: "http://x.com/t.mp3", tags: { artist: "Artist A" } };
194194+ * const item = {
195195+ * $type: "sh.diffuse.output.playlistItem", id: "i", playlist: "p",
196196+ * criteria: [{ field: "tags.artist", value: "ARTIST A", transformations: ["toLowerCase"] }],
197197+ * };
198198+ * if (!match(track, item)) throw new Error("transformation should match lowercase");
199199+ * ```
100200 */
101201export function match(track, item) {
102202 return item.criteria.every((c) => {
···129229 *
130230 * @param {PlaylistItem[]} items
131231 * @returns {PlaylistItem[]}
232232+ *
233233+ * @example Sorts a linked list in order and appends orphaned items at end
234234+ * ```js
235235+ * import { sort } from "~/common/playlist.js";
236236+ *
237237+ * const single = [{ $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null }];
238238+ * if (sort(single).map((i) => i.id).join(",") !== "a") throw new Error("single item should be unchanged");
239239+ *
240240+ * const linked = [
241241+ * { $type: "sh.diffuse.output.playlistItem", id: "c", playlist: "p", criteria: [], positionedAfter: "b" },
242242+ * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null },
243243+ * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" },
244244+ * ];
245245+ * if (sort(linked).map((i) => i.id).join(",") !== "a,b,c") throw new Error("should sort linked list in order");
246246+ *
247247+ * const withOrphan = [
248248+ * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null },
249249+ * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: "a" },
250250+ * { $type: "sh.diffuse.output.playlistItem", id: "orphan", playlist: "p", criteria: [], positionedAfter: "missing" },
251251+ * ];
252252+ * const sorted = sort(withOrphan);
253253+ * if (sorted[sorted.length - 1].id !== "orphan") throw new Error("orphaned item should be last");
254254+ * ```
255255+ *
256256+ * @example Sorts multiple heads by updatedAt ascending
257257+ * ```js
258258+ * import { sort } from "~/common/playlist.js";
259259+ *
260260+ * const items = [
261261+ * { $type: "sh.diffuse.output.playlistItem", id: "b", playlist: "p", criteria: [], positionedAfter: null, updatedAt: "2024-06-01T00:00:00.000Z" },
262262+ * { $type: "sh.diffuse.output.playlistItem", id: "a", playlist: "p", criteria: [], positionedAfter: null, updatedAt: "2024-01-01T00:00:00.000Z" },
263263+ * ];
264264+ * const result = sort(items);
265265+ * if (result[0].id !== "a" || result[1].id !== "b") throw new Error("heads should be sorted by updatedAt");
266266+ * ```
132267 */
133268export function sort(items) {
134269 if (items.length <= 1) return items;
+81
src/common/signal.js
···13131414/**
1515 * @param {function(): void} fn
1616+ *
1717+ * @example Defers effect execution until batch completes
1818+ * ```js
1919+ * import { signal, effect, batch } from "~/common/signal.js";
2020+ *
2121+ * const a = signal(0);
2222+ * const b = signal(0);
2323+ * const values = [];
2424+ *
2525+ * effect(() => { values.push(a.get() + b.get()); });
2626+ *
2727+ * const before = [...values]; // [0]
2828+ * batch(() => { a.set(1); b.set(2); });
2929+ *
3030+ * if (before.join(",") !== "0") throw new Error("expected [0] before batch");
3131+ * if (values.join(",") !== "0,3") throw new Error("expected exactly one update after batch, got " + values.join(","));
3232+ * ```
1633 */
1734export const batch = (fn) => {
1835 startBatch();
···2845 * @param {T} initialValue
2946 * @param {{ compare?: (a: T, b: T) => boolean }} [options]
3047 * @returns {Signal<T>}
4848+ *
4949+ * @example get/set and value getter/setter return and update the value
5050+ * ```js
5151+ * import { signal } from "~/common/signal.js";
5252+ *
5353+ * const s = signal(42);
5454+ * if (s.get() !== 42) throw new Error("get should return initial value");
5555+ * if (s.value !== 42) throw new Error("value getter should return initial value");
5656+ *
5757+ * s.set(99);
5858+ * if (s.get() !== 99) throw new Error("get should return updated value");
5959+ *
6060+ * s.value = "b";
6161+ * if (s.value !== "b") throw new Error("value setter should update value");
6262+ * ```
6363+ *
6464+ * @example compare option skips update when values are equal by custom comparator
6565+ * ```js
6666+ * import { signal, effect } from "~/common/signal.js";
6767+ *
6868+ * let runCount = 0;
6969+ * const s = signal({ x: 1 }, { compare: (a, b) => a.x === b.x });
7070+ *
7171+ * effect(() => { s.get(); runCount++; });
7272+ *
7373+ * const before = runCount;
7474+ * s.set({ x: 1 }); // same by compare
7575+ * if (runCount !== before) throw new Error("effect should not re-run when value is equal by compare");
7676+ *
7777+ * s.set({ x: 2 }); // different by compare
7878+ * if (runCount !== before + 1) throw new Error("effect should re-run when value differs by compare");
7979+ * ```
3180 */
3281export function signal(initialValue, options) {
3382 const s = alienSignal(initialValue);
···53102 * @template T
54103 * @param {function(): T} fn
55104 * @returns {T}
105105+ *
106106+ * @example Reads a signal without tracking it as a dependency
107107+ * ```js
108108+ * import { signal, effect, untracked } from "~/common/signal.js";
109109+ *
110110+ * const a = signal(1);
111111+ * const b = signal(10);
112112+ * let runCount = 0;
113113+ *
114114+ * effect(() => {
115115+ * a.get(); // tracked
116116+ * untracked(() => b.get()); // not tracked
117117+ * runCount++;
118118+ * });
119119+ *
120120+ * const before = runCount; // 1
121121+ * b.set(20); // should NOT re-run effect
122122+ * if (runCount !== before) throw new Error("untracked read should not trigger re-run");
123123+ *
124124+ * a.set(2); // SHOULD re-run effect
125125+ * if (runCount !== before + 1) throw new Error("tracked read should trigger re-run");
126126+ *
127127+ * if (untracked(() => a.get()) !== 2) throw new Error("untracked should return the value");
128128+ * ```
56129 */
57130export const untracked = (fn) => {
58131 const sub = setActiveSub(void 0);
···67140 * @template T
68141 * @param {function(): Promise<T>} fn
69142 * @returns {Promise<T>}
143143+ *
144144+ * @example Returns the resolved value from the async callback
145145+ * ```js
146146+ * import { untrackedAsync } from "~/common/signal.js";
147147+ *
148148+ * const result = await untrackedAsync(async () => 99);
149149+ * if (result !== 99) throw new Error("untrackedAsync should return resolved value");
150150+ * ```
70151 */
71152export const untrackedAsync = async (fn) => {
72153 const sub = setActiveSub(void 0);
+17
src/common/temporal.js
···44/**
55 * @param {string} a
66 * @param {string} b
77+ *
88+ * @example Returns 0 for equal timestamps, negative for earlier, positive for later
99+ * ```js
1010+ * import { compareTimestamps } from "~/common/temporal.js";
1111+ *
1212+ * const eq = compareTimestamps("2024-01-01T00:00:00.000Z", "2024-01-01T00:00:00.000Z");
1313+ * if (eq !== 0) throw new Error("equal timestamps should return 0");
1414+ *
1515+ * const earlier = compareTimestamps("2024-01-01T00:00:00.000Z", "2024-06-01T00:00:00.000Z");
1616+ * if (earlier >= 0) throw new Error("earlier timestamp should return negative");
1717+ *
1818+ * const later = compareTimestamps("2024-06-01T00:00:00.000Z", "2024-01-01T00:00:00.000Z");
1919+ * if (later <= 0) throw new Error("later timestamp should return positive");
2020+ *
2121+ * const ms = compareTimestamps("2024-01-01T00:00:00.001Z", "2024-01-01T00:00:00.000Z");
2222+ * if (ms <= 0) throw new Error("millisecond precision should be respected");
2323+ * ```
724 */
825export function compareTimestamps(a, b) {
926 return Temporal.Instant.compare(
+38
src/common/track.js
···6677/**
88 * @param {string} uri
99+ *
1010+ * @example Strips path and query from a URI
1111+ * ```js
1212+ * import { trackURIBase } from "~/common/track.js";
1313+ *
1414+ * const r1 = trackURIBase("https://example.com/music/track.mp3");
1515+ * if (r1 !== "https://example.com/") throw new Error(`expected "https://example.com/", got "${r1}"`);
1616+ *
1717+ * const r2 = trackURIBase("https://example.com/track.mp3?token=abc");
1818+ * if (r2 !== "https://example.com/") throw new Error(`expected "https://example.com/", got "${r2}"`);
1919+ *
2020+ * const r3 = trackURIBase("https://example.com");
2121+ * if (r3 !== "https://example.com/") throw new Error(`expected "https://example.com/", got "${r3}"`);
2222+ *
2323+ * const r4 = trackURIBase("s3://my-bucket/path/to/track.flac");
2424+ * if (r4 !== "s3://my-bucket") throw new Error(`expected "s3://my-bucket", got "${r4}"`);
2525+ * ```
926 */
1027export function trackURIBase(uri) {
1128 const p = URI.parse(uri);
···16331734/**
1835 * @param {Track[]} tracks
3636+ *
3737+ * @example Returns a deduplicated set of base URIs
3838+ * ```js
3939+ * import { uniqueTrackURIs } from "~/common/track.js";
4040+ *
4141+ * const tracks = [
4242+ * { $type: "sh.diffuse.output.track", id: "1", uri: "https://example.com/a.mp3" },
4343+ * { $type: "sh.diffuse.output.track", id: "2", uri: "https://example.com/b.mp3" },
4444+ * ];
4545+ * const set = uniqueTrackURIs(tracks);
4646+ * if (!set.has("https://example.com/")) throw new Error("expected base URI in set");
4747+ * if (set.size !== 1) throw new Error("expected deduplication to one entry");
4848+ *
4949+ * const multi = uniqueTrackURIs([
5050+ * { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" },
5151+ * { $type: "sh.diffuse.output.track", id: "2", uri: "https://b.com/2.mp3" },
5252+ * ]);
5353+ * if (multi.size !== 2) throw new Error("expected 2 distinct base URIs");
5454+ *
5555+ * if (uniqueTrackURIs([]).size !== 0) throw new Error("expected empty set for empty input");
5656+ * ```
1957 */
2058export function uniqueTrackURIs(tracks) {
2159 const set = new Set();
+134
src/common/utils.js
···88 * @template T
99 * @param {Array<T>} array
1010 * @returns Array<T>
1111+ *
1212+ * @example Returns same elements in (possibly different) order without mutating the original
1313+ * ```js
1414+ * import { arrayShuffle } from "~/common/utils.js";
1515+ *
1616+ * if (arrayShuffle([]).length !== 0) throw new Error("empty array should return empty");
1717+ *
1818+ * const sorted = arrayShuffle([1, 2, 3, 4, 5]).sort((a, b) => a - b);
1919+ * if (sorted.join(",") !== "1,2,3,4,5") throw new Error("shuffled array should contain same elements");
2020+ *
2121+ * const original = [1, 2, 3];
2222+ * arrayShuffle(original);
2323+ * if (original.join(",") !== "1,2,3") throw new Error("original array should not be mutated");
2424+ *
2525+ * if (arrayShuffle([42]).join(",") !== "42") throw new Error("single-element array should be unchanged");
2626+ * ```
1127 */
1228export function arrayShuffle(array) {
1329 if (array.length === 0) {
···28442945/**
3046 * @param {string | undefined | null} value
4747+ *
4848+ * @example Returns true only for empty string (present attribute)
4949+ * ```js
5050+ * import { boolAttr } from "~/common/utils.js";
5151+ *
5252+ * if (!boolAttr("")) throw new Error("empty string should return true");
5353+ * if (boolAttr(null)) throw new Error("null should return false");
5454+ * if (boolAttr(undefined)) throw new Error("undefined should return false");
5555+ * if (boolAttr("true")) throw new Error("non-empty string should return false");
5656+ * ```
3157 */
3258export function boolAttr(value) {
3359 return value === "";
···36623763/**
3864 * @param {any} object
6565+ *
6666+ * @example Returns a consistent string hash for the same object
6767+ * ```js
6868+ * import { hash } from "~/common/utils.js";
6969+ *
7070+ * if (typeof hash({ a: 1 }) !== "string") throw new Error("hash should return a string");
7171+ * if (hash({ a: 1, b: 2 }) !== hash({ a: 1, b: 2 })) throw new Error("same objects should produce same hash");
7272+ * if (hash({ a: 1 }) === hash({ a: 2 })) throw new Error("different objects should produce different hashes");
7373+ * ```
3974 */
4075export function hash(object) {
4176 return xxh32r(jsonEncode(object)).toString();
···4580 * @param {Track[]} tracks
4681 * @param {Record<string, Track[]>} initial
4782 * @returns {Record<string, Track[]>}
8383+ *
8484+ * @example Groups tracks by URI scheme
8585+ * ```js
8686+ * import { groupTracksPerScheme } from "~/common/utils.js";
8787+ *
8888+ * const tracks = [
8989+ * { $type: "sh.diffuse.output.track", id: "1", uri: "http://example.com/a.mp3" },
9090+ * { $type: "sh.diffuse.output.track", id: "2", uri: "s3://bucket/b.mp3" },
9191+ * { $type: "sh.diffuse.output.track", id: "3", uri: "http://example.com/c.mp3" },
9292+ * ];
9393+ * const groups = groupTracksPerScheme(tracks);
9494+ * if (groups["http"].length !== 2) throw new Error("expected 2 http tracks");
9595+ * if (groups["s3"].length !== 1) throw new Error("expected 1 s3 track");
9696+ * ```
9797+ *
9898+ * @example Merges into a provided initial object
9999+ * ```js
100100+ * import { groupTracksPerScheme } from "~/common/utils.js";
101101+ *
102102+ * const initial = { http: [{ $type: "sh.diffuse.output.track", id: "0", uri: "http://existing.com/x.mp3" }] };
103103+ * const tracks = [{ $type: "sh.diffuse.output.track", id: "1", uri: "http://example.com/a.mp3" }];
104104+ * const groups = groupTracksPerScheme(tracks, initial);
105105+ * if (groups["http"].length !== 2) throw new Error("expected 2 http tracks after merge");
106106+ * ```
48107 */
49108export function groupTracksPerScheme(
50109 tracks,
···65124/**
66125 * @param {string[]} uris
67126 * @returns {Record<string, string[]>}
127127+ *
128128+ * @example Groups URIs by scheme
129129+ * ```js
130130+ * import { groupUrisPerScheme } from "~/common/utils.js";
131131+ *
132132+ * const groups = groupUrisPerScheme(["http://a.com/t.mp3", "s3://b/t.flac", "http://c.com/t.mp3"]);
133133+ * if (groups["http"].length !== 2) throw new Error("expected 2 http URIs");
134134+ * if (groups["s3"].length !== 1) throw new Error("expected 1 s3 URI");
135135+ *
136136+ * if (Object.keys(groupUrisPerScheme([])).length !== 0) throw new Error("expected empty object for empty input");
137137+ * ```
68138 */
69139export function groupUrisPerScheme(uris) {
70140 /** @type {Record<string, string[]>} */
···8115182152/**
83153 * @param {unknown} test
154154+ *
155155+ * @example Returns true for primitives and false for objects/arrays
156156+ * ```js
157157+ * import { isPrimitive } from "~/common/utils.js";
158158+ *
159159+ * if (!isPrimitive(42)) throw new Error("number should be primitive");
160160+ * if (!isPrimitive("hello")) throw new Error("string should be primitive");
161161+ * if (!isPrimitive(true)) throw new Error("boolean should be primitive");
162162+ * if (!isPrimitive(null)) throw new Error("null should be primitive");
163163+ * if (isPrimitive({ a: 1 })) throw new Error("object should not be primitive");
164164+ * if (isPrimitive([1, 2])) throw new Error("array should not be primitive");
165165+ * ```
84166 */
85167export function isPrimitive(test) {
86168 return test !== Object(test);
···99181 * @template T
100182 * @param {T} a
101183 * @returns Uint8Array
184184+ *
185185+ * @example jsonEncode returns a Uint8Array that round-trips through jsonDecode
186186+ * ```js
187187+ * import { jsonEncode, jsonDecode } from "~/common/utils.js";
188188+ *
189189+ * const original = { a: 1, b: "hello", c: [1, 2, 3] };
190190+ * if (!(jsonEncode(original) instanceof Uint8Array)) throw new Error("jsonEncode should return a Uint8Array");
191191+ *
192192+ * const roundTripped = jsonDecode(jsonEncode(original));
193193+ * if (JSON.stringify(roundTripped) !== JSON.stringify(original)) throw new Error("round-trip should preserve values");
194194+ * ```
102195 */
103196export function jsonEncode(a) {
104197 return new TextEncoder().encode(JSON.stringify(a));
···107200/**
108201 * @template {Record<string, any>} T
109202 * @param {T} rec
203203+ *
204204+ * @example Removes keys with undefined values without mutating the original
205205+ * ```js
206206+ * import { removeUndefinedValuesFromRecord } from "~/common/utils.js";
207207+ *
208208+ * const result = removeUndefinedValuesFromRecord({ a: 1, b: undefined, c: "x" });
209209+ * if ("b" in result) throw new Error("undefined key should be removed");
210210+ * if (result.a !== 1 || result.c !== "x") throw new Error("defined keys should be preserved");
211211+ *
212212+ * const original = { a: 1, b: undefined };
213213+ * removeUndefinedValuesFromRecord(original);
214214+ * if (!("b" in original)) throw new Error("original should not be mutated");
215215+ *
216216+ * const noUndef = removeUndefinedValuesFromRecord({ a: 1, b: 2 });
217217+ * if (noUndef.a !== 1 || noUndef.b !== 2) throw new Error("record without undefined values should be unchanged");
218218+ * ```
110219 */
111220export function removeUndefinedValuesFromRecord(rec) {
112221 const recClone = { ...rec };
···123232/**
124233 * @template {Record<string, any>} T
125234 * @param {T} rec
235235+ *
236236+ * @example Deep-clones nested records without sharing references
237237+ * ```js
238238+ * import { recursivelyCloneRecords } from "~/common/utils.js";
239239+ *
240240+ * const original = { a: 1, b: "x" };
241241+ * const clone = recursivelyCloneRecords(original);
242242+ * if (clone === original) throw new Error("clone should be a different object");
243243+ * if (clone.a !== 1 || clone.b !== "x") throw new Error("values should be preserved");
244244+ *
245245+ * const nested = { outer: { inner: 42 } };
246246+ * const nestedClone = recursivelyCloneRecords(nested);
247247+ * if (nestedClone.outer === nested.outer) throw new Error("nested objects should not share references");
248248+ * if (nestedClone.outer.inner !== 42) throw new Error("nested values should be preserved");
249249+ * ```
126250 */
127251export function recursivelyCloneRecords(rec) {
128252 const recClone = { ...rec };
···140264/**
141265 * @param {string} str
142266 * @returns {string}
267267+ *
268268+ * @example Decodes percent-encoded and %u unicode escapes, leaves plain strings unchanged
269269+ * ```js
270270+ * import { safeDecodeURIComponent } from "~/common/utils.js";
271271+ *
272272+ * if (safeDecodeURIComponent("hello%20world") !== "hello world") throw new Error("should decode %20 as space");
273273+ * if (safeDecodeURIComponent("%u0041") !== "A") throw new Error("should decode %u unicode escape");
274274+ * if (safeDecodeURIComponent("plain-string") !== "plain-string") throw new Error("plain string should be unchanged");
275275+ * if (safeDecodeURIComponent("hello%2Fworld") !== "hello/world") throw new Error("should decode %2F as slash");
276276+ * ```
143277 */
144278export function safeDecodeURIComponent(str) {
145279 return str.replace(
+70
src/components/input/common.js
···1111 * @param {(arg: T) => string} keyFn
1212 * @param {number} ttl - Cache TTL in milliseconds
1313 * @returns {(arg: T) => Promise<boolean>}
1414+ *
1515+ * @example Caches results and avoids calling fn more than once per key
1616+ * ```js
1717+ * import { cachedConsult } from "~/components/input/common.js";
1818+ *
1919+ * let callCount = 0;
2020+ * const cached = cachedConsult(async (uri) => { callCount++; return true; }, (uri) => uri);
2121+ *
2222+ * const r1 = await cached("https://example.com/stream");
2323+ * const r2 = await cached("https://example.com/stream");
2424+ *
2525+ * if (r1 !== true || r2 !== true) throw new Error("should return cached value");
2626+ * if (callCount !== 1) throw new Error("fn should only be called once per key");
2727+ * ```
1428 */
1529export function cachedConsult(fn, keyFn, ttl = 60_000 * 5) {
1630 /** @type {Map<string, { value: boolean; expiry: number }>} */
···33473448/**
3549 * @param {{ fileUriOrScheme: string; handleFileUri: (args: { fileURI: string; tracks: Track[] }) => Track[]; inputScheme: string; tracks: Track[] }} _
5050+ *
5151+ * @example Removes all tracks when given a matching scheme, returns all when scheme doesn't match
5252+ * ```js
5353+ * import { detach } from "~/components/input/common.js";
5454+ *
5555+ * const tracks = [
5656+ * { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" },
5757+ * { $type: "sh.diffuse.output.track", id: "2", uri: "https://b.com/2.mp3" },
5858+ * ];
5959+ *
6060+ * const removed = detach({ fileUriOrScheme: "https", inputScheme: "https", handleFileUri: () => [], tracks });
6161+ * if (removed.length !== 0) throw new Error("matching scheme should remove all tracks");
6262+ *
6363+ * const kept = detach({ fileUriOrScheme: "ftp", inputScheme: "https", handleFileUri: () => [], tracks });
6464+ * if (kept.length !== 2) throw new Error("non-matching scheme should keep all tracks");
6565+ * ```
6666+ *
6767+ * @example Delegates to handleFileUri when a full URI is given
6868+ * ```js
6969+ * import { detach } from "~/components/input/common.js";
7070+ *
7171+ * const tracks = [
7272+ * { $type: "sh.diffuse.output.track", id: "1", uri: "https://a.com/1.mp3" },
7373+ * { $type: "sh.diffuse.output.track", id: "2", uri: "https://b.com/2.mp3" },
7474+ * ];
7575+ *
7676+ * const result = detach({
7777+ * fileUriOrScheme: "https://a.com/1.mp3",
7878+ * inputScheme: "https",
7979+ * handleFileUri: ({ tracks }) => tracks.filter((t) => t.id !== "1"),
8080+ * tracks,
8181+ * });
8282+ * if (result.length !== 1 || result[0].id !== "2") throw new Error("handleFileUri should filter by URI");
8383+ * ```
3684 */
3785export function detach(
3886 { fileUriOrScheme, handleFileUri, inputScheme, tracks },
···4997/**
5098 * @param {string} scheme
5199 * @param {string} groupId
100100+ *
101101+ * @example Returns scheme://groupId
102102+ * ```js
103103+ * import { groupKey } from "~/components/input/common.js";
104104+ *
105105+ * if (groupKey("https", "example.com") !== "https://example.com") throw new Error(`expected "https://example.com"`);
106106+ * ```
52107 */
53108export function groupKey(scheme, groupId) {
54109 return `${scheme}://${groupId}`;
···5611157112/**
58113 * @param {string} filename
114114+ *
115115+ * @example Returns truthy for audio extensions and falsy for non-audio ones
116116+ * ```js
117117+ * import { isAudioFile } from "~/components/input/common.js";
118118+ *
119119+ * const audioExts = ["track.mp3", "track.flac", "track.ogg", "track.opus", "track.wav", "track.m4a", "track.webm"];
120120+ * for (const f of audioExts) {
121121+ * if (!isAudioFile(f)) throw new Error(`${f} should be recognised as audio`);
122122+ * }
123123+ *
124124+ * const nonAudio = ["track.txt", "track.jpg", "track.pdf", "track"];
125125+ * for (const f of nonAudio) {
126126+ * if (isAudioFile(f)) throw new Error(`${f} should not be recognised as audio`);
127127+ * }
128128+ * ```
59129 */
60130export function isAudioFile(filename) {
61131 return filename.match(/\.(flac|m4a|mp3|mp4|ogg|opus|wav|webm)$/);
-80
tests/common/cid/test.ts
···11-import { describe, it } from "@std/testing/bdd";
22-import { expect } from "@std/expect";
33-44-import { testWeb } from "@tests/common/index.ts";
55-66-describe("common/cid", () => {
77- describe("create", () => {
88- it("returns a non-empty CID string", async () => {
99- const result = await testWeb(async () => {
1010- const { create } = await import("~/common/cid.js");
1111- return create(0x55, new TextEncoder().encode("hello"));
1212- });
1313- expect(typeof result).toBe("string");
1414- expect(result.length).toBeGreaterThan(0);
1515- });
1616-1717- it("returns consistent CID for same input", async () => {
1818- const result = await testWeb(async () => {
1919- const { create } = await import("~/common/cid.js");
2020- const data = new TextEncoder().encode("hello world");
2121- const a = await create(0x55, data);
2222- const b = await create(0x55, data);
2323- return a === b;
2424- });
2525- expect(result).toBe(true);
2626- });
2727-2828- it("returns different CIDs for different inputs", async () => {
2929- const result = await testWeb(async () => {
3030- const { create } = await import("~/common/cid.js");
3131- const a = await create(0x55, new TextEncoder().encode("hello"));
3232- const b = await create(0x55, new TextEncoder().encode("world"));
3333- return a === b;
3434- });
3535- expect(result).toBe(false);
3636- });
3737-3838- it("returns different CIDs for different codecs", async () => {
3939- const result = await testWeb(async () => {
4040- const { create } = await import("~/common/cid.js");
4141- const data = new TextEncoder().encode("hello");
4242- const a = await create(0x55, data);
4343- const b = await create(0x71, data);
4444- return a === b;
4545- });
4646- expect(result).toBe(false);
4747- });
4848-4949- it("returns a base32-encoded CID string", async () => {
5050- const result = await testWeb(async () => {
5151- const { create } = await import("~/common/cid.js");
5252- return create(0x55, new TextEncoder().encode("test"));
5353- });
5454- // CIDv1 base32 strings contain only lowercase alphanumeric characters
5555- expect(/^[a-z2-7]+$/.test(result)).toBe(true);
5656- });
5757- });
5858-5959- describe("verify", () => {
6060- it("returns true for matching data and CID", async () => {
6161- const result = await testWeb(async () => {
6262- const { create, verify } = await import("~/common/cid.js");
6363- const data = new TextEncoder().encode("hello");
6464- const cid = await create(0x55, data);
6565- return verify(data, cid);
6666- });
6767- expect(result).toBe(true);
6868- });
6969-7070- it("returns false for mismatched data", async () => {
7171- const result = await testWeb(async () => {
7272- const { create, verify } = await import("~/common/cid.js");
7373- const data = new TextEncoder().encode("hello");
7474- const cid = await create(0x55, data);
7575- return verify(new TextEncoder().encode("world"), cid);
7676- });
7777- expect(result).toBe(false);
7878- });
7979- });
8080-});