@iterant/site-runtime 3.6.1 → 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.1._
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.1._
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.1",
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"
@@ -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
+ }