@escape-game-over/atlas 0.1.1 → 0.1.3

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.
@@ -0,0 +1,457 @@
1
+ /**
2
+ * A filtered list's state and its URL — not its markup.
3
+ *
4
+ * In `astro/` for the reason `carousel.ts` is: it reaches for `location`,
5
+ * `history` and `window`, which the core is type-checked without.
6
+ *
7
+ * **It draws nothing.** No `hidden`, no `aria-selected`, no counts, no empty
8
+ * state. It owns which items match and what the address bar says, and calls
9
+ * `onChange` when either moves; a project does every DOM write, and its markup
10
+ * contract — however many data attributes that turns out to be — stays where the
11
+ * markup is. That is the whole split, and it is why one function can serve a
12
+ * game grid with tabs and a two-input directory.
13
+ *
14
+ * Matching is the one place this cannot copy `carousel`. A carousel's entire
15
+ * state is an integer, so it never needs to know anything about the page. A
16
+ * predicate needs each item's text, and that text lives in the markup — so the
17
+ * caller hands it over once, as plain values, rather than the library reaching
18
+ * into the DOM for it. Keys are strings for the same reason: nothing here holds
19
+ * an element, and the whole module is testable without a document.
20
+ *
21
+ * ```ts
22
+ * const list = filters({
23
+ * fields: {
24
+ * q: { kind: "text", param: "q" },
25
+ * category: { kind: "choice", param: "category" },
26
+ * },
27
+ * items: games.map((game) => ({
28
+ * key: game.id,
29
+ * values: { q: `${game.name} ${game.blurb}`, category: game.category },
30
+ * })),
31
+ * onChange: ({ matched }) => {
32
+ * for (const [key, element] of elements) element.hidden = !matched.has(key);
33
+ * },
34
+ * });
35
+ *
36
+ * const detach = list.attach();
37
+ * ```
38
+ *
39
+ * What it owns is the handful of things identical in every filtered list and
40
+ * quietly wrong in most: folding so a query without accents still matches words
41
+ * with them, a query and its facets resolved together rather than in two passes,
42
+ * and a URL that behaves — replaced while typing so one search does not bury the
43
+ * previous page in history, pushed on a deliberate choice so the back button
44
+ * undoes it, empty parameters dropped rather than left as `?q=`, and `popstate`
45
+ * applied rather than ignored.
46
+ *
47
+ * Reading the URL in `attach` is also what lets a list work before this script
48
+ * arrives: a `<form method="get">` submits, the server renders the filtered
49
+ * page, and the first `onChange` here continues from that state instead of
50
+ * resetting it.
51
+ */
52
+
53
+ /**
54
+ * What a field is, as data rather than a constructor.
55
+ *
56
+ * A plain object because that is all a field is — a kind and, if it belongs in
57
+ * the URL, a parameter name. Three exported builder functions would add three
58
+ * very general names to a module a project imports by name (`text` above all)
59
+ * and would return exactly this.
60
+ *
61
+ * The kinds are closed, and deliberately few:
62
+ *
63
+ * - `text` — free entry, folded and substring-matched. The search box.
64
+ * - `choice` — one of a set, or none. Tabs, a `<select>`, a radio group.
65
+ * - `flag` — a narrowing toggle: off matches everything, on keeps only the
66
+ * items that carry it. "Arena only", "In stock".
67
+ *
68
+ * `flag` is not a tri-state and should not become one. On, off and *either* is
69
+ * a `choice` with two values; folding that into a toggle gives a control with a
70
+ * third position nothing can reach.
71
+ */
72
+ export type Field =
73
+ | { readonly kind: "text"; readonly param?: string }
74
+ | { readonly kind: "choice"; readonly param?: string }
75
+ | { readonly kind: "flag"; readonly param?: string };
76
+
77
+ /** The fields of one list, named by the caller. */
78
+ export type FieldMap = Readonly<Record<string, Field>>;
79
+
80
+ /**
81
+ * What one field contributes, on an item and in the state.
82
+ *
83
+ * One type for both roles because per kind they genuinely coincide, though they
84
+ * mean different things: on an item a `text` value is everything that field
85
+ * searches — a name and a blurb joined — while in the state it is what someone
86
+ * typed. A `flag` is what the item is on one side and what was asked for on the
87
+ * other.
88
+ */
89
+ export type FieldValue<F extends Field> = F["kind"] extends "flag"
90
+ ? boolean
91
+ : string;
92
+
93
+ /** Every field's value for one item, derived from the field declaration. */
94
+ export type ItemValues<F extends FieldMap> = {
95
+ readonly [K in keyof F]: FieldValue<F[K]>;
96
+ };
97
+
98
+ /** What the list is filtered to right now. */
99
+ export type FilterState<F extends FieldMap> = {
100
+ readonly [K in keyof F]: FieldValue<F[K]>;
101
+ };
102
+
103
+ export interface FilterItem<F extends FieldMap> {
104
+ /**
105
+ * How the caller finds this item again.
106
+ *
107
+ * Opaque here and unique across the list — a duplicate is rejected at
108
+ * construction, because two items answering to one key make `matched`
109
+ * unable to say which of them matched.
110
+ */
111
+ readonly key: string;
112
+ readonly values: ItemValues<F>;
113
+ }
114
+
115
+ export interface FilterChange<F extends FieldMap> {
116
+ readonly state: FilterState<F>;
117
+ /**
118
+ * The keys that survive every field at once.
119
+ *
120
+ * A set rather than a filtered list, so a caller renders by asking about the
121
+ * items it already holds instead of diffing two arrays — and `size` is the
122
+ * count a "showing N" line wants, with no second pass.
123
+ */
124
+ readonly matched: ReadonlySet<string>;
125
+ }
126
+
127
+ export interface FiltersOptions<F extends FieldMap> {
128
+ readonly fields: F;
129
+ /**
130
+ * The full list, every time — this filters, it does not paginate.
131
+ *
132
+ * Fixed for the lifetime of the instance. A list whose contents change is a
133
+ * new instance, which is one line at the call site and avoids this owning a
134
+ * second lifecycle it would have to keep in step with `attach`.
135
+ */
136
+ readonly items: readonly FilterItem<F>[];
137
+ /**
138
+ * Called whenever the state moves, and once on `attach` with whatever the
139
+ * URL already said.
140
+ *
141
+ * Never called for a `set` that changes nothing: re-rendering an unchanged
142
+ * list on every keystroke that did not alter the query is work a caller
143
+ * cannot skip on its own, because by then it has been told the state
144
+ * changed.
145
+ */
146
+ onChange(change: FilterChange<F>): void;
147
+ }
148
+
149
+ export interface SetOptions {
150
+ /**
151
+ * Whether this move is worth a step in history.
152
+ *
153
+ * Defaults from the kind — `text` replaces, `choice` and `flag` push —
154
+ * because that is the pairing almost every control wants: typing emits an
155
+ * event per keystroke and would otherwise fill history with a word being
156
+ * spelled, while picking a category is one deliberate act the back button
157
+ * should undo.
158
+ *
159
+ * It is an override rather than a fixed rule because the default is really
160
+ * a fact about the *input*, not the field. A `<select>` that sets a `text`
161
+ * field, or a preset link, is a deliberate choice and wants `"push"`.
162
+ */
163
+ readonly history?: "push" | "replace";
164
+ }
165
+
166
+ export interface Filters<F extends FieldMap> {
167
+ readonly state: FilterState<F>;
168
+ readonly matched: ReadonlySet<string>;
169
+ set<K extends keyof F>(
170
+ field: K,
171
+ value: FieldValue<F[K]>,
172
+ options?: SetOptions
173
+ ): void;
174
+ /** Clears one field, or all of them. Always a push: clearing is deliberate. */
175
+ reset(field?: keyof F): void;
176
+ /**
177
+ * Reads the URL, applies it, and starts listening — returns the undo.
178
+ *
179
+ * One lifecycle rather than two, as `carousel.attach` is, and for a reason
180
+ * that bites harder here: with view transitions on, a module bound at top
181
+ * level executes once per session rather than once per navigation, so the
182
+ * incoming page gets a live list and dead controls. Driving this from
183
+ * `astro:page-load` and calling the returned function on teardown is what
184
+ * makes that correct — and the listener is aborted by its own undo, so it
185
+ * cannot outlive the page that added it.
186
+ *
187
+ * Nothing writes to the URL before this runs. `attach` is what reads it, and
188
+ * a `set` on an unattached instance would otherwise overwrite state it never
189
+ * loaded.
190
+ */
191
+ attach(): () => void;
192
+ }
193
+
194
+ /** Combining marks, left behind by `NFD` once the base character is separated. */
195
+ const DIACRITICS = /[̀-ͯ]/g;
196
+
197
+ /**
198
+ * A string reduced to what a query should match against.
199
+ *
200
+ * Decompose, drop the marks, lowercase, trim. This is the part every list needs
201
+ * and few have: without it a reader typing `abilita` is told there is no
202
+ * *Abilità*, and a Romanian catalogue hides every entry spelled with `ă`, `ș` or
203
+ * `ț` from anyone whose keyboard does not carry them.
204
+ */
205
+ function fold(value: string): string {
206
+ return value.normalize("NFD").replace(DIACRITICS, "").toLowerCase().trim();
207
+ }
208
+
209
+ /**
210
+ * How a set flag is spelled in the query string, and the only spelling read
211
+ * back as set.
212
+ *
213
+ * An unset flag is *absent* rather than `=0`. One way to say off keeps the URL
214
+ * short and means there is a single form to handle; two would both have to be
215
+ * understood forever, and a reader could not tell which of them a link was
216
+ * carrying. The cost is that a hand-written `?arena=0` reads as off, which is
217
+ * the answer it would get anyway.
218
+ *
219
+ * `1` and not `true` because that is what the existing lists already emit:
220
+ * links people have shared or bookmarked resolve to the view they named, and a
221
+ * spelling change would quietly reset every one of them.
222
+ */
223
+ const FLAG_ON = "1";
224
+
225
+ export function filters<const F extends FieldMap>(
226
+ options: FiltersOptions<F>
227
+ ): Filters<F> {
228
+ const { fields, items, onChange } = options;
229
+ const names = Object.keys(fields) as (keyof F & string)[];
230
+
231
+ // Checks that types cannot express, run once. Both failures are silent
232
+ // otherwise: a duplicate key makes `matched` ambiguous about which item it
233
+ // meant, and two fields sharing a parameter means each URL write erases the
234
+ // other's value, which reads as a filter that will not stay set.
235
+ const keys = new Set<string>();
236
+ for (const item of items) {
237
+ if (keys.has(item.key)) {
238
+ throw new Error(`Two filter items share the key "${item.key}".`);
239
+ }
240
+ keys.add(item.key);
241
+ }
242
+ const params = new Set<string>();
243
+ for (const name of names) {
244
+ const param = fields[name]?.param;
245
+ if (param === undefined) continue;
246
+ if (params.has(param)) {
247
+ throw new Error(
248
+ `Two filter fields share the URL parameter "${param}".`
249
+ );
250
+ }
251
+ params.add(param);
252
+ }
253
+
254
+ /**
255
+ * Every item's searchable text, folded once at construction.
256
+ *
257
+ * Folding is four string operations, and doing it per item per keystroke is
258
+ * the difference between a list that filters as you type and one that
259
+ * stutters. Only the query is folded per render, and there is one of those.
260
+ */
261
+ const haystacks = new Map<string, Map<string, string>>();
262
+ for (const item of items) {
263
+ const perField = new Map<string, string>();
264
+ for (const name of names) {
265
+ if (fields[name]?.kind !== "text") continue;
266
+ perField.set(name, fold(item.values[name] as string));
267
+ }
268
+ haystacks.set(item.key, perField);
269
+ }
270
+
271
+ const empty = (name: keyof F & string): FieldValue<F[typeof name]> =>
272
+ (fields[name]?.kind === "flag" ? false : "") as FieldValue<
273
+ F[typeof name]
274
+ >;
275
+
276
+ const blank = (): FilterState<F> =>
277
+ Object.fromEntries(
278
+ names.map((name) => [name, empty(name)])
279
+ ) as FilterState<F>;
280
+
281
+ let state = blank();
282
+ let matched: ReadonlySet<string> = new Set(items.map((item) => item.key));
283
+
284
+ /**
285
+ * Whether the URL is this instance's to write.
286
+ *
287
+ * Gates every write, for the reason `carousel` gates autoplay on the same
288
+ * flag: an instance that was built but never attached has not read the URL,
289
+ * so writing to it would replace state that nobody here has loaded.
290
+ */
291
+ let attached = false;
292
+
293
+ function recompute(): void {
294
+ // The query folded once per render rather than once per item. The other
295
+ // kinds compare values as they are, so there is nothing to prepare.
296
+ const queries = new Map<string, string>();
297
+ for (const name of names) {
298
+ if (fields[name]?.kind !== "text") continue;
299
+ queries.set(name, fold(state[name] as string));
300
+ }
301
+
302
+ const next = new Set<string>();
303
+ for (const item of items) {
304
+ let hit = true;
305
+ for (const name of names) {
306
+ const kind = fields[name]?.kind;
307
+ if (kind === "text") {
308
+ const query = queries.get(name) ?? "";
309
+ if (query === "") continue;
310
+ if (
311
+ !(haystacks.get(item.key)?.get(name) ?? "").includes(
312
+ query
313
+ )
314
+ ) {
315
+ hit = false;
316
+ break;
317
+ }
318
+ } else if (kind === "choice") {
319
+ const wanted = state[name] as string;
320
+ if (wanted === "") continue;
321
+ if (item.values[name] !== wanted) {
322
+ hit = false;
323
+ break;
324
+ }
325
+ } else {
326
+ // A flag off is not a filter: it keeps everything, rather
327
+ // than keeping the items that are *not* flagged. See `Field`.
328
+ if (state[name] !== true) continue;
329
+ if (item.values[name] !== true) {
330
+ hit = false;
331
+ break;
332
+ }
333
+ }
334
+ }
335
+ if (hit) next.add(item.key);
336
+ }
337
+ matched = next;
338
+ }
339
+
340
+ function readUrl(): void {
341
+ const search = new URLSearchParams(window.location.search);
342
+ const next: Record<string, string | boolean> = {};
343
+ for (const name of names) {
344
+ const field = fields[name];
345
+ if (field === undefined) continue;
346
+ if (field.param === undefined) {
347
+ // No parameter: the field is state-only, and a reload starts it
348
+ // empty rather than carrying a value the URL never held.
349
+ next[name] = empty(name);
350
+ continue;
351
+ }
352
+ const raw = search.get(field.param);
353
+ next[name] = field.kind === "flag" ? raw === FLAG_ON : (raw ?? "");
354
+ }
355
+ state = next as FilterState<F>;
356
+ }
357
+
358
+ function writeUrl(history: "push" | "replace"): void {
359
+ if (!attached) return;
360
+
361
+ // Built from the declaration rather than from the URL that is there, so
362
+ // a parameter this list owns and no longer needs is dropped instead of
363
+ // surviving because nobody thought to delete it. Fields are written in
364
+ // declaration order, so the same state always produces the same URL.
365
+ const search = new URLSearchParams();
366
+ for (const name of names) {
367
+ const field = fields[name];
368
+ if (field?.param === undefined) continue;
369
+ if (field.kind === "flag") {
370
+ if (state[name] === true) search.set(field.param, FLAG_ON);
371
+ continue;
372
+ }
373
+ const value = (state[name] as string).trim();
374
+ if (value !== "") search.set(field.param, value);
375
+ }
376
+
377
+ const query = search.toString();
378
+ // The bare path when nothing is set, rather than a trailing `?`: the two
379
+ // are the same page, and only one of them is worth sharing.
380
+ const url = query === "" ? window.location.pathname : `?${query}`;
381
+ if (history === "push") window.history.pushState({}, "", url);
382
+ else window.history.replaceState({}, "", url);
383
+ }
384
+
385
+ const announce = (): void => onChange({ state, matched });
386
+
387
+ recompute();
388
+
389
+ return {
390
+ get state() {
391
+ return state;
392
+ },
393
+ get matched() {
394
+ return matched;
395
+ },
396
+
397
+ set(field, value, setOptions) {
398
+ const name = field as keyof F & string;
399
+ // Nothing changed, so nothing is announced and nothing is pushed.
400
+ // Typing a character that does not alter a trimmed query, or
401
+ // re-selecting the tab that is already current, should not put a
402
+ // step in history for the back button to walk through.
403
+ if (state[name] === value) return;
404
+
405
+ state = { ...state, [name]: value };
406
+ recompute();
407
+ const fallback = fields[name]?.kind === "text" ? "replace" : "push";
408
+ writeUrl(setOptions?.history ?? fallback);
409
+ announce();
410
+ },
411
+
412
+ reset(field) {
413
+ if (field === undefined) {
414
+ state = blank();
415
+ } else {
416
+ const name = field as keyof F & string;
417
+ if (state[name] === empty(name)) return;
418
+ state = { ...state, [name]: empty(name) };
419
+ }
420
+ recompute();
421
+ writeUrl("push");
422
+ announce();
423
+ },
424
+
425
+ attach() {
426
+ const listeners = new AbortController();
427
+
428
+ // On `window`, and with the same signal as everything else, so a
429
+ // detach takes it with them. A `popstate` listener that outlives its
430
+ // page is the one leak here that has no visible symptom: it keeps
431
+ // rendering into markup that has been replaced.
432
+ window.addEventListener(
433
+ "popstate",
434
+ () => {
435
+ readUrl();
436
+ recompute();
437
+ announce();
438
+ },
439
+ { signal: listeners.signal }
440
+ );
441
+
442
+ attached = true;
443
+ // Read before the first announcement, not after: the URL may already
444
+ // carry a state — from a shared link, a reload, or a no-script form
445
+ // submission the server rendered — and announcing the empty one
446
+ // first would flash the unfiltered list over it.
447
+ readUrl();
448
+ recompute();
449
+ announce();
450
+
451
+ return () => {
452
+ listeners.abort();
453
+ attached = false;
454
+ };
455
+ },
456
+ };
457
+ }
@@ -10,6 +10,7 @@ import { getImage } from "astro:assets";
10
10
  import type { ImageAsset } from "../image.ts";
11
11
  import type { ThemeColor } from "../meta/index.ts";
12
12
  import type { Percentage } from "../types.ts";
13
+ import { isUrlPath, type UrlPath } from "../url.ts";
13
14
  import { warn } from "../warn.ts";
14
15
 
15
16
  /**
@@ -313,3 +314,75 @@ export async function photoSet(
313
314
  })
314
315
  );
315
316
  }
317
+
318
+ /**
319
+ * What `getImage` takes, less the source.
320
+ *
321
+ * A passthrough of Astro's own options, which is a deliberate departure from
322
+ * `ShareImageOptions` and `PhotoSetOptions` above. Those two are closed shapes
323
+ * because they are *builders*: each decides a box, and letting a caller reach
324
+ * past that decision would defeat the point of having made it. This one decides
325
+ * nothing — it exists to hand back a path instead of an object — so narrowing
326
+ * the options would only make it strictly less useful than the call it wraps.
327
+ */
328
+ export type ImagePathOptions = Omit<Parameters<typeof getImage>[0], "src">;
329
+
330
+ /**
331
+ * The built URL of an optimised image, for the places that take a path rather
332
+ * than an `<img>` — a JSON-LD node, a `<link>`, a manifest.
333
+ *
334
+ * ```ts
335
+ * import logo from "~/assets/logo.png";
336
+ * import { imagePath } from "@escape-game-over/atlas/astro/images";
337
+ *
338
+ * const company = organization({
339
+ * logo: await imagePath(logo, { width: 512, height: 512, format: "png" }),
340
+ * // …
341
+ * });
342
+ * ```
343
+ *
344
+ * The narrowing is the reason this is a function rather than three lines at
345
+ * each call site. `getImage().src` is typed `string`, every field it feeds here
346
+ * is typed `UrlPath`, and a cast at each of those would put the same unchecked
347
+ * assumption in as many places as there are consumers.
348
+ *
349
+ * **Not for entity data.** What comes back is `/_astro/logo.<hash>.png`, and the
350
+ * hash moves every time the source file is touched. That is right for a page
351
+ * resource, which is re-fetched from a document that was just re-fetched, and
352
+ * wrong for anything a search engine stores against an identity — an
353
+ * `Organization.logo` above all, which is held long after the build that
354
+ * produced it. Those want a stable path out of `public/`, written as `/logo.png`
355
+ * and joined to the site's own origin; both examples in this repository do that
356
+ * deliberately and say so at the call site.
357
+ *
358
+ * So the fit is: a `<link rel="preload">`, a manifest icon, an `<img>` built by
359
+ * hand — resources fetched *with* the page. Long-lived references belong in
360
+ * `public/`.
361
+ */
362
+ export async function imagePath(
363
+ src: ImageMetadata,
364
+ options: ImagePathOptions = {}
365
+ ): Promise<UrlPath> {
366
+ const image = await getImage({ ...options, src });
367
+
368
+ // Thrown rather than warned, and thrown here rather than widened away.
369
+ //
370
+ // `UrlPath` is root-relative by definition and the fields this feeds are
371
+ // typed that way on purpose — a `logo` on an `Organization` node is read by
372
+ // a crawler that already has the origin. What trips this is not a bad image
373
+ // but a different deployment: `build.assetsPrefix`, or an image service that
374
+ // returns absolute URLs. Both are a decision about where the whole site's
375
+ // assets live, and answering it by loosening one return type would leave
376
+ // every consumer of `UrlPath` accepting a value it cannot use.
377
+ //
378
+ // So this stops at the boundary and says which boundary it is. There is
379
+ // nothing useful to return: a caller that carried on would put a string into
380
+ // structured data that no crawler can resolve.
381
+ if (!isUrlPath(image.src)) {
382
+ throw new Error(
383
+ `imagePath produced "${image.src}", which is not root-relative. This build serves assets from somewhere else — build.assetsPrefix, or a remote image service — and the fields this feeds are typed UrlPath, which cannot express that. Serving assets from another origin is a site-wide decision and needs the absolute form carried end to end, not this one return type widened.`
384
+ );
385
+ }
386
+
387
+ return image.src;
388
+ }