@escape-game-over/atlas 0.1.24 → 0.1.25

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.
Files changed (48) hide show
  1. package/README.md +27 -44
  2. package/bin/use-project.mjs +18 -13
  3. package/docs/NOT-BUILT.md +1 -1
  4. package/docs/client-scripts.md +73 -141
  5. package/docs/rich-text.md +13 -20
  6. package/package.json +5 -12
  7. package/src/analytics/google.ts +6 -6
  8. package/src/analytics/index.ts +4 -3
  9. package/src/analytics/tags.ts +13 -58
  10. package/src/analytics/umami.ts +8 -8
  11. package/src/astro/ConsentBanner.astro +25 -0
  12. package/src/astro/ConsentElement.astro +61 -0
  13. package/src/astro/Document.astro +44 -0
  14. package/src/astro/Image.astro +102 -0
  15. package/src/astro/MetaTags.astro +3 -26
  16. package/src/astro/RichText.astro +71 -0
  17. package/src/astro/Zoom.astro +61 -0
  18. package/src/astro/client.ts +19 -9
  19. package/src/astro/consent.ts +20 -0
  20. package/src/astro/dev-log.ts +8 -14
  21. package/src/astro/element.ts +111 -112
  22. package/src/astro/filters-view.ts +48 -64
  23. package/src/astro/filters.ts +42 -35
  24. package/src/astro/index.ts +2 -9
  25. package/src/astro/markup.ts +6 -6
  26. package/src/astro/site-routes.ts +9 -15
  27. package/src/config.ts +23 -36
  28. package/src/content/index.ts +1 -1
  29. package/src/content/marks.ts +13 -13
  30. package/src/content/rich.ts +26 -42
  31. package/src/hours.ts +48 -11
  32. package/src/i18n/define.ts +14 -74
  33. package/src/index.ts +40 -57
  34. package/src/meta/index.ts +7 -13
  35. package/src/meta/share-image.ts +2 -26
  36. package/src/meta/tag.ts +1 -45
  37. package/src/money.ts +161 -6
  38. package/src/project.ts +84 -73
  39. package/src/routes/define.ts +8 -44
  40. package/src/routes/resolve.ts +1 -1
  41. package/src/site/api.ts +7 -33
  42. package/src/site/create.ts +6 -10
  43. package/src/site/define.ts +120 -0
  44. package/src/site/index.ts +2 -5
  45. package/src/site/page.ts +4 -2
  46. package/src/sitemap.ts +2 -35
  47. package/src/warn.ts +16 -17
  48. package/src/astro/dom.ts +0 -35
@@ -1,4 +1,24 @@
1
1
  import { CONSENT_UPDATE_GLOBAL } from "../analytics/google.ts";
2
+ import { component, markup } from "./markup.ts";
3
+
4
+ /** Internal to `ConsentBanner.astro`: the element and the footer control. */
5
+ export const CONSENT_ROLES = {
6
+ answer: { choice: { kind: "choice", of: ["granted", "denied"] } },
7
+ } as const;
8
+ export const consentBanner = component("atlas-consent", CONSENT_ROLES);
9
+ export const consentReopen = markup("atlas-consent-reopen");
10
+
11
+ /**
12
+ * What a site's consent markup spreads: the two answer buttons inside
13
+ * `<ConsentBanner>`, and the control that brings it back — which belongs on
14
+ * every page, usually the footer, and stays hidden until there is something to
15
+ * withdraw.
16
+ */
17
+ export const consent = {
18
+ accept: consentBanner.answer.attrs({ choice: "granted" }),
19
+ decline: consentBanner.answer.attrs({ choice: "denied" }),
20
+ reopen: { ...consentReopen.attrs(), hidden: "" },
21
+ };
2
22
 
3
23
  /**
4
24
  * The browser half of consent: remembering an answer, expiring it, and handing
@@ -7,20 +7,9 @@
7
7
  * least of all the person who broke it ten seconds ago. This puts the same
8
8
  * message where it cannot be missed.
9
9
  *
10
- * **Every call site must be behind `import.meta.env.DEV`.** The check belongs
11
- * to the caller rather than to this module, so the whole thing — panel, styles
12
- * and message strings — is dead code in a production build and gets dropped
13
- * instead of shipped and never called.
14
- *
15
- * ```ts
16
- * if (import.meta.env.DEV) reportDevError(this.localName, error);
17
- * ```
18
- *
19
- * Written as the bare expression, without an optional chain: Vite substitutes
20
- * `import.meta.env.DEV` with a literal, so the branch collapses and everything
21
- * below is dropped. `import.meta.env?.DEV` is replaced as `import.meta.env` —
22
- * an object literal whose property access a minifier has to fold rather than
23
- * simply delete.
10
+ * In a production build it is a `console.error` and nothing else: Vite
11
+ * substitutes the bare `import.meta.env.DEV` with a literal, so the panel,
12
+ * styles and message strings are dead code and get dropped.
24
13
  *
25
14
  * Not a custom element, deliberately. It would need registering, it would
26
15
  * collide with a project that registered the same name, and nothing ever
@@ -102,6 +91,11 @@ function panel(): HTMLElement {
102
91
  * second bug underneath them.
103
92
  */
104
93
  export function reportDevError(source: string, error: unknown): void {
94
+ if (!import.meta.env.DEV) {
95
+ console.error(`${source}:`, error);
96
+ return;
97
+ }
98
+
105
99
  const text = `${source} — ${describe(error)}`;
106
100
 
107
101
  console.error(text, error);
@@ -1,116 +1,143 @@
1
1
  import { reportDevError } from "./dev-log.ts";
2
- import type { Marked, Markup } from "./markup.ts";
3
- import { ROOT_ATTRIBUTE } from "./markup.ts";
4
-
5
- /**
6
- * What is left to undo when the element leaves, or nothing.
7
- *
8
- * Nothing is the common case — a connect that registered everything with
9
- * `signal` has already said how it comes down — so `void` rather than
10
- * `undefined`, which would make every connect end in a `return`.
11
- */
2
+ import {
3
+ type CheckedKeys,
4
+ type CheckedTagName,
5
+ type Component,
6
+ type ComponentRoles,
7
+ component,
8
+ type Marked,
9
+ type MarkupFields,
10
+ type Reserved,
11
+ ROOT_ATTRIBUTE,
12
+ } from "./markup.ts";
13
+
14
+ /** What is left to undo when the element leaves; usually nothing. */
12
15
  // biome-ignore lint/suspicious/noConfusingVoidType: that is the distinction.
13
16
  type Undo = void | (() => void);
14
17
 
15
- /** One role's lookups, already pointed at the element they belong to. */
16
- type Bound<M> =
17
- M extends Markup<infer F>
18
- ? {
19
- all<T extends Element = HTMLElement>(): Marked<T, F>[];
20
- one<T extends Element = HTMLElement>(): Marked<T, F> | null;
21
- require<T extends Element = HTMLElement>(): Marked<T, F>;
22
- }
23
- : never;
24
-
25
18
  /**
26
- * Every role of a component, bound to one instance.
27
- *
28
- * The root is not a parameter here, which is the point: `faq.row.all(document)`
29
- * compiles and quietly returns the roles that belong to *no* instance, and this
30
- * removes the chance to write it.
19
+ * One role's lookups inside this instance. A role with no fields is found for
20
+ * the element alone; one with fields comes back with its values read.
31
21
  */
32
- export type Roles<C> = {
33
- readonly [K in Exclude<keyof C, "tag" | "root">]: Bound<C[K]>;
22
+ type Lookups<F extends MarkupFields> = [keyof F] extends [never]
23
+ ? {
24
+ all<T extends Element = HTMLElement>(): T[];
25
+ one<T extends Element = HTMLElement>(): T | null;
26
+ require<T extends Element = HTMLElement>(): T;
27
+ }
28
+ : {
29
+ all<T extends Element = HTMLElement>(): Marked<T, F>[];
30
+ one<T extends Element = HTMLElement>(): Marked<T, F> | null;
31
+ require<T extends Element = HTMLElement>(): Marked<T, F>;
32
+ };
33
+
34
+ /** Every role of an element, bound to one instance. */
35
+ export type ElementRoles<R extends ComponentRoles> = {
36
+ readonly [K in keyof R]: Lookups<R[K]>;
34
37
  };
35
38
 
36
39
  /**
37
- * What an element does while it is on the page.
38
- *
39
- * `host` is the element itself, `signal` is aborted when it leaves — so
40
- * anything registered with `signal` comes down on its own — and `roles` are
41
- * this instance's. Whatever else has to be undone is the returned function,
42
- * which runs at the same moment.
40
+ * What an element does while it is on the page. `signal` is aborted when it
41
+ * leaves, so listeners registered with it come down on their own; anything else
42
+ * to undo is the returned function.
43
43
  */
44
- export type Connect<C> = (
44
+ export type Connect<R extends ComponentRoles> = (
45
45
  host: HTMLElement,
46
- signal: AbortSignal,
47
- roles: Roles<C>
46
+ roles: ElementRoles<R>,
47
+ signal: AbortSignal
48
48
  ) => Undo;
49
49
 
50
- /** The shape this file needs off a role, without importing its field types. */
51
- interface AnyRole {
52
- all(root: ParentNode): unknown;
53
- one(root: ParentNode): unknown;
54
- require(root: ParentNode): unknown;
55
- }
56
-
57
50
  /**
58
- * Registers a component's custom element, with its behaviour as one function.
51
+ * A custom element: its markup contract and its behaviour, in one call.
59
52
  *
60
53
  * ```ts
61
- * export const faq = component("go-faq", { row: { key: { kind: "text" } } });
62
- *
63
- * defineElement(faq, (host, signal, { row }) => {
64
- * for (const { element, values } of row.all()) { … }
65
- * return loop.attach(host); // runs when the element leaves the page
66
- * });
54
+ * // faq.ts — imported by the template, and by the page's script
55
+ * export const faq = element("go-faq", { row: { key: { kind: "text" } }, search: marker },
56
+ * (host, { row, search }, signal) => { … });
57
+ * ```
58
+ * ```astro
59
+ * <script src="./faq.ts"></script>
67
60
  * ```
68
61
  *
62
+ * Registers itself when loaded in a browser and does nothing in Node, so the
63
+ * template imports the same file for the contract.
64
+ *
69
65
  * - **An element can enter the page more than once.** Moving it runs the undo
70
- * and then `connect` again, so `connect` has to be able to run twice. Astro's
71
- * `ClientRouter` connects a persisted element three times per navigation.
66
+ * and `connect` again; Astro's `ClientRouter` does so on every navigation.
72
67
  * State that must survive belongs in a `WeakMap` keyed by `host`.
73
- * - **The defining script must stay a deferred module.** Astro emits
74
- * `<script src>` as `type="module"`, so the element has its children by the
75
- * time it is upgraded. `is:inline` runs it too early and every lookup inside
76
- * the element finds nothing.
77
- * - **A `connect` that throws leaves that one element inert** and reports it,
78
- * rather than taking its siblings down with it.
79
- *
80
- * See docs/client-scripts.md.
68
+ * - **The page's script must stay a bundled module** (a plain `<script>`), so
69
+ * the element has its children when it upgrades.
70
+ * - **A `connect` that throws leaves that one element inert** and reports it.
71
+ * - **A tag defined twice keeps the first definition** and reports the second.
81
72
  */
82
- export function defineElement<C extends { readonly tag: string }>(
83
- component: C,
84
- connect: Connect<C>
85
- ): void {
86
- const { tag } = component;
73
+ export function element<
74
+ const Tag extends string,
75
+ const R extends ComponentRoles &
76
+ CheckedKeys<R> & { readonly [K in Reserved]?: never },
77
+ >(
78
+ tag: Tag & CheckedTagName<Tag>,
79
+ roles: R,
80
+ connect: Connect<R>
81
+ ): Component<Tag, R> {
82
+ // Already checked by this function's own signature.
83
+ const contract = component(tag as never, roles) as Component<Tag, R>;
84
+ if (typeof customElements !== "undefined") {
85
+ define(contract, roles, connect);
86
+ }
87
+ return contract;
88
+ }
87
89
 
88
- // Read once: the roles are fixed when the component is built, and only the
89
- // element they point at changes.
90
- const roleNames = Object.keys(component).filter(
91
- (key) => key !== "tag" && key !== "root"
92
- );
90
+ /** The shape this file needs off a role, without its field types. */
91
+ interface AnyRole {
92
+ all(root: ParentNode): Marked<Element, MarkupFields>[];
93
+ one(root: ParentNode): Marked<Element, MarkupFields> | null;
94
+ require(root: ParentNode): Marked<Element, MarkupFields>;
95
+ }
96
+
97
+ function define<R extends ComponentRoles>(
98
+ contract: Component<string, R>,
99
+ roles: R,
100
+ connect: Connect<R>
101
+ ): void {
102
+ const { tag } = contract;
103
+ if (customElements.get(tag) !== undefined) {
104
+ reportDevError(
105
+ tag,
106
+ new Error(`<${tag}> is already defined; this definition is ignored`)
107
+ );
108
+ return;
109
+ }
110
+
111
+ const bind = (host: HTMLElement): ElementRoles<R> => {
112
+ const bound: Record<string, unknown> = {};
113
+ for (const [name, fields] of Object.entries(roles)) {
114
+ const role = (contract as unknown as Record<string, AnyRole>)[name];
115
+ if (role === undefined) continue;
116
+ bound[name] =
117
+ Object.keys(fields).length === 0
118
+ ? {
119
+ all: () => role.all(host).map((each) => each.element),
120
+ one: () => role.one(host)?.element ?? null,
121
+ require: () => role.require(host).element,
122
+ }
123
+ : {
124
+ all: () => role.all(host),
125
+ one: () => role.one(host),
126
+ require: () => role.require(host),
127
+ };
128
+ }
129
+ return bound as ElementRoles<R>;
130
+ };
93
131
 
94
- // The class is built in here rather than at module scope so `HTMLElement`
95
- // is only read in a browser.
96
132
  customElements.define(
97
133
  tag,
98
134
  class extends HTMLElement {
99
- /**
100
- * This visit's listeners, and only this visit's. Remade every time:
101
- * an `AbortController` is single-use, and a reused one comes back
102
- * already aborted, leaving the element inert but normal-looking.
103
- */
135
+ /** This visit's listeners. Remade every time: a controller is single-use. */
104
136
  #ac?: AbortController;
105
-
106
- /** What `connect` handed back, if anything. */
107
137
  #undo?: () => void;
108
138
 
109
139
  connectedCallback(): void {
110
- // Insurance: a live controller still here would orphan its
111
- // listeners.
112
140
  this.#end();
113
-
114
141
  const ac = new AbortController();
115
142
  this.#ac = ac;
116
143
  try {
@@ -123,9 +150,9 @@ export function defineElement<C extends { readonly tag: string }>(
123
150
  );
124
151
  }
125
152
  this.#undo =
126
- connect(this, ac.signal, this.#roles()) ?? undefined;
153
+ connect(this, bind(this), ac.signal) ?? undefined;
127
154
  } catch (error) {
128
- this.#report(error);
155
+ reportDevError(this.localName, error);
129
156
  }
130
157
  }
131
158
 
@@ -133,50 +160,22 @@ export function defineElement<C extends { readonly tag: string }>(
133
160
  this.#end();
134
161
  }
135
162
 
136
- /** Moving to another document ends the old document's visit. */
137
163
  adoptedCallback(): void {
138
164
  this.#end();
139
165
  }
140
166
 
141
- /** This instance's roles: the component's, with the root supplied. */
142
- #roles(): Roles<C> {
143
- const bound: Record<string, unknown> = {};
144
- for (const name of roleNames) {
145
- const role = (component as Record<string, unknown>)[
146
- name
147
- ] as AnyRole;
148
- bound[name] = {
149
- all: () => role.all(this),
150
- one: () => role.one(this),
151
- require: () => role.require(this),
152
- };
153
- }
154
- return bound as Roles<C>;
155
- }
156
-
157
167
  #end(): void {
158
168
  // Aborted before the undo runs, so an async continuation that
159
169
  // checks the signal can see the visit is over.
160
170
  this.#ac?.abort();
161
171
  this.#ac = undefined;
162
-
163
172
  const undo = this.#undo;
164
173
  this.#undo = undefined;
165
174
  try {
166
175
  undo?.();
167
176
  } catch (error) {
168
- this.#report(error);
169
- }
170
- }
171
-
172
- #report(error: unknown): void {
173
- // The bare expression Vite substitutes, so this collapses to
174
- // `if (false)` and the dev panel leaves the bundle.
175
- if (import.meta.env.DEV) {
176
177
  reportDevError(this.localName, error);
177
- return;
178
178
  }
179
- console.error(`${this.localName}:`, error);
180
179
  }
181
180
  }
182
181
  );
@@ -20,7 +20,7 @@
20
20
  * See docs/client-scripts.md.
21
21
  */
22
22
 
23
- import type { FieldMap, Filters } from "./filters.ts";
23
+ import type { FieldMap, FilterChange, Filters } from "./filters.ts";
24
24
 
25
25
  /** The `text` fields of `F`, so a search box cannot be pointed at a flag. */
26
26
  export type TextFieldOf<F extends FieldMap> = {
@@ -46,77 +46,61 @@ export interface SearchBoxElements {
46
46
  readonly count?: HTMLElement | null;
47
47
  }
48
48
 
49
- export interface SearchBox {
50
- /**
51
- * Brings the box into line with the state, from inside `onChange`. Two
52
- * scalars rather than the change object, so this does not care what the
53
- * field was named.
54
- */
55
- render(query: string, matchCount: number): void;
56
- /**
57
- * Points the input and the clear button at one `text` field, and returns
58
- * the undo. Separate from construction because `render` is called from the
59
- * list's `onChange`: the box has to exist before the list, and this needs
60
- * the list.
61
- */
62
- bind<F extends FieldMap>(
63
- list: Filters<F>,
64
- field: TextFieldOf<F>
65
- ): () => void;
66
- }
67
-
68
49
  /**
69
- * The search input beside a `filters` list.
50
+ * The search input beside a `filters` list, kept in step with it: typing sets
51
+ * `field`, the clear button resets it, and every change repaints the box.
52
+ * Returns the undo.
70
53
  *
71
54
  * ```ts
72
- * const search = searchBox({ input, clear, empty, count });
73
- * const list = filters({
74
- * fields,
75
- * items,
76
- * onChange: ({ state, matched }) => search.render(state.q, matched.size),
77
- * });
78
- * const unbind = search.bind(list, "q");
55
+ * const list = filters({ fields, items, onChange: … });
56
+ * const detach = list.attach();
57
+ * const unbind = searchBox(list, "q", { input, clear, empty, count });
79
58
  * ```
80
59
  */
81
- export function searchBox(elements: SearchBoxElements): SearchBox {
60
+ export function searchBox<F extends FieldMap>(
61
+ list: Filters<F>,
62
+ field: TextFieldOf<F>,
63
+ elements: SearchBoxElements
64
+ ): () => void {
82
65
  const { input, clear, empty, count } = elements;
66
+ // The narrowing `TextFieldOf` already did: inside a generic function
67
+ // TypeScript cannot see that this field holds a string.
68
+ const name = field as Parameters<typeof list.set>[0];
83
69
 
84
- return {
85
- render(query, matchCount) {
86
- // Guarded by inequality: assigning `value` while someone types
87
- // moves the caret to the end, and this exists for state that moved
88
- // without the keyboard — the back button, or a `reset`.
89
- if (input != null && input.value !== query) input.value = query;
90
- if (clear != null) clear.hidden = query === "";
91
- if (empty != null) empty.hidden = matchCount > 0;
92
- if (count != null) count.textContent = String(matchCount);
93
- },
94
-
95
- bind(list, field) {
96
- const listeners = new AbortController();
97
- const { signal } = listeners;
98
- // The narrowing `TextFieldOf` already did: inside a generic
99
- // function TypeScript cannot see that this field holds a string.
100
- const name = field as Parameters<typeof list.set>[0];
101
-
102
- input?.addEventListener(
103
- "input",
104
- () => list.set(name, input.value as never),
105
- { signal }
106
- );
70
+ const render = ({ state, matched }: FilterChange<F>): void => {
71
+ const query = String(state[name]);
72
+ // Guarded by inequality: assigning `value` while someone types moves
73
+ // the caret to the end, and this exists for state that moved without
74
+ // the keyboard — the back button, or a `reset`.
75
+ if (input != null && input.value !== query) input.value = query;
76
+ if (clear != null) clear.hidden = query === "";
77
+ if (empty != null) empty.hidden = matched.size > 0;
78
+ if (count != null) count.textContent = String(matched.size);
79
+ };
107
80
 
108
- clear?.addEventListener(
109
- "click",
110
- () => {
111
- list.reset(name);
112
- // The button just hid itself; focus must not stay on it.
113
- input?.focus();
114
- },
115
- { signal }
116
- );
81
+ render(list);
82
+ const unsubscribe = list.subscribe(render);
117
83
 
118
- return () => listeners.abort();
84
+ const events = new AbortController();
85
+ const { signal } = events;
86
+ input?.addEventListener(
87
+ "input",
88
+ () => list.set(name, input.value as never),
89
+ { signal }
90
+ );
91
+ clear?.addEventListener(
92
+ "click",
93
+ () => {
94
+ list.reset(name);
95
+ // The button just hid itself; focus must not stay on it.
96
+ input?.focus();
119
97
  },
98
+ { signal }
99
+ );
100
+
101
+ return () => {
102
+ events.abort();
103
+ unsubscribe();
120
104
  };
121
105
  }
122
106
 
@@ -131,8 +115,8 @@ export function searchBox(elements: SearchBoxElements): SearchBox {
131
115
  * **So the order is load-bearing: innermost first.**
132
116
  *
133
117
  * ```ts
134
- * hideEmpty(countries, (c) => within(c).all("[data-city]"));
135
- * hideEmpty(regions, (r) => within(r).all("[data-country]"));
118
+ * hideEmpty(countries, (c) => citiesIn.get(c) ?? []);
119
+ * hideEmpty(regions, (r) => countriesIn.get(r) ?? []);
136
120
  * ```
137
121
  */
138
122
  export function hideEmpty<T extends HTMLElement>(
@@ -4,8 +4,8 @@
4
4
  * ```ts
5
5
  * const list = filters({
6
6
  * fields: {
7
- * q: { kind: "text", param: "q" },
8
- * category: { kind: "choice", param: "category" },
7
+ * q: { kind: "text" },
8
+ * category: { kind: "choice" },
9
9
  * },
10
10
  * items: entries.map((entry) => ({
11
11
  * key: entry.id,
@@ -40,10 +40,11 @@
40
40
  *
41
41
  * `flag` is not a tri-state: on, off and *either* is a `choice` with two values.
42
42
  */
43
- export type Field =
44
- | { readonly kind: "text"; readonly param?: string }
45
- | { readonly kind: "choice"; readonly param?: string }
46
- | { readonly kind: "flag"; readonly param?: string };
43
+ export interface Field {
44
+ readonly kind: "text" | "choice" | "flag";
45
+ /** The URL parameter. Defaults to the field's name; `false` keeps it out of the URL. */
46
+ readonly param?: string | false;
47
+ }
47
48
 
48
49
  /** The fields of one list, named by the caller. */
49
50
  export type FieldMap = Readonly<Record<string, Field>>;
@@ -66,14 +67,6 @@ export type ItemValue<F extends Field> = F["kind"] extends "flag"
66
67
  ? string | readonly string[]
67
68
  : string;
68
69
 
69
- /**
70
- * What one field contributes.
71
- *
72
- * @deprecated Say which side you are on: `StateValue` or `ItemValue`. This
73
- * named both while they were the same type, and is `StateValue` now.
74
- */
75
- export type FieldValue<F extends Field> = StateValue<F>;
76
-
77
70
  /** Every field's value for one item, derived from the field declaration. */
78
71
  export type ItemValues<F extends FieldMap> = {
79
72
  readonly [K in keyof F]: ItemValue<F[K]>;
@@ -144,6 +137,11 @@ export interface Filters<F extends FieldMap> {
144
137
  ): void;
145
138
  /** Clears one field, or all of them. Always a push: clearing is deliberate. */
146
139
  reset(field?: keyof F): void;
140
+ /**
141
+ * Also called on every change, after `onChange`. Returns the undo. For a
142
+ * helper like `searchBox` that keeps its own part of the page in step.
143
+ */
144
+ subscribe(listener: (change: FilterChange<F>) => void): () => void;
147
145
  /**
148
146
  * Reads the URL, applies it, and starts listening — returns the undo.
149
147
  *
@@ -189,16 +187,20 @@ export function filters<const F extends FieldMap>(
189
187
  }
190
188
  keys.add(item.key);
191
189
  }
192
- const params = new Set<string>();
190
+ // Field name → URL parameter, for the fields that are in the URL.
191
+ const params = new Map<string, string>();
192
+ const taken = new Set<string>();
193
193
  for (const name of names) {
194
- const param = fields[name]?.param;
195
- if (param === undefined) continue;
196
- if (params.has(param)) {
194
+ const given = fields[name]?.param;
195
+ if (given === false) continue;
196
+ const param = given ?? name;
197
+ if (taken.has(param)) {
197
198
  throw new Error(
198
199
  `Two filter fields share the URL parameter "${param}".`
199
200
  );
200
201
  }
201
- params.add(param);
202
+ taken.add(param);
203
+ params.set(name, param);
202
204
  }
203
205
 
204
206
  // Folded once at construction: doing it per item per keystroke is the
@@ -305,15 +307,16 @@ export function filters<const F extends FieldMap>(
305
307
  const next: Record<string, string | boolean> = {};
306
308
  for (const name of names) {
307
309
  const field = fields[name];
310
+ const param = params.get(name);
308
311
  if (field === undefined) continue;
309
- if (field.param === undefined) {
312
+ if (param === undefined) {
310
313
  // A state-only field is not in the URL, so the URL has nothing
311
314
  // to say about it. Clearing it here wiped a search mid-typing
312
315
  // whenever another parameter on the page moved.
313
316
  next[name] = state[name];
314
317
  continue;
315
318
  }
316
- const raw = search.get(field.param);
319
+ const raw = search.get(param);
317
320
  next[name] = field.kind === "flag" ? raw === FLAG_ON : (raw ?? "");
318
321
  }
319
322
  state = next as FilterState<F>;
@@ -328,19 +331,14 @@ export function filters<const F extends FieldMap>(
328
331
  // attributed the first time someone typed in the search box. Its own
329
332
  // are deleted first, then rewritten, so one state gives one URL.
330
333
  const search = new URLSearchParams(window.location.search);
331
- for (const name of names) {
332
- const param = fields[name]?.param;
333
- if (param !== undefined) search.delete(param);
334
- }
335
- for (const name of names) {
336
- const field = fields[name];
337
- if (field?.param === undefined) continue;
338
- if (field.kind === "flag") {
339
- if (state[name] === true) search.set(field.param, FLAG_ON);
334
+ for (const param of params.values()) search.delete(param);
335
+ for (const [name, param] of params) {
336
+ if (fields[name]?.kind === "flag") {
337
+ if (state[name] === true) search.set(param, FLAG_ON);
340
338
  continue;
341
339
  }
342
340
  const value = (state[name] as string).trim();
343
- if (value !== "") search.set(field.param, value);
341
+ if (value !== "") search.set(param, value);
344
342
  }
345
343
 
346
344
  const query = search.toString();
@@ -356,7 +354,11 @@ export function filters<const F extends FieldMap>(
356
354
  const normalized = (name: keyof F & string, value: unknown): unknown =>
357
355
  fields[name]?.kind === "text" ? (value as string).trim() : value;
358
356
 
359
- const announce = (): void => onChange({ state, matched });
357
+ const listeners = new Set<(change: FilterChange<F>) => void>();
358
+ const announce = (): void => {
359
+ onChange({ state, matched });
360
+ for (const listener of listeners) listener({ state, matched });
361
+ };
360
362
 
361
363
  recompute();
362
364
 
@@ -417,8 +419,13 @@ export function filters<const F extends FieldMap>(
417
419
  announce();
418
420
  },
419
421
 
422
+ subscribe(listener) {
423
+ listeners.add(listener);
424
+ return () => listeners.delete(listener);
425
+ },
426
+
420
427
  attach() {
421
- const listeners = new AbortController();
428
+ const events = new AbortController();
422
429
 
423
430
  // A `popstate` listener that outlives its page is the leak with no
424
431
  // visible symptom: it keeps rendering into markup that was replaced.
@@ -429,7 +436,7 @@ export function filters<const F extends FieldMap>(
429
436
  recompute();
430
437
  announce();
431
438
  },
432
- { signal: listeners.signal }
439
+ { signal: events.signal }
433
440
  );
434
441
 
435
442
  attached = true;
@@ -441,7 +448,7 @@ export function filters<const F extends FieldMap>(
441
448
  announce();
442
449
 
443
450
  return () => {
444
- listeners.abort();
451
+ events.abort();
445
452
  attached = false;
446
453
  };
447
454
  },