@iterant/site-runtime 3.6.0 → 3.7.0

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.
@@ -50,7 +50,7 @@ runtime and says so.
50
50
 
51
51
  <!-- generated: available libraries -->
52
52
 
53
- _Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.6.0._
53
+ _Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.7.0._
54
54
 
55
55
  **Toolchain** (this package owns the version; do NOT declare these):
56
56
 
@@ -81,6 +81,7 @@ _Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.6.0._
81
81
  | `tw-animate-css` | 1.4.0 | the animation utilities Tailwind v4 dropped |
82
82
  | `github-slugger` | 2.0.0 | heading and anchor slugs |
83
83
  | `micromark` | 4.0.2 | the CommonMark compiler markdown wrappers render with |
84
+ | `micromark-extension-gfm` | 3.0.0 | tables, task lists and strikethrough on top of it |
84
85
 
85
86
  <!-- /generated -->
86
87
 
@@ -351,6 +352,48 @@ is what puts the component in the client bundle siblings hydrate from. The
351
352
  bespoke-sibling gate enforces both on every non-draft bespoke page, after the
352
353
  build.
353
354
 
355
+ ## Forms (3.7.0)
356
+
357
+ A capturing form posts to the platform, never to a backend of its own. The
358
+ authored convention (an action-less `<form data-iterant-form="<key>">`, the
359
+ `_hp` honeypot, a hidden `data-iterant-form-success` sibling) is what the
360
+ injected runtime and the edge's native fallback act on, and `IterantForm`
361
+ renders exactly that markup so a page stops hand-writing it. Rendered
362
+ statically it behaves as the authored markup does: the runtime intercepts the
363
+ submit and reveals the success element. Hydrated (`client:load`, or inside a
364
+ bespoke page) it owns the submit: it marks the form `data-iterant-form-managed`
365
+ on mount, which is how the runtime knows to stand down, posts the same body to
366
+ the same endpoint, disables the submit button with `aria-busy` while in flight,
367
+ shows the success or the error message, resets the fields on success and never
368
+ navigates. `name` is required and is the form key the submissions dashboard
369
+ aggregates on. `successMessage` and `errorMessage` take a wrapped text value or
370
+ a plain string, so a section passes its entry copy straight through, and an
371
+ absent value falls back to the package default. It ships no utility classes:
372
+ the page styles the form through `className`, the state through
373
+ `data-iterant-form-state` (`idle`, `pending`, `success`, `error`) and the two
374
+ message elements through their attributes.
375
+
376
+ ```tsx
377
+ import { readText } from "@iterant/site-runtime/content-values";
378
+ import { IterantForm } from "@iterant/site-runtime/forms";
379
+
380
+ <IterantForm name={readText(data.formKey)} successMessage={data.successCopy}>
381
+ <label htmlFor="email">{readText(data.emailLabel)}</label>
382
+ <input id="email" name="email" type="email" required />
383
+ <button type="submit">{readText(data.submitLabel)}</button>
384
+ </IterantForm>;
385
+ ```
386
+
387
+ `submitForm(name, fields)` is the same post without the component, for a
388
+ multi-step form or a custom handler. It resolves to `{ ok: true }` or
389
+ `{ ok: false, error }` and never throws, whether the platform accepted,
390
+ refused (`rate_limited`, `turnstile_failed`) or was unreachable (`network`).
391
+ Values are strings only: a `File` is refused as `unsupported_field:<name>`, and
392
+ `readFormFields` turns a form's `FormData` into the record it takes. Pass one
393
+ `idempotencyKey` across retries of a submission and the platform collapses them
394
+ to one row. Submissions are captured on a published site; the dev server has no
395
+ endpoint, so a preview submit settles as an error and captures nothing.
396
+
354
397
  ## Structured data is derived, never authored
355
398
 
356
399
  Every indexable page emits a schema.org graph (Organization, WebSite, the page
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iterant/site-runtime",
3
- "version": "3.6.0",
3
+ "version": "3.7.0",
4
4
  "type": "module",
5
5
  "description": "The platform layer every Iterant brand site runs on: content grammar, collection schemas, SEO head and JSON-LD, layout core, Astro config preset, dev integrations and the verify gates.",
6
6
  "scripts": {
@@ -49,6 +49,7 @@
49
49
  "./sitemap": "./src/lib/sitemap/index.ts",
50
50
  "./seo": "./src/components/seo.tsx",
51
51
  "./seo-json": "./src/components/seo-json.tsx",
52
+ "./forms": "./src/components/forms.tsx",
52
53
  "./layout": "./src/layouts/LayoutCore.astro",
53
54
  "./layout-core": "./src/layouts/layout-core.ts",
54
55
  "./layout-contract": "./src/layouts/layout-contract.ts",
@@ -81,7 +82,8 @@
81
82
  "class-variance-authority",
82
83
  "tw-animate-css",
83
84
  "github-slugger",
84
- "micromark"
85
+ "micromark",
86
+ "micromark-extension-gfm"
85
87
  ]
86
88
  },
87
89
  "dependencies": {
@@ -98,6 +100,7 @@
98
100
  "github-slugger": "2.0.0",
99
101
  "lucide-react": "1.31.0",
100
102
  "micromark": "4.0.2",
103
+ "micromark-extension-gfm": "3.0.0",
101
104
  "motion": "13.0.0",
102
105
  "radix-ui": "1.6.7",
103
106
  "react": "19.2.8",
@@ -114,6 +117,7 @@
114
117
  "@types/node": "24.3.1",
115
118
  "@types/react": "19.2.14",
116
119
  "@types/react-dom": "19.2.3",
120
+ "jsdom": "^26.0.0",
117
121
  "typescript": "5.9.2",
118
122
  "vitest": "^4.0.13",
119
123
  "wrangler": "^4.107.0"
@@ -19,12 +19,29 @@
19
19
  * the shape the catch-all's pickPageExport (src/lib/bespoke-pages.ts)
20
20
  * resolves.
21
21
  *
22
- * For every NON-DRAFT bespoke base entry, this asserts (1) against the built
23
- * manifest and (2) by importing the built client entry itself. Draft bespoke
24
- * pages are exempt: drafts never publish, their siblings are never advertised
25
- * (hreflang/sitemap), and the stock `not-found` page is a draft that
26
- * deliberately lives outside the `<base>/page.tsx` convention (404.astro
27
- * renders it). Publishing a bespoke page puts it in scope on the next verify.
22
+ * For every NON-DRAFT bespoke base entry THAT HAS a locale sibling file
23
+ * (`<base>.<locale>.json`, draft or not: a draft sibling still renders in a
24
+ * preview), this asserts (1) against the built manifest and (2) by importing
25
+ * the built client entry itself. Draft bespoke pages are exempt: drafts never
26
+ * publish, their siblings are never advertised (hreflang/sitemap), and the
27
+ * stock `not-found` page is a draft that deliberately lives outside the
28
+ * `<base>/page.tsx` convention (404.astro renders it). Publishing a bespoke
29
+ * page puts it in scope on the next verify.
30
+ *
31
+ * Scoping to bases-with-siblings (3.6.1) loses nothing: the hazard cannot
32
+ * materialize without a sibling, a sibling only comes into existence through
33
+ * a save, and every save runs this gate, so a base picks up the full check
34
+ * the moment its first sibling lands. Before that, a pre-convention bespoke
35
+ * page (every pre-3.6 blog) must not block a same-major runtime bump over a
36
+ * feature the repo does not use.
37
+ *
38
+ * A base whose sibling route is SHADOWED is also out of scope (3.6.1): when
39
+ * `src/pages/[locale]/<base>.astro` exists (`index.astro` for the `home`
40
+ * base), Astro's route priority sends `/es` to that explicit route, never to
41
+ * the catch-all, so the catch-all hydration contract this gate asserts does
42
+ * not serve the sibling at all. Replica-era repos localize exactly this way.
43
+ * The shadowing route's own hydration is `astro build`'s concern, like every
44
+ * other explicit route.
28
45
  *
29
46
  * It also pins the Astro runtime contract the catch-all relies on: the island
30
47
  * renderer must keep accepting `client:component-path` /
@@ -37,7 +54,7 @@
37
54
  * weaken this script.
38
55
  */
39
56
 
40
- import { readdir, readFile } from "node:fs/promises";
57
+ import { readdir, readFile, stat } from "node:fs/promises";
41
58
  import { join } from "node:path";
42
59
  import { pathToFileURL } from "node:url";
43
60
  import { exit } from "node:process";
@@ -51,41 +68,34 @@ const CONVENTIONS =
51
68
  /** @type {string[]} */
52
69
  const problems = [];
53
70
 
54
- // ---- SF-pin: the Astro runtime directive contract --------------------------
55
-
56
- const hydrationJs = join(
57
- process.cwd(),
58
- "node_modules/astro/dist/runtime/server/hydration.js",
59
- );
60
- const hydrationSrc = await readFile(hydrationJs, "utf8").catch(() => "");
61
- for (const directive of ["client:component-path", "client:component-export"]) {
62
- if (!hydrationSrc.includes(`case "${directive}"`)) {
63
- problems.push(
64
- `astro runtime contract changed: ${hydrationJs} no longer handles ` +
65
- `"${directive}" as a prop. Bespoke locale siblings hydrate through ` +
66
- `exactly that escape hatch (src/pages/[...slug].astro) — re-verify ` +
67
- `sibling hydration against this Astro version before shipping.`,
68
- );
69
- }
70
- }
71
-
72
- // ---- SF1+SF2: every non-draft bespoke base ---------------------------------
71
+ // ---- SF1+SF2: every non-draft bespoke base with a sibling ------------------
73
72
 
74
- /** Non-draft bespoke base entry ids (`home`), never locale siblings. */
75
- async function bespokeBases() {
73
+ /**
74
+ * Non-draft bespoke base entry ids (`home`), never locale siblings, plus the
75
+ * set of base ids that have at least one sibling file. Sibling presence
76
+ * counts by filename alone (`home.es.json` -> `home`): a sibling's own draft
77
+ * flag or parse failure does not excuse its base, because the sibling still
78
+ * renders in a preview.
79
+ */
80
+ async function bespokeInventory() {
76
81
  const pagesDir = join(process.cwd(), "src/content/pages");
77
82
  /** @type {string[]} */
78
83
  const bases = [];
84
+ /** @type {Set<string>} */
85
+ const siblingBases = new Set();
79
86
  /** @type {string[]} */
80
87
  let names = [];
81
88
  try {
82
89
  names = await readdir(pagesDir);
83
90
  } catch {
84
- return bases; // no pages dir astro check owns that failure
91
+ return { bases, siblingBases }; // no pages dir: astro check owns that failure
85
92
  }
86
93
  for (const name of names.filter((n) => n.endsWith(".json")).sort()) {
87
94
  const id = name.replace(/\.json$/, "");
88
- if (id.includes(".")) continue; // locale sibling — guarded via its base
95
+ if (id.includes(".")) {
96
+ siblingBases.add(id.slice(0, id.indexOf(".")));
97
+ continue; // locale sibling: guarded via its base
98
+ }
89
99
  /** @type {{ mode?: string; draft?: boolean }} */
90
100
  let entry;
91
101
  try {
@@ -95,7 +105,21 @@ async function bespokeBases() {
95
105
  }
96
106
  if (entry.mode === "bespoke" && entry.draft !== true) bases.push(id);
97
107
  }
98
- return bases;
108
+ return { bases, siblingBases };
109
+ }
110
+
111
+ /**
112
+ * Whether an explicit locale route shadows this base's siblings. Astro sends
113
+ * `/es` (and `/es/<base>`) to `src/pages/[locale]/index.astro` (respectively
114
+ * `[locale]/<base>.astro`) before any rest-param catch-all, so when that file
115
+ * exists the sibling never renders through the catch-all this gate asserts.
116
+ */
117
+ async function siblingRouteShadowed(base) {
118
+ const file = base === "home" ? "index.astro" : `${base}.astro`;
119
+ return stat(join(process.cwd(), "src/pages/[locale]", file)).then(
120
+ (s) => s.isFile(),
121
+ () => false,
122
+ );
99
123
  }
100
124
 
101
125
  /**
@@ -133,8 +157,37 @@ async function builtEntryModules() {
133
157
  return null;
134
158
  }
135
159
 
136
- const bases = await bespokeBases();
137
- if (bases.length > 0 && problems.length === 0) {
160
+ const inventory = await bespokeInventory();
161
+ /** @type {string[]} */
162
+ const bases = [];
163
+ for (const base of inventory.bases) {
164
+ if (!inventory.siblingBases.has(base)) continue;
165
+ if (await siblingRouteShadowed(base)) continue;
166
+ bases.push(base);
167
+ }
168
+ // Nothing can hydrate through the escape hatch: the first sibling arrives via
169
+ // a save, and every save re-runs this gate, so exiting here defers nothing.
170
+ if (bases.length === 0) exit(0);
171
+
172
+ // ---- SF-pin: the Astro runtime directive contract --------------------------
173
+
174
+ const hydrationJs = join(
175
+ process.cwd(),
176
+ "node_modules/astro/dist/runtime/server/hydration.js",
177
+ );
178
+ const hydrationSrc = await readFile(hydrationJs, "utf8").catch(() => "");
179
+ for (const directive of ["client:component-path", "client:component-export"]) {
180
+ if (!hydrationSrc.includes(`case "${directive}"`)) {
181
+ problems.push(
182
+ `astro runtime contract changed: ${hydrationJs} no longer handles ` +
183
+ `"${directive}" as a prop. Bespoke locale siblings hydrate through ` +
184
+ `exactly that escape hatch (src/pages/[...slug].astro): re-verify ` +
185
+ `sibling hydration against this Astro version before shipping.`,
186
+ );
187
+ }
188
+ }
189
+
190
+ if (problems.length === 0) {
138
191
  const entryModules = await builtEntryModules();
139
192
  if (!entryModules) {
140
193
  problems.push(
@@ -0,0 +1,155 @@
1
+ import {
2
+ type FormEvent,
3
+ type ReactNode,
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ } from "react";
8
+
9
+ import { type ContentLeaf, readText } from "../lib/content-values";
10
+ import {
11
+ FORM_HONEYPOT_FIELD,
12
+ mintIdempotencyKey,
13
+ readFormFields,
14
+ submitForm,
15
+ } from "../lib/submit-form";
16
+
17
+ export { readFormFields, submitForm } from "../lib/submit-form";
18
+ export type { SubmitFormOptions, SubmitFormResult } from "../lib/submit-form";
19
+
20
+ export const DEFAULT_SUCCESS_MESSAGE = "Thanks for your submission!";
21
+ export const DEFAULT_ERROR_MESSAGE = "Something went wrong. Please try again.";
22
+
23
+ export interface IterantFormProps {
24
+ /** The form key: `data-iterant-form`, and what the submissions dashboard
25
+ * aggregates on. */
26
+ name: string;
27
+ /** A wrapped text value or a plain string; anything else, or an empty one,
28
+ * falls back to the default. */
29
+ successMessage?: ContentLeaf;
30
+ errorMessage?: ContentLeaf;
31
+ className?: string;
32
+ children?: ReactNode;
33
+ onSuccess?(): void;
34
+ }
35
+
36
+ type FormState = "idle" | "pending" | "success" | "error";
37
+
38
+ // The capturing form of the forms convention (an action-less
39
+ // `data-iterant-form`, the `_hp` honeypot, a hidden success sibling), rendered
40
+ // once so pages stop hand-writing it. Rendered statically it IS that markup and
41
+ // the injected runtime submits it. Hydrated, it owns the submit: same body,
42
+ // same endpoint, its own pending, success and error states, no navigation.
43
+ export function IterantForm({
44
+ name,
45
+ successMessage,
46
+ errorMessage,
47
+ className,
48
+ children,
49
+ onSuccess,
50
+ }: IterantFormProps) {
51
+ const [state, setState] = useState<FormState>("idle");
52
+ // Set after mount, never in the static render: the mark is what tells the
53
+ // injected runtime this form's submit has an owner and to stand down.
54
+ const [managed, setManaged] = useState(false);
55
+ useEffect(() => {
56
+ setManaged(true);
57
+ }, []);
58
+ const inFlight = useRef(false);
59
+ // One key per submission, reused on retry so the platform collapses a
60
+ // retried post to one row, and dropped once the submission is accepted.
61
+ const idempotencyKey = useRef<string | null>(null);
62
+
63
+ async function handleSubmit(event: FormEvent<HTMLFormElement>) {
64
+ // A runtime from before the managed mark took this submit in its
65
+ // capture-phase listener and posts it itself.
66
+ if (event.isDefaultPrevented()) return;
67
+ event.preventDefault();
68
+ if (inFlight.current) return;
69
+ const form = event.currentTarget;
70
+ // A runtime from before the managed mark may have posted this form while
71
+ // the page was still static and be waiting on the edge: its in-flight mark
72
+ // stands down this submit, and its key is the one a retry reuses, so the
73
+ // platform collapses both posts to one row.
74
+ if (form.dataset.iterantSubmitting === "1") return;
75
+ inFlight.current = true;
76
+ const release = markBusy(form);
77
+ setState("pending");
78
+ idempotencyKey.current ??= form.dataset.iterantIk || mintIdempotencyKey();
79
+ const result = await submitForm(name, readFormFields(form), {
80
+ idempotencyKey: idempotencyKey.current,
81
+ });
82
+ release();
83
+ inFlight.current = false;
84
+ if (!result.ok) {
85
+ setState("error");
86
+ return;
87
+ }
88
+ idempotencyKey.current = null;
89
+ form.reset();
90
+ setState("success");
91
+ onSuccess?.();
92
+ }
93
+
94
+ return (
95
+ <div>
96
+ <form
97
+ data-iterant-form={name}
98
+ data-iterant-form-managed={managed ? "" : undefined}
99
+ data-iterant-form-state={state}
100
+ className={className}
101
+ onSubmit={handleSubmit}
102
+ >
103
+ <input
104
+ type="text"
105
+ name={FORM_HONEYPOT_FIELD}
106
+ tabIndex={-1}
107
+ autoComplete="off"
108
+ aria-hidden="true"
109
+ style={{ position: "absolute", left: "-9999px" }}
110
+ />
111
+ {children}
112
+ </form>
113
+ <p
114
+ data-iterant-form-success=""
115
+ role="status"
116
+ hidden={state !== "success"}
117
+ >
118
+ {readText(successMessage) || DEFAULT_SUCCESS_MESSAGE}
119
+ </p>
120
+ <p data-iterant-form-error="" role="alert" hidden={state !== "error"}>
121
+ {readText(errorMessage) || DEFAULT_ERROR_MESSAGE}
122
+ </p>
123
+ </div>
124
+ );
125
+ }
126
+
127
+ // The submit controls are the caller's children, so the pending state goes on
128
+ // the live elements: `aria-busy` for assistive tech and the page's styles,
129
+ // `disabled` so a second click cannot start a second submission.
130
+ function markBusy(form: HTMLFormElement): () => void {
131
+ const restore = Array.from(form.elements)
132
+ .filter(isSubmitControl)
133
+ .map((control) => {
134
+ const wasDisabled = control.disabled;
135
+ control.disabled = true;
136
+ control.setAttribute("aria-busy", "true");
137
+ return () => {
138
+ control.disabled = wasDisabled;
139
+ control.removeAttribute("aria-busy");
140
+ };
141
+ });
142
+ return () => {
143
+ for (const fn of restore) fn();
144
+ };
145
+ }
146
+
147
+ function isSubmitControl(
148
+ element: Element,
149
+ ): element is HTMLButtonElement | HTMLInputElement {
150
+ return (
151
+ (element instanceof HTMLButtonElement ||
152
+ element instanceof HTMLInputElement) &&
153
+ element.type === "submit"
154
+ );
155
+ }
@@ -1,4 +1,5 @@
1
1
  import { micromark } from "micromark";
2
+ import { gfm, gfmHtml } from "micromark-extension-gfm";
2
3
 
3
4
  // The one compiler for {type:"markdown"} content values (content-values.ts).
4
5
  // Runs at build time inside Astro's static render, so the browser ships HTML,
@@ -8,10 +9,18 @@ import { micromark } from "micromark";
8
9
  // tags up front, and this default is the backstop for anything that predates
9
10
  // or evades the schema. Do not pass allowDangerousHtml here, ever.
10
11
  //
12
+ // GFM is on because the dashboard's body toolbar emits it: tables and task
13
+ // lists are buttons a customer can press, and CommonMark alone would render
14
+ // what they wrote as pipe soup. It adds syntax only; the escaping contract
15
+ // above is untouched, and gfmHtml writes task checkboxes disabled.
16
+ //
11
17
  // Headings: a markdown body renders inside a section, below the page's own
12
18
  // h1, so authors start at `##`. The compiler does not rewrite heading levels;
13
19
  // a body that opens with `#` is an authoring error the review pass catches,
14
20
  // not something to silently demote.
15
21
  export function renderMarkdown(value: string): string {
16
- return micromark(value);
22
+ return micromark(value, {
23
+ extensions: [gfm()],
24
+ htmlExtensions: [gfmHtml()],
25
+ });
17
26
  }
@@ -0,0 +1,123 @@
1
+ // The page-side half of platform form capture. A submission posts to the same
2
+ // endpoint, in the same body, that the injected runtime and the edge-rewritten
3
+ // native form already use (apps/site-dispatch, iterant-endpoints.ts). There is
4
+ // one protocol: this module reuses its field names and never adds one.
5
+
6
+ /** The endpoint's own control fields. Everything else in a body is visitor data. */
7
+ export const FORM_HONEYPOT_FIELD = "_hp";
8
+ const FORM_IDEMPOTENCY_FIELD = "_ik";
9
+ const FORM_SID_FIELD = "_sid";
10
+
11
+ /** The opaque form-key grammar the endpoint routes on; a key outside it is a 404 there. */
12
+ export const FORM_KEY_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
13
+
14
+ // The direct-origin base. A page served through the platform edge carries the
15
+ // prefix-resolved base in the injected config, which wins.
16
+ const DEFAULT_FORMS_BASE = "/_iterant/forms";
17
+
18
+ export type SubmitFormResult = { ok: true } | { ok: false; error: string };
19
+
20
+ export interface SubmitFormOptions {
21
+ /** Reuse one key across retries of one submission and the platform collapses
22
+ * them to a single row. Minted per call when absent. */
23
+ idempotencyKey?: string;
24
+ }
25
+
26
+ // What the injected runtime leaves on the window: its config, and the session
27
+ // id it minted, so a lead submitted from page code still joins the visitor's
28
+ // journey without this package learning where the id is stored.
29
+ type SignalsWindow = {
30
+ __ITERANT_SIGNALS__?: { formsBase?: string };
31
+ iterantSignals?: { sid?(): string };
32
+ };
33
+
34
+ function signals(): SignalsWindow {
35
+ return typeof window === "undefined"
36
+ ? {}
37
+ : (window as unknown as SignalsWindow);
38
+ }
39
+
40
+ function formsBase(): string {
41
+ return signals().__ITERANT_SIGNALS__?.formsBase || DEFAULT_FORMS_BASE;
42
+ }
43
+
44
+ function readSid(): string {
45
+ try {
46
+ return signals().iterantSignals?.sid?.() ?? "";
47
+ } catch {
48
+ return "";
49
+ }
50
+ }
51
+
52
+ export function mintIdempotencyKey(): string {
53
+ const c = globalThis.crypto;
54
+ if (c && typeof c.randomUUID === "function") return c.randomUUID();
55
+ return `ik-${Date.now()}-${Math.random().toString(16).slice(2)}`;
56
+ }
57
+
58
+ /** A form's fields as the record `submitForm` takes: strings only, so a file
59
+ * input contributes nothing, the same read the injected runtime performs. */
60
+ export function readFormFields(form: HTMLFormElement): Record<string, string> {
61
+ const fields: Record<string, string> = {};
62
+ new FormData(form).forEach((value, name) => {
63
+ if (typeof value === "string") fields[name] = value;
64
+ });
65
+ return fields;
66
+ }
67
+
68
+ /** Post one submission for the form key `name`. Resolves to `{ ok: true }` on
69
+ * a durable accept and `{ ok: false, error }` otherwise, and never throws:
70
+ * `error` is the endpoint's own token (`rate_limited`, `turnstile_failed`,
71
+ * `drain_failed`), `http_<status>` for an answer without one, `network` when
72
+ * nothing answered, `invalid_name` for a key the endpoint would not route and
73
+ * `unsupported_field:<name>` for a value that is not a string. */
74
+ export async function submitForm(
75
+ name: string,
76
+ fields: Record<string, string>,
77
+ options: SubmitFormOptions = {},
78
+ ): Promise<SubmitFormResult> {
79
+ if (!FORM_KEY_PATTERN.test(name)) return { ok: false, error: "invalid_name" };
80
+ const body: Record<string, string> = {};
81
+ for (const [key, value] of Object.entries(fields)) {
82
+ // File inputs are unsupported: only string values are captured.
83
+ if (typeof value !== "string") {
84
+ return { ok: false, error: `unsupported_field:${key}` };
85
+ }
86
+ body[key] = value;
87
+ }
88
+ body[FORM_IDEMPOTENCY_FIELD] = options.idempotencyKey ?? mintIdempotencyKey();
89
+ const sid = readSid();
90
+ if (sid) body[FORM_SID_FIELD] = sid;
91
+
92
+ let response: Response;
93
+ try {
94
+ response = await fetch(`${formsBase()}/${encodeURIComponent(name)}`, {
95
+ method: "POST",
96
+ headers: { "content-type": "application/json" },
97
+ body: JSON.stringify(body),
98
+ credentials: "omit",
99
+ });
100
+ } catch {
101
+ return { ok: false, error: "network" };
102
+ }
103
+ if (response.ok) return { ok: true };
104
+ return {
105
+ ok: false,
106
+ error: (await errorToken(response)) ?? `http_${response.status}`,
107
+ };
108
+ }
109
+
110
+ // The endpoint refuses with a JSON envelope carrying a token. Anything else
111
+ // (a dev server's HTML 404, a proxy's error page) has no token to read.
112
+ async function errorToken(response: Response): Promise<string | null> {
113
+ try {
114
+ const parsed: unknown = await response.json();
115
+ if (parsed && typeof parsed === "object") {
116
+ const error = (parsed as { error?: unknown }).error;
117
+ if (typeof error === "string" && error !== "") return error;
118
+ }
119
+ } catch {
120
+ // not the endpoint's envelope
121
+ }
122
+ return null;
123
+ }