@escape-game-over/atlas 0.1.24 → 0.1.26
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 +27 -44
- package/bin/use-project.mjs +18 -13
- package/docs/NOT-BUILT.md +1 -1
- package/docs/client-scripts.md +96 -193
- package/docs/rich-text.md +18 -20
- package/package.json +9 -15
- package/src/analytics/google.ts +6 -6
- package/src/analytics/index.ts +4 -3
- package/src/analytics/tags.ts +13 -58
- package/src/analytics/umami.ts +8 -8
- package/src/astro/AtlasElement.astro +26 -0
- package/src/astro/ConsentBanner.astro +25 -0
- package/src/astro/ConsentElement.astro +67 -0
- package/src/astro/Document.astro +44 -0
- package/src/astro/Image.astro +101 -0
- package/src/astro/MetaTags.astro +30 -40
- package/src/astro/RichText.astro +88 -0
- package/src/astro/Zoom.astro +61 -0
- package/src/astro/client.ts +18 -9
- package/src/astro/consent.ts +20 -0
- package/src/astro/dev-log.ts +8 -14
- package/src/astro/element.ts +76 -112
- package/src/astro/filters-view.ts +48 -64
- package/src/astro/filters.ts +42 -35
- package/src/astro/index.ts +2 -9
- package/src/astro/ref.ts +46 -0
- package/src/astro/site-routes.ts +9 -15
- package/src/config.ts +23 -36
- package/src/content/index.ts +1 -1
- package/src/content/marks.ts +13 -13
- package/src/content/rich.ts +26 -42
- package/src/hours.ts +48 -11
- package/src/i18n/define.ts +14 -74
- package/src/index.ts +40 -57
- package/src/meta/index.ts +7 -13
- package/src/meta/share-image.ts +2 -26
- package/src/meta/tag.ts +1 -45
- package/src/money.ts +161 -6
- package/src/project.ts +84 -73
- package/src/routes/define.ts +8 -44
- package/src/routes/resolve.ts +1 -1
- package/src/site/api.ts +7 -33
- package/src/site/create.ts +6 -10
- package/src/site/define.ts +120 -0
- package/src/site/index.ts +2 -5
- package/src/site/page.ts +4 -2
- package/src/sitemap.ts +2 -35
- package/src/warn.ts +16 -17
- package/src/astro/dom.ts +0 -35
- package/src/astro/markup.ts +0 -656
package/src/astro/consent.ts
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
import { CONSENT_UPDATE_GLOBAL } from "../analytics/google.ts";
|
|
2
|
+
import type { ElementTag } from "./element.ts";
|
|
3
|
+
|
|
4
|
+
/** Internal to `ConsentBanner.astro`: the element, and what its script finds. */
|
|
5
|
+
export const consentBanner: ElementTag<"atlas-consent"> = {
|
|
6
|
+
tag: "atlas-consent",
|
|
7
|
+
};
|
|
8
|
+
export const CONSENT_ANSWER = "data-atlas-consent-answer";
|
|
9
|
+
export const CONSENT_REOPEN = "data-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: { [CONSENT_ANSWER]: "granted" },
|
|
19
|
+
decline: { [CONSENT_ANSWER]: "denied" },
|
|
20
|
+
reopen: { [CONSENT_REOPEN]: "", hidden: "" },
|
|
21
|
+
};
|
|
2
22
|
|
|
3
23
|
/**
|
|
4
24
|
* The browser half of consent: remembering an answer, expiring it, and handing
|
package/src/astro/dev-log.ts
CHANGED
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* and message strings
|
|
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);
|
package/src/astro/element.ts
CHANGED
|
@@ -1,116 +1,108 @@
|
|
|
1
1
|
import { reportDevError } from "./dev-log.ts";
|
|
2
|
-
import type { Marked, Markup } from "./markup.ts";
|
|
3
|
-
import { ROOT_ATTRIBUTE } from "./markup.ts";
|
|
4
2
|
|
|
5
3
|
/**
|
|
6
|
-
* What
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `signal` has already said how it comes down — so `void` rather than
|
|
10
|
-
* `undefined`, which would make every connect end in a `return`.
|
|
4
|
+
* What `AtlasElement` puts on every root: one name, so a project gives every
|
|
5
|
+
* root a display in a single stylesheet rule, and so `element` can say when a
|
|
6
|
+
* root was rendered by hand.
|
|
11
7
|
*/
|
|
8
|
+
export const ROOT_ATTRIBUTE = "data-atlas-root";
|
|
9
|
+
|
|
10
|
+
/** What is left to undo when the element leaves; usually nothing. */
|
|
12
11
|
// biome-ignore lint/suspicious/noConfusingVoidType: that is the distinction.
|
|
13
12
|
type Undo = void | (() => void);
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
one<T extends Element = HTMLElement>(): Marked<T, F> | null;
|
|
21
|
-
require<T extends Element = HTMLElement>(): Marked<T, F>;
|
|
22
|
-
}
|
|
23
|
-
: never;
|
|
24
|
-
|
|
25
|
-
/**
|
|
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.
|
|
31
|
-
*/
|
|
32
|
-
export type Roles<C> = {
|
|
33
|
-
readonly [K in Exclude<keyof C, "tag" | "root">]: Bound<C[K]>;
|
|
34
|
-
};
|
|
14
|
+
export interface ElementContext {
|
|
15
|
+
readonly host: HTMLElement;
|
|
16
|
+
/** Aborted when the element leaves, taking its listeners with it. */
|
|
17
|
+
readonly signal: AbortSignal;
|
|
18
|
+
}
|
|
35
19
|
|
|
36
20
|
/**
|
|
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.
|
|
21
|
+
* What an element does while it is on the page. Anything `signal` does not
|
|
22
|
+
* already undo is the returned function.
|
|
43
23
|
*/
|
|
44
|
-
export type Connect
|
|
45
|
-
host: HTMLElement,
|
|
46
|
-
signal: AbortSignal,
|
|
47
|
-
roles: Roles<C>
|
|
48
|
-
) => Undo;
|
|
24
|
+
export type Connect = (context: ElementContext) => Undo;
|
|
49
25
|
|
|
50
|
-
/**
|
|
51
|
-
interface
|
|
52
|
-
|
|
53
|
-
one(root: ParentNode): unknown;
|
|
54
|
-
require(root: ParentNode): unknown;
|
|
26
|
+
/** What a template renders the element from, with `AtlasElement`. */
|
|
27
|
+
export interface ElementTag<Tag extends string = string> {
|
|
28
|
+
readonly tag: Tag;
|
|
55
29
|
}
|
|
56
30
|
|
|
57
31
|
/**
|
|
58
|
-
*
|
|
32
|
+
* A custom element's behaviour, registered under `tag`.
|
|
59
33
|
*
|
|
60
34
|
* ```ts
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* return loop.attach(host); // runs when the element leaves the page
|
|
35
|
+
* // faq.ts — imported by the template for the tag, and by the page's script
|
|
36
|
+
* export const faq = element("go-faq", ({ host, signal }) => {
|
|
37
|
+
* const input = ref<HTMLInputElement>(host, "[data-faq-search]");
|
|
38
|
+
* …
|
|
66
39
|
* });
|
|
67
40
|
* ```
|
|
41
|
+
* ```astro
|
|
42
|
+
* <script src="./faq.ts"></script>
|
|
43
|
+
* <AtlasElement of={faq}><input data-faq-search type="search"></AtlasElement>
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* Registers itself when loaded in a browser and does nothing in Node, so the
|
|
47
|
+
* template imports the same file.
|
|
68
48
|
*
|
|
69
49
|
* - **An element can enter the page more than once.** Moving it runs the undo
|
|
70
|
-
* and
|
|
71
|
-
* `ClientRouter` connects a persisted element three times per navigation.
|
|
50
|
+
* and `connect` again; Astro's `ClientRouter` does so on every navigation.
|
|
72
51
|
* State that must survive belongs in a `WeakMap` keyed by `host`.
|
|
73
|
-
* - **The
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* the
|
|
77
|
-
* - **A
|
|
78
|
-
* rather than taking its siblings down with it.
|
|
79
|
-
*
|
|
80
|
-
* See docs/client-scripts.md.
|
|
52
|
+
* - **The page's script must stay a bundled module** (a plain `<script>`), so
|
|
53
|
+
* the element has its children when it upgrades.
|
|
54
|
+
* - **Failures show in dev.** A `connect` that throws leaves that one element
|
|
55
|
+
* inert, and it, or any uncaught error on the page, lands in the dev panel.
|
|
56
|
+
* - **A tag defined twice keeps the first definition** and reports the second.
|
|
81
57
|
*/
|
|
82
|
-
export function
|
|
83
|
-
|
|
84
|
-
connect: Connect
|
|
85
|
-
):
|
|
86
|
-
|
|
58
|
+
export function element<const Tag extends string>(
|
|
59
|
+
tag: Tag,
|
|
60
|
+
connect: Connect
|
|
61
|
+
): ElementTag<Tag> {
|
|
62
|
+
if (typeof customElements !== "undefined") {
|
|
63
|
+
watchDevErrors();
|
|
64
|
+
// Defining upgrades elements already on the page synchronously; a
|
|
65
|
+
// microtask later, `const x = element(…)` is assigned first.
|
|
66
|
+
queueMicrotask(() => define(tag, connect));
|
|
67
|
+
}
|
|
68
|
+
return { tag };
|
|
69
|
+
}
|
|
87
70
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
71
|
+
let watching = false;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Puts errors thrown outside `connect` — in a listener, after an `await` — in
|
|
75
|
+
* the dev panel too. Once per page load: the listeners outlive a body swap.
|
|
76
|
+
*/
|
|
77
|
+
function watchDevErrors(): void {
|
|
78
|
+
if (!import.meta.env.DEV || watching) return;
|
|
79
|
+
watching = true;
|
|
80
|
+
window.addEventListener("error", (event) => {
|
|
81
|
+
reportDevError("page", event.error ?? event.message);
|
|
82
|
+
});
|
|
83
|
+
window.addEventListener("unhandledrejection", (event) => {
|
|
84
|
+
reportDevError("page", event.reason);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function define(tag: string, connect: Connect): void {
|
|
89
|
+
if (customElements.get(tag) !== undefined) {
|
|
90
|
+
reportDevError(
|
|
91
|
+
tag,
|
|
92
|
+
new Error(`<${tag}> is already defined; this definition is ignored`)
|
|
93
|
+
);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
93
96
|
|
|
94
|
-
// The class is built in here rather than at module scope so `HTMLElement`
|
|
95
|
-
// is only read in a browser.
|
|
96
97
|
customElements.define(
|
|
97
98
|
tag,
|
|
98
99
|
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
|
-
*/
|
|
100
|
+
/** This visit's listeners. Remade every time: a controller is single-use. */
|
|
104
101
|
#ac?: AbortController;
|
|
105
|
-
|
|
106
|
-
/** What `connect` handed back, if anything. */
|
|
107
102
|
#undo?: () => void;
|
|
108
103
|
|
|
109
104
|
connectedCallback(): void {
|
|
110
|
-
// Insurance: a live controller still here would orphan its
|
|
111
|
-
// listeners.
|
|
112
105
|
this.#end();
|
|
113
|
-
|
|
114
106
|
const ac = new AbortController();
|
|
115
107
|
this.#ac = ac;
|
|
116
108
|
try {
|
|
@@ -119,13 +111,13 @@ export function defineElement<C extends { readonly tag: string }>(
|
|
|
119
111
|
!this.hasAttribute(ROOT_ATTRIBUTE)
|
|
120
112
|
) {
|
|
121
113
|
throw new Error(
|
|
122
|
-
`<${tag}> carries no ${ROOT_ATTRIBUTE} —
|
|
114
|
+
`<${tag}> carries no ${ROOT_ATTRIBUTE} — is it rendered with <AtlasElement of={…}>?`
|
|
123
115
|
);
|
|
124
116
|
}
|
|
125
117
|
this.#undo =
|
|
126
|
-
connect(this, ac.signal
|
|
118
|
+
connect({ host: this, signal: ac.signal }) ?? undefined;
|
|
127
119
|
} catch (error) {
|
|
128
|
-
this
|
|
120
|
+
reportDevError(this.localName, error);
|
|
129
121
|
}
|
|
130
122
|
}
|
|
131
123
|
|
|
@@ -133,50 +125,22 @@ export function defineElement<C extends { readonly tag: string }>(
|
|
|
133
125
|
this.#end();
|
|
134
126
|
}
|
|
135
127
|
|
|
136
|
-
/** Moving to another document ends the old document's visit. */
|
|
137
128
|
adoptedCallback(): void {
|
|
138
129
|
this.#end();
|
|
139
130
|
}
|
|
140
131
|
|
|
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
132
|
#end(): void {
|
|
158
133
|
// Aborted before the undo runs, so an async continuation that
|
|
159
134
|
// checks the signal can see the visit is over.
|
|
160
135
|
this.#ac?.abort();
|
|
161
136
|
this.#ac = undefined;
|
|
162
|
-
|
|
163
137
|
const undo = this.#undo;
|
|
164
138
|
this.#undo = undefined;
|
|
165
139
|
try {
|
|
166
140
|
undo?.();
|
|
167
141
|
} 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
142
|
reportDevError(this.localName, error);
|
|
177
|
-
return;
|
|
178
143
|
}
|
|
179
|
-
console.error(`${this.localName}:`, error);
|
|
180
144
|
}
|
|
181
145
|
}
|
|
182
146
|
);
|
|
@@ -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
|
|
73
|
-
* const
|
|
74
|
-
*
|
|
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
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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) =>
|
|
135
|
-
* hideEmpty(regions, (r) =>
|
|
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>(
|
package/src/astro/filters.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* ```ts
|
|
5
5
|
* const list = filters({
|
|
6
6
|
* fields: {
|
|
7
|
-
* q: { kind: "text"
|
|
8
|
-
* category: { kind: "choice"
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
195
|
-
if (
|
|
196
|
-
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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
|
|
332
|
-
|
|
333
|
-
if (
|
|
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(
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
451
|
+
events.abort();
|
|
445
452
|
attached = false;
|
|
446
453
|
};
|
|
447
454
|
},
|
package/src/astro/index.ts
CHANGED
|
@@ -22,15 +22,8 @@
|
|
|
22
22
|
* - `public-files.ts` types the contents of `public/`, the one part of a site
|
|
23
23
|
* the compiler cannot otherwise see.
|
|
24
24
|
*
|
|
25
|
-
* The rest
|
|
26
|
-
*
|
|
27
|
-
* a view or a config, `…/astro/consent` from a client-side script, and
|
|
28
|
-
* `…/astro/meta-tags` from a layout.
|
|
29
|
-
*
|
|
30
|
-
* That last one is a `.astro` component, and the only one lib carries. It is
|
|
31
|
-
* here rather than in a project's `src/` because it holds no design and no
|
|
32
|
-
* copy — it renders the `MetaTag` union and nothing else, which makes it the
|
|
33
|
-
* mirror of `asMetaTag` in `lib/meta/tag.ts` rather than a piece of a theme.
|
|
25
|
+
* The rest is imported from its own path: `…/astro/images` from a view,
|
|
26
|
+
* `…/astro/document` from a layout, `…/client` from a script.
|
|
34
27
|
*/
|
|
35
28
|
|
|
36
29
|
export {
|