@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.
- package/README.md +17 -1
- package/docs/NOT-BUILT.md +33 -18
- package/docs/checks.md +20 -5
- package/docs/client-scripts.md +307 -0
- package/package.json +11 -4
- package/src/astro/build-cache.ts +80 -0
- package/src/astro/filters-view.ts +253 -0
- package/src/astro/filters.ts +505 -0
- package/src/astro/images.ts +73 -0
- package/src/astro/site-routes.ts +15 -0
- package/src/contact-form.ts +1 -1
- package/src/index.ts +2 -0
- package/src/jsonld/faq.ts +105 -0
- package/src/jsonld/index.ts +1 -0
- package/src/jsonld/video.ts +2 -2
- package/src/meta/index.ts +79 -32
- package/src/site/create.ts +12 -1
|
@@ -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
|
+
}
|
package/src/astro/images.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/astro/site-routes.ts
CHANGED
|
@@ -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/contact-form.ts
CHANGED
|
@@ -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
|
|
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
|