@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 CHANGED
@@ -140,6 +140,7 @@ explicitly.
140
140
  | `Product` JSON-LD | `product()` | name, price table |
141
141
  | `Article` JSON-LD | `article()` | headline, publication date |
142
142
  | `VideoObject` JSON-LD | `videoObject()` | a video, its stills and date |
143
+ | `FAQPage` JSON-LD | `faqPage()` | the questions the page itself shows |
143
144
  | Analytics tags | `metaFor()`, from the project | ids, via `analytics` in `defineProject` |
144
145
  | `PublicFile` union | `publicFiles()`, from `public/` | nothing |
145
146
 
@@ -251,8 +252,14 @@ const careers: WhenEnabled<typeof site, "careers", Careers> = { ats: "…" }; /
251
252
  ".": "./src/index.ts",
252
253
  "./astro": "./src/astro/index.ts",
253
254
  "./astro/images": "./src/astro/images.ts",
255
+ "./astro/carousel": "./src/astro/carousel.ts",
254
256
  "./astro/consent": "./src/astro/consent.ts",
255
- "./astro/meta-tags": "./src/astro/MetaTags.astro"
257
+ "./astro/dev-log": "./src/astro/dev-log.ts",
258
+ "./astro/dom": "./src/astro/dom.ts",
259
+ "./astro/element": "./src/astro/element.ts",
260
+ "./astro/filters": "./src/astro/filters.ts",
261
+ "./astro/meta-tags": "./src/astro/MetaTags.astro",
262
+ "./astro/filters-view": "./src/astro/filters-view.ts"
256
263
  }
257
264
  ```
258
265
 
@@ -268,6 +275,12 @@ may use `node:` built-ins but not `astro:assets`. `./astro/images` is the
268
275
  opposite: it runs inside the build, from a page, and is unusable from a config.
269
276
  Merging them breaks whichever caller loads first.
270
277
 
278
+ The rest of `astro/` is the browser half — `carousel`, `filters`, `filters-view`,
279
+ `consent`, `element`, `dom`, `dev-log` — which runs in a reader's browser rather
280
+ than in the build, and is separate again for the same reason: none of it can be
281
+ reached from a config, and none of it draws. See
282
+ [`docs/client-scripts.md`](docs/client-scripts.md).
283
+
271
284
  **The package ships TypeScript source, and there is no build step.**
272
285
  `MetaTags.astro` could not go through `tsc` anyway, and Astro's own
273
286
  `tsconfigs/base.json` already sets `allowImportingTsExtensions`, so every
@@ -390,6 +403,9 @@ upstream, and each explains a rule that would otherwise read as an oversight.
390
403
  how the type tests and runtime tests divide the work.
391
404
  - [`docs/share-images.md`](docs/share-images.md) — what each social surface
392
405
  crops, and why one 1200×630 image serves all of them.
406
+ - [`docs/client-scripts.md`](docs/client-scripts.md) — the browser half: what
407
+ each client module owns, why none of them draws anything, the lifetime bug
408
+ view transitions cause, and how `filters` declares its fields and its URL.
393
409
  - [`docs/toolchain.md`](docs/toolchain.md) — the pinned TypeScript, the two
394
410
  switches `.astro` support needs from Biome, and the traps.
395
411
  - [`docs/NOT-BUILT.md`](docs/NOT-BUILT.md) — what we deliberately do not build,
package/docs/NOT-BUILT.md CHANGED
@@ -5,8 +5,8 @@ of these looks like an obvious addition until you check — so the check is
5
5
  written down once, rather than redone by whoever thinks of it next.
6
6
 
7
7
  Each answer says what is *not* built and, where it matters, what is still fine
8
- to build. The two are easy to run together: "no FAQ structured data" is not "no
9
- FAQ page", and reading it as the second would lose something worth having.
8
+ to build. The two are easy to run together: "no star-rating markup" is not "no
9
+ reviews", and reading it as the second would lose something worth having.
10
10
 
11
11
  Kept beside the code rather than in the backlog, because a backlog is a list of
12
12
  things still to do and every one of these is a thing not to do.
@@ -21,12 +21,25 @@ sites are shaped, and will not change on its own.
21
21
 
22
22
  ### Should we mark up our FAQ with `FAQPage`?
23
23
 
24
- **Not now and this one genuinely depends. The page is a fine idea either way.**
24
+ **Built, as of August 2026 `faqPage()`, opt-in, and nothing emits it for you.
25
+ The reasoning that kept it out for so long is now in `src/jsonld/faq.ts`, and it
26
+ is still the reasoning: read it before adding the node to a page.**
25
27
 
26
- The most conditional answer in this file, so it is worth reading before
27
- assuming. Three things hold it shut today and the first two could move. Two
28
- further arguments that *sound* decisive are wrong, and are recorded below so
29
- they stop being made.
28
+ This was the most conditional answer in the file, and it moved because it was
29
+ asked for rather than because Google moved if anything Google moved the other
30
+ way, removing FAQ rich results from Search entirely on 7 May 2026. So what stood
31
+ below still stands: the node renders nowhere, a model does not read it out of a
32
+ `<script>` block, and it duplicates the page's own text at several KB per load.
33
+ What is left in its favour is Bing, which has said its LLMs read schema.
34
+
35
+ Which makes this a call a *site* makes per page, not one lib makes for every
36
+ repo it syncs to — hence a builder that has to be called and passed into a
37
+ graph, rather than anything wired into `metaFor()`. A project that has not
38
+ weighed the payload against a Copilot payoff it can actually measure should keep
39
+ not calling it, which is the same answer this entry always gave.
40
+
41
+ The two arguments that *sound* decisive and are wrong are recorded below, since
42
+ they will be made again either way.
30
43
 
31
44
  Google restricted FAQ rich results to *"well-known, authoritative government and
32
45
  health websites"* in 2023, then [removed them from Search entirely](https://developers.google.com/search/docs/appearance/structured-data/faqpage)
@@ -45,8 +58,8 @@ content in visible headings and paragraphs is read reliably.
45
58
  That is not an argument against the nodes lib does build. Schema is parsed at
46
59
  *indexing* time, so it reaches the AI surfaces built on top of a search index —
47
60
  AI Overviews, Copilot — even though a chatbot fetching the page directly sees
48
- only the visible text. It is an argument against adding one that no longer
49
- renders anywhere and is not read directly either.
61
+ only the visible text. It is an argument against reaching for this one expecting
62
+ a model to pick the answers up, which is the reason it is given every time.
50
63
 
51
64
  The one cost that counts is the payload. Every other node lib builds is metadata
52
65
  *about* the page — an address, a price, a date. This one is a verbatim *copy of*
@@ -64,17 +77,18 @@ against lib itself, and applied evenly it forbids everything already in here.
64
77
  Neither survives — which is why what stands above is about what consumes the
65
78
  markup and what it costs to send, and not about how much work it is to write.
66
79
 
67
- **What would flip it.** Google reinstating FAQ rich results is the obvious one.
68
- The likelier one is Bing: it has said outright that its LLMs read schema, which
69
- is the single live third-party payoff here, so if Copilot visibility ever
70
- becomes something these venues actually measure rather than speculate about,
71
- build it. The data is already in config, so it is a small build on the day the
72
- trigger fires — which is the reason to leave it until then rather than guess now.
80
+ **What would make calling it worthwhile.** Google reinstating FAQ rich results
81
+ is the obvious one, and is not in prospect. The likelier one is Bing: it has
82
+ said outright that its LLMs read schema, which is the single live third-party
83
+ payoff here, so a site measuring Copilot visibility rather than speculating
84
+ about it has a reason. A site that is not measuring it is paying the payload for
85
+ nothing, which is the default answer and always was.
73
86
 
74
87
  Writing the page is a separate question and the answer there is yes: readers
75
88
  want one, and `llms.txt` carries question-and-answer copy unusually well, since
76
- that is close to the form a model is asked things in. It is content, needs
77
- nothing from lib, and today no structured data.
89
+ that is close to the form a model is asked things in. It is content, and it
90
+ needs nothing from lib — the node is optional on top and does not make the page
91
+ any more findable to the readers who actually want it.
78
92
 
79
93
  ### The other node types the sibling projects emit — which of them do we want?
80
94
 
@@ -114,7 +128,8 @@ answer for each is a fact about Google rather than a preference.
114
128
  rather than the sizes. Checked August 2026.
115
129
 
116
130
  `FAQPage` is above, and has its own entry because it is the one that keeps
117
- getting asked.
131
+ getting asked — it is now built, opt-in, and the entry says what calling it
132
+ does and does not buy.
118
133
 
119
134
  ### Should a post say which `articleSection` it is in?
120
135
 
package/docs/checks.md CHANGED
@@ -108,6 +108,7 @@ dependencies at all. Vitest type-checks them itself when it runs them.
108
108
  | File | What it pins |
109
109
  | -------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
110
110
  | `meta.test.ts` | the `robots` tag a policy builds, the pinned `twitter:card`, share-image warnings, length warnings, `fileUrl` |
111
+ | `not-found.test.ts` | that a 404 carries the site's icon and theme colour, still names no canonical, and is held to the same icon rules |
111
112
  | `redirects.test.ts` | rule resolution for all three target kinds, the five rejections, the Cloudflare 2000-rule cap |
112
113
  | `sitemap.test.ts` | splitting into an index plus numbered parts, and that the entry keeps its name either way |
113
114
  | `merge.test.ts` | catalog and route overlay precedence — per locale, never per key |
@@ -122,11 +123,25 @@ dependencies at all. Vitest type-checks them itself when it runs them.
122
123
  | `money.test.ts` | a bare count widened to a band, the span of a table, and the gaps and overlaps that throw |
123
124
  | `url.test.ts` | joining an origin to a path exactly once, and normalising the origin an `@id` is built from |
124
125
  | `warn.test.ts` | the shared prefix, and reducing a URL to its path |
125
-
126
- The fixture carries two nested families for that last one, differing in the only
127
- way that changes behaviour: `guides` has a route owning its bare slug and `rooms`
128
- does not, so both the linked and the unlinked section are reachable from one
129
- site.
126
+ | `filters.test.ts` | folding both sides of a query, how each kind narrows, the four URL decisions, and attach/detach |
127
+ | `filters-view.test.ts` | the write-back into the input and that it is skipped when unchanged, clear taking focus, and the nesting order |
128
+ | `build-cache.test.ts` | that an unset `CI_ASTRO_CACHE` yields nothing, so Astro's own `cacheDir` default survives |
129
+
130
+ The fixture carries two nested families for `breadcrumbs.test.ts`, differing in
131
+ the only way that changes behaviour: `guides` has a route owning its bare slug
132
+ and `rooms` does not, so both the linked and the unlinked section are reachable
133
+ from one site.
134
+
135
+ `filters.test.ts` and `filters-view.test.ts` are the two that reach the browser
136
+ half, and both can for the same reason: neither module finds an element.
137
+ `filters` takes item text as data and keys as strings, so it runs against a stub
138
+ of `location`, `history` and one `popstate` listener; `filters-view` is handed
139
+ the elements it writes to, so plain objects with a `hidden` property stand in for
140
+ them. Neither needs a document.
141
+
142
+ The rest of `src/astro/` — `carousel`, `consent`, `element`, `dom` — has no
143
+ runtime tests at all. Covering them needs a real environment, which this package
144
+ does not carry; see [`client-scripts.md`](client-scripts.md).
130
145
 
131
146
  ## What a consumer checks
132
147
 
@@ -0,0 +1,307 @@
1
+ # Client scripts: what Atlas owns in the browser, and what it refuses to
2
+
3
+ Everything else in this package runs at build time and emits text. This is the
4
+ other half: seven modules under `src/astro/` that run in a reader's browser, and
5
+ one rule they all keep.
6
+
7
+ **They draw nothing.**
8
+
9
+ No classes, no `aria`, no transforms, no markup. Each owns the part of a
10
+ behaviour that is identical in every implementation and quietly wrong in most,
11
+ calls back, and stops. The project writes every line that touches a pixel.
12
+
13
+ That is not tidiness. A carousel's arithmetic is the same on a full-bleed hero
14
+ and a three-up logo strip, and the design is not — so the moment a library owns
15
+ the transform, it has to be told about the design, and the next carousel needs an
16
+ option. Owning the half that does not vary is what lets one function serve both
17
+ without growing a configuration surface.
18
+
19
+ `filters-view` is the one that writes to elements, and the exception is narrow
20
+ enough to state exactly: an input's `value` is the control's own state rather
21
+ than a view of something else, and a `hidden` on a clear button, a no-results
22
+ message or a section with nothing left in it is binary and derived — there is no
23
+ version of "the clear button while the field is empty" that is a matter of taste.
24
+ It sets no class, no style and no `aria`, which is where the choices live. It
25
+ also never *finds* an element: it is handed the ones a project looked up, so no
26
+ attribute name in this package has to be matched by any consumer.
27
+
28
+ | Module | Owns | Leaves to the project |
29
+ | ---------------------- | -------------------------------------------------------------- | ------------------------------------------------- |
30
+ | `./astro/carousel` | which slide is current: modulo, swipe, autoplay, the move lock | the transform, dots, arrows, `aria` |
31
+ | `./astro/filters` | which items match, and what the address bar says | every DOM read and write, and the markup contract |
32
+ | `./astro/filters-view` | the two DOM writes every filtered list turned out to share | finding the elements, and everything else drawn |
33
+ | `./astro/consent` | remembering an answer, expiring it, handing it to Google | the banner — its wording, its buttons, its law |
34
+ | `./astro/element` | the two lifetimes a custom element has, and one abort signal | what the element is and does |
35
+ | `./astro/dom` | scoped `one`/`all` lookups, typed | the selectors |
36
+ | `./astro/dev-log` | a panel a failed wiring can announce itself in, in dev only | calling it behind `import.meta.env.DEV` |
37
+
38
+ ## Lifetimes are the recurring bug
39
+
40
+ Every module here that binds a listener hands back the undo, and the undo is
41
+ always one `AbortController` rather than a `removeEventListener` per
42
+ `addEventListener`. Those pairs have to match on both the function reference and
43
+ the options, and when one drifts nothing reports it — a listener added without
44
+ its twin simply survives teardown.
45
+
46
+ ```ts
47
+ const detach = slider.attach(viewport); // carousel: one element
48
+ const detach = list.attach(); // filters: no element, only popstate
49
+ ```
50
+
51
+ `AtlasElement` says the same thing in the shape a custom element needs, because
52
+ an element is constructed once and may be connected many times — moving it in
53
+ the DOM runs `disconnectedCallback` and then `connectedCallback` again:
54
+
55
+ ```ts
56
+ protected setup(): void { … } // once, ever: what the element is
57
+ protected connect(): void { … } // every connection: bind with this.signal
58
+ ```
59
+
60
+ **The trap this exists for is view transitions.** A bundled `<script src>` is an
61
+ ES module, cached by URL, so it executes once per session — not once per
62
+ navigation. Bind at module scope with `ClientRouter` on and the incoming page
63
+ gets a live list and dead controls: the markup was swapped, the handlers still
64
+ point at what was there before. Driving `attach` from `astro:page-load` and
65
+ calling its return on teardown is the fix, and it is why none of these modules
66
+ does anything at import time.
67
+
68
+ Note that the abort half alone does not help. It makes the breakage look
69
+ handled. Without a re-`attach` there is simply nothing wired.
70
+
71
+ ## `filters`
72
+
73
+ A list already rendered by the server, narrowed in the browser and mirrored in
74
+ the URL. The caller declares fields; the item shape follows from them.
75
+
76
+ ```ts
77
+ const list = filters({
78
+ fields: {
79
+ q: { kind: "text", param: "q" },
80
+ category: { kind: "choice", param: "category" },
81
+ featured: { kind: "flag", param: "featured" },
82
+ },
83
+ items: entries.map((entry) => ({
84
+ key: entry.id,
85
+ values: {
86
+ q: `${entry.title} ${entry.summary}`,
87
+ category: entry.category,
88
+ featured: entry.featured,
89
+ },
90
+ })),
91
+ onChange: ({ matched, state }) => { /* the project's DOM writes, all of them */ },
92
+ });
93
+
94
+ const detach = list.attach();
95
+ ```
96
+
97
+ Fields are plain data rather than `text()` / `choice()` / `flag()` builders:
98
+ three exported names that general — `text` above all — in a module a project
99
+ imports by name, to return exactly this object. `values` is typed *from*
100
+ `fields`, so a `flag` demands a boolean and a mismatch is a compile error rather
101
+ than an item that silently never matches.
102
+
103
+ | Kind | State | Item value | Matches when | In the URL | History |
104
+ | -------- | --------------- | --------------- | ------------------------------------------------ | ------------------------------- | ------- |
105
+ | `text` | the query | searchable text | folded query is a substring; empty keeps all | `?q=…`, dropped if empty | replace |
106
+ | `choice` | one value, `""` | the item's one | exact; `""` keeps all | `?category=…`, dropped if empty | push |
107
+ | `flag` | boolean | boolean | off keeps all; on keeps only items that carry it | `?featured=1`, absent when off | push |
108
+
109
+ **`text` folds both sides** — `NFD`, drop the combining marks, lowercase, trim.
110
+ This is the piece that is missing nearly everywhere, and it is not cosmetic: a
111
+ reader who types `malaga` is otherwise told there is no *Málaga*, and every
112
+ Romanian entry spelled with `ă`, `ș` or `ț` hides from anyone whose keyboard does
113
+ not carry them. Item text is folded once at construction; only the query is
114
+ folded per render.
115
+
116
+ **`text` matches tokens, not one run.** The query is split on whitespace and every
117
+ word must appear somewhere in the haystack. A haystack is several fields joined,
118
+ and the order they were joined in is an accident of the template — matching the
119
+ whole query as one substring makes that accident load-bearing, so a two-word
120
+ query fails whenever the words are not adjacent.
121
+
122
+ **`flag` is a narrowing, not a two-way filter.** Off keeps everything rather than
123
+ keeping the items that are *not* flagged — "In stock", unchecked, does not mean
124
+ "out of stock". On, off and *either* is a `choice` with two values; folding that
125
+ into a toggle produces a control with a third position nothing can reach.
126
+
127
+ Flags are not exclusive. A second toggle is another **field**, not another value
128
+ on the first, and any number can be on at once — every field narrows the same
129
+ pass. What is deliberately absent is one field holding several selected values;
130
+ that is a `choices` kind, and it forces a URL encoding decision (repeated
131
+ parameters or comma-joined) that nothing has needed yet.
132
+
133
+ `param` is optional. A field without one is state-only and never reaches the URL.
134
+
135
+ ### The four URL decisions
136
+
137
+ 1. **Replace while typing, push on a choice.** An input emits an event per
138
+ keystroke, and history should not fill with a word being spelled; picking a
139
+ category is one deliberate act the back button should undo. The default comes
140
+ from the kind, but it is really a fact about the *input* — a `<select>` or a
141
+ preset link bound to a `text` field wants `list.set("q", v, { history: "push" })`.
142
+ 2. **Empty parameters are dropped**, and an empty state is the bare path rather
143
+ than a trailing `?`. Both spellings are the same page; one of them is worth
144
+ sharing.
145
+ 3. **`popstate` is applied.** The back button moves the list, rather than moving
146
+ the URL and leaving the page behind.
147
+ 4. **The URL is rebuilt from the declaration**, not edited in place, so a
148
+ parameter this list owns and no longer needs disappears instead of surviving
149
+ because nobody deleted it. Fields write in declaration order, so one state
150
+ always produces one URL.
151
+
152
+ `attach` reads the URL *before* the first `onChange`. That is what lets a list
153
+ work before this script arrives: a `<form method="get">` submits, the server
154
+ renders the filtered page, and the first announcement continues from that state
155
+ instead of flashing the whole list back. Nothing writes to the URL before
156
+ `attach`, because `attach` is what reads it — a `set` on an unattached instance
157
+ would overwrite state nobody had loaded.
158
+
159
+ A set flag is written `1` and an unset one is **absent** rather than `=0`. One
160
+ spelling for off keeps the URL short and leaves a single form to parse; the cost
161
+ is that a hand-written `?featured=0` reads as off, which is the answer it would have
162
+ got anyway. `1` and not `true` because that is what the existing lists already
163
+ emit, and links people have shared should keep resolving to the view they named.
164
+
165
+ ### Two things it refuses at construction
166
+
167
+ Both are silent otherwise:
168
+
169
+ - **Two items under one key.** `matched` could not say which of them matched.
170
+ - **Two fields on one URL parameter.** Each write erases the other's value, which
171
+ reads as a filter that will not stay set.
172
+
173
+ And a `set` to the value already held announces nothing and pushes nothing, so
174
+ re-clicking the current tab does not add a history step to walk back through.
175
+
176
+ ## `filters-view`
177
+
178
+ Two helpers, and the bar for being here is not "a list might want this" but
179
+ "every list we have already written did, character for character, and getting it
180
+ wrong was silent". Three filtered lists in one consuming project — a catalogue, a
181
+ location directory and an FAQ — agree on exactly these and disagree about
182
+ everything else they draw.
183
+
184
+ ### `searchBox`
185
+
186
+ The twelve lines every one of the three wrote around its search field, byte for
187
+ byte.
188
+
189
+ ```ts
190
+ const search = searchBox({
191
+ input: one<HTMLInputElement>("[data-filter-search]"),
192
+ clear: one("[data-filter-clear]"),
193
+ empty: one("[data-filter-empty]"),
194
+ });
195
+
196
+ const list = filters({
197
+ fields, items,
198
+ onChange({ state, matched }) {
199
+ search.render(state.q, matched.size);
200
+ // …everything this list draws for itself
201
+ },
202
+ });
203
+
204
+ const unbind = search.bind(list, "q");
205
+ const detach = list.attach();
206
+ ```
207
+
208
+ `render` is the half worth centralising, and specifically this:
209
+
210
+ ```ts
211
+ if (input != null && input.value !== query) input.value = query;
212
+ ```
213
+
214
+ It exists because `popstate` and `reset` move state without touching the
215
+ keyboard — forget it and the back button leaves a stale query sitting in the box
216
+ while the list shows something else. The inequality is not an optimisation:
217
+ assigning `value` moves the caret to the end, so writing it unconditionally would
218
+ make the field unusable mid-word.
219
+
220
+ `bind` is a second call rather than part of construction because of an ordering
221
+ knot. `render` runs inside the list's `onChange`, so the box must exist before
222
+ the list; `bind` needs the list. Two `const`s in sequence is the honest shape,
223
+ and the alternative is a `let` the reader has to carry. Its return is the undo,
224
+ like `attach` — so a page-load teardown aborts both.
225
+
226
+ `TextFieldOf<F>` restricts the second argument to the list's `text` fields, so a
227
+ box pointed at a `flag` is a compile error rather than a filter that silently
228
+ never matches.
229
+
230
+ Every element is optional and `null` is accepted, because `within(root).one(…)`
231
+ returns `null` and a page may have a search field with no clear button. Handing
232
+ over what a lookup returned, unchecked, is the point.
233
+
234
+ ### `hideEmpty`
235
+
236
+ Two of the three render their results under headings, and both hit the same
237
+ thing: hide the items and the headings stay, so a search for one word leaves a
238
+ page of section titles with nothing under them. It reads as a broken template
239
+ rather than as a result, which is what makes forgetting it expensive — nothing
240
+ errors, and the page looks wrong in a way that does not point at the filter.
241
+
242
+ ```ts
243
+ onChange({ matched }) {
244
+ for (const item of items) item.hidden = !matched.has(keyOf(item));
245
+ hideEmpty(groups, (group) => within(group).all("[data-faq-key]"));
246
+ }
247
+ ```
248
+
249
+ **It reads `hidden` rather than taking the matched set**, because the caller has
250
+ just written it and that is the shortest true description of what survived — and
251
+ because reading it is what lets the helper nest. A country is empty when every
252
+ city in it is hidden; a region is empty when every country in it is hidden,
253
+ *including the ones the previous call just hid*.
254
+
255
+ **Which makes the order load-bearing: innermost first.**
256
+
257
+ ```ts
258
+ hideEmpty(countries, (c) => within(c).all("[data-city]"));
259
+ hideEmpty(regions, (r) => within(r).all("[data-country]"));
260
+ ```
261
+
262
+ Run the other way round, the regions are judged against countries nothing has
263
+ hidden yet, and an empty region survives. Nothing errors — the page just keeps a
264
+ heading it should have dropped, which is the failure this exists to prevent.
265
+ `tests/filters-view.test.ts` pins the wrong order too, so the constraint is
266
+ recorded as behaviour rather than only as prose.
267
+
268
+ A group with no children at all is hidden, by the same rule: there is nothing
269
+ visible in it.
270
+
271
+ ### Why these write instead of calling back
272
+
273
+ `filters.onChange` is already the callback that hands a consumer the answer and
274
+ gets out of the way, and it is where all three lists do what they each do
275
+ differently. A second callback layer here would hand back `query` and `count`, or
276
+ `group` and `isEmpty`, and leave the same writes spelled out at every call site —
277
+ which is the duplication these were extracted from. A helper whose action is
278
+ fixed and whose decision is trivial earns its place by doing the action; hand
279
+ that back and there is no helper left.
280
+
281
+ So the layering is three levels, each opt-in:
282
+
283
+ ```ts
284
+ filters({ … }) // total freedom, and what all three did first
285
+ filters({ … }) + searchBox // twelve lines gone
286
+ filters({ … }) + searchBox + hideEmpty // the roll-up gone too
287
+ ```
288
+
289
+ A page whose markup wants a class instead of `hidden`, or removal from the DOM,
290
+ skips the helper and writes it in `onChange`. Nothing degrades.
291
+
292
+ ## Testing
293
+
294
+ `filters` and `filters-view` are the only modules here with tests, and both for
295
+ the same reason: neither finds an element. `filters` takes item text as data and
296
+ `filters-view` takes elements it was handed, so both run against stubs rather
297
+ than a document. `tests/filters.test.ts` fakes `location`, `history` and one
298
+ `popstate` listener, covering the matching, the four URL decisions and
299
+ attach/detach; `tests/filters-view.test.ts` fakes plain objects with a `hidden`
300
+ property, pins the write-back guard by counting *writes* — so deleting the guard
301
+ fails rather than passing on an identical value — and pins the nesting order in
302
+ both directions.
303
+
304
+ `carousel`, `consent`, `element` and `dom` have none. They need a real DOM and
305
+ this package carries no environment for one; adding `happy-dom` as a dev
306
+ dependency and setting `environment` in `vitest.config.ts` is what that would
307
+ take.
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.4",
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,8 @@
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",
24
+ "./astro/filters-view": "./src/astro/filters-view.ts",
23
25
  "./astro/meta-tags": "./src/astro/MetaTags.astro"
24
26
  },
25
27
  "bin": {
@@ -31,6 +33,9 @@
31
33
  "docs",
32
34
  "README.md"
33
35
  ],
36
+ "engines": {
37
+ "node": ">=22.12.0"
38
+ },
34
39
  "scripts": {
35
40
  "test": "npm run test:types && npm run test:unit && npm run test:examples",
36
41
  "test:types": "tsc --noEmit && astro check --root checks/astro",
@@ -43,12 +48,14 @@
43
48
  "check-fmt": "biome check . --error-on-warnings"
44
49
  },
45
50
  "peerDependencies": {
46
- "astro": ">=7"
51
+ "@types/node": ">=22",
52
+ "astro": ">=7",
53
+ "typescript": ">=5"
47
54
  },
48
55
  "devDependencies": {
49
56
  "@biomejs/biome": "2.5.10",
50
- "@types/node": "26.2.0",
51
- "astro": "7.2.4",
57
+ "@types/node": "26.3.0",
58
+ "astro": "7.2.6",
52
59
  "typescript": "6.0.3",
53
60
  "vitest": "4.1.11"
54
61
  }
@@ -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
+ }