diff --git a/README.md b/README.md index e07e670..6f810dc 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ It is not a serializer, not an ORM, and not a new religion. It just does this on ## Documentation - [Docs Site](https://maxatwork.github.io/form2js/) - overview, installation, unified playground, and published API reference. -- [API Reference Source](docs/api.md) - markdown source for the published API docs page. +- [API Reference Source](docs/api-index.md) - markdown source for the published API docs page. ## Packages diff --git a/apps/docs/src/components/api/ApiPackageNav.tsx b/apps/docs/src/components/api/ApiPackageNav.tsx new file mode 100644 index 0000000..0e8d961 --- /dev/null +++ b/apps/docs/src/components/api/ApiPackageNav.tsx @@ -0,0 +1,37 @@ +import React from "react"; + +import type { ApiPackageEntry } from "../../lib/api-packages"; +import { apiPackageDocsPath } from "../../lib/site-routes"; + +type ApiPackageNavEntry = Pick; + +interface ApiPackageNavProps { + activeSlug?: ApiPackageNavEntry["slug"]; + basePath: string; + packages: ApiPackageNavEntry[]; +} + +export function ApiPackageNav({ + activeSlug, + basePath, + packages +}: ApiPackageNavProps): React.JSX.Element { + return ( + + ); +} diff --git a/apps/docs/src/components/api/ApiPackageSummaryList.tsx b/apps/docs/src/components/api/ApiPackageSummaryList.tsx new file mode 100644 index 0000000..82b6638 --- /dev/null +++ b/apps/docs/src/components/api/ApiPackageSummaryList.tsx @@ -0,0 +1,35 @@ +import React from "react"; + +import type { ApiPackageEntry } from "../../lib/api-packages"; +import { apiPackageDocsPath } from "../../lib/site-routes"; + +type ApiPackageSummaryEntry = Pick< + ApiPackageEntry, + "slug" | "packageName" | "summary" +>; + +interface ApiPackageSummaryListProps { + basePath: string; + packages: ApiPackageSummaryEntry[]; +} + +export function ApiPackageSummaryList({ + basePath, + packages +}: ApiPackageSummaryListProps): React.JSX.Element { + return ( +
+

Packages

+
+ {packages.map((entry) => ( + + ))} +
+
+ ); +} diff --git a/apps/docs/src/layouts/ApiDocsLayout.astro b/apps/docs/src/layouts/ApiDocsLayout.astro index fde42a8..4d14770 100644 --- a/apps/docs/src/layouts/ApiDocsLayout.astro +++ b/apps/docs/src/layouts/ApiDocsLayout.astro @@ -1,5 +1,7 @@ --- +import { ApiPackageNav } from "../components/api/ApiPackageNav"; import { ApiToc } from "../components/api/ApiToc"; +import type { ApiPackageEntry } from "../lib/api-packages"; import type { ApiHeading } from "../lib/api-docs-source"; import DocsShell from "./DocsShell.astro"; @@ -8,23 +10,37 @@ interface Props { introHtml: string; bodyHtml: string; headings: ApiHeading[]; + packages: ApiPackageEntry[]; + activePackageSlug?: ApiPackageEntry["slug"]; } -const { title, introHtml, bodyHtml, headings } = Astro.props; +const { title, introHtml, bodyHtml, headings, packages, activePackageSlug } = + Astro.props; +const basePath = import.meta.env.BASE_URL; ---
+

API Docs

{title}

{introHtml ?
: null}
+
- + {headings.length > 0 ? ( + + ) : null}
diff --git a/apps/docs/src/lib/api-docs-source.ts b/apps/docs/src/lib/api-docs-source.ts index 04964a9..d30c1d5 100644 --- a/apps/docs/src/lib/api-docs-source.ts +++ b/apps/docs/src/lib/api-docs-source.ts @@ -1,12 +1,15 @@ import { readFile } from "node:fs/promises"; -import path from "node:path"; import rehypeStringify from "rehype-stringify"; import remarkGfm from "remark-gfm"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; import { unified } from "unified"; -import { apiDocsPath, homepagePath } from "./site-routes"; +import { + apiIndexMarkdownPath, + getApiPackageByMarkdownBasename +} from "./api-packages"; +import { apiDocsPath, apiPackageDocsPath, homepagePath } from "./site-routes"; export interface ApiHeading { depth: 2 | 3; @@ -63,24 +66,26 @@ function rewriteMarkdownLink(url: string, basePath: string): string { const [pathname, hash] = url.split("#"); const suffix = hash ? `#${hash}` : ""; + const normalizedPathname = pathname + .replace(/^(?:\.\.\/)?docs\//, "") + .replace(/^\.\//, ""); if ( - pathname === "README.md" || - pathname === "./README.md" || + normalizedPathname === "README.md" || pathname === "../README.md" ) { return `${homepagePath(basePath)}${suffix}`; } - if ( - pathname === "api.md" || - pathname === "./api.md" || - pathname === "docs/api.md" || - pathname === "../docs/api.md" - ) { + if (normalizedPathname === "api.md" || normalizedPathname === "api-index.md") { return `${apiDocsPath(basePath)}${suffix}`; } + const apiPackage = getApiPackageByMarkdownBasename(normalizedPathname); + if (apiPackage) { + return `${apiPackageDocsPath(basePath, apiPackage.slug)}${suffix}`; + } + return url; } @@ -135,7 +140,7 @@ export function parseApiDocsMarkdown( const titleNode = rootChildren[0]; if (titleNode?.type !== "heading" || titleNode.depth !== 1) { - throw new Error("docs/api.md must start with an H1 heading."); + throw new Error("API docs markdown must start with an H1 heading."); } const title = collectText(titleNode); @@ -217,9 +222,7 @@ export function parseApiDocsMarkdown( export async function loadApiDocsSource( options: { basePath?: string; markdownPath?: string } = {} ): Promise { - const markdownPath = - options.markdownPath ?? - path.resolve(process.cwd(), "..", "..", "docs", "api.md"); + const markdownPath = options.markdownPath ?? apiIndexMarkdownPath; const markdown = await readFile(markdownPath, "utf8"); return parseApiDocsMarkdown(markdown, { diff --git a/apps/docs/src/lib/api-packages.ts b/apps/docs/src/lib/api-packages.ts new file mode 100644 index 0000000..a6d5bcd --- /dev/null +++ b/apps/docs/src/lib/api-packages.ts @@ -0,0 +1,73 @@ +import path from "node:path"; + +export type ApiPackageSlug = + | "core" + | "dom" + | "form-data" + | "react" + | "js2form" + | "jquery"; + +export interface ApiPackageEntry { + slug: ApiPackageSlug; + packageName: string; + summary: string; + markdownPath: string; +} + +function resolveDocsPath(filename: string): string { + return path.resolve(process.cwd(), "..", "..", "docs", filename); +} + +export const apiIndexMarkdownPath = resolveDocsPath("api-index.md"); + +export const apiPackages: ApiPackageEntry[] = [ + { + slug: "core", + packageName: "@form2js/core", + summary: "Turn path-like key/value pairs into nested objects and flatten them back into entries.", + markdownPath: resolveDocsPath("api-core.md") + }, + { + slug: "dom", + packageName: "@form2js/dom", + summary: "Parse browser form controls into an object while preserving native submission behavior.", + markdownPath: resolveDocsPath("api-dom.md") + }, + { + slug: "form-data", + packageName: "@form2js/form-data", + summary: "Parse FormData and tuple entries with the same path rules used by the core parser.", + markdownPath: resolveDocsPath("api-form-data.md") + }, + { + slug: "react", + packageName: "@form2js/react", + summary: "Handle React form submission with parsed payloads, optional schema validation, and submit state.", + markdownPath: resolveDocsPath("api-react.md") + }, + { + slug: "js2form", + packageName: "@form2js/js2form", + summary: "Push nested object data back into matching DOM form controls.", + markdownPath: resolveDocsPath("api-js2form.md") + }, + { + slug: "jquery", + packageName: "@form2js/jquery", + summary: "Install a jQuery plugin wrapper around the DOM parser for legacy form handling flows.", + markdownPath: resolveDocsPath("api-jquery.md") + } +]; + +export function getApiPackageBySlug(slug: string): ApiPackageEntry | undefined { + return apiPackages.find((entry) => entry.slug === slug); +} + +export function getApiPackageByMarkdownBasename( + basename: string +): ApiPackageEntry | undefined { + return apiPackages.find( + (entry) => path.basename(entry.markdownPath) === basename + ); +} diff --git a/apps/docs/src/lib/site-routes.ts b/apps/docs/src/lib/site-routes.ts index daab0bb..d64d471 100644 --- a/apps/docs/src/lib/site-routes.ts +++ b/apps/docs/src/lib/site-routes.ts @@ -15,6 +15,10 @@ export function apiDocsPath(basePath: string): string { return `${normalizeBase(basePath)}api/`; } +export function apiPackageDocsPath(basePath: string, slug: string): string { + return `${apiDocsPath(basePath)}${encodeURIComponent(slug)}/`; +} + export function homepageVariantPath(basePath: string, variant: string): string { return `${normalizeBase(basePath)}?variant=${encodeURIComponent(variant)}`; } diff --git a/apps/docs/src/pages/api/[package].astro b/apps/docs/src/pages/api/[package].astro new file mode 100644 index 0000000..7e5df42 --- /dev/null +++ b/apps/docs/src/pages/api/[package].astro @@ -0,0 +1,31 @@ +--- +import ApiDocsLayout from "../../layouts/ApiDocsLayout.astro"; +import { apiPackages, type ApiPackageEntry } from "../../lib/api-packages"; +import { loadApiDocsSource } from "../../lib/api-docs-source"; + +interface Props { + apiPackage: ApiPackageEntry; +} + +export function getStaticPaths() { + return apiPackages.map((apiPackage) => ({ + params: { package: apiPackage.slug }, + props: { apiPackage } + })); +} + +const { apiPackage } = Astro.props; +const apiDocsSource = await loadApiDocsSource({ + basePath: import.meta.env.BASE_URL, + markdownPath: apiPackage.markdownPath +}); +--- + + diff --git a/apps/docs/src/pages/api/index.astro b/apps/docs/src/pages/api/index.astro index 7a6aaea..3ee3203 100644 --- a/apps/docs/src/pages/api/index.astro +++ b/apps/docs/src/pages/api/index.astro @@ -1,5 +1,7 @@ --- +import { ApiPackageSummaryList } from "../../components/api/ApiPackageSummaryList"; import ApiDocsLayout from "../../layouts/ApiDocsLayout.astro"; +import { apiPackages } from "../../lib/api-packages"; import { loadApiDocsSource } from "../../lib/api-docs-source"; const apiDocsSource = await loadApiDocsSource({ @@ -11,5 +13,12 @@ const apiDocsSource = await loadApiDocsSource({ bodyHtml={apiDocsSource.bodyHtml} headings={apiDocsSource.headings} introHtml={apiDocsSource.introHtml} + packages={apiPackages} title={apiDocsSource.title} -/> +> + + diff --git a/apps/docs/src/styles/global.css b/apps/docs/src/styles/global.css index 2da718c..99416ed 100644 --- a/apps/docs/src/styles/global.css +++ b/apps/docs/src/styles/global.css @@ -108,14 +108,21 @@ a { } .api-docs { - width: min(82rem, calc(100% - 3rem)); + width: min(96rem, calc(100% - 3rem)); margin: 0 auto; padding: 3rem 0 4rem; display: grid; - grid-template-columns: minmax(0, 1fr) 18rem; + grid-template-columns: 16rem minmax(0, 1fr) 18rem; gap: 2rem; } +.api-docs__nav, +.api-docs__sidebar { + position: sticky; + top: 1rem; + align-self: start; +} + .api-docs__content { min-width: 0; } @@ -139,6 +146,14 @@ a { margin-top: 2rem; } +.api-package-nav, +.api-toc, +.api-package-summary { + border: 1px solid var(--border); + border-radius: 1rem; + background: var(--panel); +} + .api-docs__body h2, .api-docs__body h3 { color: var(--text); @@ -171,19 +186,11 @@ a { vertical-align: top; } -.api-docs__sidebar { - position: sticky; - top: 1rem; - align-self: start; -} - -.api-toc { +.api-package-nav { padding: 1rem; - border: 1px solid var(--border); - border-radius: 1rem; - background: var(--panel); } +.api-package-nav__eyebrow, .api-toc__eyebrow { margin: 0 0 0.75rem; color: var(--accent); @@ -193,6 +200,36 @@ a { font-size: 0.8rem; } +.api-package-nav__list, +.api-toc__list, +.api-toc__sublist { + margin: 0; + padding: 0; + list-style: none; +} + +.api-package-nav__list { + display: grid; + gap: 0.5rem; +} + +.api-package-nav__link { + display: block; + padding: 0.6rem 0.75rem; + border-radius: 0.75rem; + color: var(--muted); + text-decoration: none; +} + +.api-package-nav__link[aria-current="page"] { + background: color-mix(in srgb, var(--accent) 18%, transparent); + color: var(--text); +} + +.api-toc { + padding: 1rem; +} + .api-toc__list, .api-toc__sublist { margin: 0; @@ -223,13 +260,44 @@ a { color: var(--text); } +.api-package-summary-list { + margin-top: 2rem; +} + +.api-package-summary-list h2 { + margin-bottom: 1rem; +} + +.api-package-summary-list__items { + display: grid; + gap: 1rem; +} + +.api-package-summary { + padding: 1.25rem; +} + +.api-package-summary h3 { + margin: 0 0 0.5rem; +} + +.api-package-summary p { + margin: 0; + color: var(--muted); + line-height: 1.6; +} + @media (max-width: 900px) { .api-docs { grid-template-columns: 1fr; } - .api-docs__sidebar { + .api-docs__nav { position: static; order: -1; } + + .api-docs__sidebar { + position: static; + } } diff --git a/apps/docs/test-e2e/api-docs.spec.ts b/apps/docs/test-e2e/api-docs.spec.ts index 6a41ebc..91c9eed 100644 --- a/apps/docs/test-e2e/api-docs.spec.ts +++ b/apps/docs/test-e2e/api-docs.spec.ts @@ -1,13 +1,19 @@ import { expect, test } from "@playwright/test"; -test("api docs route renders the TOC and supports section anchors on mobile", async ({ page }) => { +test("api docs index links to package pages and package toc anchors work on mobile", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/api/"); await expect(page.getByRole("heading", { name: "form2js API Reference" })).toBeVisible(); - await expect(page.getByText("On this page")).toBeVisible(); + await expect( + page.getByLabel("API packages").getByRole("link", { name: "@form2js/react" }) + ).toBeVisible(); - await page.getByRole("link", { name: "@form2js/react" }).click(); - await expect(page).toHaveURL(/#form2js-react$/); + await page.getByLabel("API packages").getByRole("link", { name: "@form2js/react" }).click(); + await expect(page).toHaveURL(/\/api\/react\/$/); await expect(page.getByRole("heading", { name: "@form2js/react" })).toBeVisible(); + await expect(page.getByText("On this page")).toBeVisible(); + + await page.getByLabel("On this page").getByRole("link", { name: "Installation" }).click(); + await expect(page).toHaveURL(/#installation$/); }); diff --git a/apps/docs/test/api-docs-page.test.tsx b/apps/docs/test/api-docs-page.test.tsx index aab5211..226d433 100644 --- a/apps/docs/test/api-docs-page.test.tsx +++ b/apps/docs/test/api-docs-page.test.tsx @@ -4,8 +4,56 @@ import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; +import { ApiPackageNav } from "../src/components/api/ApiPackageNav"; +import { ApiPackageSummaryList } from "../src/components/api/ApiPackageSummaryList"; import { ApiToc } from "../src/components/api/ApiToc"; +describe("ApiPackageNav", () => { + it("renders package links and marks the active package", () => { + const markup = renderToStaticMarkup( + + ); + + expect(markup).toContain("Packages"); + expect(markup).toContain('href="/form2js/api/react/"'); + expect(markup).toContain('aria-current="page"'); + }); +}); + +describe("ApiPackageSummaryList", () => { + it("renders package summaries with package routes", () => { + const markup = renderToStaticMarkup( + + ); + + expect(markup).toContain("@form2js/dom"); + expect(markup).toContain("DOM parsing"); + expect(markup).toContain('href="/form2js/api/dom/"'); + }); +}); + describe("ApiToc", () => { it("renders nested section links and marks the active section", () => { const markup = renderToStaticMarkup( diff --git a/apps/docs/test/api-docs-source.test.ts b/apps/docs/test/api-docs-source.test.ts index 775b9f8..a8d9455 100644 --- a/apps/docs/test/api-docs-source.test.ts +++ b/apps/docs/test/api-docs-source.test.ts @@ -7,39 +7,42 @@ import { describe, expect, it } from "vitest"; import { parseApiDocsMarkdown } from "../src/lib/api-docs-source"; const testDir = path.dirname(fileURLToPath(import.meta.url)); -const apiDocsMarkdown = readFileSync(path.resolve(testDir, "../../../docs/api.md"), "utf8"); +const apiIndexMarkdown = readFileSync(path.resolve(testDir, "../../../docs/api-index.md"), "utf8"); +const apiCoreMarkdown = readFileSync(path.resolve(testDir, "../../../docs/api-core.md"), "utf8"); +const apiDomMarkdown = readFileSync(path.resolve(testDir, "../../../docs/api-dom.md"), "utf8"); +const apiFormDataMarkdown = readFileSync(path.resolve(testDir, "../../../docs/api-form-data.md"), "utf8"); +const apiJqueryMarkdown = readFileSync(path.resolve(testDir, "../../../docs/api-jquery.md"), "utf8"); +const apiJs2formMarkdown = readFileSync(path.resolve(testDir, "../../../docs/api-js2form.md"), "utf8"); +const apiReactMarkdown = readFileSync(path.resolve(testDir, "../../../docs/api-react.md"), "utf8"); +const readmeMarkdown = readFileSync(path.resolve(testDir, "../../../README.md"), "utf8"); describe("parseApiDocsMarkdown", () => { - it("extracts the H1 title, intro copy, headings, and rewrites markdown links", () => { + it("extracts the H1 title, intro copy, headings, and rewrites package markdown links", () => { const source = parseApiDocsMarkdown( - `# API Title + `# React API -Intro line with [README.md](README.md). +Intro with [index](api-index.md). -## First Section +## Installation -Paragraph +Text with [core](api-core.md). -### Nested Topic +### npm More text. - -## Second Section - -Tail text. `, { basePath: "/form2js/" } ); - expect(source.title).toBe("API Title"); - expect(source.introMarkdown).toContain("Intro line"); - expect(source.introHtml).toContain('href="/form2js/"'); - expect(source.bodyHtml).toContain('id="first-section"'); - expect(source.bodyHtml).toContain('id="nested-topic"'); + expect(source.title).toBe("React API"); + expect(source.introMarkdown).toContain("Intro with"); + expect(source.introHtml).toContain('href="/form2js/api/"'); + expect(source.bodyHtml).toContain('href="/form2js/api/core/"'); + expect(source.bodyHtml).toContain('id="installation"'); + expect(source.bodyHtml).toContain('id="npm"'); expect(source.headings).toEqual([ - { depth: 2, slug: "first-section", text: "First Section" }, - { depth: 3, slug: "nested-topic", text: "Nested Topic" }, - { depth: 2, slug: "second-section", text: "Second Section" } + { depth: 2, slug: "installation", text: "Installation" }, + { depth: 3, slug: "npm", text: "npm" } ]); }); @@ -89,18 +92,27 @@ Text. `, { basePath: "/" } ) - ).toThrow("docs/api.md must start with an H1 heading."); + ).toThrow("API docs markdown must start with an H1 heading."); }); - it("documents installation for every package, including standalone globals where supported", () => { - expect(apiDocsMarkdown).toContain("npm install @form2js/core"); - expect(apiDocsMarkdown).toContain("npm install @form2js/dom"); - expect(apiDocsMarkdown).toContain("npm install @form2js/form-data"); - expect(apiDocsMarkdown).toContain("npm install @form2js/react react"); - expect(apiDocsMarkdown).toContain("npm install @form2js/js2form"); - expect(apiDocsMarkdown).toContain("npm install @form2js/jquery jquery"); - expect(apiDocsMarkdown).toContain("https://unpkg.com/@form2js/dom/dist/standalone.global.js"); - expect(apiDocsMarkdown).toContain("https://unpkg.com/@form2js/jquery/dist/standalone.global.js"); - expect(apiDocsMarkdown).toContain("Standalone/global build is not shipped for this package."); + it("documents the split api markdown sources and updates the readme source link", () => { + expect(apiIndexMarkdown).toContain("# form2js API Reference"); + expect(apiCoreMarkdown).toContain("## Installation"); + expect(apiCoreMarkdown).toContain("## General Example"); + expect(apiCoreMarkdown).toContain("## Types and Properties"); + expect(apiCoreMarkdown).toContain("npm install @form2js/core"); + expect(apiCoreMarkdown).toContain("### Schema validation"); + expect(apiCoreMarkdown).toContain("entriesToObject(rawEntries, { schema: PersonSchema })"); + expect(apiDomMarkdown).toContain("https://unpkg.com/@form2js/dom/dist/standalone.global.js"); + expect(apiDomMarkdown).toContain("### `useIdIfEmptyName`"); + expect(apiDomMarkdown).toContain("### `nodeCallback`"); + expect(apiFormDataMarkdown).toContain("### Schema validation"); + expect(apiFormDataMarkdown).toContain("formDataToObject(formData, { schema: PersonSchema })"); + expect(apiJqueryMarkdown).toContain("https://unpkg.com/@form2js/jquery/dist/standalone.global.js"); + expect(apiJqueryMarkdown).toContain("### `mode: \"all\"`"); + expect(apiJs2formMarkdown).toContain("### `shouldClean: false`"); + expect(apiJs2formMarkdown).toContain("### `useIdIfEmptyName`"); + expect(apiReactMarkdown).toContain("npm install @form2js/react react"); + expect(readmeMarkdown).toContain("[API Reference Source](docs/api-index.md)"); }); }); diff --git a/apps/docs/test/site-routes.test.ts b/apps/docs/test/site-routes.test.ts index 7a5abc7..8eb1721 100644 --- a/apps/docs/test/site-routes.test.ts +++ b/apps/docs/test/site-routes.test.ts @@ -1,11 +1,18 @@ import { describe, expect, it } from "vitest"; -import { apiDocsPath, homepagePath, homepageVariantPath } from "../src/lib/site-routes"; +import { + apiDocsPath, + apiPackageDocsPath, + homepagePath, + homepageVariantPath +} from "../src/lib/site-routes"; describe("site routes", () => { it("builds homepage and api paths under a base path", () => { expect(homepagePath("/form2js/")).toBe("/form2js/"); expect(apiDocsPath("/form2js/")).toBe("/form2js/api/"); + expect(apiPackageDocsPath("/form2js/", "react")).toBe("/form2js/api/react/"); + expect(apiPackageDocsPath("/", "form-data")).toBe("/api/form-data/"); }); it("adds variant query params to the homepage only", () => { diff --git a/docs/api-core.md b/docs/api-core.md new file mode 100644 index 0000000..908ffa2 --- /dev/null +++ b/docs/api-core.md @@ -0,0 +1,115 @@ +# @form2js/core + +`@form2js/core` is the path parsing engine behind the rest of the package family. Use it when you already have key/value entries, need to turn them into nested objects, or want to flatten nested data back into entry form. + +## Installation + +```bash +npm install @form2js/core +``` + +Standalone/global build is not shipped for this package. + +## General Example + +```ts +import { entriesToObject, objectToEntries } from "@form2js/core"; + +const data = entriesToObject([ + { key: "person.name.first", value: "Esme" }, + { key: "person.roles[]", value: "witch" }, +]); + +const flat = objectToEntries(data); +``` + +## Types and Properties + +### Exported Surface + +| Export | Kind | What it does | +| --- | --- | --- | +| `createMergeContext` | function | Creates merge state used while parsing indexed arrays. | +| `setPathValue` | function | Applies one path/value into an object tree. | +| `entriesToObject` | function | Main parser for iterable entries. | +| `objectToEntries` | function | Flattens nested object/array data into `{ key, value }` entries. | +| `processNameValues` | function | Compatibility helper for `{ name, value }` input. | +| `Entry`, `EntryInput`, `EntryValue`, `NameValuePair`, `ObjectTree`, `ParseOptions`, `MergeContext`, `MergeOptions`, `SchemaValidator`, `ValidationOptions`, `InferSchemaOutput` | types | Public type surface for parser inputs, options, and results. | + +```ts +export function createMergeContext(): MergeContext; + +export function setPathValue( + target: ObjectTree, + path: string, + value: EntryValue, + options?: MergeOptions +): ObjectTree; + +export function entriesToObject(entries: Iterable, options?: ParseOptions): ObjectTree; +export function entriesToObject( + entries: Iterable, + options: ParseOptions & { schema: TSchema } +): InferSchemaOutput; + +export function objectToEntries(value: unknown): Entry[]; + +export function processNameValues( + nameValues: Iterable, + skipEmpty?: boolean, + delimiter?: string +): ObjectTree; +``` + +### Options And Defaults + +| Option | Default | Where | Why this matters | +| --- | --- | --- | --- | +| `delimiter` | `"."` | `entriesToObject`, `setPathValue`, `processNameValues` | Controls how dot-like path chunks are split. | +| `skipEmpty` | `true` | `entriesToObject`, `processNameValues` | Drops `""` and `null` values unless you opt out. | +| `allowUnsafePathSegments` | `false` | `entriesToObject`, `setPathValue` | Blocks prototype-pollution path segments unless you explicitly trust the source. | +| `schema` | unset | `entriesToObject` | Runs `schema.parse(parsedObject)` and returns schema output type. | +| `context` | fresh merge context | `setPathValue` | Keeps indexed array compaction stable across multiple writes. | + +### Schema validation + +Use `schema` when you want parsing and validation in the same step. The parser only requires a structural `{ parse(unknown) }` contract, so this works with Zod and similar validators. + +```ts +import { z } from "zod"; +import { entriesToObject } from "@form2js/core"; + +const PersonSchema = z.object({ + person: z.object({ + age: z.coerce.number().int().min(0), + email: z.string().email() + }) +}); + +const rawEntries = [ + { key: "person.age", value: "17" }, + { key: "person.email", value: "esk@example.com" } +]; + +const result = entriesToObject(rawEntries, { schema: PersonSchema }); +``` + +### `skipEmpty: false` + +Opt out of the default empty-value filtering when blank strings are meaningful in your payload. + +```ts +import { entriesToObject } from "@form2js/core"; + +const result = entriesToObject( + [{ key: "person.nickname", value: "" }], + { skipEmpty: false } +); +``` + +### Behavior Notes + +- Indexed array keys are compacted by encounter order, not preserved by numeric index. +- `EntryInput` accepts `[key, value]`, `{ key, value }`, and `{ name, value }`. +- If `schema` is provided, parser output is passed to `schema.parse()` and schema errors are rethrown. +- `objectToEntries` emits bracket indexes for arrays such as `emails[0]` and only serializes own enumerable properties. diff --git a/docs/api-dom.md b/docs/api-dom.md new file mode 100644 index 0000000..88d0d63 --- /dev/null +++ b/docs/api-dom.md @@ -0,0 +1,131 @@ +# @form2js/dom + +`@form2js/dom` solves the browser side of the problem: walk a form or DOM subtree, extract the submitted values, and return the parsed object. Use it when you want native form semantics without writing the extraction logic yourself. + +## Installation + +```bash +npm install @form2js/dom +``` + +Standalone via `unpkg`: + +```html + + +``` + +## General Example + +```ts +import { formToObject } from "@form2js/dom"; + +const result = formToObject(document.getElementById("profileForm"), { + useIdIfEmptyName: true, + getDisabled: false, +}); +``` + +## Types and Properties + +### Exported Surface + +| Export | Kind | What it does | +| --- | --- | --- | +| `NodeCallbackResult` | interface | Custom extraction payload (`name` or `key` plus `value`). | +| `FormToObjectNodeCallback` | type | Callback type used during node walk. | +| `ExtractOptions` | interface | Options for pair extraction only. | +| `FormToObjectOptions` | interface | Extraction options plus parser options. | +| `RootNodeInput` | type | Supported root inputs such as `id`, `Node`, `NodeList`, arrays, and collections. | +| `extractPairs` | function | Traverses DOM and returns path/value entries. | +| `formToObject` | function | High-level parser from DOM to object tree. | +| `form2js` | function | Compatibility wrapper around `formToObject`. | + +```ts +export interface NodeCallbackResult { + name?: string; + key?: string; + value: unknown; +} + +export const SKIP_NODE: unique symbol; + +export type FormToObjectNodeCallback = ( + node: Node +) => NodeCallbackResult | typeof SKIP_NODE | false | null | undefined; + +export interface ExtractOptions { + nodeCallback?: FormToObjectNodeCallback; + useIdIfEmptyName?: boolean; + getDisabled?: boolean; + document?: Document; +} + +export interface FormToObjectOptions extends ExtractOptions, ParseOptions {} + +export function extractPairs(rootNode: RootNodeInput, options?: ExtractOptions): Entry[]; +export function formToObject(rootNode: RootNodeInput, options?: FormToObjectOptions): ObjectTree; +``` + +### Options And Defaults + +| Option | Default | Where | Why this matters | +| --- | --- | --- | --- | +| `delimiter` | `"."` | `formToObject`, `form2js` | Matches parser path semantics. | +| `skipEmpty` | `true` | `formToObject`, `form2js` | Skips `""` and `null` values by default. | +| `allowUnsafePathSegments` | `false` | `formToObject`, `form2js` | Rejects unsafe path segments before object merging. | +| `useIdIfEmptyName` | `false` | extraction and wrappers | Lets `id` act as field key when `name` is empty. | +| `getDisabled` | `false` | extraction and wrappers | Disabled controls, including disabled fieldset descendants, are ignored unless enabled explicitly. | +| `nodeCallback` | unset | extraction and wrappers | Use it for custom field extraction from specific nodes. | +| `document` | ambient/global document | extraction and wrappers | Required outside browser globals. | + +### `useIdIfEmptyName` + +Enable this when a form control is keyed by `id` rather than `name`, which is common in older markup or UI builders. + +```ts +import { formToObject } from "@form2js/dom"; + +const result = formToObject(document.getElementById("profileForm"), { + useIdIfEmptyName: true +}); +``` + +### `nodeCallback` + +Use `nodeCallback` to rewrite or skip specific nodes before the default extraction logic runs. + +```ts +import { formToObject, SKIP_NODE } from "@form2js/dom"; + +const result = formToObject(document.getElementById("profileForm"), { + nodeCallback(node) { + if (!(node instanceof HTMLInputElement)) { + return; + } + + if (node.type === "hidden" && node.name === "csrfToken") { + return SKIP_NODE; + } + + if (node.name === "person.age") { + return { key: node.name, value: Number(node.value) }; + } + } +}); +``` + +### Behavior Notes + +- `select name="colors[]"` is emitted as key `colors`; the trailing `[]` is removed for selects. +- Checkbox and radio values follow native browser submission semantics: + - checked controls emit their string `value` + - unchecked controls are omitted + - omitted indexed controls do not reserve compacted array slots, so preserve row identity with another submitted field when it matters +- Button-like inputs (`button`, `reset`, `submit`, `image`) are excluded from extraction. +- You can merge multiple roots (`NodeList`, arrays, `HTMLCollection`) into one object. +- If the callback returns `SKIP_NODE`, that node is excluded from extraction entirely. +- If the callback returns `{ key | name, value }`, that value is used directly for that node. diff --git a/docs/api-form-data.md b/docs/api-form-data.md new file mode 100644 index 0000000..581eda2 --- /dev/null +++ b/docs/api-form-data.md @@ -0,0 +1,81 @@ +# @form2js/form-data + +`@form2js/form-data` is the server-friendly adapter for the same parsing rules used by the DOM package. Use it when your input is a `FormData` instance or a plain iterable of form-like key/value tuples. + +## Installation + +```bash +npm install @form2js/form-data +``` + +Standalone/global build is not shipped for this package. + +## General Example + +```ts +import { formDataToObject } from "@form2js/form-data"; + +const result = formDataToObject([ + ["person.name.first", "Sam"], + ["person.roles[]", "captain"], +]); +``` + +## Types and Properties + +### Exported Surface + +| Export | Kind | What it does | +| --- | --- | --- | +| `KeyValueEntryInput` | type alias | Alias of core `EntryInput`. | +| `FormDataToObjectOptions` | interface | Parser options for form-data conversion. | +| `entriesToObject` | function | Adapter to the core parser. | +| `formDataToObject` | function | Parses `FormData` or iterable form-data entries. | +| `EntryInput`, `ObjectTree`, `ParseOptions`, `SchemaValidator`, `ValidationOptions`, `InferSchemaOutput` | type re-export | Core types re-exported for convenience. | + +```ts +export type KeyValueEntryInput = EntryInput; + +export interface FormDataToObjectOptions extends ParseOptions {} + +export function entriesToObject(entries: Iterable, options?: ParseOptions): ObjectTree; +export function formDataToObject( + formData: FormData | Iterable, + options?: FormDataToObjectOptions +): ObjectTree; +``` + +### Options And Defaults + +| Option | Default | Why this matters | +| --- | --- | --- | +| `delimiter` | `"."` | Keeps path splitting aligned with core and DOM behavior. | +| `skipEmpty` | `true` | Drops empty string and `null` values unless disabled. | +| `allowUnsafePathSegments` | `false` | Rejects unsafe path segments before object merging. | +| `schema` | unset | Runs `schema.parse(parsedObject)` after parsing and returns schema output type. | + +### Schema validation + +Use the same schema pattern on `FormData` input when you want validated server-side parsing without importing `@form2js/core` separately. + +```ts +import { z } from "zod"; +import { formDataToObject } from "@form2js/form-data"; + +const PersonSchema = z.object({ + person: z.object({ + age: z.coerce.number().int().min(0) + }) +}); + +const formData = new FormData(); +formData.set("person.age", "12"); + +const result = formDataToObject(formData, { schema: PersonSchema }); +``` + +### Behavior Notes + +- Parsing rules are the same as `@form2js/core`. +- Accepts either a real `FormData` object or any iterable of readonly key/value tuples. +- Schema validation is optional and uses only a structural `{ parse(unknown) }` contract. diff --git a/docs/api-index.md b/docs/api-index.md new file mode 100644 index 0000000..a620a48 --- /dev/null +++ b/docs/api-index.md @@ -0,0 +1,31 @@ +# form2js API Reference + +This section is for developers who want the exact API surface of the current `@form2js/*` packages, plus the defaults and edge cases that usually matter when you wire forms into real applications. + +If you want the broader project overview first, start with [README.md](README.md). + +## Who this is for + +- Developers choosing between the `@form2js/*` packages +- Teams migrating from the legacy `form2js` flow to package-specific APIs +- Anyone who needs exact options, exported types, and behavior notes + +## Package Guide + +- [`@form2js/core`](api-core.md): parse path-like entries into nested objects and flatten them back out +- [`@form2js/dom`](api-dom.md): turn browser form controls into an object +- [`@form2js/form-data`](api-form-data.md): parse `FormData` or tuple entries with the same path rules +- [`@form2js/react`](api-react.md): handle React form submission with parsing, validation, and submit state +- [`@form2js/js2form`](api-js2form.md): push nested object data back into form controls +- [`@form2js/jquery`](api-jquery.md): install a jQuery plugin on top of the DOM parser + +## Shared Naming Rules + +These rules apply across parser-based packages such as `core`, `dom`, and `form-data`. + +- Dot paths build nested objects: `person.name.first` becomes `{ person: { name: { first: ... } } }` +- Repeated `[]` pushes into arrays in encounter order: `roles[]` +- Indexed arrays are compacted in first-seen order: `items[8]`, `items[5]` becomes indexes `0`, `1` +- Rails-style brackets are supported: `rails[field][value]` +- By default, empty string and `null` are skipped (`skipEmpty: true`) +- Unsafe key path segments (`__proto__`, `prototype`, `constructor`) are rejected by default diff --git a/docs/api-jquery.md b/docs/api-jquery.md new file mode 100644 index 0000000..dfde052 --- /dev/null +++ b/docs/api-jquery.md @@ -0,0 +1,84 @@ +# @form2js/jquery + +`@form2js/jquery` is the legacy-friendly adapter for projects that still rely on jQuery forms. Use it when you want `$.fn.toObject()` on top of the DOM parser without rewriting the rest of the form handling code. + +## Installation + +```bash +npm install @form2js/jquery jquery +``` + +Standalone via `unpkg`: + +```html + + + +``` + +## General Example + +```ts +import $ from "jquery"; +import { installToObjectPlugin } from "@form2js/jquery"; + +installToObjectPlugin($); + +const data = $("#profileForm").toObject({ mode: "first" }); +``` + +## Types and Properties + +### Exported Surface + +| Export | Kind | What it does | +| --- | --- | --- | +| `ToObjectMode` | type | `"first" | "all" | "combine"` | +| `ToObjectOptions` | interface | Plugin options mapped to `@form2js/dom` behavior. | +| `installToObjectPlugin` | function | Adds `toObject()` to `$.fn` if missing. | +| `maybeAutoInstallPlugin` | function | Installs the plugin only when a jQuery-like scope is detected. | + +```ts +export type ToObjectMode = "first" | "all" | "combine"; + +export interface ToObjectOptions { + mode?: ToObjectMode; + delimiter?: string; + skipEmpty?: boolean; + allowUnsafePathSegments?: boolean; + nodeCallback?: FormToObjectNodeCallback; + useIdIfEmptyName?: boolean; + getDisabled?: boolean; +} + +export function installToObjectPlugin($: JQueryLike): void; +export function maybeAutoInstallPlugin(scope?: unknown): void; +``` + +### Options And Defaults + +| Option | Default | Why this matters | +| --- | --- | --- | +| `mode` | `"first"` | Controls whether you parse one match, all matches, or merge all matches. | +| `delimiter` | `"."` | Same path splitting behavior as the other packages. | +| `skipEmpty` | `true` | Keeps default parser behavior for empty values. | +| `allowUnsafePathSegments` | `false` | Rejects unsafe path segments before object merging. | +| `useIdIfEmptyName` | `false` | Lets the plugin fall back to `id` where needed. | +| `getDisabled` | `false` | Disabled controls are skipped unless enabled. | +| `nodeCallback` | unset | Hook for custom extraction through the DOM package semantics. | + +### `mode: "all"` + +Use `all` when the selector can match multiple forms or repeated field groups and you want one parsed object per match. + +```ts +const result = $(".profile-form").toObject({ mode: "all" }); +``` + +### Behavior Notes + +- `installToObjectPlugin` is idempotent; it does not overwrite an existing `$.fn.toObject`. +- `mode: "all"` returns an array of objects, one per matched element. +- `mode: "combine"` passes all matched root nodes together into the DOM parser. diff --git a/docs/api-js2form.md b/docs/api-js2form.md new file mode 100644 index 0000000..c5d8b9a --- /dev/null +++ b/docs/api-js2form.md @@ -0,0 +1,109 @@ +# @form2js/js2form + +`@form2js/js2form` moves data in the opposite direction: take a nested object and write it into matching form controls. Use it when you need to prefill a form, restore saved draft state, or sync object data back into existing DOM controls. + +## Installation + +```bash +npm install @form2js/js2form +``` + +Standalone/global build is not shipped for this package. + +## General Example + +```ts +import { objectToForm } from "@form2js/js2form"; + +objectToForm("profileForm", { + person: { + name: { first: "Tiffany", last: "Aching" }, + roles: ["witch"], + }, +}); +``` + +## Types and Properties + +### Exported Surface + +| Export | Kind | What it does | +| --- | --- | --- | +| `RootNodeInput` | type | Root as element id, node, `null`, or `undefined`. | +| `ObjectToFormNodeCallback` | type | Write-time callback for per-node assignment control. | +| `ObjectToFormOptions` | interface | Options for name normalization, cleaning, and document resolution. | +| `SupportedField`, `SupportedFieldCollection`, `FieldMap` | types | Field typing used by mapping and assignment helpers. | +| `flattenDataForForm` | function | Flattens object data to an entry list. | +| `mapFieldsByName` | function | Builds a normalized name-to-field mapping. | +| `objectToForm` | function | Populates matching fields from object data. | +| `js2form` | function | Compatibility wrapper around `objectToForm`. | +| `normalizeName` | function | Normalizes field names and compacts indexed arrays. | + +```ts +export interface ObjectToFormOptions { + delimiter?: string; + nodeCallback?: ObjectToFormNodeCallback; + useIdIfEmptyName?: boolean; + shouldClean?: boolean; + document?: Document; +} + +export function flattenDataForForm(data: unknown): Entry[]; +export function mapFieldsByName( + rootNode: RootNodeInput, + options?: Pick +): FieldMap; +export function objectToForm(rootNode: RootNodeInput, data: unknown, options?: ObjectToFormOptions): void; +``` + +### Options And Defaults + +| Option | Default | Where | Why this matters | +| --- | --- | --- | --- | +| `delimiter` | `"."` | `objectToForm`, `mapFieldsByName`, `js2form` | Must match how your input keys are structured. | +| `useIdIfEmptyName` | `false` | `objectToForm`, `mapFieldsByName`, `js2form` | Useful when form controls are keyed by `id` instead of `name`. | +| `shouldClean` | `true` | `objectToForm`, `mapFieldsByName` | Clears form state before applying incoming values. | +| `document` | ambient/global document | all root-resolving APIs | Needed when running with a DOM shim. | +| `nodeCallback` | unset | `objectToForm`, `js2form` options | Called before default assignment; return `false` to skip default assignment for that node. | + +### `shouldClean: false` + +Disable cleaning when you want to layer partial data onto an existing form without clearing unrelated controls first. + +```ts +import { objectToForm } from "@form2js/js2form"; + +objectToForm( + "profileForm", + { + person: { + name: { first: "Tiffany" } + } + }, + { shouldClean: false } +); +``` + +### `useIdIfEmptyName` + +Match fields by `id` when the markup does not provide stable `name` attributes. + +```ts +import { objectToForm } from "@form2js/js2form"; + +objectToForm( + document.getElementById("profileForm"), + { + firstName: "Agnes" + }, + { useIdIfEmptyName: true } +); +``` + +### Behavior Notes + +- `objectToForm` is a no-op when the root cannot be resolved. +- Checkbox and radio groups are matched with `[]` and non-`[]` name fallbacks. +- Name normalization compacts sparse indexes to sequential indexes during matching. +- For multi-select names like `colors[]`, matching includes `[]` and bare-name fallbacks without creating one map key per option. +- Form updates set values, checked state, and selected state, but do not dispatch synthetic events. diff --git a/docs/api-react.md b/docs/api-react.md new file mode 100644 index 0000000..262251b --- /dev/null +++ b/docs/api-react.md @@ -0,0 +1,99 @@ +# @form2js/react + +`@form2js/react` wraps the form-data parser in a React submit hook. Use it when you want a single `onSubmit` handler that parses form input, optionally validates it with a schema, and tracks submit state without wiring your own state machine. + +## Installation + +```bash +npm install @form2js/react react +``` + +Standalone/global build is not shipped for this package. + +## General Example + +```tsx +import { z } from "zod"; +import { useForm2js } from "@form2js/react"; + +const schema = z.object({ + person: z.object({ + email: z.string().email() + }) +}); + +export function SignupForm(): React.JSX.Element { + const { onSubmit, isSubmitting, isError, error, isSuccess, reset } = useForm2js( + async (data) => { + await sendFormData(data); + }, + { schema } + ); + + return ( +
{ + void onSubmit(event); + }} + > + + + {isError ?

{String(error)}

: null} + {isSuccess ?

Saved

: null} + +
+ ); +} +``` + +## Types and Properties + +### Exported Surface + +| Export | Kind | What it does | +| --- | --- | --- | +| `UseForm2jsData` | type | Infers submit payload from the optional schema. | +| `UseForm2jsSubmit` | type | Submit callback signature. | +| `UseForm2jsOptions` | interface | Parser options plus optional schema. | +| `UseForm2jsResult` | interface | Hook return state and handlers. | +| `useForm2js` | function | Creates submit handler and submit state machine for forms. | + +```ts +export type UseForm2jsSubmit = ( + data: UseForm2jsData +) => Promise | void; + +export interface UseForm2jsOptions + extends ParseOptions { + schema?: TSchema; +} + +export interface UseForm2jsResult { + onSubmit: (event: SyntheticEvent) => Promise; + isSubmitting: boolean; + isError: boolean; + error: unknown; + isSuccess: boolean; + reset: () => void; +} +``` + +### Options And Defaults + +| Option | Default | Why this matters | +| --- | --- | --- | +| `delimiter` | `"."` | Keeps parser path splitting aligned with the other packages. | +| `skipEmpty` | `true` | Drops empty string and `null` values unless disabled. | +| `allowUnsafePathSegments` | `false` | Keeps parser hardened by default. | +| `schema` | unset | If set, the parsed payload is run through `schema.parse(...)` before the submit callback. | + +### Behavior Notes + +- `onSubmit` always calls `event.preventDefault()`. +- Re-submit attempts are ignored while a submit promise is still pending. +- Validation and submit errors are both surfaced through `error` and `isError`. +- `reset()` clears `isError`, `error`, and `isSuccess`. diff --git a/docs/api.md b/docs/api.md deleted file mode 100644 index 57580eb..0000000 --- a/docs/api.md +++ /dev/null @@ -1,627 +0,0 @@ -# form2js API Reference - -## Who this is for - -This page is for developers who want the exact API surface of the current `@form2js/*` packages, with practical notes about defaults and edge cases. - -If you want a quick tour first, start with `README.md`. - -## Package Index - -| Package | Use it when you need to... | Main exports | -| --- | --- | --- | -| `@form2js/core` | Turn path-like key/value pairs into nested objects (and back). | `entriesToObject`, `objectToEntries`, `setPathValue` | -| `@form2js/dom` | Read browser form fields into an object. | `formToObject`, `extractPairs`, `form2js` | -| `@form2js/form-data` | Parse `FormData` or entry tuples with the same path rules. | `formDataToObject`, `entriesToObject` | -| `@form2js/react` | Handle React form submit, parsing, and submit state. | `useForm2js` | -| `@form2js/js2form` | Push object values back into form controls. | `objectToForm`, `mapFieldsByName`, `js2form` | -| `@form2js/jquery` | Add `$.fn.toObject()` on top of `@form2js/dom`. | `installToObjectPlugin`, `maybeAutoInstallPlugin` | - -## Shared Naming Rules - -These rules apply across parser-based packages (`core`, `dom`, `form-data`): - -- Dot paths build nested objects: `person.name.first` -> `{ person: { name: { first: ... } } }` -- Repeated `[]` pushes into arrays in encounter order: `roles[]` -- Indexed arrays are compacted in first-seen order: `items[8]`, `items[5]` becomes indexes `0`, `1` -- Rails-style brackets are supported: `rails[field][value]` -- By default, empty string and `null` are skipped (`skipEmpty: true`) -- Unsafe key path segments (`__proto__`, `prototype`, `constructor`) are rejected by default - -## `@form2js/core` - -### Common tasks - -- Convert key/value entries into nested data (`entriesToObject`) -- Validate and transform parsed payloads with schemas (`entriesToObject` + `schema`) -- Flatten nested data into path entries (`objectToEntries`) -- Apply one path/value into an existing object (`setPathValue`) -- Keep legacy `name/value` pair input (`processNameValues`) - -### Installation - -```bash -npm install @form2js/core -``` - -Standalone/global build is not shipped for this package. - -### API - -| Export | Kind | What it does | -| --- | --- | --- | -| `createMergeContext` | function | Creates merge state used while parsing indexed arrays. | -| `setPathValue` | function | Applies one path/value into an object tree. | -| `entriesToObject` | function | Main parser for iterable entries. | -| `objectToEntries` | function | Flattens nested object/array data into `{ key, value }` entries. | -| `processNameValues` | function | Compatibility helper for `{ name, value }` input. | -| `Entry`, `EntryInput`, `EntryValue`, `NameValuePair`, `ObjectTree`, `ParseOptions`, `MergeContext`, `MergeOptions`, `SchemaValidator`, `ValidationOptions`, `InferSchemaOutput` | types | Public type surface for parser inputs/options/results. | - -```ts -export function createMergeContext(): MergeContext; - -export function setPathValue( - target: ObjectTree, - path: string, - value: EntryValue, - options?: MergeOptions -): ObjectTree; - -export function entriesToObject(entries: Iterable, options?: ParseOptions): ObjectTree; -export function entriesToObject( - entries: Iterable, - options: ParseOptions & { schema: TSchema } -): InferSchemaOutput; - -export function objectToEntries(value: unknown): Entry[]; - -export function processNameValues( - nameValues: Iterable, - skipEmpty?: boolean, - delimiter?: string -): ObjectTree; - -export type { - Entry, - EntryInput, - EntryValue, - MergeContext, - MergeOptions, - NameValuePair, - ObjectTree, - ParseOptions, - SchemaValidator, - ValidationOptions, - InferSchemaOutput -} from "./types"; -``` - -### Options and defaults - -| Option | Default | Where | Why this matters | -| --- | --- | --- | --- | -| `delimiter` | `"."` | `entriesToObject`, `setPathValue`, `processNameValues` | Controls how dot-like path chunks are split. | -| `skipEmpty` | `true` | `entriesToObject`, `processNameValues` | Drops `""` and `null` values unless you opt out. | -| `allowUnsafePathSegments` | `false` | `entriesToObject`, `setPathValue` | Blocks prototype-pollution path segments unless you explicitly trust the source. | -| `schema` | unset | `entriesToObject` | Runs `schema.parse(parsedObject)` and returns schema output type. | -| `context` | fresh merge context | `setPathValue` | Keeps indexed array compaction stable across multiple writes. | - -### Behavior notes - -- Indexed array keys are compacted by encounter order, not preserved by numeric index. -- `EntryInput` accepts `[key, value]`, `{ key, value }`, and `{ name, value }`. -- If `schema` is provided, parser output is passed to `schema.parse()` and schema errors are rethrown. -- `objectToEntries` emits bracket indexes for arrays (for example `emails[0]`) and only serializes own enumerable properties. - -### Quick example - -```ts -import { entriesToObject, objectToEntries } from "@form2js/core"; - -const data = entriesToObject([ - { key: "person.name.first", value: "Esme" }, - { key: "person.roles[]", value: "witch" }, -]); - -const flat = objectToEntries(data); -``` - -## `@form2js/dom` - -### Common tasks - -- Convert a form (or a subtree) into an object (`formToObject`) -- Extract raw `{ key, value }` pairs before parsing (`extractPairs`) -- Keep the legacy function signature (`form2js`) - -### Installation - -```bash -npm install @form2js/dom -``` - -Standalone via `unpkg`: - -```html - - -``` - -### API - -| Export | Kind | What it does | -| --- | --- | --- | -| `NodeCallbackResult` | interface | Custom extraction payload (`name`/`key` + `value`). | -| `FormToObjectNodeCallback` | type | Callback type used during node walk. | -| `ExtractOptions` | interface | Options for pair extraction only. | -| `FormToObjectOptions` | interface | Extraction options plus parser options. | -| `RootNodeInput` | type | Supported root inputs (`id`, `Node`, collections, etc.). | -| `extractPairs` | function | Traverses DOM and returns path/value entries. | -| `formToObject` | function | High-level parser from DOM to object tree. | -| `form2js` | function | Compatibility wrapper around `formToObject`. | - -```ts -export interface NodeCallbackResult { - name?: string; - key?: string; - value: unknown; -} - -export const SKIP_NODE: unique symbol; - -export type FormToObjectNodeCallback = ( - node: Node -) => NodeCallbackResult | typeof SKIP_NODE | false | null | undefined; - -export interface ExtractOptions { - nodeCallback?: FormToObjectNodeCallback; - useIdIfEmptyName?: boolean; - getDisabled?: boolean; - document?: Document; -} - -export interface FormToObjectOptions extends ExtractOptions, ParseOptions {} - -export type RootNodeInput = - | string - | Node - | NodeListOf - | Node[] - | HTMLCollection - | null - | undefined; - -export function extractPairs(rootNode: RootNodeInput, options?: ExtractOptions): Entry[]; - -export function formToObject(rootNode: RootNodeInput, options?: FormToObjectOptions): ObjectTree; - -export function form2js( - rootNode: RootNodeInput, - delimiter?: string, - skipEmpty?: boolean, - nodeCallback?: FormToObjectNodeCallback, - useIdIfEmptyName?: boolean, - getDisabled?: boolean, - allowUnsafePathSegments?: boolean -): ObjectTree; -``` - -### Options and defaults - -| Option | Default | Where | Why this matters | -| --- | --- | --- | --- | -| `delimiter` | `"."` | `formToObject`, `form2js` | Matches parser path semantics. | -| `skipEmpty` | `true` | `formToObject`, `form2js` | Skips `""` and `null` values by default. | -| `allowUnsafePathSegments` | `false` | `formToObject`, `form2js` | Rejects unsafe path segments before object merging. | -| `useIdIfEmptyName` | `false` | extraction + wrappers | Lets `id` act as field key when `name` is empty. | -| `getDisabled` | `false` | extraction + wrappers | Disabled controls, including disabled fieldset descendants, are ignored unless enabled explicitly. | -| `nodeCallback` | unset | extraction + wrappers | Use for custom field extraction from specific nodes. | -| `document` | ambient/global document | extraction + wrappers | Required outside browser globals. | - -### Behavior notes - -- `select name="colors[]"` is emitted as key `colors` (the trailing `[]` is removed for selects). -- Checkbox and radio values follow native browser form submission semantics: - - checked controls emit their string `value` - - unchecked controls are omitted - - omitted indexed controls do not reserve compacted array slots; preserve row identity with another submitted field when it matters -- Button-like inputs (`button`, `reset`, `submit`, `image`) are excluded from extraction. -- Can merge multiple roots (`NodeList`, arrays, `HTMLCollection`) into one object. -- If callback returns `SKIP_NODE`, that node is excluded from extraction entirely. -- If callback returns `{ key|name, value }`, that value is used directly for that node. - -### Quick example - -```ts -import { formToObject } from "@form2js/dom"; - -const result = formToObject(document.getElementById("profileForm"), { - useIdIfEmptyName: true, - getDisabled: false, -}); -``` - -## `@form2js/form-data` - -### Common tasks - -- Parse a `FormData` instance with the same semantics as DOM parsing -- Parse tuple entries in server pipelines (`Iterable<[string, value]>`) -- Validate and transform payloads with schema `parse` -- Reuse core parser options without importing `@form2js/core` directly - -### Installation - -```bash -npm install @form2js/form-data -``` - -Standalone/global build is not shipped for this package. - -### API - -| Export | Kind | What it does | -| --- | --- | --- | -| `KeyValueEntryInput` | type alias | Alias of core `EntryInput`. | -| `FormDataToObjectOptions` | interface | Parser options for form-data conversion. | -| `entriesToObject` | function | Adapter to core parser. | -| `formDataToObject` | function | Parses `FormData` or iterable form-data entries. | -| `EntryInput`, `ObjectTree`, `ParseOptions`, `SchemaValidator`, `ValidationOptions`, `InferSchemaOutput` | type re-export | Core types re-exported for convenience. | - -```ts -export type KeyValueEntryInput = EntryInput; - -export interface FormDataToObjectOptions extends ParseOptions {} - -export function entriesToObject(entries: Iterable, options?: ParseOptions): ObjectTree; -export function entriesToObject( - entries: Iterable, - options: ParseOptions & { schema: TSchema } -): InferSchemaOutput; - -export function formDataToObject( - formData: FormData | Iterable, - options?: FormDataToObjectOptions -): ObjectTree; -export function formDataToObject( - formData: FormData | Iterable, - options: FormDataToObjectOptions & { schema: TSchema } -): InferSchemaOutput; - -export type { - EntryInput, - ObjectTree, - ParseOptions, - SchemaValidator, - ValidationOptions, - InferSchemaOutput -} from "@form2js/core"; -``` - -### Options and defaults - -| Option | Default | Why this matters | -| --- | --- | --- | -| `delimiter` | `"."` | Keeps path splitting aligned with core/dom behavior. | -| `skipEmpty` | `true` | Drops empty string and `null` values unless disabled. | -| `allowUnsafePathSegments` | `false` | Rejects unsafe path segments before object merging. | -| `schema` | unset | Runs `schema.parse(parsedObject)` after parsing and returns schema output type. | - -### Behavior notes - -- Parsing rules are the same as `@form2js/core`. -- Accepts either a real `FormData` object or any iterable of readonly key/value tuples. -- Schema validation is optional and uses only a structural `{ parse(unknown) }` contract. - -### Quick example - -```ts -import { formDataToObject } from "@form2js/form-data"; - -const result = formDataToObject([ - ["person.name.first", "Sam"], - ["person.roles[]", "captain"], -]); -``` - -## `@form2js/react` - -### Common tasks - -- Handle `
` with parsed object payloads -- Validate payloads with optional schema `parse` -- Track async submit status (`isSubmitting`, `isError`, `error`, `isSuccess`) -- Reset hook state after submit attempts - -### Installation - -```bash -npm install @form2js/react react -``` - -Standalone/global build is not shipped for this package. - -### API - -| Export | Kind | What it does | -| --- | --- | --- | -| `UseForm2jsData` | type | Infers submit payload from optional schema. | -| `UseForm2jsSubmit` | type | Submit callback signature. | -| `UseForm2jsOptions` | interface | Parser options plus optional schema. | -| `UseForm2jsResult` | interface | Hook return state and handlers. | -| `useForm2js` | function | Creates submit handler and submit state machine for forms. | - -```ts -export type UseForm2jsData = - TSchema extends SchemaValidator ? InferSchemaOutput : ObjectTree; - -export type UseForm2jsSubmit = ( - data: UseForm2jsData -) => Promise | void; - -export interface UseForm2jsOptions - extends ParseOptions { - schema?: TSchema; -} - -export interface UseForm2jsResult { - onSubmit: (event: SyntheticEvent) => Promise; - isSubmitting: boolean; - isError: boolean; - error: unknown; - isSuccess: boolean; - reset: () => void; -} - -export function useForm2js( - submit: UseForm2jsSubmit, - options?: UseForm2jsOptions -): UseForm2jsResult; -``` - -### Options and defaults - -| Option | Default | Why this matters | -| --- | --- | --- | -| `delimiter` | `"."` | Keeps parser path splitting aligned with other packages. | -| `skipEmpty` | `true` | Drops empty string and `null` values unless disabled. | -| `allowUnsafePathSegments` | `false` | Keeps parser hardened by default. | -| `schema` | unset | If set, parsed payload is run through `schema.parse(...)` before submit callback. | - -### Behavior notes - -- `onSubmit` always calls `event.preventDefault()`. -- Re-submit attempts are ignored while a submit promise is still pending. -- Validation and submit errors are both surfaced through `error` and `isError`. -- `reset()` clears `isError`, `error`, and `isSuccess`. - -### Quick example - -```ts -import { z } from "zod"; -import { useForm2js } from "@form2js/react"; - -const schema = z.object({ - person: z.object({ - email: z.string().email() - }) -}); - -export function SignupForm(): React.JSX.Element { - const { onSubmit, isSubmitting, isError, error, isSuccess, reset } = useForm2js( - async (data) => { - await sendFormData(data); - }, - { schema } - ); - - return ( - { - void onSubmit(event); - }} - > - - - {isError ?

{String(error)}

: null} - {isSuccess ?

Saved

: null} - - - ); -} -``` - -## `@form2js/js2form` - -### Common tasks - -- Populate a form from nested object data (`objectToForm`) -- Precompute form field mapping (`mapFieldsByName`) -- Flatten object data to path entries (`flattenDataForForm`) -- Keep legacy wrapper call style (`js2form`) - -### Installation - -```bash -npm install @form2js/js2form -``` - -Standalone/global build is not shipped for this package. - -### API - -| Export | Kind | What it does | -| --- | --- | --- | -| `RootNodeInput` | type | Root as element id, node, `null`, or `undefined`. | -| `ObjectToFormNodeCallback` | type | Write-time callback for per-node assignment control. | -| `ObjectToFormOptions` | interface | Options for name normalization, cleaning, and document resolution. | -| `SupportedField`, `SupportedFieldCollection`, `FieldMap` | types | Field typing used by mapping and assignment helpers. | -| `flattenDataForForm` | function | Flattens object data to entry list. | -| `mapFieldsByName` | function | Builds normalized name -> field mapping. | -| `objectToForm` | function | Populates matching fields from object data. | -| `js2form` | function | Compatibility wrapper around `objectToForm`. | -| `normalizeName` | function | Normalizes field names and compacts indexed arrays. | -| `Entry` | type re-export | Core entry type re-export. | - -```ts -export type RootNodeInput = string | Node | null | undefined; - -export type ObjectToFormNodeCallback = ((node: Node) => unknown) | null | undefined; - -export interface ObjectToFormOptions { - delimiter?: string; - nodeCallback?: ObjectToFormNodeCallback; - useIdIfEmptyName?: boolean; - shouldClean?: boolean; - document?: Document; -} - -export type SupportedField = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; -export type SupportedFieldCollection = SupportedField | SupportedField[]; -export type FieldMap = Record; - -export function flattenDataForForm(data: unknown): Entry[]; - -export function mapFieldsByName( - rootNode: RootNodeInput, - options?: Pick -): FieldMap; - -export function objectToForm(rootNode: RootNodeInput, data: unknown, options?: ObjectToFormOptions): void; - -export function js2form( - rootNode: RootNodeInput, - data: unknown, - delimiter?: string, - nodeCallback?: ObjectToFormNodeCallback, - useIdIfEmptyName?: boolean -): void; - -export { normalizeName }; -export type { Entry } from "@form2js/core"; -``` - -### Options and defaults - -| Option | Default | Where | Why this matters | -| --- | --- | --- | --- | -| `delimiter` | `"."` | `objectToForm`, `mapFieldsByName`, `js2form` | Must match how your input keys are structured. | -| `useIdIfEmptyName` | `false` | `objectToForm`, `mapFieldsByName`, `js2form` | Useful when form controls are keyed by `id` instead of `name`. | -| `shouldClean` | `true` | `objectToForm`, `mapFieldsByName` | Clears form state before applying incoming values. | -| `document` | ambient/global document | all root-resolving APIs | Needed when running with a DOM shim. | -| `nodeCallback` | unset | `objectToForm`, `js2form` options | Called before default assignment; return `false` to skip default assignment for that node. | - -### Behavior notes - -- `objectToForm` is a no-op when root cannot be resolved. -- Checkbox/radio groups are matched with `[]` and non-`[]` name fallbacks. -- Name normalization compacts sparse indexes to sequential indexes during matching. -- For multi-select names like `colors[]`, matching includes `[]` and bare-name fallbacks without creating one map key per option. -- Form updates set values/checked/selected state, but do not dispatch synthetic events. - -### Quick example - -```ts -import { objectToForm } from "@form2js/js2form"; - -objectToForm("profileForm", { - person: { - name: { first: "Tiffany", last: "Aching" }, - roles: ["witch"], - }, -}); -``` - -## `@form2js/jquery` - -### Common tasks - -- Install `toObject()` plugin on a jQuery instance -- Read first match, all matches, or a combined result from matched roots -- Auto-install the plugin from global `jQuery` in browser standalone usage - -### Installation - -```bash -npm install @form2js/jquery jquery -``` - -Standalone via `unpkg`: - -```html - - - -``` - -### API - -| Export | Kind | What it does | -| --- | --- | --- | -| `ToObjectMode` | type | `"first" | "all" | "combine"` | -| `ToObjectOptions` | interface | Plugin options mapped to `@form2js/dom` behavior. | -| `installToObjectPlugin` | function | Adds `toObject()` to `$.fn` if missing. | -| `maybeAutoInstallPlugin` | function | Installs plugin only when a jQuery-like scope is detected. | - -```ts -export type ToObjectMode = "first" | "all" | "combine"; - -export interface ToObjectOptions { - mode?: ToObjectMode; - delimiter?: string; - skipEmpty?: boolean; - allowUnsafePathSegments?: boolean; - nodeCallback?: FormToObjectNodeCallback; - useIdIfEmptyName?: boolean; - getDisabled?: boolean; -} - -export function installToObjectPlugin($: JQueryLike): void; -export function maybeAutoInstallPlugin(scope?: unknown): void; -``` - -### Options and defaults - -| Option | Default | Why this matters | -| --- | --- | --- | -| `mode` | `"first"` | Controls whether you parse one match, all matches, or merge all matches. | -| `delimiter` | `"."` | Same path splitting behavior as other packages. | -| `skipEmpty` | `true` | Keeps default parser behavior for empty values. | -| `allowUnsafePathSegments` | `false` | Rejects unsafe path segments before object merging. | -| `useIdIfEmptyName` | `false` | Lets plugin fall back to `id` where needed. | -| `getDisabled` | `false` | Disabled controls are skipped unless enabled. | -| `nodeCallback` | unset | Hook for custom extraction via DOM package semantics. | - -### Behavior notes - -- `installToObjectPlugin` is idempotent; it does not overwrite existing `$.fn.toObject`. -- `mode: "all"` returns an array of objects, one per matched element. -- `mode: "combine"` passes all matched root nodes together into DOM parser. - -### Quick example - -```ts -import $ from "jquery"; -import { installToObjectPlugin } from "@form2js/jquery"; - -installToObjectPlugin($); - -const data = $("#profileForm").toObject({ mode: "first" }); -``` - -## Standalone and Browser Globals - -- `@form2js/dom/standalone` exposes globals: - - `formToObject` - - `form2js` -- `@form2js/jquery/standalone` checks global `jQuery` and installs `$.fn.toObject()` when available. -- `@form2js/core`, `@form2js/form-data`, `@form2js/react`, and `@form2js/js2form` are module-only (no standalone global bundle in this repo).