@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 +2 -1
- package/src/astro/build-cache.ts +80 -0
- package/src/astro/filters.ts +457 -0
- package/src/astro/images.ts +73 -0
- package/src/astro/site-routes.ts +15 -0
- package/src/meta/index.ts +79 -32
- package/src/site/create.ts +12 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@escape-game-over/atlas",
|
|
3
|
-
"version": "0.1.
|
|
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
|
+
}
|
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/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
|
|
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
|
}
|
package/src/site/create.ts
CHANGED
|
@@ -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
|
-
|
|
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: [],
|