@zerolith/forms 0.1.0-alpha.7 → 0.1.0-alpha.9

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 CHANGED
@@ -17,15 +17,15 @@ html`<form method="post" action="/notes" @submit=${form.submit}>
17
17
 
18
18
  Validation is written once as a Standard Schema and runs on both sides: `validateForm` turns the schema's issues into the same problem document the server returns, and `problemResponse` writes that document as a WinterTC `Response`, so the no-JavaScript path and the enhanced path share one handler and one wire shape. Any backend that can answer with `application/problem+json` fits.
19
19
 
20
- The guide is on the [docs site](https://molexxxx.github.io/zerolith/guide/forms/).
20
+ The guide is on the [docs site](https://zerolith.dev/guide/forms/).
21
21
 
22
22
  Status: alpha. Install with `npm install @zerolith/forms@alpha`; the API may change before 1.0.
23
23
 
24
24
  ## Documentation
25
25
 
26
- - The [forms guide](https://molexxxx.github.io/zerolith/guide/forms/) explains it with examples that run as tests.
27
- - The [reference](https://molexxxx.github.io/zerolith/reference/zerolith-forms/) lists every export, generated from the source.
28
- - New to zerolith? The [tutorial](https://molexxxx.github.io/zerolith/learn/) builds an app step by step, and the [playground](https://molexxxx.github.io/zerolith/playground/) runs code in the browser.
26
+ - The [forms guide](https://zerolith.dev/guide/forms/) explains it with examples that run as tests.
27
+ - The [reference](https://zerolith.dev/reference/zerolith-forms/) lists every export, generated from the source.
28
+ - New to zerolith? The [tutorial](https://zerolith.dev/learn/) builds an app step by step, and the [playground](https://zerolith.dev/playground/) runs code in the browser.
29
29
 
30
30
  ## License
31
31
 
@@ -0,0 +1,2 @@
1
+ import{PROBLEM_MEDIA_TYPE as e,batch as t,computed as n,isProblem as r,signal as i}from"zerolith";import{validateForm as a}from"./schema.js";const o=[];function s(e){try{e()}catch(e){typeof reportError==`function`?reportError(e):queueMicrotask(()=>{throw e})}}let c;function l(e,t){let n=new FormData(e,t??void 0);if(t===null)return n;if(c?.implementation!==FormData){let e=document.createElement(`form`),t=document.createElement(`button`);t.name=`probe`,t.value=`kept`,e.append(t),c={implementation:FormData,keepsSubmitter:new FormData(e,t).get(`probe`)===`kept`}}let r=t.getAttribute(`name`);return c.keepsSubmitter||r===null||r===``||(t instanceof HTMLInputElement&&t.type===`image`?(n.append(`${r}.x`,`0`),n.append(`${r}.y`,`0`)):n.append(r,t.value)),n}function u(e,t){if(!r(e))return{status:t.status,title:t.statusText===``?`HTTP ${t.status}`:t.statusText};if(e.fields===void 0)return e;let n=Object.create(null);for(let[t,r]of Object.entries(e.fields))if(typeof r==`string`)n[t]=[r];else if(Array.isArray(r)){let e=r.filter(e=>typeof e==`string`);e.length>0&&(n[t]=e)}return{...e,fields:n}}function d(e){return[...e].map(([e,t])=>[e,typeof t==`string`?t:t.name])}async function f(t,n,r){let{element:i,submitter:a,data:o,method:s,action:c,signal:l}=t,u=new Headers(n);u.has(`accept`)||u.set(`accept`,`application/json, ${e}`);let f={method:s.toUpperCase(),headers:u,signal:l,credentials:`same-origin`};if(s===`get`){let e=new URL(c);return e.search=new URLSearchParams(d(o)).toString(),r(e,f)}let p=a!==null&&a.hasAttribute(`formenctype`)?a.formEnctype:i.enctype;return p===`multipart/form-data`?f.body=o:p===`text/plain`?(u.set(`content-type`,`text/plain; charset=utf-8`),f.body=d(o).map(([e,t])=>`${e}=${t}\r\n`).join(``)):f.body=new URLSearchParams(d(o)),r(c,f)}async function p(e){if(e.status===204||e.status===205)return;if((e.headers.get(`content-type`)??``).includes(`json`))return e.json();let t=await e.text();return t===``?void 0:t}export function createForm(r={}){let c=i(`idle`),d=i(void 0),m=i(void 0),h=i(void 0),g=n(()=>c.get()===`pending`),_=new Map,v,y=(e,n)=>{t(()=>{d.set(e),c.set(`failure`)}),s(()=>r.onFailure?.(e,n))},b=async(n,i,o,g,_)=>{let v=l(n,i);if(t(()=>{c.set(`pending`),d.set(void 0),h.set(void 0)}),r.schema!==void 0){let e=await a(r.schema,v);if(_.signal.aborted)return;if(e.problem!==void 0){y(e.problem,n);return}}let b={element:n,submitter:i,data:v,method:o,action:g,signal:_.signal},x=await(r.send??(e=>f(e,r.headers,r.fetch??fetch)))(b);if(_.signal.aborted)return;let S=(x.headers.get(`content-type`)??``).toLowerCase();if(S.includes(e)||!x.ok){let e=S.includes(`json`)?await x.json().catch(()=>void 0):void 0;if(_.signal.aborted)return;y(u(e,x),n);return}let C=x.redirected?void 0:await p(x);_.signal.aborted||(t(()=>{m.set(C),c.set(`success`)}),r.reset===!0&&n.reset(),s(()=>r.onSuccess?.({result:C,response:x,element:n})),x.redirected&&(r.navigate??(e=>location.assign(e)))(x.url))};return{status:c,pending:g,problem:d,result:m,error:h,field(e){let t=_.get(e);if(t===void 0){let r=n(()=>{let t=d.get()?.fields;return t!==void 0&&Object.hasOwn(t,e)?t[e]??o:o});t={errors:r,error:n(()=>r.get()[0]),invalid:n(()=>r.get().length>0)},_.set(e,t)}return t},submit(e){let n=e.currentTarget,i=e.submitter,a=i!==null&&i.hasAttribute(`formmethod`)?i.formMethod:n.method;if(a===`dialog`)return;e.preventDefault();let o=new URL(i?.hasAttribute(`formaction`)===!0?i.formAction:n.action);v?.abort();let l=new AbortController;v=l,b(n,i,a,o,l).catch(e=>{l.signal.aborted||(t(()=>{h.set(e),c.set(`error`)}),s(()=>r.onError?.(e,n)))})},reset(){v?.abort(),v=void 0,t(()=>{c.set(`idle`),d.set(void 0),m.set(void 0),h.set(void 0)})}}}
2
+ //# sourceMappingURL=form.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"form.js","sources":["../../src/form.ts"],"sourcesContent":["/**\n * The enhancer. A form stays a native `<form>` that posts to its action and\n * works without JavaScript; `createForm` takes over its submit event when\n * JavaScript runs, sends the same request with `fetch`, and exposes the\n * outcome as signals: pending state, a parsed result, or a problem whose\n * `fields` feed the inputs that failed.\n */\nimport { PROBLEM_MEDIA_TYPE, type Problem, type ReadableSignal, batch, computed, isProblem, signal } from \"zerolith\";\nimport { type StandardSchema, validateForm } from \"./schema.js\";\n\n/** Where a form is in its current submission. */\nexport type FormStatus = \"idle\" | \"pending\" | \"success\" | \"failure\" | \"error\";\n\n/** What the default sender, or a custom `send`, receives. */\nexport interface Submission\n{\n /** The form that was submitted. */\n readonly element: HTMLFormElement;\n /** The button or input that submitted it, when there was one. */\n readonly submitter: HTMLElement | null;\n /** The form's data, including the submitter's name and value. */\n readonly data: FormData;\n /** The lowercase method, after the submitter's `formmethod` override. */\n readonly method: string;\n /** The resolved action URL, after the submitter's `formaction` override. */\n readonly action: URL;\n /** Aborted when the form is submitted again or reset. */\n readonly signal: AbortSignal;\n}\n\n/** The outcome of one submission. */\nexport interface SubmissionOutcome\n{\n /** The parsed body: JSON when the response says so, text otherwise, undefined for an empty body. */\n readonly result: unknown;\n /** The successful response, as `fetch` or a custom `send` returned it; its body has already been read into `result` unless it was redirected. */\n readonly response: Response;\n /** The form that was submitted, already reset when the `reset` option is set. */\n readonly element: HTMLFormElement;\n}\n\n/** Options for `createForm`. */\nexport interface FormOptions\n{\n /** Validated on the client before anything is sent; failing issues become the problem's `fields`. */\n schema?: StandardSchema;\n /** Sends the submission. Defaults to a `fetch` of the form's method, action, and encoding that asks for JSON. */\n send?: (submission: Submission) => Promise<Response>;\n /** Headers added to the default request. */\n headers?: HeadersInit;\n /** The `fetch` the default sender uses; defaults to the global one. */\n fetch?: typeof fetch;\n /** Where a redirected response takes the page. Defaults to `location.assign`; pass the router's `navigate` to stay in the application. */\n navigate?: (url: string) => void;\n /** Resets the form element after a successful response. */\n reset?: boolean;\n /** Called after a successful response. */\n onSuccess?: (outcome: SubmissionOutcome) => void;\n /** Called with the problem from client-side validation or from the server. */\n onFailure?: (problem: Problem, element: HTMLFormElement) => void;\n /** Called when the request itself fails. */\n onError?: (error: unknown, element: HTMLFormElement) => void;\n}\n\n/** One input's slice of the current problem. */\nexport interface Field\n{\n /** Every message for the field, oldest first. */\n readonly errors: ReadableSignal<readonly string[]>;\n /** The first message, or undefined. */\n readonly error: ReadableSignal<string | undefined>;\n /** True while the field has a message; bind it to `aria-invalid`. */\n readonly invalid: ReadableSignal<boolean>;\n}\n\n/** The enhanced form. */\nexport interface Form\n{\n /** The current submission's stage: `pending` while in flight, then `success`, `failure` with a problem, or `error` when it did not complete; `idle` before the first submission and after `reset()`. */\n readonly status: ReadableSignal<FormStatus>;\n /** True while a submission is in flight. */\n readonly pending: ReadableSignal<boolean>;\n /** The problem from the last failed submission, or undefined. */\n readonly problem: ReadableSignal<Problem | undefined>;\n /** The parsed body of the last successful submission. */\n readonly result: ReadableSignal<unknown>;\n /** The error of the last submission that did not complete. */\n readonly error: ReadableSignal<unknown>;\n /** A field's view of the problem; the same object for the same name. */\n field(name: string): Field;\n /** The submit handler; bind it with `@submit`. */\n submit(event: SubmitEvent): void;\n /** Aborts any submission in flight and returns the form to idle. */\n reset(): void;\n}\n\nconst EMPTY: readonly string[] = [];\n\n/** Calls an application callback, reporting what it throws as an uncaught error would be, without changing the form's state. */\nfunction guarded(callback: () => void): void\n{\n try\n {\n callback();\n }\n catch (thrown)\n {\n if (typeof reportError === \"function\")\n {\n reportError(thrown);\n }\n else\n {\n queueMicrotask(() =>\n {\n throw thrown;\n });\n }\n }\n}\n\n/** The `FormData` implementation probed, and whether its constructor adds the submitter's entry, which browsers before Chrome 112 and Firefox 111 skip. */\nlet probed: { implementation: typeof FormData; keepsSubmitter: boolean } | undefined;\n\n/** The form's data as the browser submits it, with the submitter's entry added where `new FormData(form, submitter)` leaves it out. */\nfunction submittedData(element: HTMLFormElement, submitter: HTMLElement | null): FormData\n{\n const data = new FormData(element, submitter ?? undefined);\n if (submitter === null)\n {\n return data;\n }\n if (probed?.implementation !== FormData)\n {\n const form = document.createElement(\"form\");\n const button = document.createElement(\"button\");\n button.name = \"probe\";\n button.value = \"kept\";\n form.append(button);\n probed = { implementation: FormData, keepsSubmitter: new FormData(form, button).get(\"probe\") === \"kept\" };\n }\n const name = submitter.getAttribute(\"name\");\n if (probed.keepsSubmitter || name === null || name === \"\")\n {\n return data;\n }\n if (submitter instanceof HTMLInputElement && submitter.type === \"image\")\n {\n data.append(`${name}.x`, \"0\");\n data.append(`${name}.y`, \"0\");\n }\n else\n {\n data.append(name, (submitter as HTMLButtonElement | HTMLInputElement).value);\n }\n return data;\n}\n\n/**\n * The problem a failed response describes: its body when that is a problem\n * document, or one built from the status. The server is a trust boundary,\n * so `fields` keeps only own keys whose value is a message or a list of\n * messages, a lone message becoming a list of one.\n */\nfunction problemOf(body: unknown, response: Response): Problem\n{\n if (!isProblem(body))\n {\n return { status: response.status, title: response.statusText === \"\" ? `HTTP ${response.status}` : response.statusText };\n }\n if (body.fields === undefined)\n {\n return body;\n }\n const fields: Record<string, readonly string[]> = Object.create(null) as Record<string, readonly string[]>;\n for (const [name, value] of Object.entries(body.fields as Record<string, unknown>))\n {\n if (typeof value === \"string\")\n {\n fields[name] = [value];\n }\n else if (Array.isArray(value))\n {\n const messages = value.filter((message): message is string => typeof message === \"string\");\n if (messages.length > 0)\n {\n fields[name] = messages;\n }\n }\n }\n return { ...body, fields };\n}\n\nfunction entries(data: FormData): [string, string][]\n{\n return [...data].map(([name, value]) => [name, typeof value === \"string\" ? value : value.name]);\n}\n\nasync function defaultSend(submission: Submission, headers: HeadersInit | undefined, fetchImpl: typeof fetch): Promise<Response>\n{\n const { element, submitter, data, method, action, signal: abort } = submission;\n const requestHeaders = new Headers(headers);\n if (!requestHeaders.has(\"accept\"))\n {\n requestHeaders.set(\"accept\", `application/json, ${PROBLEM_MEDIA_TYPE}`);\n }\n const init: RequestInit = { method: method.toUpperCase(), headers: requestHeaders, signal: abort, credentials: \"same-origin\" };\n if (method === \"get\")\n {\n const url = new URL(action);\n url.search = new URLSearchParams(entries(data)).toString();\n return fetchImpl(url, init);\n }\n const enctype = submitter !== null && submitter.hasAttribute(\"formenctype\") ? (submitter as HTMLButtonElement).formEnctype : element.enctype;\n if (enctype === \"multipart/form-data\")\n {\n init.body = data;\n }\n else if (enctype === \"text/plain\")\n {\n requestHeaders.set(\"content-type\", \"text/plain; charset=utf-8\");\n init.body = entries(data).map(([name, value]) => `${name}=${value}\\r\\n`).join(\"\");\n }\n else\n {\n init.body = new URLSearchParams(entries(data));\n }\n return fetchImpl(action, init);\n}\n\nasync function parseBody(response: Response): Promise<unknown>\n{\n if (response.status === 204 || response.status === 205)\n {\n return undefined;\n }\n const type = response.headers.get(\"content-type\") ?? \"\";\n if (type.includes(\"json\"))\n {\n return response.json();\n }\n const text = await response.text();\n return text === \"\" ? undefined : text;\n}\n\n/**\n * Creates the enhancer for one form. Bind `submit` to the form's `@submit`;\n * the method, action, and encoding stay on the form element, so the same\n * markup submits natively when the script has not run.\n */\nexport function createForm(options: FormOptions = {}): Form\n{\n const status = signal<FormStatus>(\"idle\");\n const problem = signal<Problem | undefined>(undefined);\n const result = signal<unknown>(undefined);\n const error = signal<unknown>(undefined);\n const pending = computed(() => status.get() === \"pending\");\n const fields = new Map<string, Field>();\n let controller: AbortController | undefined;\n\n const fail = (found: Problem, element: HTMLFormElement): void =>\n {\n batch(() =>\n {\n problem.set(found);\n status.set(\"failure\");\n });\n guarded(() => options.onFailure?.(found, element));\n };\n\n const run = async (element: HTMLFormElement, submitter: HTMLElement | null, method: string, action: URL, current: AbortController): Promise<void> =>\n {\n const data = submittedData(element, submitter);\n batch(() =>\n {\n status.set(\"pending\");\n problem.set(undefined);\n error.set(undefined);\n });\n if (options.schema !== undefined)\n {\n const validation = await validateForm(options.schema, data);\n if (current.signal.aborted)\n {\n return;\n }\n if (validation.problem !== undefined)\n {\n fail(validation.problem, element);\n return;\n }\n }\n const submission: Submission = { element, submitter, data, method, action, signal: current.signal };\n const response = await (options.send ?? ((request) => defaultSend(request, options.headers, options.fetch ?? fetch)))(submission);\n if (current.signal.aborted)\n {\n return;\n }\n const type = (response.headers.get(\"content-type\") ?? \"\").toLowerCase();\n if (type.includes(PROBLEM_MEDIA_TYPE) || !response.ok)\n {\n const body: unknown = type.includes(\"json\") ? await response.json().catch(() => undefined) : undefined;\n if (current.signal.aborted)\n {\n return;\n }\n fail(problemOf(body, response), element);\n return;\n }\n const parsed = response.redirected ? undefined : await parseBody(response);\n if (current.signal.aborted)\n {\n return;\n }\n batch(() =>\n {\n result.set(parsed);\n status.set(\"success\");\n });\n if (options.reset === true)\n {\n element.reset();\n }\n guarded(() => options.onSuccess?.({ result: parsed, response, element }));\n if (response.redirected)\n {\n (options.navigate ?? ((url) => location.assign(url)))(response.url);\n }\n };\n\n return {\n status,\n pending,\n problem,\n result,\n error,\n field(name: string): Field\n {\n let field = fields.get(name);\n if (field === undefined)\n {\n const errors = computed<readonly string[]>(() =>\n {\n const found = problem.get()?.fields;\n return found !== undefined && Object.hasOwn(found, name) ? found[name] ?? EMPTY : EMPTY;\n });\n field = {\n errors,\n error: computed(() => errors.get()[0]),\n invalid: computed(() => errors.get().length > 0)\n };\n fields.set(name, field);\n }\n return field;\n },\n submit(event: SubmitEvent): void\n {\n const element = event.currentTarget as HTMLFormElement;\n const submitter = event.submitter;\n const method = submitter !== null && submitter.hasAttribute(\"formmethod\") ? (submitter as HTMLButtonElement).formMethod : element.method;\n if (method === \"dialog\")\n {\n return;\n }\n event.preventDefault();\n const action = new URL(submitter?.hasAttribute(\"formaction\") === true ? (submitter as HTMLButtonElement).formAction : element.action);\n controller?.abort();\n const current = new AbortController();\n controller = current;\n run(element, submitter, method, action, current).catch((thrown: unknown) =>\n {\n if (current.signal.aborted)\n {\n return;\n }\n batch(() =>\n {\n error.set(thrown);\n status.set(\"error\");\n });\n guarded(() => options.onError?.(thrown, element));\n });\n },\n reset(): void\n {\n controller?.abort();\n controller = undefined;\n batch(() =>\n {\n status.set(\"idle\");\n problem.set(undefined);\n result.set(undefined);\n error.set(undefined);\n });\n }\n };\n}\n"],"names":["EMPTY","guarded","callback","thrown","probed","submittedData","data","element","submitter","form","button","name","problemOf","isProblem","body","response","fields","value","messages","message","entries","defaultSend","abort","submission","requestHeaders","headers","PROBLEM_MEDIA_TYPE","init","method","url","action","fetchImpl","enctype","parseBody","text","status","signal","problem","result","error","pending","computed","controller","fail","batch","found","options","run","validation","validateForm","current","request","type","parsed","field","errors","event"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export{problemResponse}from"./problem.js";export{formValues,problemFromIssues,validateForm}from"./schema.js";export{createForm}from"./form.js";
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":["../../src/index.ts"],"sourcesContent":["/**\n * @zerolith/forms: native forms, enhanced. The public surface is assembled here.\n * The `Problem` type, `isProblem`, and `PROBLEM_MEDIA_TYPE` come from `zerolith`.\n */\nexport { problemResponse } from \"./problem.js\";\n\nexport type { FormValues, InferOutput, StandardIssue, StandardPathSegment, StandardResult, StandardSchema, Validation } from \"./schema.js\";\nexport { formValues, problemFromIssues, validateForm } from \"./schema.js\";\n\nexport type { Field, Form, FormOptions, FormStatus, Submission, SubmissionOutcome } from \"./form.js\";\nexport { createForm } from \"./form.js\";\n"],"names":[],"mappings":"AAIA,OAAS,oBAAuB,eAGhC,OAAS,WAAY,kBAAmB,iBAAoB,cAG5D,OAAS,eAAkB"}
@@ -0,0 +1,2 @@
1
+ import{PROBLEM_MEDIA_TYPE as e}from"zerolith";export function problemResponse(t,n={}){let r=n.status??t.status??400,i=new Headers(n.headers);i.set(`content-type`,`${e}; charset=utf-8`);let a={...t,status:r};return new Response(JSON.stringify(a),{...n,status:r,headers:i})}
2
+ //# sourceMappingURL=problem.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"problem.js","sources":["../../src/problem.ts"],"sourcesContent":["/**\n * The server side of the problem document: writing a `Problem` as a\n * WinterTC `Response` with the problem details media type.\n */\nimport { PROBLEM_MEDIA_TYPE, type Problem } from \"zerolith\";\n\n/**\n * Writes a problem as a `Response` with the problem details media type.\n * The status comes from `init`, or the problem, or defaults to 400.\n */\nexport function problemResponse(problem: Problem, init: ResponseInit = {}): Response\n{\n const status = init.status ?? problem.status ?? 400;\n const headers = new Headers(init.headers);\n headers.set(\"content-type\", `${PROBLEM_MEDIA_TYPE}; charset=utf-8`);\n const body: Problem = { ...problem, status };\n return new Response(JSON.stringify(body), { ...init, status, headers });\n}\n"],"names":["status","init","problem","headers","PROBLEM_MEDIA_TYPE","body"],"mappings":"AAIA,OAAS,4BAAwC,WAMjD,OAAM,SAAU,gBAAgB,EAAkB,EAAqB,CAAA,EAAE,CAErE,IAAMA,EAASC,EAAK,QAAUC,EAAQ,QAAU,IAC1CC,EAAU,IAAI,QAAQF,EAAK,OAAO,EACxCE,EAAQ,IAAI,eAAgB,GAAGC,EAAkB,gBAAiB,EAClE,IAAMC,EAAgB,CAAE,GAAGH,EAAS,QAAM,EAC1C,OAAO,IAAI,SAAS,KAAK,UAAUG,CAAI,EAAG,CAAE,GAAGJ,EAAM,SAAQ,SAAO,CAAE,CAC1E"}
@@ -0,0 +1,2 @@
1
+ export function formValues(e){let t=Object.create(null);for(let[n,r]of e){let e=t[n];e===void 0?t[n]=r:Array.isArray(e)?e.push(r):t[n]=[e,r]}return t}function e(e){if(e!==void 0&&e.length!==0)return e.map(e=>String(typeof e==`object`?e.key:e)).join(`.`)}export function problemFromIssues(t){let n=Object.create(null),r=[];for(let i of t){let t=e(i.path);t===void 0?r.push(i.message):(n[t]??=[]).push(i.message)}let i={type:`about:blank`,title:`The submission has invalid fields`,status:400,fields:n};return r.length>0&&(i.detail=r.join(` `)),i}export async function validateForm(e,t){let n=t instanceof FormData?formValues(t):t,r=await e[`~standard`].validate(n);return r.issues!==void 0&&r.issues.length>0?{problem:problemFromIssues(r.issues)}:{value:r.value}}
2
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.js","sources":["../../src/schema.ts"],"sourcesContent":["/**\n * Validation through Standard Schema, the interface that zod, valibot,\n * arktype, and others implement, so a schema written once validates a\n * submission in the browser before it is sent and on the server when it\n * arrives, producing the same problem document in both places.\n */\nimport type { FieldErrors, Problem } from \"zerolith\";\n\n/** One segment of an issue path in object form. */\nexport interface StandardPathSegment\n{\n /** The key representing the path segment; `problemFromIssues` converts it to a string and joins the path's keys with dots to name the field. */\n readonly key: PropertyKey;\n}\n\n/** One validation failure. */\nexport interface StandardIssue\n{\n /** The error message of the issue; `problemFromIssues` lists it under the field its path names, or in `detail` when the path is absent or empty. */\n readonly message: string;\n /** The path to the failing value; absent for an issue about the whole input. */\n readonly path?: ReadonlyArray<PropertyKey | StandardPathSegment> | undefined;\n}\n\n/** What `validate` resolves to: a value, or issues. */\nexport type StandardResult<Output> =\n | { readonly value: Output; readonly issues?: undefined }\n | { readonly issues: ReadonlyArray<StandardIssue> };\n\n/** The Standard Schema V1 interface, as published at standardschema.dev. */\nexport interface StandardSchema<Input = unknown, Output = Input>\n{\n /** The Standard properties: the `version` of the specification, the `vendor` name of the schema library, the optional inferred `types`, and the `validate` function that `validateForm` calls and awaits. */\n readonly \"~standard\": {\n readonly version: 1;\n readonly vendor: string;\n readonly validate: (value: unknown) => StandardResult<Output> | Promise<StandardResult<Output>>;\n readonly types?: { readonly input: Input; readonly output: Output } | undefined;\n };\n}\n\n/** The output type of a schema. */\nexport type InferOutput<S extends StandardSchema> = S extends StandardSchema<unknown, infer Output> ? Output : never;\n\n/** A form's values as a plain object: repeated names become arrays. */\nexport type FormValues = Record<string, FormDataEntryValue | FormDataEntryValue[]>;\n\n/** What `validateForm` resolves to: the parsed value, or the problem to show. */\nexport type Validation<Output> =\n | { readonly value: Output; readonly problem?: undefined }\n | { readonly value?: undefined; readonly problem: Problem };\n\n/** Turns `FormData` into a plain object with no prototype, so a field named `constructor` or `__proto__` is a field like any other. A name that appears once maps to its value; a name that appears more than once maps to an array of its values, in order. */\nexport function formValues(data: FormData): FormValues\n{\n const values = Object.create(null) as FormValues;\n for (const [name, value] of data)\n {\n const current = values[name];\n if (current === undefined)\n {\n values[name] = value;\n }\n else if (Array.isArray(current))\n {\n current.push(value);\n }\n else\n {\n values[name] = [current, value];\n }\n }\n return values;\n}\n\nfunction fieldName(path: ReadonlyArray<PropertyKey | StandardPathSegment> | undefined): string | undefined\n{\n if (path === undefined || path.length === 0)\n {\n return undefined;\n }\n return path.map((segment) => String(typeof segment === \"object\" ? segment.key : segment)).join(\".\");\n}\n\n/** Groups issues into a problem: path-bearing issues under `fields`, the rest in `detail`. */\nexport function problemFromIssues(issues: ReadonlyArray<StandardIssue>): Problem\n{\n const fields = Object.create(null) as Record<string, string[]>;\n const general: string[] = [];\n for (const issue of issues)\n {\n const name = fieldName(issue.path);\n if (name === undefined)\n {\n general.push(issue.message);\n continue;\n }\n (fields[name] ??= []).push(issue.message);\n }\n const problem: Problem = { type: \"about:blank\", title: \"The submission has invalid fields\", status: 400, fields: fields as FieldErrors };\n if (general.length > 0)\n {\n problem.detail = general.join(\" \");\n }\n return problem;\n}\n\n/**\n * Validates a submission with a Standard Schema. `FormData` is converted\n * with `formValues` first; any other value is validated as it is. Issues\n * become a problem whose `fields` carry the messages per path.\n */\nexport async function validateForm<S extends StandardSchema>(schema: S, data: FormData | Record<string, unknown>): Promise<Validation<InferOutput<S>>>\n{\n const input = data instanceof FormData ? formValues(data) : data;\n const result = await schema[\"~standard\"].validate(input);\n if (result.issues !== undefined && result.issues.length > 0)\n {\n return { problem: problemFromIssues(result.issues) };\n }\n return { value: (result as { value: InferOutput<S> }).value };\n}\n"],"names":["values","name","value","data","current","fieldName","path","segment","fields","general","issue","issues","problem","input","result","schema"],"mappings":"AAqDA,OAAM,SAAU,WAAW,EAAc,CAErC,IAAMA,EAAS,OAAO,OAAO,IAAI,EACjC,IAAK,GAAM,CAACC,EAAMC,KAAUC,EAC5B,CACI,IAAMC,EAAUJ,EAAOC,GACnBG,IAAY,OAEZ,EAAOH,GAAQC,EAEV,MAAM,QAAQE,CAAO,EAE1BA,EAAQ,KAAKF,CAAK,EAIlB,EAAOD,GAAQ,CAACG,EAASF,CAAK,CAEtC,CACA,OAAOF,CACX,CAEA,SAASK,EAAU,EAAkE,CAEjF,GAAIC,IAAS,QAAaA,EAAK,SAAW,EAI1C,OAAOA,EAAK,IAAK,GAAY,OAAO,OAAOC,GAAY,SAAWA,EAAQ,IAAMA,CAAO,CAAC,CAAC,CAAC,KAAK,GAAG,CACtG,CAGA,OAAM,SAAU,kBAAkB,EAAoC,CAElE,IAAMC,EAAS,OAAO,OAAO,IAAI,EAC3BC,EAAoB,CAAA,EAC1B,IAAK,IAAMC,KAASC,EACpB,CACI,IAAMV,EAAOI,EAAUK,EAAM,IAAI,EAC7BT,IAAS,OAETQ,EAAQ,KAAKC,EAAM,OAAO,GAG7B,EAAOT,KAAU,CAAA,EAAE,CAAE,KAAKS,EAAM,OAAO,CAC5C,CACA,IAAME,EAAmB,CAAE,KAAM,cAAe,MAAO,oCAAqC,OAAQ,IAAK,OAAQJ,CAAqB,EAKtI,OAJIC,EAAQ,OAAS,IAEjB,EAAQ,OAASA,EAAQ,KAAK,GAAG,GAE9BG,CACX,CAOA,OAAO,eAAe,aAAuC,EAAW,EAAwC,CAE5G,IAAMC,EAAQV,aAAgB,SAAW,WAAWA,CAAI,EAAIA,EACtDW,EAAS,MAAMC,EAAO,YAAY,CAAC,SAASF,CAAK,EAKvD,OAJIC,EAAO,SAAW,QAAaA,EAAO,OAAO,OAAS,EAE/C,CAAE,QAAS,kBAAkBA,EAAO,MAAM,CAAC,EAE/C,CAAE,MAAQA,EAAqC,KAAK,CAC/D"}
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@zerolith/forms",
3
- "version": "0.1.0-alpha.7",
3
+ "version": "0.1.0-alpha.9",
4
4
  "description": "Forms for zerolith: native forms that post FormData and work without JavaScript, enhanced with fetch, pending state, and RFC 9457 problem+json field errors when it runs, with Standard Schema validation shared by client and server",
5
5
  "license": "Apache-2.0",
6
6
  "author": "molexxxx",
7
- "homepage": "https://github.com/molexxxx/zerolith#readme",
7
+ "homepage": "https://zerolith.dev/",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/molexxxx/zerolith.git",
@@ -28,7 +28,8 @@
28
28
  "exports": {
29
29
  ".": {
30
30
  "types": "./dist/index.d.ts",
31
- "default": "./dist/index.js"
31
+ "development": "./dist/index.js",
32
+ "default": "./dist/production/index.js"
32
33
  },
33
34
  "./package.json": "./package.json"
34
35
  },
@@ -39,12 +40,12 @@
39
40
  "README.md"
40
41
  ],
41
42
  "scripts": {
42
- "build": "tsc -p tsconfig.build.json"
43
+ "build": "tsc -p tsconfig.build.json && node ../../scripts/production.ts"
43
44
  },
44
45
  "peerDependencies": {
45
- "zerolith": "^0.1.0-alpha.7"
46
+ "zerolith": "^0.1.0-alpha.9"
46
47
  },
47
48
  "devDependencies": {
48
- "zerolith": "^0.1.0-alpha.7"
49
+ "zerolith": "^0.1.0-alpha.9"
49
50
  }
50
51
  }