···11-import { computed, effect, type Signal } from "spellcaster";
22-import { repeat, tags, text } from "spellcaster/hyperscript.js";
11+import { computed, effect, type Signal } from "@scripts/spellcaster";
22+import { repeat, tags, text } from "@scripts/spellcaster/hyperscript.js";
3344import { mount, mounts, unmount } from "./mounting";
55import { isSupported } from "./common";
+7-7
src/scripts/input/opensubsonic/ui.ts
···11-import { computed, effect, type Signal, signal } from "spellcaster";
22-import { type Props, repeat, tags, text } from "spellcaster/hyperscript.js";
11+import { computed, effect, type Signal, signal } from "@scripts/spellcaster";
22+import { type Props, repeat, tags, text } from "@scripts/spellcaster/hyperscript.js";
3344import type { Server } from "./types.d.ts";
55import { loadServers, saveServers, serverId } from "./common";
···77////////////////////////////////////////////
88// UI
99////////////////////////////////////////////
1010-export const [servers, setServers] = signal<Record<string, Server>>(await loadServers());
1111-const [form, setForm] = signal<{
1010+export const servers = signal<Record<string, Server>>(await loadServers());
1111+const form = signal<{
1212 api_key?: string;
1313 host?: string;
1414 password?: string;
···3434 const col = { ...servers() };
3535 delete col[id];
36363737- setServers(col);
3737+ servers(col);
3838 };
39394040 return tags.li({ onclick, style: "cursor: pointer" }, text(server().host));
···6868 password: f.password,
6969 };
70707171- setServers({
7171+ servers({
7272 ...servers(),
7373 [serverId(server)]: server,
7474 });
···103103}
104104105105function formInput(name: string, value: string) {
106106- setForm({ ...form(), [name]: value });
106106+ form({ ...form(), [name]: value });
107107}
108108109109// 🚀
+7-7
src/scripts/input/s3/ui.ts
···11-import { computed, effect, type Signal, signal } from "spellcaster";
22-import { type Props, repeat, tags, text } from "spellcaster/hyperscript.js";
11+import { computed, effect, type Signal, signal } from "@scripts/spellcaster";
22+import { type Props, repeat, tags, text } from "@scripts/spellcaster/hyperscript.js";
3344import type { Bucket } from "./types";
55import { bucketId, loadBuckets, saveBuckets } from "./common";
···77////////////////////////////////////////////
88// UI
99////////////////////////////////////////////
1010-export const [buckets, setBuckets] = signal<Record<string, Bucket>>(await loadBuckets());
1111-export const [form, setForm] = signal<{
1010+export const buckets = signal<Record<string, Bucket>>(await loadBuckets());
1111+export const form = signal<{
1212 access_key?: string;
1313 bucket_name?: string;
1414 host?: string;
···3636 const col = { ...buckets() };
3737 delete col[id];
38383939- setBuckets(col);
3939+ buckets(col);
4040 };
41414242 return tags.li({ onclick, style: "cursor: pointer" }, text(bucket().host));
···7171 secretKey: f.secret_key || "",
7272 };
73737474- setBuckets({
7474+ buckets({
7575 ...buckets(),
7676 [bucketId(bucket)]: bucket,
7777 });
···111111}
112112113113function formInput(name: string, value: string) {
114114- setForm({ ...form(), [name]: value });
114114+ form({ ...form(), [name]: value });
115115}
116116117117// 🚀
+1
src/scripts/spellcaster/README.md
···11+Reusing various parts of the `spellcaster` library, swapped out the signals library with `alien-signals`.
+241
src/scripts/spellcaster/hyperscript.ts
···11+import { effect } from "alien-signals";
22+import { type Signal, takeValues, sample } from "./spellcaster";
33+export { cid, getId, indexById, index, type Identifiable } from "./util";
44+55+/** A view-constructing function */
66+export type View<State> = (state: Signal<State>) => HTMLElement;
77+88+/** Symbol for list item key */
99+const __key__ = Symbol("list item key");
1010+1111+/**
1212+ * Create a function to efficiently render a dynamic list of views on a
1313+ * parent element.
1414+ */
1515+export const repeat =
1616+ <Key, State>(states: Signal<Map<Key, State>>, view: View<State>) =>
1717+ (parent: HTMLElement) =>
1818+ effect(() => {
1919+ // Build an index of children and a list of children to remove.
2020+ // Note that we must build a list of children to remove, since
2121+ // removing in-place would change the live node list and bork iteration.
2222+ const children = new Map();
2323+ const removes: Array<Element> = [];
2424+2525+ // @ts-ignore
2626+ for (const child of parent.children) {
2727+ children.set(child[__key__], child);
2828+ if (!states().has(child[__key__])) {
2929+ removes.push(child);
3030+ }
3131+ }
3232+3333+ for (const child of removes) {
3434+ parent.removeChild(child);
3535+ }
3636+3737+ let i = 0;
3838+ for (const key of states().keys()) {
3939+ const index = i++;
4040+ const child = children.get(key);
4141+ if (child != null) {
4242+ insertElementAt(parent, child, index);
4343+ } else {
4444+ const child = view(takeValues(() => states().get(key)));
4545+ // @ts-ignore
4646+ child[__key__] = key;
4747+ insertElementAt(parent, child, index);
4848+ }
4949+ }
5050+ });
5151+5252+/**
5353+ * Insert element at index.
5454+ * If element is already at index, this function is a no-op
5555+ * (it doesn't remove-and-then-add element). By avoiding moving the element
5656+ * unless needed, we preserve focus and selection state for elements that
5757+ * don't move.
5858+ */
5959+export const insertElementAt = (parent: HTMLElement, element: HTMLElement, index: number) => {
6060+ const elementAtIndex = parent.children[index];
6161+ if (elementAtIndex === element) {
6262+ return;
6363+ }
6464+ parent.insertBefore(element, elementAtIndex);
6565+};
6666+6767+export const children =
6868+ (...children: Array<HTMLElement | string>) =>
6969+ (parent: HTMLElement) => {
7070+ parent.replaceChildren(...children);
7171+ };
7272+7373+export const shadow =
7474+ (...children: Array<HTMLElement | string>) =>
7575+ (parent: HTMLElement) => {
7676+ parent.attachShadow({ mode: "open" });
7777+ parent.shadowRoot!.replaceChildren(...children);
7878+ };
7979+8080+/**
8181+ * Write a value or signal of values to the text content of a parent element.
8282+ * Value will be coerced to string. If nullish, will be coerced to empty string.
8383+ */
8484+export const text = (text: Signal<any> | any) => (parent: Node) =>
8585+ effect(() => setProp(parent, "textContent", sample(text) ?? ""));
8686+8787+const isArray = Array.isArray;
8888+8989+export type ElementConfigurator = Array<HTMLElement | string> | ((element: HTMLElement) => void);
9090+9191+/**
9292+ * Signals-aware hyperscript.
9393+ * Create an element that can be updated with signals.
9494+ * @param tag - the HTML element type to create
9595+ * @param props - a signal or object containing
9696+ * properties to set on the element.
9797+ * @param configure - either a function called with the element to configure it,
9898+ * or an array of HTMLElements and strings to append. Optional.
9999+ */
100100+export const h = <T = HTMLElement>(
101101+ tag: string,
102102+ props: Record<string, any> | Signal<Record<string, any>> = {},
103103+ configure: ElementConfigurator = noConfigure,
104104+): T => {
105105+ const element = document.createElement(tag);
106106+107107+ effect(() => setProps(element, sample(props)));
108108+ configureElement(element, configure);
109109+110110+ return element as T;
111111+};
112112+113113+export type Props = Record<string, any> | Signal<Record<string, any>>;
114114+115115+type TagFactory = (props?: Props, configure?: ElementConfigurator) => HTMLElement;
116116+117117+/**
118118+ * Create a tag factory function - a specialized version of `h()` for a
119119+ * specific tag.
120120+ * @example
121121+ * const div = tag('div')
122122+ * div({className: 'wrapper'})
123123+ */
124124+export const tag =
125125+ (tag: string): TagFactory =>
126126+ (props = {}, configure = noConfigure) =>
127127+ h(tag, props, configure);
128128+129129+/**
130130+ * Create a tag factory function by accessing any property of `tags`.
131131+ * The key will be used as the tag name for the factory.
132132+ * Key must be a string, and will be passed verbatim as the tag name to
133133+ * `document.createElement()` under the hood.
134134+ * @example
135135+ * const {div} = tags
136136+ * div({className: 'wrapper'})
137137+ */
138138+export const tags: Record<string, TagFactory> = new Proxy(Object.freeze({}), {
139139+ get: (_, key): TagFactory => {
140140+ if (typeof key !== "string") {
141141+ throw new TypeError("Tag must be string");
142142+ }
143143+ return tag(key);
144144+ },
145145+});
146146+147147+const noConfigure = (parent: HTMLElement) => {};
148148+149149+const configureElement = (element: HTMLElement, configure: ElementConfigurator = noConfigure) => {
150150+ if (isArray(configure)) {
151151+ element.replaceChildren(...configure);
152152+ } else {
153153+ configure(element);
154154+ }
155155+};
156156+157157+/**
158158+ * Layout-triggering DOM properties.
159159+ * @see https://gist.github.com/paulirish/5d52fb081b3570c81e3a
160160+ */
161161+const LAYOUT_TRIGGERING_PROPS = new Set(["innerText"]);
162162+163163+/**
164164+ * Set object key, but only if value has actually changed.
165165+ * This is useful when setting keys on DOM elements, where setting the same
166166+ * value twice might trigger an unnecessary reflow or a style recalc.
167167+ * prop caches the written value and only writes the new value if it
168168+ * is different from the last-written value.
169169+ *
170170+ * In most cases, we can simply read the value of the DOM property itself.
171171+ * However, there are footgun properties such as `innerText` which
172172+ * will trigger reflow if you read from them. In these cases we warn developers.
173173+ * @see https://gist.github.com/paulirish/5d52fb081b3570c81e3a
174174+ *
175175+ * @param object - the object to set property on
176176+ * @param key - the key
177177+ * @param value - the value to set
178178+ */
179179+export const setProp = (element: Node, key: string, value: any) => {
180180+ if (LAYOUT_TRIGGERING_PROPS.has(key)) {
181181+ console.warn(
182182+ `Checking property value for ${key} triggers layout. Consider writing to this property without using setProp().`,
183183+ );
184184+ }
185185+186186+ if (key === "attrs" && typeof value === "object" && element instanceof HTMLElement) {
187187+ for (const [k, v] of Object.entries(value)) {
188188+ const value = typeof v === "string" ? v : (v as any).toString();
189189+ if (element.getAttribute(k) !== value) element.setAttribute(k, value);
190190+ }
191191+ // @ts-ignore
192192+ } else if (element[key] !== value) {
193193+ // @ts-ignore
194194+ element[key] = value;
195195+ }
196196+};
197197+198198+/**
199199+ * Set properties on an element, but only if the value has actually changed.
200200+ */
201201+const setProps = (element: Node, props: Record<string, any>) => {
202202+ for (const [key, value] of Object.entries(props)) {
203203+ setProp(element, key, value);
204204+ }
205205+};
206206+207207+const createStylesheetCache = () => {
208208+ const cache = new Map<string, CSSStyleSheet>();
209209+210210+ /** Get or create a cached stylesheet from a string */
211211+ const stylesheet = (cssString: string): CSSStyleSheet => {
212212+ const cachedSheet = cache.get(cssString);
213213+ if (cachedSheet) {
214214+ return cachedSheet;
215215+ }
216216+ const sheet = new CSSStyleSheet();
217217+ sheet.replaceSync(cssString);
218218+ cache.set(cssString, sheet);
219219+ return sheet;
220220+ };
221221+222222+ stylesheet.clearCache = () => {
223223+ cache.clear();
224224+ };
225225+226226+ return stylesheet;
227227+};
228228+229229+export const stylesheet = createStylesheetCache();
230230+231231+/**
232232+ * CSS template literal tag
233233+ * Takes a string without replacements and returns a CSSStyleSheet.
234234+ */
235235+export const css = (parts: TemplateStringsArray) => {
236236+ if (parts.length !== 1) {
237237+ throw new TypeError(`css string must not contain dynamic replacements`);
238238+ }
239239+ const [cssString] = parts;
240240+ return stylesheet(cssString);
241241+};
+3
src/scripts/spellcaster/index.ts
···11+export * from "alien-signals";
22+export * from "./spellcaster.js";
33+export * as hyperscript from "./hyperscript.js";
+58
src/scripts/spellcaster/spellcaster.ts
···11+import { computed } from "alien-signals";
22+33+/**
44+ * A signal is a zero-argument function that returns a value.
55+ * Reactive signals created with `signal()` will cause reactive contexts
66+ * to automatically re-execute when the signal changes.
77+ * Constant signals can be modeled as zero-argument functions that
88+ * return a constant value.
99+ */
1010+export type Signal<T> = () => T;
1111+1212+/**
1313+ * Is value a signal-like function?
1414+ * A signal is any zero-argument function.
1515+ */
1616+export const isSignal = (value: any): value is Signal<any> =>
1717+ typeof value === "function" && value.length === 0;
1818+1919+/** Sample a value that may be a signal, or just an ordinary value */
2020+export const sample = <T>(value: T | Signal<T>): T => (isSignal(value) ? value() : value);
2121+2222+/**
2323+ * Transform a signal, returning a computed signal that takes values until
2424+ * the given signal returns null. Once the given signal returns null, the
2525+ * signal is considered to be complete and no further updates will occur.
2626+ *
2727+ * This utility is useful for signals representing a child in a dynamic
2828+ * collection of children, where the child may cease to exist.
2929+ * A computed signal looks up the child, returns null if that child no longer
3030+ * exists. This completes the signal and breaks the connection with upstream
3131+ * signals, allowing the child signal to be garbaged.
3232+ */
3333+export const takeValues = <T>(maybeSignal: Signal<T | null | undefined>) => {
3434+ const initial = maybeSignal();
3535+3636+ if (initial == null) {
3737+ throw new TypeError("Signal initial value cannot be null");
3838+ }
3939+4040+ let state = initial;
4141+ let isComplete = false;
4242+4343+ return computed(() => {
4444+ if (isComplete) {
4545+ return state;
4646+ }
4747+4848+ const next = maybeSignal();
4949+5050+ if (next != null) {
5151+ state = next;
5252+ return state;
5353+ } else {
5454+ isComplete = true;
5555+ return state;
5656+ }
5757+ });
5858+};
+31
src/scripts/spellcaster/util.ts
···11+/** The counter that is incremented for `cid()` */
22+let _cid = 0;
33+44+/**
55+ * Get an auto-incrementing client-side ID value.
66+ * IDs are NOT guaranteed to be stable across page refreshes.
77+ */
88+export const cid = (): string => `cid${_cid++}`;
99+1010+/** Index an iterable of items by key, returning a map. */
1111+export const index = <Key, Item>(
1212+ iter: Iterable<Item>,
1313+ getKey: (item: Item) => Key,
1414+): Map<Key, Item> => {
1515+ const indexed = new Map<Key, Item>();
1616+ for (const item of iter) {
1717+ indexed.set(getKey(item), item);
1818+ }
1919+ return indexed;
2020+};
2121+2222+/** An item that exposes an ID field that is unique within its collection */
2323+export interface Identifiable {
2424+ id: any;
2525+}
2626+2727+export const getId = <Key, Item extends Identifiable>(item: Item) => item.id;
2828+2929+/** Index a collection by ID */
3030+export const indexById = <Key, Item extends Identifiable>(iter: Iterable<Item>): Map<Key, Item> =>
3131+ index(iter, getId);