@escape-game-over/atlas 0.1.1 → 0.1.2
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 +48 -7
- package/docs/NOT-BUILT.md +84 -0
- package/docs/toolchain.md +49 -22
- package/package.json +10 -6
- package/src/astro/MetaTags.astro +2 -4
- package/src/astro/carousel.ts +342 -0
- package/src/astro/dev-log.ts +128 -0
- package/src/astro/dom.ts +53 -0
- package/src/astro/element.ts +259 -0
- package/src/astro/site-routes.ts +111 -21
- package/src/astro/tsconfig.json +8 -0
- package/src/contact-form.ts +177 -0
- package/src/index.ts +7 -0
- package/src/routes/define.ts +3 -1
- package/src/routes/resolve.ts +28 -3
- package/src/site/api.ts +12 -0
- package/src/site/create.ts +132 -42
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { reportDevError } from "./dev-log.ts";
|
|
2
|
+
import { within } from "./dom.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A base for custom elements, holding the parts every one of them gets wrong.
|
|
6
|
+
*
|
|
7
|
+
* In `astro/` because it touches the DOM, which the core is type-checked
|
|
8
|
+
* without — this, `consent.ts`, `carousel.ts` and `dom.ts` are the folder
|
|
9
|
+
* allowed it.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* class Thing extends AtlasElement {
|
|
13
|
+
* #count = 0;
|
|
14
|
+
*
|
|
15
|
+
* protected setup(): void { … } // once, ever
|
|
16
|
+
* protected connect(): void { // every time it enters the document
|
|
17
|
+
* this.require("form").addEventListener("submit", this.#send, {
|
|
18
|
+
* signal: this.signal,
|
|
19
|
+
* });
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
* customElements.define("atlas-thing", Thing);
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* **Two lifetimes, and confusing them is the whole reason this class exists.**
|
|
26
|
+
* An element is constructed once and may be connected many times: moving it in
|
|
27
|
+
* the DOM runs `disconnectedCallback` then `connectedCallback` again. So
|
|
28
|
+
* `setup` is for what the element *is* — state, defaults, anything a reader
|
|
29
|
+
* would be annoyed to lose — and `connect` is for what a connection *owns* —
|
|
30
|
+
* listeners, timers, observers. Anything registered with `signal` is torn down
|
|
31
|
+
* on every disconnect and re-registered on every connect, so a re-entry rebinds
|
|
32
|
+
* rather than duplicating.
|
|
33
|
+
*
|
|
34
|
+
* **Subclasses implement `setup`, `connect` and `disconnect` — never
|
|
35
|
+
* `connectedCallback` or `disconnectedCallback`.** Overriding those replaces
|
|
36
|
+
* this class's, the controller is never created, and every listener leaks on
|
|
37
|
+
* each disconnect. `connect` is abstract, so forgetting it is a compile error;
|
|
38
|
+
* the adjacent mistake is caught at construction in development, below.
|
|
39
|
+
*
|
|
40
|
+
* **The script that defines a subclass must stay a deferred module.** Astro
|
|
41
|
+
* emits `<script>` and `<script src="./…">` as `type="module"`, which runs after
|
|
42
|
+
* the document is parsed, so an element has its children by the time it is
|
|
43
|
+
* upgraded. `is:inline` opts out and runs the script where it sits — usually
|
|
44
|
+
* above the element it wires — and every lookup then finds nothing. The
|
|
45
|
+
* protection comes from the bundling, not from custom elements.
|
|
46
|
+
*
|
|
47
|
+
* **`attributeChangedCallback` runs before `connectedCallback`** for attributes
|
|
48
|
+
* present in the initial markup, so `signal` is not available there and reading
|
|
49
|
+
* it throws. A subclass with `observedAttributes` should record what changed and
|
|
50
|
+
* act on it in `connect`.
|
|
51
|
+
*
|
|
52
|
+
* What is deliberately *not* modelled is destruction. `disconnect` fires on
|
|
53
|
+
* every move, so it is connection teardown and nothing else — it is not the
|
|
54
|
+
* place to flush state or release something genuinely scarce, because the
|
|
55
|
+
* element may well be back a microtask later.
|
|
56
|
+
*/
|
|
57
|
+
export abstract class AtlasElement extends HTMLElement {
|
|
58
|
+
/**
|
|
59
|
+
* Cancels this connection's listeners, and only this connection's.
|
|
60
|
+
*
|
|
61
|
+
* Remade on every connect rather than held in a field initializer, because
|
|
62
|
+
* an `AbortController` is single-use: a controller that outlived the first
|
|
63
|
+
* connection would come back already aborted, `addEventListener` with an
|
|
64
|
+
* aborted signal silently adds nothing, and the element would return looking
|
|
65
|
+
* perfectly normal and be inert forever.
|
|
66
|
+
*/
|
|
67
|
+
#ac?: AbortController;
|
|
68
|
+
|
|
69
|
+
/** Whether `setup` has run. See the two-lifetimes note above. */
|
|
70
|
+
#ready = false;
|
|
71
|
+
|
|
72
|
+
constructor() {
|
|
73
|
+
super();
|
|
74
|
+
// Written as the bare expression Vite substitutes: `import.meta.env.DEV`
|
|
75
|
+
// is replaced with a literal, so this collapses to `if (false)` and the
|
|
76
|
+
// method below is dropped from the bundle. An optional chain would be
|
|
77
|
+
// replaced as `import.meta.env` instead — an object literal, whose
|
|
78
|
+
// `.DEV` a minifier has to fold rather than simply delete.
|
|
79
|
+
if (import.meta.env.DEV) this.#assertHooks();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Refuses a subclass that overrode the wrong lifecycle method.
|
|
84
|
+
*
|
|
85
|
+
* An override is an *own* property of the subclass prototype, where the
|
|
86
|
+
* inherited one is not — so walking up to this class's prototype finds it.
|
|
87
|
+
* The loop rather than a single check, because an intermediate base class
|
|
88
|
+
* could be the one that got it wrong.
|
|
89
|
+
*/
|
|
90
|
+
#assertHooks(): void {
|
|
91
|
+
// A tuple array rather than an object: `Object.entries` widens its keys
|
|
92
|
+
// back to `string`, so the pairing would stop being checked exactly
|
|
93
|
+
// where a typo would matter.
|
|
94
|
+
const wrong = [
|
|
95
|
+
["connectedCallback", "connect"],
|
|
96
|
+
["disconnectedCallback", "disconnect"],
|
|
97
|
+
] as const;
|
|
98
|
+
|
|
99
|
+
for (
|
|
100
|
+
let proto = Object.getPrototypeOf(this);
|
|
101
|
+
proto && proto !== AtlasElement.prototype;
|
|
102
|
+
proto = Object.getPrototypeOf(proto)
|
|
103
|
+
) {
|
|
104
|
+
for (const [callback, hook] of wrong) {
|
|
105
|
+
if (Object.hasOwn(proto, callback)) {
|
|
106
|
+
const error = new Error(
|
|
107
|
+
`override "${hook}", not "${callback}" — see AtlasElement`
|
|
108
|
+
);
|
|
109
|
+
// Reported before throwing: this runs during upgrade, so
|
|
110
|
+
// `connectedCallback`'s catch has not been entered and the
|
|
111
|
+
// throw would surface only as an uncaught error in the
|
|
112
|
+
// console — invisible, for the one mistake this whole
|
|
113
|
+
// apparatus exists to catch.
|
|
114
|
+
reportDevError(this.constructor.name, error);
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Pass to `addEventListener`, observers, anything that should stop when the
|
|
123
|
+
* element leaves the document.
|
|
124
|
+
*
|
|
125
|
+
* Throws when read outside a connection, which is a programming error
|
|
126
|
+
* rather than a state to handle: there is nothing sensible to return.
|
|
127
|
+
*/
|
|
128
|
+
protected get signal(): AbortSignal {
|
|
129
|
+
if (!this.#ac) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`${this.localName}: signal read outside a connection`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return this.#ac.signal;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
connectedCallback(): void {
|
|
138
|
+
// Insurance. The spec pairs the callbacks, so this should never find a
|
|
139
|
+
// live controller — but if it ever did, overwriting one would orphan
|
|
140
|
+
// every listener it owned, silently, which is the failure this class
|
|
141
|
+
// exists to make impossible.
|
|
142
|
+
this.#ac?.abort();
|
|
143
|
+
this.#ac = new AbortController();
|
|
144
|
+
|
|
145
|
+
// Caught here so one broken element logs and sits inert rather than
|
|
146
|
+
// taking its siblings with it: the browser calls this once per element,
|
|
147
|
+
// so the blast radius is already one. That is what lets `require`
|
|
148
|
+
// throw instead of returning something every call site has to check.
|
|
149
|
+
try {
|
|
150
|
+
if (!this.#ready) {
|
|
151
|
+
this.setup();
|
|
152
|
+
// Only once it returned. Setting the flag first would mean a
|
|
153
|
+
// `setup` that threw was never retried, and the next connect
|
|
154
|
+
// would run `connect` against state that was never initialized
|
|
155
|
+
// — a component that renders perfectly and does nothing, which
|
|
156
|
+
// is the failure this class exists to make impossible.
|
|
157
|
+
this.#ready = true;
|
|
158
|
+
}
|
|
159
|
+
this.connect();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
this.#report(error);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
disconnectedCallback(): void {
|
|
166
|
+
// Idempotent, so `adoptedCallback` can delegate here without running a
|
|
167
|
+
// subclass's teardown twice.
|
|
168
|
+
if (!this.#ac) return;
|
|
169
|
+
this.#ac.abort();
|
|
170
|
+
|
|
171
|
+
// Caught for the same reason as `connect`, and it matters more: a
|
|
172
|
+
// throw here escapes into whatever is swapping the DOM — a router, a
|
|
173
|
+
// view transition — rather than staying in the component.
|
|
174
|
+
try {
|
|
175
|
+
// Before `#ac` is cleared, so `this.signal` is readable and already
|
|
176
|
+
// aborted: an async continuation can check `signal.aborted` and
|
|
177
|
+
// bail rather than finishing against a detached element.
|
|
178
|
+
this.disconnect();
|
|
179
|
+
} catch (error) {
|
|
180
|
+
this.#report(error);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this.#ac = undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Fires when the element moves to another document.
|
|
188
|
+
*
|
|
189
|
+
* Delegates, because the controller belongs to the connection in the old
|
|
190
|
+
* document and nothing else would tear it down. Exotic — `adoptNode` and
|
|
191
|
+
* iframe work — and one line either way.
|
|
192
|
+
*/
|
|
193
|
+
adoptedCallback(): void {
|
|
194
|
+
this.disconnectedCallback();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#report(error: unknown): void {
|
|
198
|
+
if (import.meta.env.DEV) {
|
|
199
|
+
reportDevError(this.localName, error);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
console.error(`${this.localName}:`, error);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Runs once per element, before its first `connect`.
|
|
207
|
+
*
|
|
208
|
+
* Where state belongs. A move re-runs `connect` but never this, so anything
|
|
209
|
+
* initialised here survives one — which is the difference between a reader
|
|
210
|
+
* coming back to the slide they left and coming back to the first one.
|
|
211
|
+
*/
|
|
212
|
+
protected setup(): void {}
|
|
213
|
+
|
|
214
|
+
/** Runs on every connect, with `signal` and the children both available. */
|
|
215
|
+
protected abstract connect(): void;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Runs on every disconnect, once the signal has been aborted.
|
|
219
|
+
*
|
|
220
|
+
* `signal` is still readable here and reports `aborted` — which is what an
|
|
221
|
+
* async continuation should check before touching a now-detached element.
|
|
222
|
+
* What it is *not* is a destructor: a move fires this and then `connect`
|
|
223
|
+
* again a moment later, so it is connection teardown and nothing else.
|
|
224
|
+
* Flushing state or releasing something scarce does not belong here.
|
|
225
|
+
*
|
|
226
|
+
* Empty by default: anything registered with `signal` is already gone, and
|
|
227
|
+
* most elements have nothing else to undo.
|
|
228
|
+
*/
|
|
229
|
+
protected disconnect(): void {}
|
|
230
|
+
|
|
231
|
+
/** The first match inside this element, or `null`. */
|
|
232
|
+
protected one<T extends Element = HTMLElement>(selector: string): T | null {
|
|
233
|
+
return within(this).one<T>(selector);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Every match inside this element, as an array. */
|
|
237
|
+
protected all<T extends Element = HTMLElement>(selector: string): T[] {
|
|
238
|
+
return within(this).all<T>(selector);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The first match, or a thrown error naming what was missing.
|
|
243
|
+
*
|
|
244
|
+
* Throws rather than returning `T | null`, because a nullable return puts an
|
|
245
|
+
* `?.` at every call site — and that optional chain swallows the failure
|
|
246
|
+
* just as thoroughly as the missing element did, which is the thing worth
|
|
247
|
+
* avoiding. `connectedCallback` catches it, so one element with broken
|
|
248
|
+
* markup logs and stops while every other element on the page is untouched.
|
|
249
|
+
*/
|
|
250
|
+
protected require<T extends Element = HTMLElement>(selector: string): T {
|
|
251
|
+
const found = this.one<T>(selector);
|
|
252
|
+
if (!found) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`nothing matched "${selector}" — is the script still a deferred module?`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return found;
|
|
258
|
+
}
|
|
259
|
+
}
|
package/src/astro/site-routes.ts
CHANGED
|
@@ -82,17 +82,19 @@ export interface SiteRoutesOptions {
|
|
|
82
82
|
* Emits the files a static site owes the outside world.
|
|
83
83
|
*
|
|
84
84
|
* Written into the output directory when the build is done, rather than served
|
|
85
|
-
* by injected routes.
|
|
85
|
+
* by injected routes. One reason, and it is not the obvious one:
|
|
86
86
|
*
|
|
87
|
-
* - `_redirects` cannot be a page at all. Astro refuses to route anything in
|
|
88
|
-
* `src/pages` whose name begins with `_`, and URL-escaping does not help —
|
|
89
|
-
* `%5F` is decoded before the entrypoint is opened.
|
|
90
87
|
* - A route is injected by *entrypoint*, and that file is compiled into the
|
|
91
|
-
* build's own module graph, where it cannot see a caller's `site`.
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
88
|
+
* build's own module graph, where it cannot see a caller's `site`. Every way
|
|
89
|
+
* of reaching one from there is worse than the staleness it would cure — see
|
|
90
|
+
* the entry in `docs/NOT-BUILT.md`, which records what was measured.
|
|
91
|
+
*
|
|
92
|
+
* **`_redirects` is not the obstacle, whatever this comment used to say.** What
|
|
93
|
+
* Astro refuses is a *filename* beginning with `_` in `src/pages`; an injected
|
|
94
|
+
* route names its pattern and its entrypoint separately, so the underscore
|
|
95
|
+
* lands only on the URL and the entrypoint is an ordinary module elsewhere.
|
|
96
|
+
* That was built and served, in dev and in a build, before being taken out
|
|
97
|
+
* again for the reason above.
|
|
96
98
|
*/
|
|
97
99
|
export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
|
|
98
100
|
const { site, redirects, llms, sitemap = true, robots = true } = options;
|
|
@@ -102,9 +104,15 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
|
|
|
102
104
|
`${file.name} (${size(file.body)})`;
|
|
103
105
|
|
|
104
106
|
// Rebuilt per request in dev and once at the end of a build, rather than
|
|
105
|
-
// computed here
|
|
106
|
-
//
|
|
107
|
-
//
|
|
107
|
+
// computed here.
|
|
108
|
+
//
|
|
109
|
+
// Worth knowing what that does *not* buy: it does not make dev current. The
|
|
110
|
+
// `site` and the `llms` file this closes over were built when the Astro
|
|
111
|
+
// config was evaluated, and nothing re-evaluates that until the server
|
|
112
|
+
// restarts — so rebuilding per request derives the same answer from the same
|
|
113
|
+
// frozen inputs. Measured: editing a message refreshes the page that renders
|
|
114
|
+
// it and leaves the same string in `llms.txt` untouched. A build is always
|
|
115
|
+
// right, since it evaluates the config in a fresh process.
|
|
108
116
|
const generate = (): GeneratedFile[] => {
|
|
109
117
|
const files: GeneratedFile[] = [];
|
|
110
118
|
if (sitemap) files.push(...site.sitemap().files);
|
|
@@ -143,11 +151,12 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
|
|
|
143
151
|
name: "site-routes",
|
|
144
152
|
hooks: {
|
|
145
153
|
/**
|
|
146
|
-
* The
|
|
147
|
-
*
|
|
154
|
+
* The Astro settings this integration decides, and the one it
|
|
155
|
+
* refuses.
|
|
148
156
|
*
|
|
149
|
-
*
|
|
150
|
-
* contract whose other half this library
|
|
157
|
+
* Three are set because lib's own output would contradict them —
|
|
158
|
+
* each is the half of a contract whose other half this library
|
|
159
|
+
* already wrote:
|
|
151
160
|
*
|
|
152
161
|
* - `output: "static"`. Every file below is generated once, at
|
|
153
162
|
* build time, from data known then. There is no request to
|
|
@@ -167,12 +176,89 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
|
|
|
167
176
|
* refuse a form the host answers with a redirect rather than a
|
|
168
177
|
* 404 — dev stricter than production, which teaches you nothing.
|
|
169
178
|
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
179
|
+
* A fourth is set on weaker grounds, and deliberately so:
|
|
180
|
+
*
|
|
181
|
+
* - `prefetch`, all links, on `viewport`. Not a contract — nothing
|
|
182
|
+
* lib emits contradicts a consumer preferring `hover` — but the
|
|
183
|
+
* documents here are small enough that the usual objection does
|
|
184
|
+
* not apply: every page of the larger example's 25-item grid is
|
|
185
|
+
* around 3 kB gzipped, so prefetching the lot costs under 80 kB,
|
|
186
|
+
* and `rel="prefetch"` fetches the HTML alone rather than its
|
|
187
|
+
* images. A default worth overriding per site, not a rule —
|
|
188
|
+
* though not into `prerender`; see the refusal below.
|
|
189
|
+
*
|
|
190
|
+
* Two are refused rather than set, both because Astro resolves them
|
|
191
|
+
* to a default a deliberate value is distinguishable from — so the
|
|
192
|
+
* check fires at whoever asked for it rather than at everyone:
|
|
193
|
+
*
|
|
194
|
+
* - `compressHTML: true`, which lib's output does not contradict
|
|
195
|
+
* but its *source* does: the templates are written for Astro's
|
|
196
|
+
* `"jsx"` whitespace rules, and `true` renders them differently.
|
|
197
|
+
* - `experimental.clientPrerender: true`, which turns the `prefetch`
|
|
198
|
+
* above into `prerender` and runs every script on the page inside
|
|
199
|
+
* a document nobody opened.
|
|
200
|
+
*
|
|
201
|
+
* The three contracts at the top are set instead of validated for
|
|
202
|
+
* the opposite reason: there is no way to tell a deliberate
|
|
203
|
+
* `"directory"` from Astro's default, since both arrive here as the
|
|
204
|
+
* same resolved value, so a check could only warn at everyone or at
|
|
205
|
+
* no one.
|
|
174
206
|
*/
|
|
175
|
-
"astro:config:setup": ({
|
|
207
|
+
"astro:config:setup": ({
|
|
208
|
+
config: current,
|
|
209
|
+
updateConfig,
|
|
210
|
+
logger,
|
|
211
|
+
}) => {
|
|
212
|
+
/**
|
|
213
|
+
* `compressHTML: true` is refused rather than overridden.
|
|
214
|
+
*
|
|
215
|
+
* Unlike the three below, this is not a setting lib's output
|
|
216
|
+
* contradicts — both modes produce valid HTML. What it
|
|
217
|
+
* contradicts is the *source*: every template here was written
|
|
218
|
+
* under `"jsx"`, Astro 7's default, where whitespace around
|
|
219
|
+
* elements is dropped and a deliberate space is written `{" "}`.
|
|
220
|
+
* `true` keeps whitespace "as needed to maintain the visual
|
|
221
|
+
* rendering" instead, so the same markup renders differently.
|
|
222
|
+
*
|
|
223
|
+
* Thrown at rather than set, because unlike `build.format` a
|
|
224
|
+
* deliberate `true` is distinguishable from the default — so a
|
|
225
|
+
* check can fire at exactly the person who asked for it, and
|
|
226
|
+
* `false` stays available for reading the built HTML.
|
|
227
|
+
*/
|
|
228
|
+
if (current.compressHTML === true) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
"compressHTML: true is not supported. The templates are written for " +
|
|
231
|
+
'Astro\'s default "jsx" whitespace rules, where a deliberate space is `{" "}`; ' +
|
|
232
|
+
"`true` preserves whitespace differently and renders the same markup " +
|
|
233
|
+
'differently. Use "jsx", or `false` while inspecting the built HTML.'
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* `experimental.clientPrerender: true` is refused outright.
|
|
239
|
+
*
|
|
240
|
+
* It swaps the `rel="prefetch"` behind the default below for
|
|
241
|
+
* speculation-rules `prerender`: the browser builds each
|
|
242
|
+
* prefetched page in a hidden renderer and runs every script on
|
|
243
|
+
* it for a visitor who never opened it. Umami posts a pageview,
|
|
244
|
+
* its recorder starts a replay, and a third-party widget does
|
|
245
|
+
* whatever it does. Gating that takes `document.prerendering`,
|
|
246
|
+
* once per script, and a page is never done gaining scripts.
|
|
247
|
+
*
|
|
248
|
+
* Thrown rather than warned because none of it errors on its
|
|
249
|
+
* own — the numbers just read high, and plausibly. `false` is
|
|
250
|
+
* the resolved default, so this fires only at someone who
|
|
251
|
+
* asked.
|
|
252
|
+
*/
|
|
253
|
+
if (current.experimental.clientPrerender) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
"experimental.clientPrerender: true is not supported. It prerenders " +
|
|
256
|
+
"prefetched pages, running every script on them — analytics and " +
|
|
257
|
+
"third-party widgets included — for visitors who never open them. " +
|
|
258
|
+
"Which can cause either issues or invalid metrics."
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
176
262
|
// Annotated, not just passed: `updateConfig(config)` alone
|
|
177
263
|
// would not catch a misspelled key, because excess-property
|
|
178
264
|
// checking fires on a fresh object literal at the call site and
|
|
@@ -183,6 +269,10 @@ export function siteRoutes(options: SiteRoutesOptions): AstroIntegration {
|
|
|
183
269
|
output: "static",
|
|
184
270
|
build: { format: "file" },
|
|
185
271
|
trailingSlash: "ignore",
|
|
272
|
+
prefetch: {
|
|
273
|
+
prefetchAll: true,
|
|
274
|
+
defaultStrategy: "viewport",
|
|
275
|
+
},
|
|
186
276
|
};
|
|
187
277
|
updateConfig(config);
|
|
188
278
|
// Said out loud: a setting changed from under you is worth a
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { type HttpsUrl, joinUrl } from "./url.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Posting a contact form to the central mail API, from the browser.
|
|
5
|
+
*
|
|
6
|
+
* The API takes the message and decides everything else: which mailbox it goes
|
|
7
|
+
* to, which template renders it, whether the sending domain is allowed at all.
|
|
8
|
+
* A form therefore never states a recipient — it states which account it is
|
|
9
|
+
* writing on behalf of, and the account owns the rest. That is what lets this
|
|
10
|
+
* ship as a static page with no server of its own.
|
|
11
|
+
*
|
|
12
|
+
* One function, and the wire format stays inside it. What a message *is* — which
|
|
13
|
+
* fields a form asks for, whether a phone number is required, how long is long
|
|
14
|
+
* enough — is a per-site decision, and the API applies its own rules regardless;
|
|
15
|
+
* checking them again here would be the same rules written twice, drifting.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Which template renders the mail, and which mailbox it reaches.
|
|
20
|
+
*
|
|
21
|
+
* Both fall back to `default` when the type is unknown, so a typo does not lose
|
|
22
|
+
* the message — it files it as an ordinary enquiry, which is the failure worth
|
|
23
|
+
* catching. Hence a closed union rather than `string`.
|
|
24
|
+
*/
|
|
25
|
+
export type ContactFormType =
|
|
26
|
+
| "default"
|
|
27
|
+
| "birthday_parties"
|
|
28
|
+
| "birthday_party"
|
|
29
|
+
| "board_games"
|
|
30
|
+
| "group_booking"
|
|
31
|
+
| "team_building"
|
|
32
|
+
| "voucher";
|
|
33
|
+
|
|
34
|
+
/** Where a deployment's contact mail goes. */
|
|
35
|
+
export interface MailEndpoint {
|
|
36
|
+
/**
|
|
37
|
+
* The API's send route, without the account.
|
|
38
|
+
*
|
|
39
|
+
* `https` because the API reads the browser's `Origin` header and refuses
|
|
40
|
+
* anything else — see `sendContactMessage`.
|
|
41
|
+
*/
|
|
42
|
+
readonly url: HttpsUrl;
|
|
43
|
+
/**
|
|
44
|
+
* The account the message is sent on behalf of: `"b2b_cube"`.
|
|
45
|
+
*
|
|
46
|
+
* What the API looks the mailbox and the allowed domains up by. A domain
|
|
47
|
+
* that is not registered against it is refused, and nothing configured here
|
|
48
|
+
* substitutes for that.
|
|
49
|
+
*/
|
|
50
|
+
readonly account: string;
|
|
51
|
+
/** Defaults to `"default"`, which every account has. */
|
|
52
|
+
readonly type?: ContactFormType;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A filled-in form, in the words a page uses rather than the API's. */
|
|
56
|
+
export interface ContactMessage {
|
|
57
|
+
readonly name: string;
|
|
58
|
+
readonly email: string;
|
|
59
|
+
readonly subject: string;
|
|
60
|
+
readonly message: string;
|
|
61
|
+
/**
|
|
62
|
+
* Anything else the form asked for: a phone number, a company, a date.
|
|
63
|
+
*
|
|
64
|
+
* Free-form because the API stores it as received and its template renders
|
|
65
|
+
* one titled row per entry — so `estimated_players` arrives as "Estimated
|
|
66
|
+
* Players" with no change on either side. Blanks are dropped rather than
|
|
67
|
+
* sent.
|
|
68
|
+
*/
|
|
69
|
+
readonly additional?: Readonly<Record<string, string | undefined>>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What happened to a message.
|
|
74
|
+
*
|
|
75
|
+
* Three outcomes rather than a boolean, because a form says something different
|
|
76
|
+
* about each: `rejected` is the API disagreeing and may name fields, while
|
|
77
|
+
* `unreachable` is nobody's fault and is the one worth printing an email
|
|
78
|
+
* address next to.
|
|
79
|
+
*/
|
|
80
|
+
export type ContactResult =
|
|
81
|
+
| { readonly ok: true }
|
|
82
|
+
| { readonly ok: false; readonly reason: "unreachable" }
|
|
83
|
+
| {
|
|
84
|
+
readonly ok: false;
|
|
85
|
+
readonly reason: "rejected";
|
|
86
|
+
/** Per field, when the API named any. Keyed as the form names them. */
|
|
87
|
+
readonly problems: Readonly<Record<string, string>>;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The account as the API files it: lowercase, dashes as underscores, no spaces.
|
|
92
|
+
*
|
|
93
|
+
* The API normalizes what it receives, so this is not needed for a request to
|
|
94
|
+
* work — it is here so what a project writes and what the mail is filed under
|
|
95
|
+
* cannot quietly differ.
|
|
96
|
+
*/
|
|
97
|
+
function normalizeAccount(account: string): string {
|
|
98
|
+
return account.toLowerCase().replaceAll("-", "_").replaceAll(" ", "");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The API's field names, mapped back to the ones a form uses. */
|
|
102
|
+
const FIELD_OF: Readonly<Record<string, string>> = {
|
|
103
|
+
reply_to_name: "name",
|
|
104
|
+
reply_to_email: "email",
|
|
105
|
+
subject: "subject",
|
|
106
|
+
message: "message",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Posts a message, and says what happened.
|
|
111
|
+
*
|
|
112
|
+
* **The page must be served over https.** The API reads the `Origin` header and
|
|
113
|
+
* refuses anything else before it looks at the body, so a form works on a
|
|
114
|
+
* deployed site and is rejected from `http://localhost`. Preview builds on
|
|
115
|
+
* `*.pages.dev` are recognised and routed to a development mailbox, which is
|
|
116
|
+
* the intended way to try one out.
|
|
117
|
+
*
|
|
118
|
+
* `fetch` is a parameter so this is testable without a global.
|
|
119
|
+
*/
|
|
120
|
+
export async function sendContactMessage(
|
|
121
|
+
endpoint: MailEndpoint,
|
|
122
|
+
message: ContactMessage,
|
|
123
|
+
fetchImpl: typeof fetch = fetch
|
|
124
|
+
): Promise<ContactResult> {
|
|
125
|
+
const additional_info: Record<string, string> = {};
|
|
126
|
+
for (const [key, value] of Object.entries(message.additional ?? {})) {
|
|
127
|
+
const trimmed = value?.trim();
|
|
128
|
+
if (trimmed) additional_info[key] = trimmed;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const url = joinUrl(
|
|
132
|
+
endpoint.url,
|
|
133
|
+
`/${normalizeAccount(endpoint.account)}/${endpoint.type ?? "default"}`
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
let response: Response;
|
|
137
|
+
try {
|
|
138
|
+
response = await fetchImpl(url, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "Content-Type": "application/json", Accept: "*/*" },
|
|
141
|
+
redirect: "follow",
|
|
142
|
+
body: JSON.stringify({
|
|
143
|
+
reply_to_name: message.name.trim(),
|
|
144
|
+
reply_to_email: message.email.trim(),
|
|
145
|
+
subject: message.subject.trim(),
|
|
146
|
+
message: message.message.trim(),
|
|
147
|
+
additional_info,
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
} catch {
|
|
151
|
+
return { ok: false, reason: "unreachable" };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (response.ok) return { ok: true };
|
|
155
|
+
|
|
156
|
+
// The API answers a refusal with a field-keyed `problems` object, which is
|
|
157
|
+
// worth showing — but it refuses a disallowed origin or an ignored address
|
|
158
|
+
// with no fields at all, and those bodies are not always JSON. Either way
|
|
159
|
+
// the message did not send, so a parse failure is the same outcome with
|
|
160
|
+
// nothing to add.
|
|
161
|
+
try {
|
|
162
|
+
const body: unknown = await response.json();
|
|
163
|
+
const reported = (() => {
|
|
164
|
+
if (typeof body !== "object" || body === null) return undefined;
|
|
165
|
+
if (!("problems" in body)) return undefined;
|
|
166
|
+
return (body as { problems?: Record<string, string> }).problems;
|
|
167
|
+
})();
|
|
168
|
+
|
|
169
|
+
const problems: Record<string, string> = {};
|
|
170
|
+
for (const [key, value] of Object.entries(reported ?? {})) {
|
|
171
|
+
problems[FIELD_OF[key] ?? key] = value;
|
|
172
|
+
}
|
|
173
|
+
return { ok: false, reason: "rejected", problems };
|
|
174
|
+
} catch {
|
|
175
|
+
return { ok: false, reason: "rejected", problems: {} };
|
|
176
|
+
}
|
|
177
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -53,6 +53,13 @@ export {
|
|
|
53
53
|
type PostalAddress,
|
|
54
54
|
telHref,
|
|
55
55
|
} from "./contact.ts";
|
|
56
|
+
export {
|
|
57
|
+
type ContactFormType,
|
|
58
|
+
type ContactMessage,
|
|
59
|
+
type ContactResult,
|
|
60
|
+
type MailEndpoint,
|
|
61
|
+
sendContactMessage,
|
|
62
|
+
} from "./contact-form.ts";
|
|
56
63
|
export type { GeneratedFile } from "./file.ts";
|
|
57
64
|
export type { PublicFilePath, PublicFileRegistry } from "./files.ts";
|
|
58
65
|
export {
|
package/src/routes/define.ts
CHANGED
|
@@ -49,7 +49,9 @@ export interface RouteData<L extends string> {
|
|
|
49
49
|
* `1` or omitted is an ordinary route. Above that, lib emits
|
|
50
50
|
* `/news/page/2` … `/news/page/n` alongside `/news` — page one is always
|
|
51
51
|
* the bare slug, never `page/1`, so there is no duplicate to canonicalise
|
|
52
|
-
* away.
|
|
52
|
+
* away. `site.redirects()` then claims `/news/page/1` with a 301 onto the
|
|
53
|
+
* bare slug, since a URL nothing publishes is still one a reader edits
|
|
54
|
+
* their way to.
|
|
53
55
|
*
|
|
54
56
|
* Usually set per project rather than here: the count is posts divided by
|
|
55
57
|
* page size, and one venue has more posts than another.
|