@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.
@@ -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
+ }
@@ -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. Two reasons, and the first is not a preference:
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`. Reaching it
92
- * means naming a module in a string and resolving it through a virtual module
93
- * a lot of machinery, and nothing type-checks the string. None of these
94
- * files needs the asset pipeline or a page context, so none of that buys
95
- * anything.
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: in dev the site's data changes under the server, and a list
106
- // captured when the integration was constructed would serve yesterday's
107
- // sitemap until you restarted.
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 three settings lib's own output would contradict, set here
147
- * rather than asked for.
154
+ * The Astro settings this integration decides, and the one it
155
+ * refuses.
148
156
  *
149
- * Not a preference imposed on a consumer each is the half of a
150
- * contract whose other half this library already wrote:
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
- * Set instead of validated because there is no way to tell a
171
- * deliberate `"directory"` from Astro's default: both arrive here
172
- * as the same resolved value, so a check could only warn at
173
- * everyone or at no one.
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": ({ updateConfig, logger }) => {
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,8 @@
1
+ {
2
+ "extends": ["../../tsconfig.json", "astro/tsconfigs/strict"],
3
+ "compilerOptions": {
4
+ "types": ["astro/client"]
5
+ },
6
+ "include": ["**/*"],
7
+ "exclude": ["node_modules", "dist"]
8
+ }
@@ -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 {
@@ -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.