@escape-game-over/atlas 0.1.3 → 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 +10 -4
- package/src/astro/filters-view.ts +253 -0
- package/src/astro/filters.ts +79 -31
- 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/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/
|
|
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
|
|
9
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
49
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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,
|
|
77
|
-
nothing from lib
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
|
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,
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"./astro/dom": "./src/astro/dom.ts",
|
|
22
22
|
"./astro/element": "./src/astro/element.ts",
|
|
23
23
|
"./astro/filters": "./src/astro/filters.ts",
|
|
24
|
+
"./astro/filters-view": "./src/astro/filters-view.ts",
|
|
24
25
|
"./astro/meta-tags": "./src/astro/MetaTags.astro"
|
|
25
26
|
},
|
|
26
27
|
"bin": {
|
|
@@ -32,6 +33,9 @@
|
|
|
32
33
|
"docs",
|
|
33
34
|
"README.md"
|
|
34
35
|
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=22.12.0"
|
|
38
|
+
},
|
|
35
39
|
"scripts": {
|
|
36
40
|
"test": "npm run test:types && npm run test:unit && npm run test:examples",
|
|
37
41
|
"test:types": "tsc --noEmit && astro check --root checks/astro",
|
|
@@ -44,12 +48,14 @@
|
|
|
44
48
|
"check-fmt": "biome check . --error-on-warnings"
|
|
45
49
|
},
|
|
46
50
|
"peerDependencies": {
|
|
47
|
-
"
|
|
51
|
+
"@types/node": ">=22",
|
|
52
|
+
"astro": ">=7",
|
|
53
|
+
"typescript": ">=5"
|
|
48
54
|
},
|
|
49
55
|
"devDependencies": {
|
|
50
56
|
"@biomejs/biome": "2.5.10",
|
|
51
|
-
"@types/node": "26.
|
|
52
|
-
"astro": "7.2.
|
|
57
|
+
"@types/node": "26.3.0",
|
|
58
|
+
"astro": "7.2.6",
|
|
53
59
|
"typescript": "6.0.3",
|
|
54
60
|
"vitest": "4.1.11"
|
|
55
61
|
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DOM writes every filtered list turns out to make, and only those.
|
|
3
|
+
*
|
|
4
|
+
* `filters` decides which items match and what the address bar says, and hands
|
|
5
|
+
* both to `onChange`. Everything a page does about that answer is its own — and
|
|
6
|
+
* mostly stays that way: three lists in one repository draw their results three
|
|
7
|
+
* different ways, with tabs and a blurb on one, an exclusive `<details>`
|
|
8
|
+
* accordion on another, a two-level country roll-up on the third.
|
|
9
|
+
*
|
|
10
|
+
* These two helpers are what all three, or two of three, wrote identically. That
|
|
11
|
+
* is the whole bar for being here: not "a list might want this" but "every list
|
|
12
|
+
* we have written already did, character for character, and getting it wrong was
|
|
13
|
+
* silent". Anything the three disagreed about is still theirs.
|
|
14
|
+
*
|
|
15
|
+
* **Both take elements, never selectors.** A project decides what its markup is
|
|
16
|
+
* called and hands over what it found, so no attribute name in this package has
|
|
17
|
+
* to be matched by any consumer — the reason `filters` itself has no markup
|
|
18
|
+
* contract, kept intact one layer up.
|
|
19
|
+
*
|
|
20
|
+
* These write to elements, which the rest of `astro/` refuses to do, and the
|
|
21
|
+
* line is worth being exact about. `carousel` will not write a transform because
|
|
22
|
+
* a transform is a design decision with no single right answer. Everything
|
|
23
|
+
* written here is the opposite: an input's `value` is the control's own state,
|
|
24
|
+
* and `hidden` on a clear button, an empty message or a section with nothing
|
|
25
|
+
* left in it is binary and derived. Nothing here sets a class, a style or an
|
|
26
|
+
* `aria` attribute, which is where the choices live.
|
|
27
|
+
*
|
|
28
|
+
* Two notes for the markup on the other side of that boundary:
|
|
29
|
+
*
|
|
30
|
+
* - **`hidden` needs help in a grid.** It is a `display: none` from the user
|
|
31
|
+
* agent, and any author rule setting `display: flex` or `grid` beats it. A
|
|
32
|
+
* project on Tailwind v4 gets `[hidden] { display: none !important }` from
|
|
33
|
+
* preflight and needs nothing; anything else should ship that rule itself, or
|
|
34
|
+
* every one of these writes is inert and the list simply never filters.
|
|
35
|
+
* - **A result count wants `aria-live="polite"`.** Filtering as you type changes
|
|
36
|
+
* the page silently for anyone not looking at it. The attribute belongs on the
|
|
37
|
+
* element in the template, not here — this package is handed elements and does
|
|
38
|
+
* not decide what they are.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import type { FieldMap, Filters } from "./filters.ts";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The fields of `F` that hold text, so a search box cannot be pointed at a flag.
|
|
45
|
+
*
|
|
46
|
+
* `list.set(field, input.value)` hands over a string; aimed at a `flag` that is
|
|
47
|
+
* a type error worth getting at the call site rather than a filter that silently
|
|
48
|
+
* never matches.
|
|
49
|
+
*/
|
|
50
|
+
export type TextFieldOf<F extends FieldMap> = {
|
|
51
|
+
[K in keyof F]: F[K] extends { kind: "text" } ? K : never;
|
|
52
|
+
}[keyof F];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What a search box is made of, as much of it as exists.
|
|
56
|
+
*
|
|
57
|
+
* Every part is optional and `null` is accepted, because `within(root).one(…)`
|
|
58
|
+
* returns `null` and a page is allowed to have a search field with no clear
|
|
59
|
+
* button, or a list with no empty state. Handing over what a lookup returned,
|
|
60
|
+
* without checking it first, is the point.
|
|
61
|
+
*/
|
|
62
|
+
export interface SearchBoxElements {
|
|
63
|
+
readonly input?: HTMLInputElement | null;
|
|
64
|
+
/** Clears the field. Hidden while the field is already empty. */
|
|
65
|
+
readonly clear?: HTMLElement | null;
|
|
66
|
+
/**
|
|
67
|
+
* The "nothing matched" message.
|
|
68
|
+
*
|
|
69
|
+
* Tracks the whole result set rather than this field alone — a list emptied
|
|
70
|
+
* by a category is as empty as one emptied by a query, and there is one
|
|
71
|
+
* message either way. It lives here because it is the same line in every
|
|
72
|
+
* consumer, not because it belongs to the search box.
|
|
73
|
+
*/
|
|
74
|
+
readonly empty?: HTMLElement | null;
|
|
75
|
+
/**
|
|
76
|
+
* How many matched, written as digits and nothing else.
|
|
77
|
+
*
|
|
78
|
+
* Two of the three write this, and the same way. Digits only, because the
|
|
79
|
+
* sentence around them is not ours to build: a list that reads "6 of 42" in
|
|
80
|
+
* one language reads "6 din 42" in another, and a translated string is not
|
|
81
|
+
* something to take apart in a browser to get at one number. Give the count
|
|
82
|
+
* an element of its own and let the copy sit beside it — which is what the
|
|
83
|
+
* consumer doing this already had to do.
|
|
84
|
+
*
|
|
85
|
+
* Same rule as the rest of this file, one field further: derived, and with
|
|
86
|
+
* no choice in it. Where the number *goes* is still the template's.
|
|
87
|
+
*
|
|
88
|
+
* Give it `aria-live="polite"` there. Filtering as you type changes the
|
|
89
|
+
* page silently for anyone not watching it, and this package is handed
|
|
90
|
+
* elements rather than deciding what they are.
|
|
91
|
+
*/
|
|
92
|
+
readonly count?: HTMLElement | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface SearchBox {
|
|
96
|
+
/**
|
|
97
|
+
* Brings the box into line with the state, from inside `onChange`.
|
|
98
|
+
*
|
|
99
|
+
* Takes two scalars rather than the change object, so this stays independent
|
|
100
|
+
* of what the field was named and of which other fields the list has.
|
|
101
|
+
*
|
|
102
|
+
* Safe before `bind` and after the undo it returns: this only writes to the
|
|
103
|
+
* elements it was given, so a box that is no longer wired renders the last
|
|
104
|
+
* thing it was told rather than throwing.
|
|
105
|
+
*/
|
|
106
|
+
render(query: string, matchCount: number): void;
|
|
107
|
+
/**
|
|
108
|
+
* Points the input and the clear button at one `text` field of a list, and
|
|
109
|
+
* returns the undo.
|
|
110
|
+
*
|
|
111
|
+
* Separate from construction because of an ordering knot rather than a
|
|
112
|
+
* preference: `render` is called from the list's own `onChange`, so the box
|
|
113
|
+
* has to exist before the list, and this needs the list. Two calls and a
|
|
114
|
+
* `const` each way is the honest version of that; the alternative is a `let`
|
|
115
|
+
* the reader has to hold in their head.
|
|
116
|
+
*
|
|
117
|
+
* One `AbortController`, as everywhere else here — see `carousel.attach`.
|
|
118
|
+
*/
|
|
119
|
+
bind<F extends FieldMap>(
|
|
120
|
+
list: Filters<F>,
|
|
121
|
+
field: TextFieldOf<F>
|
|
122
|
+
): () => void;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The search input beside a `filters` list, and the two things every one of them
|
|
127
|
+
* has to remember.
|
|
128
|
+
*
|
|
129
|
+
* ```ts
|
|
130
|
+
* const search = searchBox({
|
|
131
|
+
* input: one<HTMLInputElement>("[data-filter-search]"),
|
|
132
|
+
* clear: one("[data-filter-clear]"),
|
|
133
|
+
* empty: one("[data-filter-empty]"),
|
|
134
|
+
* count: one("[data-filter-count]"),
|
|
135
|
+
* });
|
|
136
|
+
*
|
|
137
|
+
* const list = filters({
|
|
138
|
+
* fields, items,
|
|
139
|
+
* onChange({ state, matched }) {
|
|
140
|
+
* search.render(state.q, matched.size);
|
|
141
|
+
* // …everything this list draws for itself
|
|
142
|
+
* },
|
|
143
|
+
* });
|
|
144
|
+
*
|
|
145
|
+
* const unbind = search.bind(list, "q");
|
|
146
|
+
* const detach = list.attach();
|
|
147
|
+
* ```
|
|
148
|
+
*
|
|
149
|
+
* It writes rather than calling back, and that is the only reason it exists.
|
|
150
|
+
* `filters.onChange` is already the callback that hands a consumer the value and
|
|
151
|
+
* gets out of the way; a second one here would hand back `query` and `count` and
|
|
152
|
+
* leave the same three writes to be spelled out at every call site, which is the
|
|
153
|
+
* duplication this was extracted from.
|
|
154
|
+
*/
|
|
155
|
+
export function searchBox(elements: SearchBoxElements): SearchBox {
|
|
156
|
+
const { input, clear, empty, count } = elements;
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
render(query, matchCount) {
|
|
160
|
+
// Guarded by inequality, not written unconditionally. Assigning
|
|
161
|
+
// `value` while someone is typing in the field moves the caret to
|
|
162
|
+
// the end, so the one case this exists for — state that moved
|
|
163
|
+
// without the keyboard, from the back button or a `reset` — must not
|
|
164
|
+
// cost a caret jump on every other keystroke.
|
|
165
|
+
if (input != null && input.value !== query) input.value = query;
|
|
166
|
+
if (clear != null) clear.hidden = query === "";
|
|
167
|
+
if (empty != null) empty.hidden = matchCount > 0;
|
|
168
|
+
if (count != null) count.textContent = String(matchCount);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
bind(list, field) {
|
|
172
|
+
const listeners = new AbortController();
|
|
173
|
+
const { signal } = listeners;
|
|
174
|
+
// The narrowing `TextFieldOf` already did, restated for the
|
|
175
|
+
// implementation: inside a generic function TypeScript cannot see
|
|
176
|
+
// that this field's value type is `string`, though every caller can.
|
|
177
|
+
const name = field as Parameters<typeof list.set>[0];
|
|
178
|
+
|
|
179
|
+
input?.addEventListener(
|
|
180
|
+
"input",
|
|
181
|
+
() => list.set(name, input.value as never),
|
|
182
|
+
{ signal }
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
clear?.addEventListener(
|
|
186
|
+
"click",
|
|
187
|
+
() => {
|
|
188
|
+
list.reset(name);
|
|
189
|
+
// Focus follows the click, because the button the pointer
|
|
190
|
+
// was on has just hidden itself — leaving focus on a
|
|
191
|
+
// `hidden` element strands a keyboard reader at a control
|
|
192
|
+
// that is no longer there.
|
|
193
|
+
input?.focus();
|
|
194
|
+
},
|
|
195
|
+
{ signal }
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
return () => listeners.abort();
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Hides each group that has nothing visible left inside it.
|
|
205
|
+
*
|
|
206
|
+
* ```ts
|
|
207
|
+
* onChange({ matched }) {
|
|
208
|
+
* for (const item of items) item.hidden = !matched.has(keyOf(item));
|
|
209
|
+
* hideEmpty(groups, (group) => within(group).all("[data-faq-key]"));
|
|
210
|
+
* }
|
|
211
|
+
* ```
|
|
212
|
+
*
|
|
213
|
+
* A filtered list that renders its results under headings has this problem and
|
|
214
|
+
* nothing else does: hide the items and the headings stay, so a search for one
|
|
215
|
+
* word leaves a page of section titles with nothing under them. It reads as a
|
|
216
|
+
* broken template rather than as a result, which is what makes forgetting it
|
|
217
|
+
* expensive — nothing errors, and the page looks wrong in a way that does not
|
|
218
|
+
* point at the filter.
|
|
219
|
+
*
|
|
220
|
+
* **Reads `hidden`, rather than taking the matched set.** The items were just
|
|
221
|
+
* hidden by the caller, so their state is the shortest true description of what
|
|
222
|
+
* survived — and reading it is what lets this nest. A country is empty when
|
|
223
|
+
* every city in it is hidden; a region is empty when every country in it is
|
|
224
|
+
* hidden, *including the ones this call just hid*.
|
|
225
|
+
*
|
|
226
|
+
* **Which makes the order load-bearing: innermost first.**
|
|
227
|
+
*
|
|
228
|
+
* ```ts
|
|
229
|
+
* hideEmpty(countries, (c) => within(c).all("[data-city]"));
|
|
230
|
+
* hideEmpty(regions, (r) => within(r).all("[data-country]"));
|
|
231
|
+
* ```
|
|
232
|
+
*
|
|
233
|
+
* Run the other way round, the regions are judged against countries that have
|
|
234
|
+
* not been hidden yet, and a region with nothing in it survives.
|
|
235
|
+
*
|
|
236
|
+
* A group with no children at all is hidden, which is the same answer by the
|
|
237
|
+
* same rule — there is nothing visible in it.
|
|
238
|
+
*/
|
|
239
|
+
export function hideEmpty<T extends HTMLElement>(
|
|
240
|
+
groups: Iterable<T>,
|
|
241
|
+
childrenOf: (group: T) => Iterable<HTMLElement>
|
|
242
|
+
): void {
|
|
243
|
+
for (const group of groups) {
|
|
244
|
+
let visible = false;
|
|
245
|
+
for (const child of childrenOf(group)) {
|
|
246
|
+
if (!child.hidden) {
|
|
247
|
+
visible = true;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
group.hidden = !visible;
|
|
252
|
+
}
|
|
253
|
+
}
|
package/src/astro/filters.ts
CHANGED
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
* q: { kind: "text", param: "q" },
|
|
25
25
|
* category: { kind: "choice", param: "category" },
|
|
26
26
|
* },
|
|
27
|
-
* items:
|
|
28
|
-
* key:
|
|
29
|
-
* values: { q: `${
|
|
27
|
+
* items: entries.map((entry) => ({
|
|
28
|
+
* key: entry.id,
|
|
29
|
+
* values: { q: `${entry.title} ${entry.summary}`, category: entry.category },
|
|
30
30
|
* })),
|
|
31
31
|
* onChange: ({ matched }) => {
|
|
32
32
|
* for (const [key, element] of elements) element.hidden = !matched.has(key);
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
* - `text` — free entry, folded and substring-matched. The search box.
|
|
64
64
|
* - `choice` — one of a set, or none. Tabs, a `<select>`, a radio group.
|
|
65
65
|
* - `flag` — a narrowing toggle: off matches everything, on keeps only the
|
|
66
|
-
* items that carry it. "
|
|
66
|
+
* items that carry it. "In stock", "Step-free access".
|
|
67
67
|
*
|
|
68
68
|
* `flag` is not a tri-state and should not become one. On, off and *either* is
|
|
69
69
|
* a `choice` with two values; folding that into a toggle gives a control with a
|
|
@@ -198,8 +198,8 @@ const DIACRITICS = /[̀-ͯ]/g;
|
|
|
198
198
|
* A string reduced to what a query should match against.
|
|
199
199
|
*
|
|
200
200
|
* Decompose, drop the marks, lowercase, trim. This is the part every list needs
|
|
201
|
-
* and few have: without it a reader typing `
|
|
202
|
-
* *
|
|
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
203
|
* `ț` from anyone whose keyboard does not carry them.
|
|
204
204
|
*/
|
|
205
205
|
function fold(value: string): string {
|
|
@@ -213,7 +213,7 @@ function fold(value: string): string {
|
|
|
213
213
|
* An unset flag is *absent* rather than `=0`. One way to say off keeps the URL
|
|
214
214
|
* short and means there is a single form to handle; two would both have to be
|
|
215
215
|
* understood forever, and a reader could not tell which of them a link was
|
|
216
|
-
* carrying. The cost is that a hand-written `?
|
|
216
|
+
* carrying. The cost is that a hand-written `?featured=0` reads as off, which is
|
|
217
217
|
* the answer it would get anyway.
|
|
218
218
|
*
|
|
219
219
|
* `1` and not `true` because that is what the existing lists already emit:
|
|
@@ -293,10 +293,18 @@ export function filters<const F extends FieldMap>(
|
|
|
293
293
|
function recompute(): void {
|
|
294
294
|
// The query folded once per render rather than once per item. The other
|
|
295
295
|
// kinds compare values as they are, so there is nothing to prepare.
|
|
296
|
-
|
|
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[]>();
|
|
297
304
|
for (const name of names) {
|
|
298
305
|
if (fields[name]?.kind !== "text") continue;
|
|
299
|
-
|
|
306
|
+
const folded = fold(state[name] as string);
|
|
307
|
+
queries.set(name, folded === "" ? [] : folded.split(/\s+/));
|
|
300
308
|
}
|
|
301
309
|
|
|
302
310
|
const next = new Set<string>();
|
|
@@ -305,13 +313,10 @@ export function filters<const F extends FieldMap>(
|
|
|
305
313
|
for (const name of names) {
|
|
306
314
|
const kind = fields[name]?.kind;
|
|
307
315
|
if (kind === "text") {
|
|
308
|
-
const
|
|
309
|
-
if (
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
query
|
|
313
|
-
)
|
|
314
|
-
) {
|
|
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))) {
|
|
315
320
|
hit = false;
|
|
316
321
|
break;
|
|
317
322
|
}
|
|
@@ -344,9 +349,13 @@ export function filters<const F extends FieldMap>(
|
|
|
344
349
|
const field = fields[name];
|
|
345
350
|
if (field === undefined) continue;
|
|
346
351
|
if (field.param === undefined) {
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
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];
|
|
350
359
|
continue;
|
|
351
360
|
}
|
|
352
361
|
const raw = search.get(field.param);
|
|
@@ -358,11 +367,23 @@ export function filters<const F extends FieldMap>(
|
|
|
358
367
|
function writeUrl(history: "push" | "replace"): void {
|
|
359
368
|
if (!attached) return;
|
|
360
369
|
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
|
|
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
|
+
}
|
|
366
387
|
for (const name of names) {
|
|
367
388
|
const field = fields[name];
|
|
368
389
|
if (field?.param === undefined) continue;
|
|
@@ -375,13 +396,24 @@ export function filters<const F extends FieldMap>(
|
|
|
375
396
|
}
|
|
376
397
|
|
|
377
398
|
const query = search.toString();
|
|
378
|
-
// The bare path when nothing is
|
|
379
|
-
// are the same page, and only one of them is worth sharing.
|
|
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.
|
|
380
403
|
const url = query === "" ? window.location.pathname : `?${query}`;
|
|
381
404
|
if (history === "push") window.history.pushState({}, "", url);
|
|
382
405
|
else window.history.replaceState({}, "", url);
|
|
383
406
|
}
|
|
384
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
|
+
|
|
385
417
|
const announce = (): void => onChange({ state, matched });
|
|
386
418
|
|
|
387
419
|
recompute();
|
|
@@ -396,11 +428,21 @@ export function filters<const F extends FieldMap>(
|
|
|
396
428
|
|
|
397
429
|
set(field, value, setOptions) {
|
|
398
430
|
const name = field as keyof F & string;
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
|
|
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
|
+
}
|
|
404
446
|
|
|
405
447
|
state = { ...state, [name]: value };
|
|
406
448
|
recompute();
|
|
@@ -411,6 +453,12 @@ export function filters<const F extends FieldMap>(
|
|
|
411
453
|
|
|
412
454
|
reset(field) {
|
|
413
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;
|
|
414
462
|
state = blank();
|
|
415
463
|
} else {
|
|
416
464
|
const name = field as keyof F & string;
|
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
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { warn } from "../warn.ts";
|
|
2
|
+
import type { JsonLdNode } from "./node.ts";
|
|
3
|
+
|
|
4
|
+
/** One question and the answer the page gives for it. */
|
|
5
|
+
export interface FaqEntry {
|
|
6
|
+
readonly question: string;
|
|
7
|
+
/**
|
|
8
|
+
* The answer, as plain text.
|
|
9
|
+
*
|
|
10
|
+
* Google accepts a small amount of HTML here — `<p>`, `<br>`, `<ol>`,
|
|
11
|
+
* `<ul>`, `<li>`, `<a>`, `<b>`, `<strong>`, `<i>`, `<em>` — and drops
|
|
12
|
+
* everything else. Plain text is what a caller passing a translated string
|
|
13
|
+
* has anyway, and it cannot be silently half-rendered, so nothing here
|
|
14
|
+
* builds markup for you. Pass the markup yourself if the answer needs it.
|
|
15
|
+
*
|
|
16
|
+
* Checked August 2026.
|
|
17
|
+
*/
|
|
18
|
+
readonly answer: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The questions a page answers, as a `FAQPage`.
|
|
23
|
+
*
|
|
24
|
+
* **Read this before calling it: it renders nowhere, and it is not free.** This
|
|
25
|
+
* node was in `docs/NOT-BUILT.md` until it was asked for, and the reasoning
|
|
26
|
+
* there is the reason it stays opt-in rather than something `metaFor()` emits.
|
|
27
|
+
*
|
|
28
|
+
* Google restricted FAQ rich results to *"well-known, authoritative government
|
|
29
|
+
* and health websites"* in 2023, then removed them from Search entirely on
|
|
30
|
+
* 7 May 2026 and archived the documentation. There is no expandable Q&A under a
|
|
31
|
+
* commercial result any more, and no flag to earn one back.
|
|
32
|
+
*
|
|
33
|
+
* **A model does not read it either** — the argument for emitting it anyway,
|
|
34
|
+
* and it does not hold. JSON-LD sits in a `<script>` block, and the
|
|
35
|
+
* HTML-to-text pipelines feeding a model routinely drop `<script>` and often
|
|
36
|
+
* the whole `<head>`. Independent testing through 2025–26 repeatedly found that
|
|
37
|
+
* content present *only* in structured data goes unextracted, while the same
|
|
38
|
+
* content in visible headings and paragraphs is read reliably. A FAQ page's
|
|
39
|
+
* questions are already visible text; that is what gets read.
|
|
40
|
+
*
|
|
41
|
+
* **What it can still buy.** Schema is parsed at *indexing* time, so it reaches
|
|
42
|
+
* the surfaces built on a search index rather than only those fetching the page.
|
|
43
|
+
* Bing has said outright that its LLMs read schema, which is the live
|
|
44
|
+
* third-party payoff and the reason this exists at all.
|
|
45
|
+
*
|
|
46
|
+
* **The cost, which is the part that decides it.** Every other node here is
|
|
47
|
+
* metadata *about* a page — an address, a price, a date. This one is a verbatim
|
|
48
|
+
* *copy of* it: every question and every full answer a second time, in a script
|
|
49
|
+
* block on the page already showing them. A ten-question FAQ is several KB
|
|
50
|
+
* duplicated on every load, and page weight is paid per visitor, forever. Call
|
|
51
|
+
* this where that trade has been made deliberately, not by default.
|
|
52
|
+
*
|
|
53
|
+
* **Only questions the page actually shows.** The node is a description of the
|
|
54
|
+
* page, not a second copy of a FAQ that lives elsewhere: Google's first quality
|
|
55
|
+
* rule is that the content "must be visible to the user on the source page",
|
|
56
|
+
* and a node listing answers a reader cannot find is the kind of mismatch that
|
|
57
|
+
* earns a manual action rather than a warning. A page rendering a subset — the
|
|
58
|
+
* questions it has answers for, say — passes that subset.
|
|
59
|
+
*
|
|
60
|
+
* No `@id`: nothing in a graph refers to a `FAQPage`, and an id is for being
|
|
61
|
+
* pointed at. See the note at the top of `ids.ts`.
|
|
62
|
+
*
|
|
63
|
+
* Throws on an empty list rather than emitting an empty node. `mainEntity` is
|
|
64
|
+
* required, so a `FAQPage` holding no questions is not a thin node — it is an
|
|
65
|
+
* invalid one, saying the page is a FAQ and then naming nothing it answers.
|
|
66
|
+
* Whether a page has questions to show is the caller's to know, and a page with
|
|
67
|
+
* none should leave the node out of its graph rather than pass an empty list
|
|
68
|
+
* and hope.
|
|
69
|
+
*
|
|
70
|
+
* Reference, now archived rather than current guidance:
|
|
71
|
+
* <https://developers.google.com/search/docs/appearance/structured-data/faqpage>
|
|
72
|
+
*
|
|
73
|
+
* @param at What to name when this is wrong — the page's canonical URL.
|
|
74
|
+
*/
|
|
75
|
+
export function faqPage(entries: readonly FaqEntry[], at: string): JsonLdNode {
|
|
76
|
+
if (entries.length === 0) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`${at}: a FAQPage needs at least one question. Leave the node out of the graph on a page that shows none, rather than emitting one that answers nothing.`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const seen = new Set<string>();
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
const key = entry.question.trim().toLowerCase();
|
|
85
|
+
if (seen.has(key)) {
|
|
86
|
+
warn(
|
|
87
|
+
at,
|
|
88
|
+
`two questions read "${entry.question}". Google merges a repeated question rather than listing it twice, so the second answer is dropped.`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
seen.add(key);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
"@type": "FAQPage",
|
|
96
|
+
mainEntity: entries.map((entry) => ({
|
|
97
|
+
"@type": "Question",
|
|
98
|
+
name: entry.question,
|
|
99
|
+
acceptedAnswer: {
|
|
100
|
+
"@type": "Answer",
|
|
101
|
+
text: entry.answer,
|
|
102
|
+
},
|
|
103
|
+
})),
|
|
104
|
+
};
|
|
105
|
+
}
|
package/src/jsonld/index.ts
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
export { type ArticleInput, article } from "./article.ts";
|
|
33
33
|
export { type BreadcrumbStep, breadcrumbList } from "./breadcrumb.ts";
|
|
34
34
|
export { type LocalBusinessInput, localBusiness } from "./business.ts";
|
|
35
|
+
export { type FaqEntry, faqPage } from "./faq.ts";
|
|
35
36
|
export {
|
|
36
37
|
type BusinessId,
|
|
37
38
|
businessId,
|
package/src/jsonld/video.ts
CHANGED
|
@@ -11,8 +11,8 @@ interface VideoDetails {
|
|
|
11
11
|
* The site's own origin, which the thumbnails are made absolute against.
|
|
12
12
|
*
|
|
13
13
|
* An origin rather than the page's URL: `joinUrl` puts a path on the end of
|
|
14
|
-
* what it is given, so handing it `…/
|
|
15
|
-
* `…/
|
|
14
|
+
* what it is given, so handing it `…/rooms/blue-room` would produce
|
|
15
|
+
* `…/rooms/blue-room/thumb.png`. The same bargain `localBusiness` strikes,
|
|
16
16
|
* named for what it actually needs.
|
|
17
17
|
*/
|
|
18
18
|
readonly origin: HttpsUrl;
|