@lacspace/form 1.0.0 → 1.0.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 +12 -6
- package/dist/index.cjs +3 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -88,16 +88,22 @@ Pairs with [`@lacspace/validate`](https://www.npmjs.com/package/@lacspace/valida
|
|
|
88
88
|
|
|
89
89
|
## Licensing
|
|
90
90
|
|
|
91
|
-
Free under the **[Lacspace Free Licence](https://lacspace.com/licenses/lacspace-free-1.0)** —
|
|
91
|
+
Free under the **[Lacspace Free Licence](https://lacspace.com/licenses/lacspace-free-1.0)** — permissive freedoms. Use it in personal and commercial projects at no cost; just keep the notice. See the **[Lacspace Licence Centre](https://lacspace.com/licenses)**.
|
|
92
|
+
|
|
93
|
+
<!-- LACSPACE-DEV-PLATFORM -->
|
|
92
94
|
|
|
93
95
|
---
|
|
94
96
|
|
|
95
|
-
|
|
97
|
+
## The Lacspace Developer Platform
|
|
96
98
|
|
|
97
|
-
|
|
99
|
+
`@lacspace/form` is part of **63 zero-dependency, isomorphic TypeScript packages** — one standard library for the modern web. Explore the ecosystem:
|
|
98
100
|
|
|
99
|
-
|
|
101
|
+
- 📦 **This package, documented** — https://developer.lacspace.com/packages/form
|
|
102
|
+
- 🗂️ **All 63 packages** — https://developer.lacspace.com/packages
|
|
103
|
+
- 🧭 **Developer handbook** — guides & runnable recipes — https://developer.lacspace.com/handbook
|
|
104
|
+
- 🧪 **Live playground** — run any package in your browser — https://developer.lacspace.com/playground
|
|
105
|
+
- 🖥️ **Finished app templates** — https://templates.lacspace.com
|
|
106
|
+
- 🚀 **Scaffold a full app** — `npm create lacspace-app@latest`
|
|
100
107
|
|
|
101
|
-
|
|
108
|
+
Free under the **[Lacspace Free Licence](https://lacspace.com/licenses/lacspace-free-1.0)** — a permissive, free-to-use licence.
|
|
102
109
|
|
|
103
|
-
<div align="center"><sub>Built with care by <a href="https://lacspace.com">Lacspace</a> · Lacspace Free Licence · <a href="https://github.com/lacspace/npm-packages">source</a></sub></div>
|
package/dist/index.cjs
CHANGED
|
@@ -35,12 +35,14 @@ function handleForm(input, opts) {
|
|
|
35
35
|
if (opts.minSubmitMs && opts.minSubmitMs > 0) {
|
|
36
36
|
const tsField = opts.timestampField ?? DEFAULT_TS_FIELD;
|
|
37
37
|
const raw = values[tsField];
|
|
38
|
-
const ts = typeof raw === "string" ? Number(raw) : typeof raw === "number" ? raw : NaN;
|
|
38
|
+
const ts = typeof raw === "string" && raw.trim() !== "" ? Number(raw) : typeof raw === "number" ? raw : NaN;
|
|
39
39
|
if (Number.isFinite(ts)) {
|
|
40
40
|
const elapsed = Date.now() - ts;
|
|
41
41
|
if (elapsed >= 0 && elapsed < opts.minSubmitMs) {
|
|
42
42
|
return spam(opts, values, formKey);
|
|
43
43
|
}
|
|
44
|
+
} else if (opts.requireTimestamp) {
|
|
45
|
+
return spam(opts, values, formKey);
|
|
44
46
|
}
|
|
45
47
|
}
|
|
46
48
|
const cleaned = stripInternal(values, opts);
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @lacspace/form\n * End-to-end form handling for the server — turn a `FormData` (or a plain\n * object) into typed, validated data with built-in spam protection, and get\n * back either your data or per-field errors ready to re-render.\n *\n * Framework-agnostic, but shaped for Next.js Server Actions.\n *\n * ```ts\n * \"use server\";\n * import { createForm } from \"@lacspace/form\";\n * import { v } from \"@lacspace/validate\";\n *\n * const contact = createForm({\n * schema: v.object({\n * name: v.string().min(2),\n * email: v.string().email(),\n * message: v.string().min(10),\n * }),\n * honeypot: \"company\", // a hidden field bots love to fill\n * });\n *\n * export async function submit(prev, formData) {\n * const r = contact.action(prev, formData);\n * if (!r.ok) return r; // { errors, values } → re-render\n * await sendEmail(r.data); // fully typed\n * return { ok: true };\n * }\n * ```\n *\n * Zero dependencies · isomorphic · fully typed.\n */\n\n/* ------------------------------------------------------------------ *\n * Validator contract — structurally compatible with @lacspace/validate\n * (and, in practice, with zod). No hard dependency either way.\n * ------------------------------------------------------------------ */\n\nexport interface Validator<T> {\n safeParse(input: unknown):\n | { success: true; data: T }\n | { success: false; error: { flatten(): Record<string, string> } };\n}\n\n/* ------------------------------------------------------------------ *\n * Options & results\n * ------------------------------------------------------------------ */\n\nexport interface FormOptions<T> {\n /** A schema with `safeParse` — e.g. `v.object({...})` from @lacspace/validate. */\n schema: Validator<T>;\n /**\n * Name of a hidden \"honeypot\" field that real users never see and never fill.\n * If it arrives non-empty, the submission is treated as spam.\n */\n honeypot?: string;\n /**\n * Reject submissions that arrive faster than this many ms after the form was\n * rendered. Requires a hidden timestamp field (see `timestampField`).\n */\n minSubmitMs?: number;\n /** Hidden field holding the render time in ms. Default `\"_ts\"`. */\n timestampField?: string;\n /** Message returned when a submission is flagged as spam. */\n spamMessage?: string;\n /** Key used for form-level (non-field) errors. Default `\"_form\"`. */\n formErrorKey?: string;\n}\n\nexport type FormResult<T> =\n | { ok: true; data: T }\n | {\n ok: false;\n /** `{ email: \"Invalid email\", _form: \"...\" }` — render next to inputs. */\n errors: Record<string, string>;\n /** The raw submitted values, so the form can be re-rendered as typed. */\n values: Record<string, unknown>;\n /** True when the failure was a spam/bot heuristic, not user error. */\n spam?: boolean;\n };\n\nconst DEFAULT_TS_FIELD = \"_ts\";\nconst DEFAULT_FORM_KEY = \"_form\";\nconst DEFAULT_SPAM_MSG = \"Your submission could not be processed. Please try again.\";\n\n/* ------------------------------------------------------------------ *\n * FormData → plain object\n * ------------------------------------------------------------------ */\n\n/** Minimal structural shape of the parts of FormData we use. */\ninterface FormDataLike {\n entries(): IterableIterator<[string, unknown]>;\n}\n\nfunction isFormData(x: unknown): x is FormDataLike {\n return (\n typeof x === \"object\" &&\n x !== null &&\n typeof (x as { entries?: unknown }).entries === \"function\" &&\n typeof (x as { append?: unknown }).append === \"function\"\n );\n}\n\n/**\n * Convert a `FormData` into a plain object. Repeated keys become arrays; File\n * values are passed through untouched. Empty strings are preserved (validation\n * decides what \"required\" means).\n */\nexport function formDataToObject(fd: FormDataLike): Record<string, unknown> {\n const out: Record<string, unknown> = {};\n for (const [key, value] of fd.entries()) {\n if (key in out) {\n const existing = out[key];\n if (Array.isArray(existing)) existing.push(value);\n else out[key] = [existing, value];\n } else {\n out[key] = value;\n }\n }\n return out;\n}\n\n/** Normalise any accepted input to a plain object. */\nfunction toObject(input: FormDataLike | Record<string, unknown>): Record<string, unknown> {\n return isFormData(input) ? formDataToObject(input) : { ...input };\n}\n\n/* ------------------------------------------------------------------ *\n * Core handler\n * ------------------------------------------------------------------ */\n\n/**\n * Validate an input (FormData or object) against a schema, applying spam\n * heuristics first. Returns typed data or per-field errors + the raw values.\n */\nexport function handleForm<T>(\n input: FormDataLike | Record<string, unknown>,\n opts: FormOptions<T>,\n): FormResult<T> {\n const values = toObject(input);\n const formKey = opts.formErrorKey ?? DEFAULT_FORM_KEY;\n\n // 1. Honeypot — a non-empty hidden field means a bot.\n if (opts.honeypot) {\n const trap = values[opts.honeypot];\n if (typeof trap === \"string\" ? trap.trim() !== \"\" : trap != null && trap !== \"\") {\n return spam(opts, values, formKey);\n }\n }\n\n // 2. Timing — submitted implausibly fast after render.\n if (opts.minSubmitMs && opts.minSubmitMs > 0) {\n const tsField = opts.timestampField ?? DEFAULT_TS_FIELD;\n const raw = values[tsField];\n const ts = typeof raw === \"string\" ? Number(raw) : typeof raw === \"number\" ? raw : NaN;\n if (Number.isFinite(ts)) {\n const elapsed = Date.now() - ts;\n if (elapsed >= 0 && elapsed < opts.minSubmitMs) {\n return spam(opts, values, formKey);\n }\n }\n }\n\n // 3. Strip internal fields before validation so schemas can stay `.strict()`.\n const cleaned = stripInternal(values, opts);\n\n // 4. Validate.\n const r = opts.schema.safeParse(cleaned);\n if (r.success) return { ok: true, data: r.data };\n return { ok: false, errors: r.error.flatten(), values: cleaned };\n}\n\nfunction stripInternal<T>(values: Record<string, unknown>, opts: FormOptions<T>): Record<string, unknown> {\n const drop = new Set<string>();\n if (opts.honeypot) drop.add(opts.honeypot);\n drop.add(opts.timestampField ?? DEFAULT_TS_FIELD);\n if (drop.size === 0) return values;\n const out: Record<string, unknown> = {};\n for (const k of Object.keys(values)) if (!drop.has(k)) out[k] = values[k];\n return out;\n}\n\nfunction spam<T>(opts: FormOptions<T>, values: Record<string, unknown>, formKey: string): FormResult<T> {\n return {\n ok: false,\n spam: true,\n values,\n errors: { [formKey]: opts.spamMessage ?? DEFAULT_SPAM_MSG },\n };\n}\n\n/* ------------------------------------------------------------------ *\n * createForm — reusable handler bound to one schema\n * ------------------------------------------------------------------ */\n\nexport interface Form<T> {\n /** Validate any input; returns typed data or errors. */\n handle(input: FormDataLike | Record<string, unknown>): FormResult<T>;\n /**\n * Next.js Server Action signature `(prevState, formData) => result`.\n * The previous state is ignored; it exists so this drops straight into\n * `useActionState`.\n */\n action(prevState: unknown, formData: FormDataLike): FormResult<T>;\n}\n\n/** Bind a schema + spam options once and reuse the handler across requests. */\nexport function createForm<T>(opts: FormOptions<T>): Form<T> {\n return {\n handle: (input) => handleForm(input, opts),\n action: (_prev, formData) => handleForm(formData, opts),\n };\n}\n\n/* ------------------------------------------------------------------ *\n * Client helpers (framework-agnostic, no React needed)\n * ------------------------------------------------------------------ */\n\n/**\n * Attributes for a visually-hidden honeypot input. Spread onto an `<input>`:\n * `<input {...honeypotProps(\"company\")} />`.\n */\nexport function honeypotProps(name: string): {\n type: \"text\";\n name: string;\n tabIndex: -1;\n autoComplete: \"off\";\n \"aria-hidden\": \"true\";\n style: Record<string, string>;\n} {\n return {\n type: \"text\",\n name,\n tabIndex: -1,\n autoComplete: \"off\",\n \"aria-hidden\": \"true\",\n style: {\n position: \"absolute\",\n width: \"1px\",\n height: \"1px\",\n padding: \"0\",\n margin: \"-1px\",\n overflow: \"hidden\",\n clip: \"rect(0 0 0 0)\",\n whiteSpace: \"nowrap\",\n border: \"0\",\n },\n };\n}\n\n/** A `<input type=\"hidden\">`-ready render timestamp for the timing check. */\nexport function timestampValue(): string {\n return String(Date.now());\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * @lacspace/form\n * End-to-end form handling for the server — turn a `FormData` (or a plain\n * object) into typed, validated data with built-in spam protection, and get\n * back either your data or per-field errors ready to re-render.\n *\n * Framework-agnostic, but shaped for Next.js Server Actions.\n *\n * ```ts\n * \"use server\";\n * import { createForm } from \"@lacspace/form\";\n * import { v } from \"@lacspace/validate\";\n *\n * const contact = createForm({\n * schema: v.object({\n * name: v.string().min(2),\n * email: v.string().email(),\n * message: v.string().min(10),\n * }),\n * honeypot: \"company\", // a hidden field bots love to fill\n * });\n *\n * export async function submit(prev, formData) {\n * const r = contact.action(prev, formData);\n * if (!r.ok) return r; // { errors, values } → re-render\n * await sendEmail(r.data); // fully typed\n * return { ok: true };\n * }\n * ```\n *\n * Zero dependencies · isomorphic · fully typed.\n */\n\n/* ------------------------------------------------------------------ *\n * Validator contract — structurally compatible with @lacspace/validate\n * (and, in practice, with zod). No hard dependency either way.\n * ------------------------------------------------------------------ */\n\nexport interface Validator<T> {\n safeParse(input: unknown):\n | { success: true; data: T }\n | { success: false; error: { flatten(): Record<string, string> } };\n}\n\n/* ------------------------------------------------------------------ *\n * Options & results\n * ------------------------------------------------------------------ */\n\nexport interface FormOptions<T> {\n /** A schema with `safeParse` — e.g. `v.object({...})` from @lacspace/validate. */\n schema: Validator<T>;\n /**\n * Name of a hidden \"honeypot\" field that real users never see and never fill.\n * If it arrives non-empty, the submission is treated as spam.\n */\n honeypot?: string;\n /**\n * Reject submissions that arrive faster than this many ms after the form was\n * rendered. Requires a hidden timestamp field (see `timestampField`).\n */\n minSubmitMs?: number;\n /** Hidden field holding the render time in ms. Default `\"_ts\"`. */\n timestampField?: string;\n /**\n * When `true`, a submission whose timestamp field is missing or non-numeric is\n * itself treated as spam (rejected) rather than silently skipping the timing\n * check — closing the loophole where a bot simply omits the field. Only takes\n * effect alongside `minSubmitMs`. Default `false` (non-breaking).\n */\n requireTimestamp?: boolean;\n /** Message returned when a submission is flagged as spam. */\n spamMessage?: string;\n /** Key used for form-level (non-field) errors. Default `\"_form\"`. */\n formErrorKey?: string;\n}\n\nexport type FormResult<T> =\n | { ok: true; data: T }\n | {\n ok: false;\n /** `{ email: \"Invalid email\", _form: \"...\" }` — render next to inputs. */\n errors: Record<string, string>;\n /** The raw submitted values, so the form can be re-rendered as typed. */\n values: Record<string, unknown>;\n /** True when the failure was a spam/bot heuristic, not user error. */\n spam?: boolean;\n };\n\nconst DEFAULT_TS_FIELD = \"_ts\";\nconst DEFAULT_FORM_KEY = \"_form\";\nconst DEFAULT_SPAM_MSG = \"Your submission could not be processed. Please try again.\";\n\n/* ------------------------------------------------------------------ *\n * FormData → plain object\n * ------------------------------------------------------------------ */\n\n/** Minimal structural shape of the parts of FormData we use. */\ninterface FormDataLike {\n entries(): IterableIterator<[string, unknown]>;\n}\n\nfunction isFormData(x: unknown): x is FormDataLike {\n return (\n typeof x === \"object\" &&\n x !== null &&\n typeof (x as { entries?: unknown }).entries === \"function\" &&\n typeof (x as { append?: unknown }).append === \"function\"\n );\n}\n\n/**\n * Convert a `FormData` into a plain object. Repeated keys become arrays; File\n * values are passed through untouched. Empty strings are preserved (validation\n * decides what \"required\" means).\n */\nexport function formDataToObject(fd: FormDataLike): Record<string, unknown> {\n const out: Record<string, unknown> = {};\n for (const [key, value] of fd.entries()) {\n if (key in out) {\n const existing = out[key];\n if (Array.isArray(existing)) existing.push(value);\n else out[key] = [existing, value];\n } else {\n out[key] = value;\n }\n }\n return out;\n}\n\n/** Normalise any accepted input to a plain object. */\nfunction toObject(input: FormDataLike | Record<string, unknown>): Record<string, unknown> {\n return isFormData(input) ? formDataToObject(input) : { ...input };\n}\n\n/* ------------------------------------------------------------------ *\n * Core handler\n * ------------------------------------------------------------------ */\n\n/**\n * Validate an input (FormData or object) against a schema, applying spam\n * heuristics first. Returns typed data or per-field errors + the raw values.\n */\nexport function handleForm<T>(\n input: FormDataLike | Record<string, unknown>,\n opts: FormOptions<T>,\n): FormResult<T> {\n const values = toObject(input);\n const formKey = opts.formErrorKey ?? DEFAULT_FORM_KEY;\n\n // 1. Honeypot — a non-empty hidden field means a bot.\n if (opts.honeypot) {\n const trap = values[opts.honeypot];\n if (typeof trap === \"string\" ? trap.trim() !== \"\" : trap != null && trap !== \"\") {\n return spam(opts, values, formKey);\n }\n }\n\n // 2. Timing — submitted implausibly fast after render.\n if (opts.minSubmitMs && opts.minSubmitMs > 0) {\n const tsField = opts.timestampField ?? DEFAULT_TS_FIELD;\n const raw = values[tsField];\n const ts = typeof raw === \"string\" && raw.trim() !== \"\" ? Number(raw) : typeof raw === \"number\" ? raw : NaN;\n if (Number.isFinite(ts)) {\n const elapsed = Date.now() - ts;\n if (elapsed >= 0 && elapsed < opts.minSubmitMs) {\n return spam(opts, values, formKey);\n }\n } else if (opts.requireTimestamp) {\n // A bot that simply omits (or corrupts) the timestamp would otherwise\n // bypass the timing heuristic entirely — treat that as suspicious.\n return spam(opts, values, formKey);\n }\n }\n\n // 3. Strip internal fields before validation so schemas can stay `.strict()`.\n const cleaned = stripInternal(values, opts);\n\n // 4. Validate.\n const r = opts.schema.safeParse(cleaned);\n if (r.success) return { ok: true, data: r.data };\n return { ok: false, errors: r.error.flatten(), values: cleaned };\n}\n\nfunction stripInternal<T>(values: Record<string, unknown>, opts: FormOptions<T>): Record<string, unknown> {\n const drop = new Set<string>();\n if (opts.honeypot) drop.add(opts.honeypot);\n drop.add(opts.timestampField ?? DEFAULT_TS_FIELD);\n if (drop.size === 0) return values;\n const out: Record<string, unknown> = {};\n for (const k of Object.keys(values)) if (!drop.has(k)) out[k] = values[k];\n return out;\n}\n\nfunction spam<T>(opts: FormOptions<T>, values: Record<string, unknown>, formKey: string): FormResult<T> {\n return {\n ok: false,\n spam: true,\n values,\n errors: { [formKey]: opts.spamMessage ?? DEFAULT_SPAM_MSG },\n };\n}\n\n/* ------------------------------------------------------------------ *\n * createForm — reusable handler bound to one schema\n * ------------------------------------------------------------------ */\n\nexport interface Form<T> {\n /** Validate any input; returns typed data or errors. */\n handle(input: FormDataLike | Record<string, unknown>): FormResult<T>;\n /**\n * Next.js Server Action signature `(prevState, formData) => result`.\n * The previous state is ignored; it exists so this drops straight into\n * `useActionState`.\n */\n action(prevState: unknown, formData: FormDataLike): FormResult<T>;\n}\n\n/** Bind a schema + spam options once and reuse the handler across requests. */\nexport function createForm<T>(opts: FormOptions<T>): Form<T> {\n return {\n handle: (input) => handleForm(input, opts),\n action: (_prev, formData) => handleForm(formData, opts),\n };\n}\n\n/* ------------------------------------------------------------------ *\n * Client helpers (framework-agnostic, no React needed)\n * ------------------------------------------------------------------ */\n\n/**\n * Attributes for a visually-hidden honeypot input. Spread onto an `<input>`:\n * `<input {...honeypotProps(\"company\")} />`.\n */\nexport function honeypotProps(name: string): {\n type: \"text\";\n name: string;\n tabIndex: -1;\n autoComplete: \"off\";\n \"aria-hidden\": \"true\";\n style: Record<string, string>;\n} {\n return {\n type: \"text\",\n name,\n tabIndex: -1,\n autoComplete: \"off\",\n \"aria-hidden\": \"true\",\n style: {\n position: \"absolute\",\n width: \"1px\",\n height: \"1px\",\n padding: \"0\",\n margin: \"-1px\",\n overflow: \"hidden\",\n clip: \"rect(0 0 0 0)\",\n whiteSpace: \"nowrap\",\n border: \"0\",\n },\n };\n}\n\n/** A `<input type=\"hidden\">`-ready render timestamp for the timing check. */\nexport function timestampValue(): string {\n return String(Date.now());\n}\n"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -56,6 +56,13 @@ interface FormOptions<T> {
|
|
|
56
56
|
minSubmitMs?: number;
|
|
57
57
|
/** Hidden field holding the render time in ms. Default `"_ts"`. */
|
|
58
58
|
timestampField?: string;
|
|
59
|
+
/**
|
|
60
|
+
* When `true`, a submission whose timestamp field is missing or non-numeric is
|
|
61
|
+
* itself treated as spam (rejected) rather than silently skipping the timing
|
|
62
|
+
* check — closing the loophole where a bot simply omits the field. Only takes
|
|
63
|
+
* effect alongside `minSubmitMs`. Default `false` (non-breaking).
|
|
64
|
+
*/
|
|
65
|
+
requireTimestamp?: boolean;
|
|
59
66
|
/** Message returned when a submission is flagged as spam. */
|
|
60
67
|
spamMessage?: string;
|
|
61
68
|
/** Key used for form-level (non-field) errors. Default `"_form"`. */
|
package/dist/index.d.ts
CHANGED
|
@@ -56,6 +56,13 @@ interface FormOptions<T> {
|
|
|
56
56
|
minSubmitMs?: number;
|
|
57
57
|
/** Hidden field holding the render time in ms. Default `"_ts"`. */
|
|
58
58
|
timestampField?: string;
|
|
59
|
+
/**
|
|
60
|
+
* When `true`, a submission whose timestamp field is missing or non-numeric is
|
|
61
|
+
* itself treated as spam (rejected) rather than silently skipping the timing
|
|
62
|
+
* check — closing the loophole where a bot simply omits the field. Only takes
|
|
63
|
+
* effect alongside `minSubmitMs`. Default `false` (non-breaking).
|
|
64
|
+
*/
|
|
65
|
+
requireTimestamp?: boolean;
|
|
59
66
|
/** Message returned when a submission is flagged as spam. */
|
|
60
67
|
spamMessage?: string;
|
|
61
68
|
/** Key used for form-level (non-field) errors. Default `"_form"`. */
|
package/dist/index.js
CHANGED
|
@@ -33,12 +33,14 @@ function handleForm(input, opts) {
|
|
|
33
33
|
if (opts.minSubmitMs && opts.minSubmitMs > 0) {
|
|
34
34
|
const tsField = opts.timestampField ?? DEFAULT_TS_FIELD;
|
|
35
35
|
const raw = values[tsField];
|
|
36
|
-
const ts = typeof raw === "string" ? Number(raw) : typeof raw === "number" ? raw : NaN;
|
|
36
|
+
const ts = typeof raw === "string" && raw.trim() !== "" ? Number(raw) : typeof raw === "number" ? raw : NaN;
|
|
37
37
|
if (Number.isFinite(ts)) {
|
|
38
38
|
const elapsed = Date.now() - ts;
|
|
39
39
|
if (elapsed >= 0 && elapsed < opts.minSubmitMs) {
|
|
40
40
|
return spam(opts, values, formKey);
|
|
41
41
|
}
|
|
42
|
+
} else if (opts.requireTimestamp) {
|
|
43
|
+
return spam(opts, values, formKey);
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
const cleaned = stripInternal(values, opts);
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @lacspace/form\n * End-to-end form handling for the server — turn a `FormData` (or a plain\n * object) into typed, validated data with built-in spam protection, and get\n * back either your data or per-field errors ready to re-render.\n *\n * Framework-agnostic, but shaped for Next.js Server Actions.\n *\n * ```ts\n * \"use server\";\n * import { createForm } from \"@lacspace/form\";\n * import { v } from \"@lacspace/validate\";\n *\n * const contact = createForm({\n * schema: v.object({\n * name: v.string().min(2),\n * email: v.string().email(),\n * message: v.string().min(10),\n * }),\n * honeypot: \"company\", // a hidden field bots love to fill\n * });\n *\n * export async function submit(prev, formData) {\n * const r = contact.action(prev, formData);\n * if (!r.ok) return r; // { errors, values } → re-render\n * await sendEmail(r.data); // fully typed\n * return { ok: true };\n * }\n * ```\n *\n * Zero dependencies · isomorphic · fully typed.\n */\n\n/* ------------------------------------------------------------------ *\n * Validator contract — structurally compatible with @lacspace/validate\n * (and, in practice, with zod). No hard dependency either way.\n * ------------------------------------------------------------------ */\n\nexport interface Validator<T> {\n safeParse(input: unknown):\n | { success: true; data: T }\n | { success: false; error: { flatten(): Record<string, string> } };\n}\n\n/* ------------------------------------------------------------------ *\n * Options & results\n * ------------------------------------------------------------------ */\n\nexport interface FormOptions<T> {\n /** A schema with `safeParse` — e.g. `v.object({...})` from @lacspace/validate. */\n schema: Validator<T>;\n /**\n * Name of a hidden \"honeypot\" field that real users never see and never fill.\n * If it arrives non-empty, the submission is treated as spam.\n */\n honeypot?: string;\n /**\n * Reject submissions that arrive faster than this many ms after the form was\n * rendered. Requires a hidden timestamp field (see `timestampField`).\n */\n minSubmitMs?: number;\n /** Hidden field holding the render time in ms. Default `\"_ts\"`. */\n timestampField?: string;\n /** Message returned when a submission is flagged as spam. */\n spamMessage?: string;\n /** Key used for form-level (non-field) errors. Default `\"_form\"`. */\n formErrorKey?: string;\n}\n\nexport type FormResult<T> =\n | { ok: true; data: T }\n | {\n ok: false;\n /** `{ email: \"Invalid email\", _form: \"...\" }` — render next to inputs. */\n errors: Record<string, string>;\n /** The raw submitted values, so the form can be re-rendered as typed. */\n values: Record<string, unknown>;\n /** True when the failure was a spam/bot heuristic, not user error. */\n spam?: boolean;\n };\n\nconst DEFAULT_TS_FIELD = \"_ts\";\nconst DEFAULT_FORM_KEY = \"_form\";\nconst DEFAULT_SPAM_MSG = \"Your submission could not be processed. Please try again.\";\n\n/* ------------------------------------------------------------------ *\n * FormData → plain object\n * ------------------------------------------------------------------ */\n\n/** Minimal structural shape of the parts of FormData we use. */\ninterface FormDataLike {\n entries(): IterableIterator<[string, unknown]>;\n}\n\nfunction isFormData(x: unknown): x is FormDataLike {\n return (\n typeof x === \"object\" &&\n x !== null &&\n typeof (x as { entries?: unknown }).entries === \"function\" &&\n typeof (x as { append?: unknown }).append === \"function\"\n );\n}\n\n/**\n * Convert a `FormData` into a plain object. Repeated keys become arrays; File\n * values are passed through untouched. Empty strings are preserved (validation\n * decides what \"required\" means).\n */\nexport function formDataToObject(fd: FormDataLike): Record<string, unknown> {\n const out: Record<string, unknown> = {};\n for (const [key, value] of fd.entries()) {\n if (key in out) {\n const existing = out[key];\n if (Array.isArray(existing)) existing.push(value);\n else out[key] = [existing, value];\n } else {\n out[key] = value;\n }\n }\n return out;\n}\n\n/** Normalise any accepted input to a plain object. */\nfunction toObject(input: FormDataLike | Record<string, unknown>): Record<string, unknown> {\n return isFormData(input) ? formDataToObject(input) : { ...input };\n}\n\n/* ------------------------------------------------------------------ *\n * Core handler\n * ------------------------------------------------------------------ */\n\n/**\n * Validate an input (FormData or object) against a schema, applying spam\n * heuristics first. Returns typed data or per-field errors + the raw values.\n */\nexport function handleForm<T>(\n input: FormDataLike | Record<string, unknown>,\n opts: FormOptions<T>,\n): FormResult<T> {\n const values = toObject(input);\n const formKey = opts.formErrorKey ?? DEFAULT_FORM_KEY;\n\n // 1. Honeypot — a non-empty hidden field means a bot.\n if (opts.honeypot) {\n const trap = values[opts.honeypot];\n if (typeof trap === \"string\" ? trap.trim() !== \"\" : trap != null && trap !== \"\") {\n return spam(opts, values, formKey);\n }\n }\n\n // 2. Timing — submitted implausibly fast after render.\n if (opts.minSubmitMs && opts.minSubmitMs > 0) {\n const tsField = opts.timestampField ?? DEFAULT_TS_FIELD;\n const raw = values[tsField];\n const ts = typeof raw === \"string\" ? Number(raw) : typeof raw === \"number\" ? raw : NaN;\n if (Number.isFinite(ts)) {\n const elapsed = Date.now() - ts;\n if (elapsed >= 0 && elapsed < opts.minSubmitMs) {\n return spam(opts, values, formKey);\n }\n }\n }\n\n // 3. Strip internal fields before validation so schemas can stay `.strict()`.\n const cleaned = stripInternal(values, opts);\n\n // 4. Validate.\n const r = opts.schema.safeParse(cleaned);\n if (r.success) return { ok: true, data: r.data };\n return { ok: false, errors: r.error.flatten(), values: cleaned };\n}\n\nfunction stripInternal<T>(values: Record<string, unknown>, opts: FormOptions<T>): Record<string, unknown> {\n const drop = new Set<string>();\n if (opts.honeypot) drop.add(opts.honeypot);\n drop.add(opts.timestampField ?? DEFAULT_TS_FIELD);\n if (drop.size === 0) return values;\n const out: Record<string, unknown> = {};\n for (const k of Object.keys(values)) if (!drop.has(k)) out[k] = values[k];\n return out;\n}\n\nfunction spam<T>(opts: FormOptions<T>, values: Record<string, unknown>, formKey: string): FormResult<T> {\n return {\n ok: false,\n spam: true,\n values,\n errors: { [formKey]: opts.spamMessage ?? DEFAULT_SPAM_MSG },\n };\n}\n\n/* ------------------------------------------------------------------ *\n * createForm — reusable handler bound to one schema\n * ------------------------------------------------------------------ */\n\nexport interface Form<T> {\n /** Validate any input; returns typed data or errors. */\n handle(input: FormDataLike | Record<string, unknown>): FormResult<T>;\n /**\n * Next.js Server Action signature `(prevState, formData) => result`.\n * The previous state is ignored; it exists so this drops straight into\n * `useActionState`.\n */\n action(prevState: unknown, formData: FormDataLike): FormResult<T>;\n}\n\n/** Bind a schema + spam options once and reuse the handler across requests. */\nexport function createForm<T>(opts: FormOptions<T>): Form<T> {\n return {\n handle: (input) => handleForm(input, opts),\n action: (_prev, formData) => handleForm(formData, opts),\n };\n}\n\n/* ------------------------------------------------------------------ *\n * Client helpers (framework-agnostic, no React needed)\n * ------------------------------------------------------------------ */\n\n/**\n * Attributes for a visually-hidden honeypot input. Spread onto an `<input>`:\n * `<input {...honeypotProps(\"company\")} />`.\n */\nexport function honeypotProps(name: string): {\n type: \"text\";\n name: string;\n tabIndex: -1;\n autoComplete: \"off\";\n \"aria-hidden\": \"true\";\n style: Record<string, string>;\n} {\n return {\n type: \"text\",\n name,\n tabIndex: -1,\n autoComplete: \"off\",\n \"aria-hidden\": \"true\",\n style: {\n position: \"absolute\",\n width: \"1px\",\n height: \"1px\",\n padding: \"0\",\n margin: \"-1px\",\n overflow: \"hidden\",\n clip: \"rect(0 0 0 0)\",\n whiteSpace: \"nowrap\",\n border: \"0\",\n },\n };\n}\n\n/** A `<input type=\"hidden\">`-ready render timestamp for the timing check. */\nexport function timestampValue(): string {\n return String(Date.now());\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";AAwFA,IAAM,gBAAA,GAAmB,KAAA;AACzB,IAAM,gBAAA,GAAmB,OAAA;AACzB,IAAM,gBAAA,GAAmB,2DAAA;AAWzB,SAAS,WAAW,CAAA,EAA+B;AACjD,EAAA,OACE,OAAO,CAAA,KAAM,QAAA,IACb,CAAA,KAAM,IAAA,IACN,OAAQ,CAAA,CAA4B,OAAA,KAAY,UAAA,IAChD,OAAQ,CAAA,CAA2B,MAAA,KAAW,UAAA;AAElD;AAOO,SAAS,iBAAiB,EAAA,EAA2C;AAC1E,EAAA,MAAM,MAA+B,EAAC;AACtC,EAAA,KAAA,MAAW,CAAC,GAAA,EAAK,KAAK,CAAA,IAAK,EAAA,CAAG,SAAQ,EAAG;AACvC,IAAA,IAAI,OAAO,GAAA,EAAK;AACd,MAAA,MAAM,QAAA,GAAW,IAAI,GAAG,CAAA;AACxB,MAAA,IAAI,MAAM,OAAA,CAAQ,QAAQ,CAAA,EAAG,QAAA,CAAS,KAAK,KAAK,CAAA;AAAA,WAC3C,GAAA,CAAI,GAAG,CAAA,GAAI,CAAC,UAAU,KAAK,CAAA;AAAA,IAClC,CAAA,MAAO;AACL,MAAA,GAAA,CAAI,GAAG,CAAA,GAAI,KAAA;AAAA,IACb;AAAA,EACF;AACA,EAAA,OAAO,GAAA;AACT;AAGA,SAAS,SAAS,KAAA,EAAwE;AACxF,EAAA,OAAO,UAAA,CAAW,KAAK,CAAA,GAAI,gBAAA,CAAiB,KAAK,CAAA,GAAI,EAAE,GAAG,KAAA,EAAM;AAClE;AAUO,SAAS,UAAA,CACd,OACA,IAAA,EACe;AACf,EAAA,MAAM,MAAA,GAAS,SAAS,KAAK,CAAA;AAC7B,EAAA,MAAM,OAAA,GAAU,KAAK,YAAA,IAAgB,gBAAA;AAGrC,EAAA,IAAI,KAAK,QAAA,EAAU;AACjB,IAAA,MAAM,IAAA,GAAO,MAAA,CAAO,IAAA,CAAK,QAAQ,CAAA;AACjC,IAAA,IAAI,OAAO,IAAA,KAAS,QAAA,GAAW,IAAA,CAAK,IAAA,OAAW,EAAA,GAAK,IAAA,IAAQ,IAAA,IAAQ,IAAA,KAAS,EAAA,EAAI;AAC/E,MAAA,OAAO,IAAA,CAAK,IAAA,EAAM,MAAA,EAAQ,OAAO,CAAA;AAAA,IACnC;AAAA,EACF;AAGA,EAAA,IAAI,IAAA,CAAK,WAAA,IAAe,IAAA,CAAK,WAAA,GAAc,CAAA,EAAG;AAC5C,IAAA,MAAM,OAAA,GAAU,KAAK,cAAA,IAAkB,gBAAA;AACvC,IAAA,MAAM,GAAA,GAAM,OAAO,OAAO,CAAA;AAC1B,IAAA,MAAM,EAAA,GAAK,OAAO,GAAA,KAAQ,QAAA,IAAY,IAAI,IAAA,EAAK,KAAM,EAAA,GAAK,MAAA,CAAO,GAAG,CAAA,GAAI,OAAO,GAAA,KAAQ,WAAW,GAAA,GAAM,GAAA;AACxG,IAAA,IAAI,MAAA,CAAO,QAAA,CAAS,EAAE,CAAA,EAAG;AACvB,MAAA,MAAM,OAAA,GAAU,IAAA,CAAK,GAAA,EAAI,GAAI,EAAA;AAC7B,MAAA,IAAI,OAAA,IAAW,CAAA,IAAK,OAAA,GAAU,IAAA,CAAK,WAAA,EAAa;AAC9C,QAAA,OAAO,IAAA,CAAK,IAAA,EAAM,MAAA,EAAQ,OAAO,CAAA;AAAA,MACnC;AAAA,IACF,CAAA,MAAA,IAAW,KAAK,gBAAA,EAAkB;AAGhC,MAAA,OAAO,IAAA,CAAK,IAAA,EAAM,MAAA,EAAQ,OAAO,CAAA;AAAA,IACnC;AAAA,EACF;AAGA,EAAA,MAAM,OAAA,GAAU,aAAA,CAAc,MAAA,EAAQ,IAAI,CAAA;AAG1C,EAAA,MAAM,CAAA,GAAI,IAAA,CAAK,MAAA,CAAO,SAAA,CAAU,OAAO,CAAA;AACvC,EAAA,IAAI,CAAA,CAAE,SAAS,OAAO,EAAE,IAAI,IAAA,EAAM,IAAA,EAAM,EAAE,IAAA,EAAK;AAC/C,EAAA,OAAO,EAAE,IAAI,KAAA,EAAO,MAAA,EAAQ,EAAE,KAAA,CAAM,OAAA,EAAQ,EAAG,MAAA,EAAQ,OAAA,EAAQ;AACjE;AAEA,SAAS,aAAA,CAAiB,QAAiC,IAAA,EAA+C;AACxG,EAAA,MAAM,IAAA,uBAAW,GAAA,EAAY;AAC7B,EAAA,IAAI,IAAA,CAAK,QAAA,EAAU,IAAA,CAAK,GAAA,CAAI,KAAK,QAAQ,CAAA;AACzC,EAAA,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,cAAA,IAAkB,gBAAgB,CAAA;AAChD,EAAA,IAAI,IAAA,CAAK,IAAA,KAAS,CAAA,EAAG,OAAO,MAAA;AAC5B,EAAA,MAAM,MAA+B,EAAC;AACtC,EAAA,KAAA,MAAW,CAAA,IAAK,MAAA,CAAO,IAAA,CAAK,MAAM,GAAG,IAAI,CAAC,IAAA,CAAK,GAAA,CAAI,CAAC,CAAA,EAAG,GAAA,CAAI,CAAC,CAAA,GAAI,OAAO,CAAC,CAAA;AACxE,EAAA,OAAO,GAAA;AACT;AAEA,SAAS,IAAA,CAAQ,IAAA,EAAsB,MAAA,EAAiC,OAAA,EAAgC;AACtG,EAAA,OAAO;AAAA,IACL,EAAA,EAAI,KAAA;AAAA,IACJ,IAAA,EAAM,IAAA;AAAA,IACN,MAAA;AAAA,IACA,QAAQ,EAAE,CAAC,OAAO,GAAG,IAAA,CAAK,eAAe,gBAAA;AAAiB,GAC5D;AACF;AAkBO,SAAS,WAAc,IAAA,EAA+B;AAC3D,EAAA,OAAO;AAAA,IACL,MAAA,EAAQ,CAAC,KAAA,KAAU,UAAA,CAAW,OAAO,IAAI,CAAA;AAAA,IACzC,QAAQ,CAAC,KAAA,EAAO,QAAA,KAAa,UAAA,CAAW,UAAU,IAAI;AAAA,GACxD;AACF;AAUO,SAAS,cAAc,IAAA,EAO5B;AACA,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,MAAA;AAAA,IACN,IAAA;AAAA,IACA,QAAA,EAAU,EAAA;AAAA,IACV,YAAA,EAAc,KAAA;AAAA,IACd,aAAA,EAAe,MAAA;AAAA,IACf,KAAA,EAAO;AAAA,MACL,QAAA,EAAU,UAAA;AAAA,MACV,KAAA,EAAO,KAAA;AAAA,MACP,MAAA,EAAQ,KAAA;AAAA,MACR,OAAA,EAAS,GAAA;AAAA,MACT,MAAA,EAAQ,MAAA;AAAA,MACR,QAAA,EAAU,QAAA;AAAA,MACV,IAAA,EAAM,eAAA;AAAA,MACN,UAAA,EAAY,QAAA;AAAA,MACZ,MAAA,EAAQ;AAAA;AACV,GACF;AACF;AAGO,SAAS,cAAA,GAAyB;AACvC,EAAA,OAAO,MAAA,CAAO,IAAA,CAAK,GAAA,EAAK,CAAA;AAC1B","file":"index.js","sourcesContent":["/**\n * @lacspace/form\n * End-to-end form handling for the server — turn a `FormData` (or a plain\n * object) into typed, validated data with built-in spam protection, and get\n * back either your data or per-field errors ready to re-render.\n *\n * Framework-agnostic, but shaped for Next.js Server Actions.\n *\n * ```ts\n * \"use server\";\n * import { createForm } from \"@lacspace/form\";\n * import { v } from \"@lacspace/validate\";\n *\n * const contact = createForm({\n * schema: v.object({\n * name: v.string().min(2),\n * email: v.string().email(),\n * message: v.string().min(10),\n * }),\n * honeypot: \"company\", // a hidden field bots love to fill\n * });\n *\n * export async function submit(prev, formData) {\n * const r = contact.action(prev, formData);\n * if (!r.ok) return r; // { errors, values } → re-render\n * await sendEmail(r.data); // fully typed\n * return { ok: true };\n * }\n * ```\n *\n * Zero dependencies · isomorphic · fully typed.\n */\n\n/* ------------------------------------------------------------------ *\n * Validator contract — structurally compatible with @lacspace/validate\n * (and, in practice, with zod). No hard dependency either way.\n * ------------------------------------------------------------------ */\n\nexport interface Validator<T> {\n safeParse(input: unknown):\n | { success: true; data: T }\n | { success: false; error: { flatten(): Record<string, string> } };\n}\n\n/* ------------------------------------------------------------------ *\n * Options & results\n * ------------------------------------------------------------------ */\n\nexport interface FormOptions<T> {\n /** A schema with `safeParse` — e.g. `v.object({...})` from @lacspace/validate. */\n schema: Validator<T>;\n /**\n * Name of a hidden \"honeypot\" field that real users never see and never fill.\n * If it arrives non-empty, the submission is treated as spam.\n */\n honeypot?: string;\n /**\n * Reject submissions that arrive faster than this many ms after the form was\n * rendered. Requires a hidden timestamp field (see `timestampField`).\n */\n minSubmitMs?: number;\n /** Hidden field holding the render time in ms. Default `\"_ts\"`. */\n timestampField?: string;\n /**\n * When `true`, a submission whose timestamp field is missing or non-numeric is\n * itself treated as spam (rejected) rather than silently skipping the timing\n * check — closing the loophole where a bot simply omits the field. Only takes\n * effect alongside `minSubmitMs`. Default `false` (non-breaking).\n */\n requireTimestamp?: boolean;\n /** Message returned when a submission is flagged as spam. */\n spamMessage?: string;\n /** Key used for form-level (non-field) errors. Default `\"_form\"`. */\n formErrorKey?: string;\n}\n\nexport type FormResult<T> =\n | { ok: true; data: T }\n | {\n ok: false;\n /** `{ email: \"Invalid email\", _form: \"...\" }` — render next to inputs. */\n errors: Record<string, string>;\n /** The raw submitted values, so the form can be re-rendered as typed. */\n values: Record<string, unknown>;\n /** True when the failure was a spam/bot heuristic, not user error. */\n spam?: boolean;\n };\n\nconst DEFAULT_TS_FIELD = \"_ts\";\nconst DEFAULT_FORM_KEY = \"_form\";\nconst DEFAULT_SPAM_MSG = \"Your submission could not be processed. Please try again.\";\n\n/* ------------------------------------------------------------------ *\n * FormData → plain object\n * ------------------------------------------------------------------ */\n\n/** Minimal structural shape of the parts of FormData we use. */\ninterface FormDataLike {\n entries(): IterableIterator<[string, unknown]>;\n}\n\nfunction isFormData(x: unknown): x is FormDataLike {\n return (\n typeof x === \"object\" &&\n x !== null &&\n typeof (x as { entries?: unknown }).entries === \"function\" &&\n typeof (x as { append?: unknown }).append === \"function\"\n );\n}\n\n/**\n * Convert a `FormData` into a plain object. Repeated keys become arrays; File\n * values are passed through untouched. Empty strings are preserved (validation\n * decides what \"required\" means).\n */\nexport function formDataToObject(fd: FormDataLike): Record<string, unknown> {\n const out: Record<string, unknown> = {};\n for (const [key, value] of fd.entries()) {\n if (key in out) {\n const existing = out[key];\n if (Array.isArray(existing)) existing.push(value);\n else out[key] = [existing, value];\n } else {\n out[key] = value;\n }\n }\n return out;\n}\n\n/** Normalise any accepted input to a plain object. */\nfunction toObject(input: FormDataLike | Record<string, unknown>): Record<string, unknown> {\n return isFormData(input) ? formDataToObject(input) : { ...input };\n}\n\n/* ------------------------------------------------------------------ *\n * Core handler\n * ------------------------------------------------------------------ */\n\n/**\n * Validate an input (FormData or object) against a schema, applying spam\n * heuristics first. Returns typed data or per-field errors + the raw values.\n */\nexport function handleForm<T>(\n input: FormDataLike | Record<string, unknown>,\n opts: FormOptions<T>,\n): FormResult<T> {\n const values = toObject(input);\n const formKey = opts.formErrorKey ?? DEFAULT_FORM_KEY;\n\n // 1. Honeypot — a non-empty hidden field means a bot.\n if (opts.honeypot) {\n const trap = values[opts.honeypot];\n if (typeof trap === \"string\" ? trap.trim() !== \"\" : trap != null && trap !== \"\") {\n return spam(opts, values, formKey);\n }\n }\n\n // 2. Timing — submitted implausibly fast after render.\n if (opts.minSubmitMs && opts.minSubmitMs > 0) {\n const tsField = opts.timestampField ?? DEFAULT_TS_FIELD;\n const raw = values[tsField];\n const ts = typeof raw === \"string\" && raw.trim() !== \"\" ? Number(raw) : typeof raw === \"number\" ? raw : NaN;\n if (Number.isFinite(ts)) {\n const elapsed = Date.now() - ts;\n if (elapsed >= 0 && elapsed < opts.minSubmitMs) {\n return spam(opts, values, formKey);\n }\n } else if (opts.requireTimestamp) {\n // A bot that simply omits (or corrupts) the timestamp would otherwise\n // bypass the timing heuristic entirely — treat that as suspicious.\n return spam(opts, values, formKey);\n }\n }\n\n // 3. Strip internal fields before validation so schemas can stay `.strict()`.\n const cleaned = stripInternal(values, opts);\n\n // 4. Validate.\n const r = opts.schema.safeParse(cleaned);\n if (r.success) return { ok: true, data: r.data };\n return { ok: false, errors: r.error.flatten(), values: cleaned };\n}\n\nfunction stripInternal<T>(values: Record<string, unknown>, opts: FormOptions<T>): Record<string, unknown> {\n const drop = new Set<string>();\n if (opts.honeypot) drop.add(opts.honeypot);\n drop.add(opts.timestampField ?? DEFAULT_TS_FIELD);\n if (drop.size === 0) return values;\n const out: Record<string, unknown> = {};\n for (const k of Object.keys(values)) if (!drop.has(k)) out[k] = values[k];\n return out;\n}\n\nfunction spam<T>(opts: FormOptions<T>, values: Record<string, unknown>, formKey: string): FormResult<T> {\n return {\n ok: false,\n spam: true,\n values,\n errors: { [formKey]: opts.spamMessage ?? DEFAULT_SPAM_MSG },\n };\n}\n\n/* ------------------------------------------------------------------ *\n * createForm — reusable handler bound to one schema\n * ------------------------------------------------------------------ */\n\nexport interface Form<T> {\n /** Validate any input; returns typed data or errors. */\n handle(input: FormDataLike | Record<string, unknown>): FormResult<T>;\n /**\n * Next.js Server Action signature `(prevState, formData) => result`.\n * The previous state is ignored; it exists so this drops straight into\n * `useActionState`.\n */\n action(prevState: unknown, formData: FormDataLike): FormResult<T>;\n}\n\n/** Bind a schema + spam options once and reuse the handler across requests. */\nexport function createForm<T>(opts: FormOptions<T>): Form<T> {\n return {\n handle: (input) => handleForm(input, opts),\n action: (_prev, formData) => handleForm(formData, opts),\n };\n}\n\n/* ------------------------------------------------------------------ *\n * Client helpers (framework-agnostic, no React needed)\n * ------------------------------------------------------------------ */\n\n/**\n * Attributes for a visually-hidden honeypot input. Spread onto an `<input>`:\n * `<input {...honeypotProps(\"company\")} />`.\n */\nexport function honeypotProps(name: string): {\n type: \"text\";\n name: string;\n tabIndex: -1;\n autoComplete: \"off\";\n \"aria-hidden\": \"true\";\n style: Record<string, string>;\n} {\n return {\n type: \"text\",\n name,\n tabIndex: -1,\n autoComplete: \"off\",\n \"aria-hidden\": \"true\",\n style: {\n position: \"absolute\",\n width: \"1px\",\n height: \"1px\",\n padding: \"0\",\n margin: \"-1px\",\n overflow: \"hidden\",\n clip: \"rect(0 0 0 0)\",\n whiteSpace: \"nowrap\",\n border: \"0\",\n },\n };\n}\n\n/** A `<input type=\"hidden\">`-ready render timestamp for the timing check. */\nexport function timestampValue(): string {\n return String(Date.now());\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lacspace/form",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "End-to-end form handling for the server — turn FormData into typed, validated data with a honeypot + timing spam guard, and get back your data or per-field errors ready to re-render. Shaped for Next.js Server Actions. Zero-dependency, isomorphic.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
],
|
|
46
46
|
"author": "Lacspace <contact@lacspace.com>",
|
|
47
47
|
"license": "SEE LICENSE IN LICENSE",
|
|
48
|
-
"homepage": "https://lacspace.com/packages",
|
|
48
|
+
"homepage": "https://developer.lacspace.com/packages/form",
|
|
49
49
|
"repository": {
|
|
50
50
|
"type": "git",
|
|
51
51
|
"url": "git+https://github.com/lacspace/npm-packages.git",
|