@escape-game-over/atlas 0.1.2 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -20,6 +20,7 @@
20
20
  "./astro/dev-log": "./src/astro/dev-log.ts",
21
21
  "./astro/dom": "./src/astro/dom.ts",
22
22
  "./astro/element": "./src/astro/element.ts",
23
+ "./astro/filters": "./src/astro/filters.ts",
23
24
  "./astro/meta-tags": "./src/astro/MetaTags.astro"
24
25
  },
25
26
  "bin": {
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Astro's build cache, pointed at a volume that outlives a CI run.
3
+ *
4
+ * The default — `node_modules/.astro` — sits inside the one directory a CI
5
+ * install deletes before it starts, so a build there re-optimizes every image
6
+ * and re-downloads every font on every run, however little changed. A site with
7
+ * a few hundred images spends minutes of every pipeline regenerating bytes it
8
+ * already has. Moving `cacheDir` onto a mounted volume is the whole fix, and
9
+ * Astro offers no environment variable of its own to say it — which is why this
10
+ * is read here and set through `updateConfig` rather than configured per repo.
11
+ *
12
+ * One volume is shared by every site that mounts it, rather than a directory
13
+ * each. Astro keeps four things under `cacheDir` and they do not want the same
14
+ * treatment:
15
+ *
16
+ * - `assets/` — optimized images, named after a hash of the source bytes and
17
+ * the transform. Two sites rendering the same asset at the same size produce
18
+ * the same filename holding the same bytes, so a hit one of them takes is a
19
+ * hit the next does not pay for.
20
+ * - `fonts/` — provider downloads, keyed the same way on family, weight and
21
+ * subset. Sibling sites share a typeface far more often than not.
22
+ * - `incremental-build.json` and `dist/` — a per-project route manifest, read
23
+ * only when `experimental.incrementalBuild` is on.
24
+ * - `data-store.json` — the content layer's store, written only by a project
25
+ * that has content collections.
26
+ *
27
+ * The first two are content-addressed and want to be shared. The last two are
28
+ * per-project state, and two projects in one directory each overwrite the
29
+ * other's copy. Both guard themselves — the manifest against a config and
30
+ * lockfile hash, the store against a digest of the resolved Astro config — so a
31
+ * collision costs a rebuild rather than serving one project's content to
32
+ * another. It is still a cache that never hits, and the day a site turns either
33
+ * one on is the day this wants a per-repository sub-directory instead.
34
+ *
35
+ * A caveat that belongs to sharing rather than to this file: Astro writes a
36
+ * cache entry with a plain write and reads it with a copy, neither atomic, so
37
+ * two builds first generating the same transform in the same instant can copy a
38
+ * half-written file. The window is a few milliseconds per file and only opens
39
+ * on a miss both take together; the cost is one bad image in an output that a
40
+ * rebuild fixes.
41
+ */
42
+
43
+ import { posix } from "node:path";
44
+ import { pathToFileURL } from "node:url";
45
+
46
+ /**
47
+ * The variable CI sets to a cache volume that outlives a single build.
48
+ *
49
+ * Named for the environment and not for Astro because that is what it
50
+ * describes: nothing outside CI sets it, and on a machine that does not, the
51
+ * default cache is already the right answer.
52
+ */
53
+ const CACHE_ROOT = "CI_ASTRO_CACHE";
54
+
55
+ /**
56
+ * The volume to build in, or `undefined` to leave `cacheDir` alone.
57
+ *
58
+ * `undefined` rather than a fallback path is what makes this safe to call
59
+ * unconditionally: `updateConfig` skips a key whose value is nullish, so an
60
+ * unset variable leaves the config exactly as it would be if nothing here ran.
61
+ *
62
+ * A `URL` and not a string, because this arrives after Astro resolved its own
63
+ * config: `cacheDir` is a `file:` URL by the time an integration can see it,
64
+ * and the merge behind `updateConfig` replaces a URL only with another URL. The
65
+ * trailing separator is load-bearing for the same reason — every reader
66
+ * composes against it with `new URL("assets/", cacheDir)`, which without one
67
+ * would resolve into the parent directory instead.
68
+ */
69
+ export function buildCacheDir(): URL | undefined {
70
+ const root = process.env[CACHE_ROOT]?.trim();
71
+ if (!root) {
72
+ return undefined;
73
+ }
74
+
75
+ // `posix` rather than the platform's own: the value names a path inside the
76
+ // CI container, and a checkout on Windows reading it would still be
77
+ // describing that container. Joining the separator is what normalizes a
78
+ // root written with one, without one, or with several.
79
+ return pathToFileURL(posix.join(root, posix.sep));
80
+ }
@@ -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
+ }
@@ -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
package/src/meta/index.ts CHANGED
@@ -78,7 +78,7 @@ export type MetaInput<L extends string> = Omit<MetaContentBase, "image"> &
78
78
  PageKind &
79
79
  MetaInputDerived<L>;
80
80
 
81
- interface MetaInputDerived<L extends string> {
81
+ interface MetaInputDerived<L extends string> extends ChromeInput {
82
82
  /** The locale of *this* page, not the site default. */
83
83
  readonly locale: L;
84
84
  readonly canonical: HttpsUrl;
@@ -119,8 +119,6 @@ interface MetaInputDerived<L extends string> {
119
119
  * Graph equivalent to fall back to. Site-level, like `siteName`.
120
120
  */
121
121
  readonly twitterSite?: string;
122
- /** The site's square icon, used for every icon link. */
123
- readonly icon: SiteIcon;
124
122
  /**
125
123
  * Webmaster-tool ownership tokens. Site-level, like `siteName`.
126
124
  *
@@ -144,6 +142,20 @@ interface MetaInputDerived<L extends string> {
144
142
  * generated — a link to a missing one is worse than no link.
145
143
  */
146
144
  readonly llmsUrl?: HttpsUrl;
145
+ }
146
+
147
+ /**
148
+ * The site's own furniture, as opposed to anything about a page.
149
+ *
150
+ * Its own interface because it is what the 404 shares with every real page and
151
+ * nearly all it shares: that document has no canonical, no description, no
152
+ * alternates and no share image, but it is still served under the site's name
153
+ * in the site's tab, and a reader who lands on it should not be able to tell
154
+ * from the chrome that they left.
155
+ */
156
+ export interface ChromeInput {
157
+ /** The site's square icon, used for every icon link. */
158
+ readonly icon: SiteIcon;
147
159
  /**
148
160
  * Tints the browser UI — Android Chrome's bar, iOS Safari, an installed PWA.
149
161
  * Two values emit one tag per `prefers-color-scheme`.
@@ -172,6 +184,45 @@ export interface DocumentTags {
172
184
  readonly body: MetaTag[];
173
185
  }
174
186
 
187
+ /**
188
+ * What the browser dresses its own furniture with: the tab's icon, the tint of
189
+ * the address bar, and which schemes the page renders form controls for.
190
+ *
191
+ * Pulled out of `buildMeta` because the 404 needs exactly this and nothing else
192
+ * around it. Sharing the code is the point rather than a convenience: these are
193
+ * the tags a reader sees *as* the site — a 404 with a different tab icon looks
194
+ * like it came from somewhere else, which is the opposite of what a 404 is for —
195
+ * and two copies of a four-branch block is how one of them quietly stops
196
+ * matching the other.
197
+ */
198
+ function chromeTags(input: ChromeInput): MetaTag[] {
199
+ const tags: MetaTag[] = [...iconLinks(input.icon)];
200
+
201
+ if (input.colorScheme !== undefined) {
202
+ tags.push(meta({ name: "color-scheme", content: input.colorScheme }));
203
+ }
204
+ if (typeof input.themeColor === "string") {
205
+ tags.push(meta({ name: "theme-color", content: input.themeColor }));
206
+ } else if (input.themeColor !== undefined) {
207
+ tags.push(
208
+ meta({
209
+ name: "theme-color",
210
+ media: "(prefers-color-scheme: light)",
211
+ content: input.themeColor.light,
212
+ })
213
+ );
214
+ tags.push(
215
+ meta({
216
+ name: "theme-color",
217
+ media: "(prefers-color-scheme: dark)",
218
+ content: input.themeColor.dark,
219
+ })
220
+ );
221
+ }
222
+
223
+ return tags;
224
+ }
225
+
175
226
  /** Builds the head tags every page needs. */
176
227
  export function buildMeta<L extends string>(input: MetaInput<L>): DocumentTags {
177
228
  const tags: MetaTag[] = [
@@ -209,29 +260,7 @@ export function buildMeta<L extends string>(input: MetaInput<L>): DocumentTags {
209
260
  tags.push(link({ rel: "describedby", href: input.llmsUrl }));
210
261
  }
211
262
 
212
- for (const iconLink of iconLinks(input.icon)) tags.push(iconLink);
213
-
214
- if (input.colorScheme !== undefined) {
215
- tags.push(meta({ name: "color-scheme", content: input.colorScheme }));
216
- }
217
- if (typeof input.themeColor === "string") {
218
- tags.push(meta({ name: "theme-color", content: input.themeColor }));
219
- } else if (input.themeColor !== undefined) {
220
- tags.push(
221
- meta({
222
- name: "theme-color",
223
- media: "(prefers-color-scheme: light)",
224
- content: input.themeColor.light,
225
- })
226
- );
227
- tags.push(
228
- meta({
229
- name: "theme-color",
230
- media: "(prefers-color-scheme: dark)",
231
- content: input.themeColor.dark,
232
- })
233
- );
234
- }
263
+ for (const tag of chromeTags(input)) tags.push(tag);
235
264
 
236
265
  for (const alternate of input.alternates) {
237
266
  tags.push(
@@ -400,6 +429,20 @@ export function buildMeta<L extends string>(input: MetaInput<L>): DocumentTags {
400
429
  return { head: tags, body: bodyTags };
401
430
  }
402
431
 
432
+ /**
433
+ * What a 404 needs, which is the site's furniture and a title and nothing else.
434
+ *
435
+ * An object rather than the positional arguments this took before: the two it
436
+ * gained are both site-level and both optional-looking at a call site, and
437
+ * `buildNotFoundMeta(title, icon, themeColor, analytics)` is four positions
438
+ * where three of them are interchangeable to a reader.
439
+ */
440
+ export interface NotFoundInput extends ChromeInput {
441
+ readonly title: string;
442
+ /** Site-level, and only Umami reaches this page — see `notFoundAnalytics`. */
443
+ readonly analytics?: AnalyticsSettings;
444
+ }
445
+
403
446
  /**
404
447
  * Builds the head of a 404 page.
405
448
  *
@@ -412,13 +455,16 @@ export function buildMeta<L extends string>(input: MetaInput<L>): DocumentTags {
412
455
  * host serves this file at the address that was requested, so what gets
413
456
  * recorded is the missing path itself, which is the difference between knowing
414
457
  * there are 404s and knowing which redirect to write.
458
+ *
459
+ * It also carries the site's chrome, through the same `chromeTags` every real
460
+ * page goes through. A 404 is the one page a visitor reaches by accident, and
461
+ * the tab it opens in is how they judge whether they are still on the site they
462
+ * meant to be on — an unstyled tab showing the browser's default globe reads as
463
+ * a different origin, or as nothing at all.
415
464
  */
416
- export function buildNotFoundMeta(
417
- title: string,
418
- analytics?: AnalyticsSettings
419
- ): MetaTag[] {
465
+ export function buildNotFoundMeta(input: NotFoundInput): MetaTag[] {
420
466
  return [
421
- ...preamble({ title }),
467
+ ...preamble({ title: input.title }),
422
468
  // Through the same builder as every other page, so the one document lib
423
469
  // writes for itself cannot spell the tag differently from the ones it
424
470
  // writes for a project. Links are still followed: a 404 often carries
@@ -427,6 +473,7 @@ export function buildNotFoundMeta(
427
473
  name: "robots",
428
474
  content: robotsContent({ index: false, follow: true }),
429
475
  }),
430
- ...notFoundAnalytics(analytics).map(asMetaTag),
476
+ ...notFoundAnalytics(input.analytics).map(asMetaTag),
477
+ ...chromeTags(input),
431
478
  ];
432
479
  }
@@ -322,7 +322,18 @@ export function createSite<
322
322
  // Umami only, tagged so the misses read on their own — the point
323
323
  // of measuring this page is finding a redirect somebody forgot,
324
324
  // not counting the people who mistype. See `notFoundAnalytics`.
325
- tags: buildNotFoundMeta(title, project.analytics),
325
+ //
326
+ // `checkedIcon()` rather than `project.icon`: the 404 is held to the
327
+ // same square-and-PNG rules as every other page. It is also the one
328
+ // page that could plausibly render before any other, so letting it
329
+ // read the icon unchecked would move where a bad one is caught.
330
+ tags: buildNotFoundMeta({
331
+ title,
332
+ analytics: project.analytics,
333
+ icon: checkedIcon(),
334
+ themeColor: project.themeColor,
335
+ colorScheme: project.colorScheme,
336
+ }),
326
337
  // Nothing. The body channel exists for Tag Manager's `<noscript>`,
327
338
  // and no Google tag reaches this page.
328
339
  bodyTags: [],