A music player that connects to your cloud/distributed storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: fork spellcaster to use alien-signals for performance/stability

+426 -107
+1 -1
deno.lock
··· 36 36 "npm:@tokenizer/range@0.13", 37 37 "npm:@types/throttle-debounce@^5.0.2", 38 38 "npm:@types/wicg-file-system-access@^2023.10.6", 39 + "npm:alien-signals@^2.0.7", 39 40 "npm:astro-purgecss@^5.2.2", 40 41 "npm:astro-scope@^3.0.1", 41 42 "npm:astro@^5.7.4", ··· 48 49 "npm:purgecss@^7.0.2", 49 50 "npm:query-string@^9.1.2", 50 51 "npm:sass@^1.87.0", 51 - "npm:spellcaster@6", 52 52 "npm:subsonic-api@^3.1.2", 53 53 "npm:throttle-debounce@^5.0.2", 54 54 "npm:uint8arrays@^5.1.0",
+7 -16
package-lock.json
··· 19 19 "@tokenizer/range": "^0.13.0", 20 20 "@web-applets/sdk": "https://gitpkg.vercel.app/unternet-co/web-applets/sdk?tokono.ma/experiment&scripts.postinstall=npm%20i%20%40types%2Fnode%20%26%26%20npx%20tsc", 21 21 "98.css": "^0.1.21", 22 + "alien-signals": "^2.0.7", 22 23 "comlink": "^4.4.2", 23 24 "fast-average-color": "^9.5.0", 24 25 "iconoir": "^7.11.0", 25 26 "idb-keyval": "^6.2.1", 26 27 "music-metadata": "^11.2.3", 27 28 "query-string": "^9.1.2", 28 - "spellcaster": "^6.0.0", 29 29 "subsonic-api": "^3.1.2", 30 30 "throttle-debounce": "^5.0.2", 31 31 "uint8arrays": "^5.1.0", ··· 2281 2281 "engines": { 2282 2282 "node": ">=0.4.0" 2283 2283 } 2284 + }, 2285 + "node_modules/alien-signals": { 2286 + "version": "2.0.7", 2287 + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-2.0.7.tgz", 2288 + "integrity": "sha512-wE7y3jmYeb0+h6mr5BOovuqhFv22O/MV9j5p0ndJsa7z1zJNPGQ4ph5pQk/kTTCWRC3xsA4SmtwmkzQO+7NCNg==", 2289 + "license": "MIT" 2284 2290 }, 2285 2291 "node_modules/ani-cursor": { 2286 2292 "version": "0.0.5", ··· 21314 21320 "url": "https://github.com/sponsors/isaacs" 21315 21321 } 21316 21322 }, 21317 - "node_modules/signal-polyfill": { 21318 - "version": "0.2.2", 21319 - "resolved": "https://registry.npmjs.org/signal-polyfill/-/signal-polyfill-0.2.2.tgz", 21320 - "integrity": "sha512-p63Y4Er5/eMQ9RHg0M0Y64NlsQKpiu6MDdhBXpyywRuWiPywhJTpKJ1iB5K2hJEbFZ0BnDS7ZkJ+0AfTuL37Rg==", 21321 - "license": "Apache-2.0" 21322 - }, 21323 21323 "node_modules/simple-swizzle": { 21324 21324 "version": "0.2.2", 21325 21325 "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", ··· 21370 21370 "funding": { 21371 21371 "type": "github", 21372 21372 "url": "https://github.com/sponsors/wooorm" 21373 - } 21374 - }, 21375 - "node_modules/spellcaster": { 21376 - "version": "6.0.0", 21377 - "resolved": "https://registry.npmjs.org/spellcaster/-/spellcaster-6.0.0.tgz", 21378 - "integrity": "sha512-BLHYZFnvf5XtVwVr2x/esn7gJjUCevywkJoVmlN33MrneSR7AVTTYkeu6Nt9NUguGaOv11yb4zjLo5hV0PYj0w==", 21379 - "license": "MIT", 21380 - "dependencies": { 21381 - "signal-polyfill": "^0.2.0" 21382 21373 } 21383 21374 }, 21384 21375 "node_modules/split-on-first": {
+1 -1
package.json
··· 14 14 "@tokenizer/range": "^0.13.0", 15 15 "@web-applets/sdk": "https://gitpkg.vercel.app/unternet-co/web-applets/sdk?tokono.ma/experiment&scripts.postinstall=npm%20i%20%40types%2Fnode%20%26%26%20npx%20tsc", 16 16 "98.css": "^0.1.21", 17 + "alien-signals": "^2.0.7", 17 18 "comlink": "^4.4.2", 18 19 "fast-average-color": "^9.5.0", 19 20 "iconoir": "^7.11.0", 20 21 "idb-keyval": "^6.2.1", 21 22 "music-metadata": "^11.2.3", 22 23 "query-string": "^9.1.2", 23 - "spellcaster": "^6.0.0", 24 24 "subsonic-api": "^3.1.2", 25 25 "throttle-debounce": "^5.0.2", 26 26 "uint8arrays": "^5.1.0",
+1 -1
src/pages/configurator/output/_applet.astro
··· 43 43 <script> 44 44 import { INITIAL_MANAGED_OUTPUT } from "@scripts/output/common"; 45 45 import { inIframe } from "@scripts/common"; 46 - import { effect } from "spellcaster"; 46 + import { effect } from "@scripts/spellcaster"; 47 47 48 48 import { connection } from "@scripts/configurator/output/connections"; 49 49 import { active } from "@scripts/configurator/output/signals";
+33 -33
src/pages/constituent/blur/artwork-controller/_applet.astro
··· 336 336 import { Temporal } from "@js-temporal/polyfill"; 337 337 import { xxh32r } from "xxh32/dist/raw.js"; 338 338 339 - import { computed, effect, type Signal, signal } from "spellcaster"; 340 - import { tags, text, type ElementConfigurator } from "spellcaster/hyperscript.js"; 339 + import { computed, effect, signal } from "@scripts/spellcaster"; 340 + import { tags, text, type ElementConfigurator } from "@scripts/spellcaster/hyperscript.js"; 341 341 342 342 import type { Track } from "@applets/core/types"; 343 343 import { applet, hs, inputUrl, reactive, register } from "@scripts/applet/common"; ··· 356 356 const context = register(); 357 357 358 358 // Signals 359 - const [activeTrack, setActiveTrack] = signal<Track | undefined>(undefined); 360 - const [artwork, setArtwork] = signal<Artwork[]>([]); 361 - const [artworkColor, setArtworkColor] = signal<string | undefined>(undefined); 362 - const [artworkLightMode, setArtworkLightMode] = signal<boolean>(false); 363 - const [duration, setDuration] = signal<string>("0:00"); 364 - const [isLoading, setIsLoading] = signal<boolean>(true); 365 - const [isPlaying, setIsPlaying] = signal<boolean>(false); 366 - const [progress, setProgress] = signal<number>(0); 367 - const [time, setTime] = signal<string>("0:00"); 368 - const [volume, setVolume] = signal<number>(0); 359 + const activeTrack = signal<Track | undefined>(undefined); 360 + const artwork = signal<Artwork[]>([]); 361 + const artworkColor = signal<string | undefined>(undefined); 362 + const artworkLightMode = signal<boolean>(false); 363 + const duration = signal<string>("0:00"); 364 + const isLoading = signal<boolean>(true); 365 + const isPlaying = signal<boolean>(false); 366 + const progress = signal<number>(0); 367 + const time = signal<string>("0:00"); 368 + const volume = signal<number>(0); 369 369 370 370 // Is main group 371 371 function isMainGroup() { ··· 402 402 const prog = progress(); 403 403 const curr = engine.queue.data.now; 404 404 const audio = curr ? engine.audio.data.items[curr.id] : undefined; 405 - const duration = curr?.stats?.duration ?? audio?.duration; 405 + const dur = curr?.stats?.duration ?? audio?.duration; 406 406 407 - if (audio && duration != undefined && !isNaN(duration)) { 407 + if (audio && dur != undefined && !isNaN(dur)) { 408 408 const p = Temporal.Duration.from({ 409 - milliseconds: Math.round(duration * prog * 1000), 409 + milliseconds: Math.round(dur * prog * 1000), 410 410 }).round({ 411 411 largestUnit: "hours", 412 412 }); 413 413 414 - const d = Temporal.Duration.from({ milliseconds: Math.round(duration * 1000) }).round({ 414 + const d = Temporal.Duration.from({ milliseconds: Math.round(dur * 1000) }).round({ 415 415 largestUnit: "hours", 416 416 }); 417 417 418 - setTime(formatTime(p)); 419 - setDuration(formatTime(d)); 418 + time(formatTime(p)); 419 + duration(formatTime(d)); 420 420 } else { 421 - setTime("0:00"); 422 - setDuration("0:00"); 421 + time("0:00"); 422 + duration("0:00"); 423 423 } 424 424 }; 425 425 ··· 434 434 // ⏳️ Loading 435 435 //////////////////////////////////////////// 436 436 437 - const debouncedSetIsLoading = debounce(2000, setIsLoading); 437 + const debouncedSetIsLoading = debounce(2000, isLoading); 438 438 439 439 //////////////////////////////////////////// 440 440 // 🔊 AUDIO ··· 450 450 engine.audio, 451 451 (data) => 452 452 data.isPlaying && (data.items[engine.queue.data.now?.id ?? Infinity]?.isPlaying ?? false), 453 - (isPlaying) => setIsPlaying(isPlaying), 453 + (i) => isPlaying(i), 454 454 ); 455 455 456 456 reactive( 457 457 engine.audio, 458 458 (data) => data.items[engine.queue.data.now?.id ?? Infinity]?.progress ?? 0, 459 - (progress) => setProgress(progress), 459 + (p) => progress(p), 460 460 ); 461 461 462 462 reactive( 463 463 engine.audio, 464 464 (data) => data.volume.default, 465 - (volume) => setVolume(volume), 465 + (v) => volume(v), 466 466 ); 467 467 468 468 //////////////////////////////////////////// ··· 475 475 engine.queue, 476 476 (data) => data.now, 477 477 (track) => { 478 - setActiveTrack(track || undefined); 479 - setProgress(0); 478 + activeTrack(track || undefined); 479 + progress(0); 480 480 }, 481 481 ); 482 482 ··· 489 489 const track = engine.queue.data.now; 490 490 491 491 if (!track) { 492 - setArtwork([]); 492 + artwork([]); 493 493 return; 494 494 } 495 495 ··· 516 516 517 517 const currTrack = activeTrack(); 518 518 const currCacheId = currTrack ? await trackArtworkCacheId(currTrack) : undefined; 519 - if (cacheId === currCacheId) setArtwork(art); 519 + if (cacheId === currCacheId) artwork(art); 520 520 } 521 521 522 522 //////////////////////////////////////////// ··· 531 531 532 532 const h = ( 533 533 tag: string, 534 - props?: Record<string, any> | Signal<Record<string, any>>, 534 + props?: Record<string, any> | (() => Record<string, any>), 535 535 configure?: ElementConfigurator, 536 536 ) => hs(tag, scope, props, configure); 537 537 ··· 567 567 } 568 568 569 569 // Add new artwork 570 - const blob = new Blob([art[0].bytes], { type: art[0].mime }); 570 + const blob = new Blob([art[0].bytes.buffer as ArrayBuffer], { type: art[0].mime }); 571 571 const url = URL.createObjectURL(blob); 572 572 573 573 // Create img for new artwork ··· 586 586 const rgb = color.value; 587 587 const o = Math.round((rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000); 588 588 589 - setArtworkColor(color.rgba); 590 - setArtworkLightMode(o > 165); 589 + artworkColor(color.rgba); 590 + artworkLightMode(o > 165); 591 591 bg.style.backgroundColor = color.rgba; 592 592 main.style.backgroundColor = color.rgba; 593 593 img.style.opacity = "1"; ··· 697 697 const Control = ( 698 698 label: string, 699 699 icon: string, 700 - props: Record<string, any> | Signal<Record<string, any>>, 700 + props: Record<string, any> | (() => Record<string, any>), 701 701 ) => { 702 702 return h("command", props, [h("i", { className: icon, title: label })]); 703 703 };
+3 -3
src/pages/engine/audio/_applet.astro
··· 1 1 <script> 2 - import { effect, signal } from "spellcaster"; 2 + import { effect, signal } from "@scripts/spellcaster"; 3 3 4 4 import type { State, Audio, AudioState } from "./types"; 5 5 import { register } from "@scripts/applet/common"; ··· 51 51 } 52 52 53 53 // Effects 54 - const [defaultVolume, setDefaultVolume] = signal<number | undefined>(undefined); 55 - context.scope.ondata = (event: any) => setDefaultVolume(event.data.volume.default); 54 + const defaultVolume = signal<number | undefined>(undefined); 55 + context.scope.ondata = (event: any) => defaultVolume(event.data.volume.default); 56 56 57 57 effect(() => { 58 58 if (context.isMainInstance()) {
+9 -9
src/scripts/applet/common.ts
··· 2 2 import * as Comlink from "comlink"; 3 3 4 4 import { applets } from "@web-applets/sdk"; 5 - import { type ElementConfigurator, h } from "spellcaster/hyperscript.js"; 6 - import { effect, isSignal, type Signal, signal } from "spellcaster/spellcaster.js"; 7 5 import QS from "query-string"; 6 + 7 + import { type ElementConfigurator, h } from "@scripts/spellcaster/hyperscript.js"; 8 + import { isSignal, type Signal, signal } from "@scripts/spellcaster"; 8 9 9 10 import type { ResolvedUri } from "@applets/core/types"; 10 11 import { transfer, type WorkerTasks } from "@scripts/common"; ··· 168 169 codec, 169 170 170 171 isMainInstance() { 171 - return channelContext?.mainSignal[0]() ?? null; 172 + return channelContext?.mainSignal() ?? null; 172 173 }, 173 174 174 175 setActionHandler: <H extends Function>(actionId: string, actionHandler: H) => { ··· 214 215 instanceId: string; 215 216 scope: AppletScope<DataType>; 216 217 }) { 217 - const mainSignal = signal<boolean>(true); 218 - const [isMain, setIsMain] = mainSignal; 218 + const isMain = signal<boolean>(true); 219 219 220 220 // One instance to rule them all 221 221 // ··· 246 246 247 247 case "PONG": { 248 248 if (event.data.instanceId === instanceId) { 249 - setIsMain(false); 249 + isMain(false); 250 250 } 251 251 break; 252 252 } ··· 256 256 // We need to wait until the other side is actually unloaded 🤷‍♀️ 257 257 setTimeout(async () => { 258 258 const promised = await makeMainPromise(); 259 - setIsMain(promised.isMain); 259 + isMain(promised.isMain); 260 260 if (promised.isMain) context.unloadHandler?.(); 261 261 }, 250); 262 262 } ··· 339 339 // Check if a main instance is still available, 340 340 // if not, then this is the new main. 341 341 const promised = await makeMainPromise(); 342 - setIsMain(promised.isMain); 342 + isMain(promised.isMain); 343 343 344 344 if (isMain()) { 345 345 return actionHandler(...args); ··· 383 383 // Fin 384 384 return { 385 385 channel, 386 - mainSignal, 386 + mainSignal: isMain, 387 387 promise, 388 388 setActionHandler, 389 389 };
+2 -2
src/scripts/configurator/output/signals.ts
··· 1 - import { signal } from "spellcaster"; 1 + import { signal } from "@scripts/spellcaster"; 2 2 3 3 import type { Method } from "./types"; 4 4 import { DEFAULT_METHOD, LOCALSTORAGE_KEY, METHODS } from "./constants"; 5 5 6 6 export const stored = localStorage.getItem(LOCALSTORAGE_KEY); 7 - export const [active, setActive] = signal<Method>( 7 + export const active = signal<Method>( 8 8 stored && METHODS.includes(stored as Method) ? (stored as Method) : DEFAULT_METHOD, 9 9 );
+15 -21
src/scripts/configurator/output/ui.ts
··· 1 - import { type Signal, computed, effect, signal } from "spellcaster/spellcaster.js"; 2 - import { type ElementConfigurator, h, repeat, text } from "spellcaster/hyperscript.js"; 1 + import { type Signal, computed, effect, signal } from "@scripts/spellcaster"; 2 + import { h, repeat, text } from "@scripts/spellcaster/hyperscript.js"; 3 3 4 - import { applet, hs, reactive } from "@scripts/applet/common"; 4 + import { applet, reactive } from "@scripts/applet/common"; 5 5 import { CUSTOM_KEY } from "./constants"; 6 - import { active, setActive } from "./signals"; 6 + import { active } from "./signals"; 7 7 import { connection } from "./connections"; 8 8 import { context } from "./context"; 9 9 import type { List, ListItem, Method } from "./types"; 10 10 import { setContextData } from "./events"; 11 11 12 - // const h = ( 13 - // tag: string, 14 - // props?: Record<string, any> | Signal<Record<string, any>>, 15 - // configure?: ElementConfigurator, 16 - // ) => hs(tag, scope, props, configure); 17 - 18 12 //////////////////////////////////////////// 19 13 // EFFECTS 20 14 //////////////////////////////////////////// ··· 36 30 async function mountStorageMethod(method: Method) { 37 31 switch (method) { 38 32 case "custom": 39 - setModalIsOpen(true); 33 + modalIsOpen(true); 40 34 break; 41 35 default: 42 36 const conn = await connection(method); 43 37 try { 44 38 await conn.sendAction("mount", undefined, { timeoutDuration: 60000 }); 45 - setActive(method); 39 + active(method); 46 40 } catch (err) { 47 41 const msg: string = 48 42 err && typeof err === "object" && "message" in err ? `${err.message}` : `${err}`; ··· 142 136 //////////////////////////////////////////// 143 137 type CustomAppletState = "waiting" | "connecting" | { error: string } | "connected"; 144 138 145 - const [modalIsOpen, setModalIsOpen] = signal(false); 146 - const [customState, setCustomState] = signal<CustomAppletState>("waiting"); 139 + const modalIsOpen = signal(false); 140 + const customState = signal<CustomAppletState>("waiting"); 147 141 148 142 const Modal = () => { 149 143 const Header = h("header", {}, [ ··· 209 203 210 204 // Events 211 205 function close() { 212 - setModalIsOpen(false); 206 + modalIsOpen(false); 213 207 } 214 208 215 209 async function submit(event: SubmitEvent) { ··· 222 216 if (!input) return; 223 217 224 218 const url = input.value; 225 - setCustomState("connecting"); 219 + customState("connecting"); 226 220 227 221 const apl = await applet(url).catch((err) => { 228 - setCustomState({ error: "Failed to connect" }); 222 + customState({ error: "Failed to connect" }); 229 223 throw err; 230 224 }); 231 225 ··· 236 230 }); 237 231 238 232 if (missingAction) { 239 - setCustomState({ error: `Applet is missing a required action: "${missingAction}"` }); 233 + customState({ error: `Applet is missing a required action: "${missingAction}"` }); 240 234 return; 241 235 } 242 236 243 237 localStorage.setItem(CUSTOM_KEY, url); 244 238 await apl.sendAction("mount", undefined, { timeoutDuration: 60000 }); 245 239 246 - setActive("custom"); 247 - setModalIsOpen(false); 248 - setCustomState("waiting"); 240 + active("custom"); 241 + modalIsOpen(false); 242 + customState("waiting"); 249 243 } 250 244 251 245 // Add to DOM
+4 -4
src/scripts/input/native-fs/mounting.ts
··· 1 - import { signal } from "spellcaster"; 1 + import { signal } from "@scripts/spellcaster"; 2 2 import * as IDB from "idb-keyval"; 3 3 4 4 import { fetchHandles, fetchHandlesList } from "./common"; ··· 7 7 //////////////////////////////////////////// 8 8 // SIGNALS 9 9 //////////////////////////////////////////// 10 - export const [mounts, setMounts] = signal(await fetchHandlesList()); 10 + export const mounts = signal(await fetchHandlesList()); 11 11 12 12 //////////////////////////////////////////// 13 13 // ACTIONS ··· 20 20 21 21 await handle.requestPermission({ mode: "read" }); 22 22 await IDB.set(IDB_HANDLES, { ...existingHandles, [id]: handle }); 23 - setMounts(await fetchHandlesList()); 23 + mounts(await fetchHandlesList()); 24 24 }) 25 25 .catch(() => {}); 26 26 }; ··· 29 29 const handles = await fetchHandles(); 30 30 delete handles[handleId]; 31 31 await IDB.set(IDB_HANDLES, { ...handles }); 32 - setMounts(await fetchHandlesList()); 32 + mounts(await fetchHandlesList()); 33 33 };
+2 -2
src/scripts/input/native-fs/ui.ts
··· 1 - import { computed, effect, type Signal } from "spellcaster"; 2 - import { repeat, tags, text } from "spellcaster/hyperscript.js"; 1 + import { computed, effect, type Signal } from "@scripts/spellcaster"; 2 + import { repeat, tags, text } from "@scripts/spellcaster/hyperscript.js"; 3 3 4 4 import { mount, mounts, unmount } from "./mounting"; 5 5 import { isSupported } from "./common";
+7 -7
src/scripts/input/opensubsonic/ui.ts
··· 1 - import { computed, effect, type Signal, signal } from "spellcaster"; 2 - import { type Props, repeat, tags, text } from "spellcaster/hyperscript.js"; 1 + import { computed, effect, type Signal, signal } from "@scripts/spellcaster"; 2 + import { type Props, repeat, tags, text } from "@scripts/spellcaster/hyperscript.js"; 3 3 4 4 import type { Server } from "./types.d.ts"; 5 5 import { loadServers, saveServers, serverId } from "./common"; ··· 7 7 //////////////////////////////////////////// 8 8 // UI 9 9 //////////////////////////////////////////// 10 - export const [servers, setServers] = signal<Record<string, Server>>(await loadServers()); 11 - const [form, setForm] = signal<{ 10 + export const servers = signal<Record<string, Server>>(await loadServers()); 11 + const form = signal<{ 12 12 api_key?: string; 13 13 host?: string; 14 14 password?: string; ··· 34 34 const col = { ...servers() }; 35 35 delete col[id]; 36 36 37 - setServers(col); 37 + servers(col); 38 38 }; 39 39 40 40 return tags.li({ onclick, style: "cursor: pointer" }, text(server().host)); ··· 68 68 password: f.password, 69 69 }; 70 70 71 - setServers({ 71 + servers({ 72 72 ...servers(), 73 73 [serverId(server)]: server, 74 74 }); ··· 103 103 } 104 104 105 105 function formInput(name: string, value: string) { 106 - setForm({ ...form(), [name]: value }); 106 + form({ ...form(), [name]: value }); 107 107 } 108 108 109 109 // 🚀
+7 -7
src/scripts/input/s3/ui.ts
··· 1 - import { computed, effect, type Signal, signal } from "spellcaster"; 2 - import { type Props, repeat, tags, text } from "spellcaster/hyperscript.js"; 1 + import { computed, effect, type Signal, signal } from "@scripts/spellcaster"; 2 + import { type Props, repeat, tags, text } from "@scripts/spellcaster/hyperscript.js"; 3 3 4 4 import type { Bucket } from "./types"; 5 5 import { bucketId, loadBuckets, saveBuckets } from "./common"; ··· 7 7 //////////////////////////////////////////// 8 8 // UI 9 9 //////////////////////////////////////////// 10 - export const [buckets, setBuckets] = signal<Record<string, Bucket>>(await loadBuckets()); 11 - export const [form, setForm] = signal<{ 10 + export const buckets = signal<Record<string, Bucket>>(await loadBuckets()); 11 + export const form = signal<{ 12 12 access_key?: string; 13 13 bucket_name?: string; 14 14 host?: string; ··· 36 36 const col = { ...buckets() }; 37 37 delete col[id]; 38 38 39 - setBuckets(col); 39 + buckets(col); 40 40 }; 41 41 42 42 return tags.li({ onclick, style: "cursor: pointer" }, text(bucket().host)); ··· 71 71 secretKey: f.secret_key || "", 72 72 }; 73 73 74 - setBuckets({ 74 + buckets({ 75 75 ...buckets(), 76 76 [bucketId(bucket)]: bucket, 77 77 }); ··· 111 111 } 112 112 113 113 function formInput(name: string, value: string) { 114 - setForm({ ...form(), [name]: value }); 114 + form({ ...form(), [name]: value }); 115 115 } 116 116 117 117 // 🚀
+1
src/scripts/spellcaster/README.md
··· 1 + Reusing various parts of the `spellcaster` library, swapped out the signals library with `alien-signals`.
+241
src/scripts/spellcaster/hyperscript.ts
··· 1 + import { effect } from "alien-signals"; 2 + import { type Signal, takeValues, sample } from "./spellcaster"; 3 + export { cid, getId, indexById, index, type Identifiable } from "./util"; 4 + 5 + /** A view-constructing function */ 6 + export type View<State> = (state: Signal<State>) => HTMLElement; 7 + 8 + /** Symbol for list item key */ 9 + const __key__ = Symbol("list item key"); 10 + 11 + /** 12 + * Create a function to efficiently render a dynamic list of views on a 13 + * parent element. 14 + */ 15 + export const repeat = 16 + <Key, State>(states: Signal<Map<Key, State>>, view: View<State>) => 17 + (parent: HTMLElement) => 18 + effect(() => { 19 + // Build an index of children and a list of children to remove. 20 + // Note that we must build a list of children to remove, since 21 + // removing in-place would change the live node list and bork iteration. 22 + const children = new Map(); 23 + const removes: Array<Element> = []; 24 + 25 + // @ts-ignore 26 + for (const child of parent.children) { 27 + children.set(child[__key__], child); 28 + if (!states().has(child[__key__])) { 29 + removes.push(child); 30 + } 31 + } 32 + 33 + for (const child of removes) { 34 + parent.removeChild(child); 35 + } 36 + 37 + let i = 0; 38 + for (const key of states().keys()) { 39 + const index = i++; 40 + const child = children.get(key); 41 + if (child != null) { 42 + insertElementAt(parent, child, index); 43 + } else { 44 + const child = view(takeValues(() => states().get(key))); 45 + // @ts-ignore 46 + child[__key__] = key; 47 + insertElementAt(parent, child, index); 48 + } 49 + } 50 + }); 51 + 52 + /** 53 + * Insert element at index. 54 + * If element is already at index, this function is a no-op 55 + * (it doesn't remove-and-then-add element). By avoiding moving the element 56 + * unless needed, we preserve focus and selection state for elements that 57 + * don't move. 58 + */ 59 + export const insertElementAt = (parent: HTMLElement, element: HTMLElement, index: number) => { 60 + const elementAtIndex = parent.children[index]; 61 + if (elementAtIndex === element) { 62 + return; 63 + } 64 + parent.insertBefore(element, elementAtIndex); 65 + }; 66 + 67 + export const children = 68 + (...children: Array<HTMLElement | string>) => 69 + (parent: HTMLElement) => { 70 + parent.replaceChildren(...children); 71 + }; 72 + 73 + export const shadow = 74 + (...children: Array<HTMLElement | string>) => 75 + (parent: HTMLElement) => { 76 + parent.attachShadow({ mode: "open" }); 77 + parent.shadowRoot!.replaceChildren(...children); 78 + }; 79 + 80 + /** 81 + * Write a value or signal of values to the text content of a parent element. 82 + * Value will be coerced to string. If nullish, will be coerced to empty string. 83 + */ 84 + export const text = (text: Signal<any> | any) => (parent: Node) => 85 + effect(() => setProp(parent, "textContent", sample(text) ?? "")); 86 + 87 + const isArray = Array.isArray; 88 + 89 + export type ElementConfigurator = Array<HTMLElement | string> | ((element: HTMLElement) => void); 90 + 91 + /** 92 + * Signals-aware hyperscript. 93 + * Create an element that can be updated with signals. 94 + * @param tag - the HTML element type to create 95 + * @param props - a signal or object containing 96 + * properties to set on the element. 97 + * @param configure - either a function called with the element to configure it, 98 + * or an array of HTMLElements and strings to append. Optional. 99 + */ 100 + export const h = <T = HTMLElement>( 101 + tag: string, 102 + props: Record<string, any> | Signal<Record<string, any>> = {}, 103 + configure: ElementConfigurator = noConfigure, 104 + ): T => { 105 + const element = document.createElement(tag); 106 + 107 + effect(() => setProps(element, sample(props))); 108 + configureElement(element, configure); 109 + 110 + return element as T; 111 + }; 112 + 113 + export type Props = Record<string, any> | Signal<Record<string, any>>; 114 + 115 + type TagFactory = (props?: Props, configure?: ElementConfigurator) => HTMLElement; 116 + 117 + /** 118 + * Create a tag factory function - a specialized version of `h()` for a 119 + * specific tag. 120 + * @example 121 + * const div = tag('div') 122 + * div({className: 'wrapper'}) 123 + */ 124 + export const tag = 125 + (tag: string): TagFactory => 126 + (props = {}, configure = noConfigure) => 127 + h(tag, props, configure); 128 + 129 + /** 130 + * Create a tag factory function by accessing any property of `tags`. 131 + * The key will be used as the tag name for the factory. 132 + * Key must be a string, and will be passed verbatim as the tag name to 133 + * `document.createElement()` under the hood. 134 + * @example 135 + * const {div} = tags 136 + * div({className: 'wrapper'}) 137 + */ 138 + export const tags: Record<string, TagFactory> = new Proxy(Object.freeze({}), { 139 + get: (_, key): TagFactory => { 140 + if (typeof key !== "string") { 141 + throw new TypeError("Tag must be string"); 142 + } 143 + return tag(key); 144 + }, 145 + }); 146 + 147 + const noConfigure = (parent: HTMLElement) => {}; 148 + 149 + const configureElement = (element: HTMLElement, configure: ElementConfigurator = noConfigure) => { 150 + if (isArray(configure)) { 151 + element.replaceChildren(...configure); 152 + } else { 153 + configure(element); 154 + } 155 + }; 156 + 157 + /** 158 + * Layout-triggering DOM properties. 159 + * @see https://gist.github.com/paulirish/5d52fb081b3570c81e3a 160 + */ 161 + const LAYOUT_TRIGGERING_PROPS = new Set(["innerText"]); 162 + 163 + /** 164 + * Set object key, but only if value has actually changed. 165 + * This is useful when setting keys on DOM elements, where setting the same 166 + * value twice might trigger an unnecessary reflow or a style recalc. 167 + * prop caches the written value and only writes the new value if it 168 + * is different from the last-written value. 169 + * 170 + * In most cases, we can simply read the value of the DOM property itself. 171 + * However, there are footgun properties such as `innerText` which 172 + * will trigger reflow if you read from them. In these cases we warn developers. 173 + * @see https://gist.github.com/paulirish/5d52fb081b3570c81e3a 174 + * 175 + * @param object - the object to set property on 176 + * @param key - the key 177 + * @param value - the value to set 178 + */ 179 + export const setProp = (element: Node, key: string, value: any) => { 180 + if (LAYOUT_TRIGGERING_PROPS.has(key)) { 181 + console.warn( 182 + `Checking property value for ${key} triggers layout. Consider writing to this property without using setProp().`, 183 + ); 184 + } 185 + 186 + if (key === "attrs" && typeof value === "object" && element instanceof HTMLElement) { 187 + for (const [k, v] of Object.entries(value)) { 188 + const value = typeof v === "string" ? v : (v as any).toString(); 189 + if (element.getAttribute(k) !== value) element.setAttribute(k, value); 190 + } 191 + // @ts-ignore 192 + } else if (element[key] !== value) { 193 + // @ts-ignore 194 + element[key] = value; 195 + } 196 + }; 197 + 198 + /** 199 + * Set properties on an element, but only if the value has actually changed. 200 + */ 201 + const setProps = (element: Node, props: Record<string, any>) => { 202 + for (const [key, value] of Object.entries(props)) { 203 + setProp(element, key, value); 204 + } 205 + }; 206 + 207 + const createStylesheetCache = () => { 208 + const cache = new Map<string, CSSStyleSheet>(); 209 + 210 + /** Get or create a cached stylesheet from a string */ 211 + const stylesheet = (cssString: string): CSSStyleSheet => { 212 + const cachedSheet = cache.get(cssString); 213 + if (cachedSheet) { 214 + return cachedSheet; 215 + } 216 + const sheet = new CSSStyleSheet(); 217 + sheet.replaceSync(cssString); 218 + cache.set(cssString, sheet); 219 + return sheet; 220 + }; 221 + 222 + stylesheet.clearCache = () => { 223 + cache.clear(); 224 + }; 225 + 226 + return stylesheet; 227 + }; 228 + 229 + export const stylesheet = createStylesheetCache(); 230 + 231 + /** 232 + * CSS template literal tag 233 + * Takes a string without replacements and returns a CSSStyleSheet. 234 + */ 235 + export const css = (parts: TemplateStringsArray) => { 236 + if (parts.length !== 1) { 237 + throw new TypeError(`css string must not contain dynamic replacements`); 238 + } 239 + const [cssString] = parts; 240 + return stylesheet(cssString); 241 + };
+3
src/scripts/spellcaster/index.ts
··· 1 + export * from "alien-signals"; 2 + export * from "./spellcaster.js"; 3 + export * as hyperscript from "./hyperscript.js";
+58
src/scripts/spellcaster/spellcaster.ts
··· 1 + import { computed } from "alien-signals"; 2 + 3 + /** 4 + * A signal is a zero-argument function that returns a value. 5 + * Reactive signals created with `signal()` will cause reactive contexts 6 + * to automatically re-execute when the signal changes. 7 + * Constant signals can be modeled as zero-argument functions that 8 + * return a constant value. 9 + */ 10 + export type Signal<T> = () => T; 11 + 12 + /** 13 + * Is value a signal-like function? 14 + * A signal is any zero-argument function. 15 + */ 16 + export const isSignal = (value: any): value is Signal<any> => 17 + typeof value === "function" && value.length === 0; 18 + 19 + /** Sample a value that may be a signal, or just an ordinary value */ 20 + export const sample = <T>(value: T | Signal<T>): T => (isSignal(value) ? value() : value); 21 + 22 + /** 23 + * Transform a signal, returning a computed signal that takes values until 24 + * the given signal returns null. Once the given signal returns null, the 25 + * signal is considered to be complete and no further updates will occur. 26 + * 27 + * This utility is useful for signals representing a child in a dynamic 28 + * collection of children, where the child may cease to exist. 29 + * A computed signal looks up the child, returns null if that child no longer 30 + * exists. This completes the signal and breaks the connection with upstream 31 + * signals, allowing the child signal to be garbaged. 32 + */ 33 + export const takeValues = <T>(maybeSignal: Signal<T | null | undefined>) => { 34 + const initial = maybeSignal(); 35 + 36 + if (initial == null) { 37 + throw new TypeError("Signal initial value cannot be null"); 38 + } 39 + 40 + let state = initial; 41 + let isComplete = false; 42 + 43 + return computed(() => { 44 + if (isComplete) { 45 + return state; 46 + } 47 + 48 + const next = maybeSignal(); 49 + 50 + if (next != null) { 51 + state = next; 52 + return state; 53 + } else { 54 + isComplete = true; 55 + return state; 56 + } 57 + }); 58 + };
+31
src/scripts/spellcaster/util.ts
··· 1 + /** The counter that is incremented for `cid()` */ 2 + let _cid = 0; 3 + 4 + /** 5 + * Get an auto-incrementing client-side ID value. 6 + * IDs are NOT guaranteed to be stable across page refreshes. 7 + */ 8 + export const cid = (): string => `cid${_cid++}`; 9 + 10 + /** Index an iterable of items by key, returning a map. */ 11 + export const index = <Key, Item>( 12 + iter: Iterable<Item>, 13 + getKey: (item: Item) => Key, 14 + ): Map<Key, Item> => { 15 + const indexed = new Map<Key, Item>(); 16 + for (const item of iter) { 17 + indexed.set(getKey(item), item); 18 + } 19 + return indexed; 20 + }; 21 + 22 + /** An item that exposes an ID field that is unique within its collection */ 23 + export interface Identifiable { 24 + id: any; 25 + } 26 + 27 + export const getId = <Key, Item extends Identifiable>(item: Item) => item.id; 28 + 29 + /** Index a collection by ID */ 30 + export const indexById = <Key, Item extends Identifiable>(iter: Iterable<Item>): Map<Key, Item> => 31 + index(iter, getId);