@escape-game-over/atlas 0.1.2 → 0.1.4

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,505 @@
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: entries.map((entry) => ({
28
+ * key: entry.id,
29
+ * values: { q: `${entry.title} ${entry.summary}`, category: entry.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. "In stock", "Step-free access".
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 `malaga` is told there is no
202
+ * *Málaga*, 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 `?featured=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
+ // Split into tokens, all of which must appear — not one substring that
297
+ // must appear whole. A haystack is several things joined (a title, plus
298
+ // its category, plus its summary), and the order they were joined in is
299
+ // an accident of whoever wrote the template. Matching the query as one
300
+ // run made that accident load-bearing: against "Copper Kettle" in the
301
+ // Kitchen category, "kettle kitchen" found nothing while "kettle" alone
302
+ // worked, and the first is what someone narrowing a list types.
303
+ const queries = new Map<string, string[]>();
304
+ for (const name of names) {
305
+ if (fields[name]?.kind !== "text") continue;
306
+ const folded = fold(state[name] as string);
307
+ queries.set(name, folded === "" ? [] : folded.split(/\s+/));
308
+ }
309
+
310
+ const next = new Set<string>();
311
+ for (const item of items) {
312
+ let hit = true;
313
+ for (const name of names) {
314
+ const kind = fields[name]?.kind;
315
+ if (kind === "text") {
316
+ const tokens = queries.get(name) ?? [];
317
+ if (tokens.length === 0) continue;
318
+ const haystack = haystacks.get(item.key)?.get(name) ?? "";
319
+ if (!tokens.every((token) => haystack.includes(token))) {
320
+ hit = false;
321
+ break;
322
+ }
323
+ } else if (kind === "choice") {
324
+ const wanted = state[name] as string;
325
+ if (wanted === "") continue;
326
+ if (item.values[name] !== wanted) {
327
+ hit = false;
328
+ break;
329
+ }
330
+ } else {
331
+ // A flag off is not a filter: it keeps everything, rather
332
+ // than keeping the items that are *not* flagged. See `Field`.
333
+ if (state[name] !== true) continue;
334
+ if (item.values[name] !== true) {
335
+ hit = false;
336
+ break;
337
+ }
338
+ }
339
+ }
340
+ if (hit) next.add(item.key);
341
+ }
342
+ matched = next;
343
+ }
344
+
345
+ function readUrl(): void {
346
+ const search = new URLSearchParams(window.location.search);
347
+ const next: Record<string, string | boolean> = {};
348
+ for (const name of names) {
349
+ const field = fields[name];
350
+ if (field === undefined) continue;
351
+ if (field.param === undefined) {
352
+ // A state-only field is not in the URL, so the URL has nothing
353
+ // to say about it — and a back button is the URL speaking.
354
+ // Clearing it here read "absent from the URL" as "empty", which
355
+ // wiped a search the reader was in the middle of the moment any
356
+ // other parameter on the page moved. Left as it is instead: this
357
+ // function applies the URL, and this field is not in it.
358
+ next[name] = state[name];
359
+ continue;
360
+ }
361
+ const raw = search.get(field.param);
362
+ next[name] = field.kind === "flag" ? raw === FLAG_ON : (raw ?? "");
363
+ }
364
+ state = next as FilterState<F>;
365
+ }
366
+
367
+ function writeUrl(history: "push" | "replace"): void {
368
+ if (!attached) return;
369
+
370
+ // Started from the URL that is there, not from an empty set. A list owns
371
+ // the parameters it declared and nothing else, and the page around it is
372
+ // full of parameters that are not its business — `utm_*` on a link
373
+ // someone shared, a page number, another widget's state. Building from
374
+ // scratch published a URL with all of them gone, and the loss only
375
+ // showed up somewhere else: a campaign that stopped being attributed the
376
+ // first time a visitor typed in the search box.
377
+ //
378
+ // Its own are deleted first, then rewritten, so a parameter this list
379
+ // owns and no longer needs is dropped rather than surviving because
380
+ // nobody thought to remove it. Deletion and writing both run in
381
+ // declaration order, so one state still produces one URL.
382
+ const search = new URLSearchParams(window.location.search);
383
+ for (const name of names) {
384
+ const param = fields[name]?.param;
385
+ if (param !== undefined) search.delete(param);
386
+ }
387
+ for (const name of names) {
388
+ const field = fields[name];
389
+ if (field?.param === undefined) continue;
390
+ if (field.kind === "flag") {
391
+ if (state[name] === true) search.set(field.param, FLAG_ON);
392
+ continue;
393
+ }
394
+ const value = (state[name] as string).trim();
395
+ if (value !== "") search.set(field.param, value);
396
+ }
397
+
398
+ const query = search.toString();
399
+ // The bare path when nothing is left, rather than a trailing `?`: the
400
+ // two are the same page, and only one of them is worth sharing. Read
401
+ // from the merged set, so a page carrying somebody else's parameter
402
+ // keeps it instead of being reduced to its path.
403
+ const url = query === "" ? window.location.pathname : `?${query}`;
404
+ if (history === "push") window.history.pushState({}, "", url);
405
+ else window.history.replaceState({}, "", url);
406
+ }
407
+
408
+ /**
409
+ * One field's value as the matcher and the URL will read it.
410
+ *
411
+ * Only `text` has a form that differs from what was handed over — the other
412
+ * kinds compare and publish exactly what they hold.
413
+ */
414
+ const normalized = (name: keyof F & string, value: unknown): unknown =>
415
+ fields[name]?.kind === "text" ? (value as string).trim() : value;
416
+
417
+ const announce = (): void => onChange({ state, matched });
418
+
419
+ recompute();
420
+
421
+ return {
422
+ get state() {
423
+ return state;
424
+ },
425
+ get matched() {
426
+ return matched;
427
+ },
428
+
429
+ set(field, value, setOptions) {
430
+ const name = field as keyof F & string;
431
+ // Compared as everything downstream will read it, not as it arrived.
432
+ // Both the matcher and the URL trim a query, so "sol" and "sol "
433
+ // filter the same list and publish the same address — but comparing
434
+ // them raw called that a change, and a trailing space cost a
435
+ // recompute, a `replaceState` to a URL identical to the current one,
436
+ // and a full re-render of every item.
437
+ //
438
+ // The *raw* value is what gets stored, though, and that asymmetry is
439
+ // deliberate. A search box mirrors the state back into the input
440
+ // (see `filters-view`), so normalising here would delete the space a
441
+ // reader had just typed, from under the caret, every time they
442
+ // reached for the second word.
443
+ if (normalized(name, state[name]) === normalized(name, value)) {
444
+ return;
445
+ }
446
+
447
+ state = { ...state, [name]: value };
448
+ recompute();
449
+ const fallback = fields[name]?.kind === "text" ? "replace" : "push";
450
+ writeUrl(setOptions?.history ?? fallback);
451
+ announce();
452
+ },
453
+
454
+ reset(field) {
455
+ if (field === undefined) {
456
+ // Short-circuited like the single-field arm below, which it was
457
+ // not. A "clear all" on a list nobody has filtered yet is a
458
+ // no-op, and pushing for it puts a step in history that goes
459
+ // back to the state it is already in — so the back button looks
460
+ // broken to the one reader who pressed clear twice.
461
+ if (names.every((name) => state[name] === empty(name))) return;
462
+ state = blank();
463
+ } else {
464
+ const name = field as keyof F & string;
465
+ if (state[name] === empty(name)) return;
466
+ state = { ...state, [name]: empty(name) };
467
+ }
468
+ recompute();
469
+ writeUrl("push");
470
+ announce();
471
+ },
472
+
473
+ attach() {
474
+ const listeners = new AbortController();
475
+
476
+ // On `window`, and with the same signal as everything else, so a
477
+ // detach takes it with them. A `popstate` listener that outlives its
478
+ // page is the one leak here that has no visible symptom: it keeps
479
+ // rendering into markup that has been replaced.
480
+ window.addEventListener(
481
+ "popstate",
482
+ () => {
483
+ readUrl();
484
+ recompute();
485
+ announce();
486
+ },
487
+ { signal: listeners.signal }
488
+ );
489
+
490
+ attached = true;
491
+ // Read before the first announcement, not after: the URL may already
492
+ // carry a state — from a shared link, a reload, or a no-script form
493
+ // submission the server rendered — and announcing the empty one
494
+ // first would flash the unfiltered list over it.
495
+ readUrl();
496
+ recompute();
497
+ announce();
498
+
499
+ return () => {
500
+ listeners.abort();
501
+ attached = false;
502
+ };
503
+ },
504
+ };
505
+ }
@@ -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
+ }
@@ -10,6 +10,7 @@ import {
10
10
  import type { Sitemap } from "../sitemap.ts";
11
11
  import type { HttpsUrl } from "../url.ts";
12
12
  import { warn } from "../warn.ts";
13
+ import { buildCacheDir } from "./build-cache.ts";
13
14
 
14
15
  /**
15
16
  * What this integration needs of a site, and no more.
@@ -187,6 +188,19 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
187
188
  * images. A default worth overriding per site, not a rule —
188
189
  * though not into `prerender`; see the refusal below.
189
190
  *
191
+ * A fifth is set only when the environment asks for it:
192
+ *
193
+ * - `cacheDir`, moved onto whatever cache volume CI mounted. Not a
194
+ * preference — Astro's default sits inside `node_modules`, which
195
+ * a CI install deletes before every run, so a build there
196
+ * re-optimizes every image and re-downloads every font each time,
197
+ * however little changed. Set here rather than per repo because
198
+ * Astro exposes no environment variable for it, and `updateConfig`
199
+ * is already how this integration says what a site's build looks
200
+ * like. Off CI the value is `undefined`, the merge skips the key,
201
+ * and the default stays exactly where it was. See
202
+ * `build-cache.ts` for what shares the volume and what would not.
203
+ *
190
204
  * Two are refused rather than set, both because Astro resolves them
191
205
  * to a default a deliberate value is distinguishable from — so the
192
206
  * check fires at whoever asked for it rather than at everyone:
@@ -273,6 +287,7 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
273
287
  prefetchAll: true,
274
288
  defaultStrategy: "viewport",
275
289
  },
290
+ cacheDir: buildCacheDir(),
276
291
  };
277
292
  updateConfig(config);
278
293
  // Said out loud: a setting changed from under you is worth a
@@ -41,7 +41,7 @@ export interface MailEndpoint {
41
41
  */
42
42
  readonly url: HttpsUrl;
43
43
  /**
44
- * The account the message is sent on behalf of: `"b2b_cube"`.
44
+ * The account the message is sent on behalf of, e.g. `"acme_b2b"`.
45
45
  *
46
46
  * What the API looks the mailbox and the allowed domains up by. A domain
47
47
  * that is not registered against it is refused, and nothing configured here
package/src/index.ts CHANGED
@@ -101,6 +101,8 @@ export {
101
101
  breadcrumbList,
102
102
  businessId,
103
103
  type CatalogEntry,
104
+ type FaqEntry,
105
+ faqPage,
104
106
  geoCoordinates,
105
107
  type JsonLdNode,
106
108
  type LocalBusinessInput,