Repository: maxatwork/form2js
Branch: master
Commit: f9fe5aed9e47
Files: 131
Total size: 316.8 KB
Directory structure:
gitextract_xzm58w39/
├── .changeset/
│ ├── README.md
│ └── config.json
├── .github/
│ └── workflows/
│ ├── ci.yml
│ ├── pages.yml
│ └── release.yml
├── .gitignore
├── LICENSE
├── README.md
├── apps/
│ └── docs/
│ ├── astro.config.mjs
│ ├── package.json
│ ├── playwright.config.ts
│ ├── src/
│ │ ├── components/
│ │ │ ├── api/
│ │ │ │ ├── ApiPackageNav.tsx
│ │ │ │ ├── ApiPackageSummaryList.tsx
│ │ │ │ └── ApiToc.tsx
│ │ │ ├── landing/
│ │ │ │ ├── ApiDocsCta.astro
│ │ │ │ ├── Hero.astro
│ │ │ │ └── InstallSection.astro
│ │ │ └── playground/
│ │ │ ├── PlaygroundShell.tsx
│ │ │ ├── ReactInspectorPanel.tsx
│ │ │ ├── ResultPanel.tsx
│ │ │ ├── StandardResultPanel.tsx
│ │ │ ├── VariantHeader.tsx
│ │ │ ├── bootstrap/
│ │ │ │ └── jquery-bootstrap.ts
│ │ │ ├── types.ts
│ │ │ ├── variant-registry.ts
│ │ │ └── variants/
│ │ │ ├── core-variant.tsx
│ │ │ ├── form-data-variant.tsx
│ │ │ ├── form-variant.tsx
│ │ │ ├── jquery-variant.tsx
│ │ │ ├── js2form-variant.tsx
│ │ │ └── react-variant.tsx
│ │ ├── env.d.ts
│ │ ├── layouts/
│ │ │ ├── ApiDocsLayout.astro
│ │ │ ├── DesignShell.astro
│ │ │ └── DocsShell.astro
│ │ ├── lib/
│ │ │ ├── api-docs-source.ts
│ │ │ ├── api-packages.ts
│ │ │ └── site-routes.ts
│ │ ├── pages/
│ │ │ ├── api/
│ │ │ │ ├── [package].astro
│ │ │ │ └── index.astro
│ │ │ ├── index.astro
│ │ │ └── migrate.astro
│ │ └── styles/
│ │ ├── global.css
│ │ ├── landing.css
│ │ ├── playground.css
│ │ └── themes/
│ │ ├── dark-brutalism.css
│ │ ├── editorial.css
│ │ ├── linear-dark.css
│ │ ├── outrun-sunset.css
│ │ └── terminal-noir.css
│ ├── test/
│ │ ├── api-docs-page.test.tsx
│ │ ├── api-docs-source.test.ts
│ │ ├── data-variants.test.tsx
│ │ ├── docs-pipeline.test.ts
│ │ ├── docs-root-scripts.test.ts
│ │ ├── homepage-shell.test.ts
│ │ ├── playground-shell.test.tsx
│ │ ├── playground-styles.test.ts
│ │ ├── react-variant.test.tsx
│ │ ├── site-routes.test.ts
│ │ ├── standard-variants.test.tsx
│ │ └── variant-registry.test.ts
│ ├── test-e2e/
│ │ ├── api-docs.spec.ts
│ │ └── homepage.spec.ts
│ ├── tsconfig.json
│ └── vitest.config.ts
├── changeset/
│ └── config.json
├── docs/
│ ├── api-core.md
│ ├── api-dom.md
│ ├── api-form-data.md
│ ├── api-index.md
│ ├── api-jquery.md
│ ├── api-js2form.md
│ ├── api-react.md
│ └── migrate.md
├── eslint.config.js
├── package.json
├── packages/
│ ├── core/
│ │ ├── CHANGELOG.md
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── test/
│ │ │ └── core.test.ts
│ │ ├── tsconfig.json
│ │ └── tsup.config.ts
│ ├── dom/
│ │ ├── CHANGELOG.md
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── index.ts
│ │ │ └── standalone.ts
│ │ ├── test/
│ │ │ └── dom.test.ts
│ │ ├── tsconfig.json
│ │ └── tsup.config.ts
│ ├── form-data/
│ │ ├── CHANGELOG.md
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ └── index.ts
│ │ ├── test/
│ │ │ └── form-data.test.ts
│ │ ├── tsconfig.json
│ │ └── tsup.config.ts
│ ├── jquery/
│ │ ├── CHANGELOG.md
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ ├── index.ts
│ │ │ └── standalone.ts
│ │ ├── test/
│ │ │ └── jquery.test.ts
│ │ ├── tsconfig.json
│ │ └── tsup.config.ts
│ ├── js2form/
│ │ ├── CHANGELOG.md
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src/
│ │ │ └── index.ts
│ │ ├── test/
│ │ │ └── js2form.test.ts
│ │ ├── tsconfig.json
│ │ └── tsup.config.ts
│ └── react/
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── package.json
│ ├── src/
│ │ └── index.ts
│ ├── test/
│ │ └── react.test.ts
│ ├── tsconfig.json
│ └── tsup.config.ts
├── scripts/
│ ├── bump-version.mjs
│ └── rewrite-scope.mjs
├── test/
│ └── integration/
│ ├── bump-version.test.ts
│ ├── dependency-security.test.ts
│ ├── form-flow.test.ts
│ ├── package-metadata.test.ts
│ └── workflow-node-version.test.ts
├── tsconfig.base.json
└── turbo.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .changeset/README.md
================================================
# Changesets
Run `npm run changeset` to add release notes and version bumps.
================================================
FILE: .changeset/config.json
================================================
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [
[
"@form2js/core",
"@form2js/dom",
"@form2js/form-data",
"@form2js/js2form",
"@form2js/jquery",
"@form2js/react"
]
],
"linked": [],
"access": "public",
"baseBranch": "master",
"updateInternalDependencies": "patch",
"ignore": []
}
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
push:
branches:
- master
pull_request:
permissions:
contents: read
jobs:
checks:
name: checks (node ${{ matrix.node-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [22.14.0]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Install
run: npm ci
- name: Lint
run: npm run lint
- name: Typecheck
run: npm run typecheck
- name: Test
run: npm run test:packages && npm run test:integration
- name: Build
run: npm run build
- name: Package Dry Run
run: npm run pack:dry-run
docs-e2e:
name: docs-e2e (node 22.14.0)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22.14.0
cache: npm
- name: Install
run: npm ci
- name: Install Playwright Chromium
run: npm -w @form2js/docs exec playwright install --with-deps chromium
- name: Build workspace packages
run: npm run build:packages
- name: Test Docs E2E
run: npm run test:docs
================================================
FILE: .github/workflows/pages.yml
================================================
name: Deploy Docs Site
on:
push:
branches:
- master
paths:
- "apps/docs/**"
- "docs/**"
- "packages/**"
- ".github/workflows/pages.yml"
- "package.json"
- "package-lock.json"
- "turbo.json"
- "tsconfig.base.json"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22.14.0
cache: npm
- name: Install
run: npm ci
- name: Configure Pages
id: pages
uses: actions/configure-pages@v5
- name: Build workspace packages
run: npm run build:packages
- name: Build docs app
run: npm -w @form2js/docs run build
env:
DOCS_BASE_PATH: ${{ steps.pages.outputs.base_path }}
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: apps/docs/dist
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
================================================
FILE: .github/workflows/release.yml
================================================
name: Release
on:
push:
branches:
- master
permissions:
contents: write
pull-requests: write
id-token: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22.14.0
registry-url: https://registry.npmjs.org
cache: npm
- name: Use npm 11
run: npm install --global npm@11.6.2
- name: Verify toolchain
run: node --version && npm --version
- name: Install
run: npm ci
- name: Build workspace packages
run: npm run build:packages
- name: Create release PR or publish
uses: changesets/action@v1
with:
version: npx changeset version
publish: npm run release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_CONFIG_PROVENANCE: true
================================================
FILE: .gitignore
================================================
node_modules
.npm
.turbo
dist
coverage
*.tsbuildinfo
.idea
.hg
.hgignore
.astro
**/.astro/
docs/triage-*
.env
.env.local
.superpowers/brainstorm
.worktrees/
.worktrees
apps/docs/test-results/
apps/docs/playwright-report/
docs/superpowers/
================================================
FILE: LICENSE
================================================
Copyright (c) 2010 Maxim Vasiliev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================
FILE: README.md
================================================
# form2js
🚀 **form2js is back — modernized and actively maintained.**
Originally created in 2010, now rewritten for modern JavaScript, TypeScript, ESM, React, and modular usage.
Legacy version is available in the [legacy branch](https://github.com/maxatwork/form2js/tree/legacy).
Migrating from legacy form2js? Start with the [migration guide](https://maxatwork.github.io/form2js/migrate/).
## Description
A small family of packages for turning form-shaped data into objects, and objects back into forms.
It is not a serializer, not an ORM, and not a new religion. It just does this one job, does it reliably, and leaves before anyone starts a committee about it.
## Documentation
- [Docs Site](https://maxatwork.github.io/form2js/) - overview, installation, unified playground, and published API reference.
- [Migration Guide](https://maxatwork.github.io/form2js/migrate/) - map old `form2js` and `jquery.toObject` usage to the current package family.
- [API Reference Source](docs/api-index.md) - markdown source for the published API docs page.
## Migration from Legacy
If you are moving from the archived single-package version, start with the [migration guide](https://maxatwork.github.io/form2js/migrate/).
Quick package map:
- Legacy browser `form2js(...)` usage -> `@form2js/dom`
- Legacy jQuery `$("#form").toObject()` usage -> `@form2js/jquery`
- Server or pipeline `FormData` parsing -> `@form2js/form-data`
- React submit handling -> `@form2js/react`
- Object back into fields -> `@form2js/js2form`
The current project keeps the naming rules and core parsing model, but splits the old browser-era API into environment-specific packages.
## Packages
| Package | npm | Purpose | Module | Standalone | Node.js |
| ------- | --- | ------- | ------ | ---------- | ------- |
| [`@form2js/react`](https://www.npmjs.com/package/@form2js/react) | [](https://www.npmjs.com/package/@form2js/react) | React submit hook with parsing/validation state | Yes | No | Browser-focused |
| [`@form2js/dom`](https://www.npmjs.com/package/@form2js/dom) | [](https://www.npmjs.com/package/@form2js/dom) | Extract DOM fields to object (`formToObject`, `form2js`) | Yes | Yes | With DOM shim (`jsdom`) |
| [`@form2js/form-data`](https://www.npmjs.com/package/@form2js/form-data) | [](https://www.npmjs.com/package/@form2js/form-data) | Convert `FormData`/entries to object | Yes | No | Yes |
| [`@form2js/js2form`](https://www.npmjs.com/package/@form2js/js2form) | [](https://www.npmjs.com/package/@form2js/js2form) | Populate DOM fields from object (`objectToForm`, `js2form`) | Yes | No | With DOM shim (`jsdom`) |
| [`@form2js/core`](https://www.npmjs.com/package/@form2js/core) | [](https://www.npmjs.com/package/@form2js/core) | Path parsing and object transformation engine | Yes | No | Yes |
| [`@form2js/jquery`](https://www.npmjs.com/package/@form2js/jquery) | [](https://www.npmjs.com/package/@form2js/jquery) | jQuery plugin adapter (`$.fn.toObject`) | Yes | Yes | Browser-focused |
## Installation
Install only what you need:
```bash
npm install @form2js/react react
npm install @form2js/dom
npm install @form2js/form-data
npm install @form2js/js2form
npm install @form2js/core
npm install @form2js/jquery jquery
```
For browser standalone usage, use script builds where available:
- `@form2js/dom`: `dist/standalone.global.js`
- `@form2js/jquery`: `dist/standalone.global.js`
## Usage
### `@form2js/dom`
HTML used in examples:
```html
```
Module:
```ts
import { formToObject } from "@form2js/dom";
const result = formToObject(document.getElementById("profileForm"));
// => { person: { name: { first: "Esme", last: "Weatherwax" }, tags: ["witch"] } }
```
Standalone:
```html
```
### `@form2js/form-data`
Module (browser or Node 18+):
```ts
import { formDataToObject } from "@form2js/form-data";
const fd = new FormData(formElement);
const result = formDataToObject(fd);
```
Node.js note:
- Node 18+ has global `FormData`.
- You can also pass iterable entries directly, which is handy in server pipelines:
```ts
import { entriesToObject } from "@form2js/form-data";
const result = entriesToObject([
["person.name.first", "Sam"],
["person.roles[]", "captain"],
]);
// => { person: { name: { first: "Sam" }, roles: ["captain"] } }
```
With schema validation (works with Zod or any `{ parse(unknown) }` schema):
```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 result = formDataToObject([["person.age", "42"]], {
schema: PersonSchema
});
// => { person: { age: 42 } }
```
Standalone:
- Not shipped for this package. Use module imports.
### `@form2js/react`
Module:
```ts
import { z } from "zod";
import { useForm2js } from "@form2js/react";
const FormDataSchema = z.object({
person: z.object({
name: z.object({
first: z.string().min(1)
})
})
});
export function ProfileForm(): React.JSX.Element {
const { onSubmit, isSubmitting, isError, error, isSuccess, reset } = useForm2js(
async (data) => {
// data is inferred from schema when schema is provided
await sendFormData(data);
},
{
schema: FormDataSchema
}
);
return (
);
}
```
### `@form2js/jquery`
HTML used in examples:
```html
```
Module:
```ts
import $ from "jquery";
import { installToObjectPlugin } from "@form2js/jquery";
installToObjectPlugin($);
const data = $("#profileForm").toObject({ mode: "first" });
// => { person: { name: { first: "Sam", last: "Vimes" } } }
```
Standalone:
```html
```
### `@form2js/js2form`
HTML used in examples (before calling `objectToForm`):
```html
```
Module:
```ts
import { objectToForm } from "@form2js/js2form";
objectToForm(document.getElementById("profileForm"), {
person: { name: { first: "Tiffany", last: "Aching" } },
});
// fields are now populated in the form
```
Standalone:
- Not shipped as a dedicated global bundle. Use module imports.
### `@form2js/core`
Module:
```ts
import { entriesToObject, objectToEntries } from "@form2js/core";
const data = entriesToObject([
{ key: "person.name.first", value: "Vimes" },
{ key: "person.tags[]", value: "watch" },
]);
const pairs = objectToEntries(data);
```
Node.js:
- Fully supported (no DOM dependency).
Standalone:
- Not shipped for this package. Use module imports.
## Legacy behavior notes
Compatibility with the old project is intentional.
- Name paths define output shape (`person.name.first`).
- Array and indexed syntax is preserved (`items[]`, `items[5].name`).
- Rails-style names are supported (`rails[field][value]`).
- DOM extraction follows native browser form submission semantics for checkbox and radio values.
- Unsafe key path segments (`__proto__`, `prototype`, `constructor`) are rejected by default.
- This library does data shaping, not JSON/XML serialization.
## Design boundaries and non-goals
These boundaries are intentional and are used for issue triage.
- Sparse indexes are compacted in first-seen order (`items[5]`, `items[8]` -> `items[0]`, `items[1]`).
- Type inference is minimal by design; DOM extraction keeps native string values instead of coercing checkbox/radio fields.
- Unchecked indexed controls are omitted and therefore do not reserve compacted array slots; include another submitted field when row identity matters.
- `formToObject` reads successful form control values, not option labels. Disabled controls (including disabled fieldset descendants) and button-like inputs are excluded unless you explicitly opt in to disabled values.
- `extractPairs`/`formToObject` support `nodeCallback`; return `SKIP_NODE` to exclude a node entirely, or `{ name|key, value }` to inject a custom entry.
- Parser inputs reject unsafe path segments by default. Use `allowUnsafePathSegments: true` only with trusted inputs.
- `objectToForm` supports `nodeCallback`; returning `false` skips the default assignment for that node.
- `objectToForm` sets form control state and values; it does not dispatch synthetic `change` or `input` events.
- Empty collections are not synthesized when no matching fields are present (for example, unchecked checkbox groups).
- Dynamic key/value remapping (for example, converting `key`/`val` fields into arbitrary object keys) is application logic.
- For file payloads and richer multipart semantics, use `FormData` and `@form2js/form-data`.
## Contributing
### Setup
```bash
npm ci
```
### Local checks
```bash
npm run lint
npm run typecheck
npm run test
npm run build
npm run pack:dry-run
```
### Local docs site
```bash
npm run docs
npm run docs:build
```
The homepage includes the unified playground for `@form2js/react`, `@form2js/dom`, `@form2js/jquery`, `@form2js/js2form`, `@form2js/core`, and `@form2js/form-data`.
### GitHub Pages docs site
The published docs site is deployed by `.github/workflows/pages.yml`.
- Trigger: pushes to `master` that touch `apps/docs/**`, `docs/**`, `packages/**`, `.github/workflows/pages.yml`, `package.json`, `package-lock.json`, `turbo.json`, or `tsconfig.base.json`, plus manual `workflow_dispatch`.
- Output: `apps/docs/dist`.
- URL: `https://maxatwork.github.io/form2js/`.
In repository settings, set Pages source to `GitHub Actions` once, and then the workflow handles updates.
### Before opening a PR
1. Keep changes focused to one problem area where possible.
2. Add or update tests for behavior changes.
3. Add a changeset (`npm run changeset`) for user-visible changes.
4. Include migration notes in README if behavior or API changes.
### Filing PRs and issues
Please include:
- Clear expected vs actual behavior.
- Minimal reproduction (HTML snippet or input entries).
- Package name and version.
- Environment (`node -v`, browser/version if relevant).
## Release workflow
- CI runs lint, typecheck, test, build, and package dry-run.
- Releases are managed with Changesets and the published `@form2js/*` packages are versioned in lockstep.
## Scope rewrite helper
Default scope is `@form2js/*`.
If you need to publish under another scope:
```bash
npm run scope:rewrite -- --scope @your-scope --dry-run
npm run scope:rewrite -- --scope @your-scope
```
This rewrites package names, internal dependencies, and import references.
## License
MIT, see `LICENSE`.
================================================
FILE: apps/docs/astro.config.mjs
================================================
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
const base = process.env.DOCS_BASE_PATH ?? "/";
export default defineConfig({
base,
integrations: [react()],
vite: {
server: {
fs: {
allow: ["../.."]
}
}
}
});
================================================
FILE: apps/docs/package.json
================================================
{
"name": "@form2js/docs",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"test": "vitest run",
"test:e2e": "playwright test",
"lint": "eslint \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\" \"test-e2e/**/*.ts\"",
"typecheck": "astro sync && tsc --noEmit",
"clean": "rimraf dist .astro"
},
"dependencies": {
"@astrojs/react": "^5.0.2",
"@form2js/core": "3.4.0",
"@form2js/dom": "3.4.0",
"@form2js/form-data": "3.4.0",
"@form2js/jquery": "3.4.0",
"@form2js/js2form": "3.4.0",
"@form2js/react": "3.4.0",
"astro": "^6.1.1",
"jquery": "^3.7.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"unified": "^11.0.5",
"zod": "^4.1.5"
},
"devDependencies": {
"@playwright/test": "^1.54.2",
"@types/jquery": "^3.5.33",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9"
}
}
================================================
FILE: apps/docs/playwright.config.ts
================================================
import { defineConfig, devices } from "@playwright/test";
const docsE2eHost = process.env.DOCS_E2E_HOST ?? "127.0.0.1";
const docsE2ePort = Number(process.env.DOCS_E2E_PORT ?? "4321");
const docsE2eUrl = `http://${docsE2eHost}:${docsE2ePort}/`;
export default defineConfig({
testDir: "./test-e2e",
use: {
baseURL: docsE2eUrl,
trace: "retain-on-failure"
},
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"]
}
}
],
webServer: {
command:
`PUBLIC_DOCS_E2E_FAULTS=1 npm -w @form2js/docs run build && npm -w @form2js/docs run preview -- --host ${docsE2eHost} --port ${docsE2ePort}`,
url: docsE2eUrl,
reuseExistingServer: !process.env.CI
}
});
================================================
FILE: apps/docs/src/components/api/ApiPackageNav.tsx
================================================
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 (
);
}
================================================
FILE: apps/docs/src/components/api/ApiPackageSummaryList.tsx
================================================
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) => (
{entry.summary}
))}
);
}
================================================
FILE: apps/docs/src/components/api/ApiToc.tsx
================================================
import React, { useEffect, useMemo, useState } from "react";
import type { ApiHeading } from "../../lib/api-docs-source";
interface ApiTocProps {
headings: ApiHeading[];
initialActiveSlug?: string;
}
interface TocGroup {
heading: ApiHeading;
children: ApiHeading[];
}
function groupHeadings(headings: ApiHeading[]): TocGroup[] {
const groups: TocGroup[] = [];
for (const heading of headings) {
if (heading.depth === 2 || groups.length === 0) {
groups.push({
heading,
children: []
});
continue;
}
groups[groups.length - 1]?.children.push(heading);
}
return groups;
}
export function ApiToc({ headings, initialActiveSlug }: ApiTocProps): React.JSX.Element {
const groups = useMemo(() => groupHeadings(headings), [headings]);
const [activeSlug, setActiveSlug] = useState(initialActiveSlug ?? headings[0]?.slug ?? "");
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const hashSlug = window.location.hash.replace(/^#/, "");
if (hashSlug) {
setActiveSlug(hashSlug);
}
const observedHeadings = headings
.map((heading) => document.getElementById(heading.slug))
.filter((heading): heading is HTMLElement => Boolean(heading));
if (observedHeadings.length === 0 || typeof IntersectionObserver === "undefined") {
return;
}
const observer = new IntersectionObserver(
(entries) => {
const visibleEntry = entries
.filter((entry) => entry.isIntersecting)
.sort((left, right) => right.intersectionRatio - left.intersectionRatio)[0];
if (visibleEntry?.target.id) {
setActiveSlug(visibleEntry.target.id);
}
},
{
rootMargin: "-20% 0px -60% 0px",
threshold: [0.2, 0.6, 1]
}
);
for (const heading of observedHeadings) {
observer.observe(heading);
}
const handleHashChange = (): void => {
const nextHashSlug = window.location.hash.replace(/^#/, "");
if (nextHashSlug) {
setActiveSlug(nextHashSlug);
}
};
window.addEventListener("hashchange", handleHashChange);
return () => {
observer.disconnect();
window.removeEventListener("hashchange", handleHashChange);
};
}, [headings]);
return (
);
}
================================================
FILE: apps/docs/src/components/landing/ApiDocsCta.astro
================================================
---
// apps/docs/src/components/landing/ApiDocsCta.astro
import { apiDocsPath } from "../../lib/site-routes";
const basePath = import.meta.env.BASE_URL;
---
Reference
API Documentation
Exact signatures, option defaults, TypeScript types, and compatibility
notes for every package — all generated from the same source of truth.
Open API Docs →
================================================
FILE: apps/docs/src/components/landing/Hero.astro
================================================
---
// apps/docs/src/components/landing/Hero.astro
import { apiDocsPath } from "../../lib/site-routes";
const basePath = import.meta.env.BASE_URL;
---
form serialization library
Turn forms
into objects.
Parse browser forms into structured JavaScript objects. Six adapters —
React hooks, vanilla DOM, jQuery, FormData, and more. One coherent API.
================================================
FILE: apps/docs/src/components/landing/InstallSection.astro
================================================
---
// apps/docs/src/components/landing/InstallSection.astro
---
Use in your project
Install
Pick the adapter for your stack. All packages share the same
path-syntax so switching is painless.
================================================
FILE: apps/docs/src/components/playground/PlaygroundShell.tsx
================================================
// apps/docs/src/components/playground/PlaygroundShell.tsx
import React, { useEffect, useState } from "react";
import { VARIANT_IDS, variantsById } from "./variant-registry";
import { ResultPanel } from "./ResultPanel";
import type { ErrorInfo, OutputState, VariantDefinition, VariantId } from "./types";
import { VariantHeader } from "./VariantHeader";
function getRequestedRenderFault(): { variantId: VariantId; message: string } | null {
if (typeof window === "undefined") return null;
const requestedFault = new URLSearchParams(window.location.search).get("__fault");
if (!requestedFault) return null;
const [requestedVariantId, source] = requestedFault.split(":");
if (source !== "render" || !VARIANT_IDS.includes(requestedVariantId as VariantId)) return null;
const variantId = requestedVariantId as VariantId;
return { variantId, message: `Injected render fault for ${variantsById[variantId].label}` };
}
function getActiveVariantId(): VariantId {
if (typeof window === "undefined") return "react";
const current = new URLSearchParams(window.location.search).get("variant");
if (current && VARIANT_IDS.includes(current as VariantId)) return current as VariantId;
return "react";
}
function createInitialOutputStates(
variantId: VariantId
): Partial> {
return { [variantId]: variantsById[variantId].createInitialOutputState() };
}
function dispatchVariantChange(variantId: VariantId): void {
if (typeof window === "undefined") return;
const variant = variantsById[variantId];
window.dispatchEvent(
new CustomEvent("form2js:variant-change", {
detail: { variantId, packages: variant.packages }
})
);
}
interface VariantErrorBoundaryProps {
children: React.ReactNode;
onError: (errorInfo: ErrorInfo) => void;
}
interface VariantErrorBoundaryState { hasError: boolean; }
class VariantErrorBoundary extends React.Component {
state: VariantErrorBoundaryState = { hasError: false };
static getDerivedStateFromError(): VariantErrorBoundaryState { return { hasError: true }; }
componentDidCatch(error: Error): void {
this.props.onError({ message: error.message, source: "render" });
}
render(): React.ReactNode {
if (this.state.hasError) return null;
return this.props.children;
}
}
export function PlaygroundShell(): React.JSX.Element {
const [activeVariantId, setActiveVariantId] = useState(() => getActiveVariantId());
const [mountedVariantIds, setMountedVariantIds] = useState(() => [getActiveVariantId()]);
const [outputStates, setOutputStates] = useState>>(() =>
createInitialOutputStates(getActiveVariantId())
);
const [failedVariants, setFailedVariants] = useState>>({});
useEffect(() => {
dispatchVariantChange(activeVariantId);
}, []);
useEffect(() => {
if (!mountedVariantIds.includes(activeVariantId)) {
setMountedVariantIds((current) => [...current, activeVariantId]);
}
setOutputStates((current) => {
if (current[activeVariantId]) return current;
return { ...current, [activeVariantId]: variantsById[activeVariantId].createInitialOutputState() };
});
}, [activeVariantId, mountedVariantIds]);
useEffect(() => {
const requestedFault = getRequestedRenderFault();
if (requestedFault?.variantId !== activeVariantId || failedVariants[activeVariantId] !== undefined) {
return;
}
handleVariantFailure(activeVariantId, { message: requestedFault.message, source: "render" });
}, [activeVariantId, failedVariants]);
const activeVariant = variantsById[activeVariantId];
const activeOutputState = outputStates[activeVariantId] ?? activeVariant.createInitialOutputState();
const variants = VARIANT_IDS.map((variantId) => variantsById[variantId]);
function handleVariantFailure(variantId: VariantId, errorInfo: ErrorInfo): void {
setFailedVariants((current) => ({ ...current, [variantId]: errorInfo }));
setOutputStates((current) => {
return Object.fromEntries(
Object.entries(current).filter(([currentVariantId]) => currentVariantId !== variantId)
) as Partial>;
});
}
function selectVariant(variantId: VariantId): void {
if (typeof window !== "undefined") {
const nextUrl = new URL(window.location.href);
nextUrl.searchParams.set("variant", variantId);
window.history.replaceState({}, "", `${nextUrl.pathname}${nextUrl.search}`);
dispatchVariantChange(variantId);
}
setActiveVariantId(variantId);
}
return (
{activeVariant.summary}
{mountedVariantIds.map((variantId) => {
const variant: VariantDefinition = variantsById[variantId];
const isActive = variantId === activeVariantId;
if (failedVariants[variantId]) return null;
return (
{
handleVariantFailure(variantId, errorInfo);
}}
>
{
setOutputStates((current) => ({ ...current, [variantId]: outputState }));
}}
reportFatalError={(errorInfo) => {
handleVariantFailure(variantId, errorInfo);
}}
/>
);
})}
{failedVariants[activeVariantId] && (
{activeVariant.label} failed to load.
{failedVariants[activeVariantId]?.message}
)}
);
}
================================================
FILE: apps/docs/src/components/playground/ReactInspectorPanel.tsx
================================================
import React from "react";
import type { ReactOutputState } from "./types";
interface ReactInspectorPanelProps {
outputState: ReactOutputState;
}
export function ReactInspectorPanel({ outputState }: ReactInspectorPanelProps): React.JSX.Element {
const metaEntries = outputState.meta ? Object.entries(outputState.meta) : [];
const hasParsedPayload = outputState.parsedPayload !== null;
return (
Submit state
{outputState.statusMessage}
isSubmitting: {String(outputState.submitFlags.isSubmitting)}
isError: {String(outputState.submitFlags.isError)}
isSuccess: {String(outputState.submitFlags.isSuccess)}
{metaEntries.length > 0 ? (
{metaEntries.map(([key, value]) => (
- {key}
- {value === null ? "null" : typeof value === "boolean" ? String(value) : value}
))}
) : null}
{hasParsedPayload ? {JSON.stringify(outputState.parsedPayload, null, 2)} : null}
{outputState.error ? {outputState.error.message}
: null}
);
}
================================================
FILE: apps/docs/src/components/playground/ResultPanel.tsx
================================================
// apps/docs/src/components/playground/ResultPanel.tsx
import React from "react";
import type { OutputState } from "./types";
interface ResultPanelProps {
outputState: OutputState;
}
function formatZodErrors(error: { message: string }): string[] {
// The error message from react-variant is pre-formatted as "path: msg\npath: msg"
return error.message.split("\n").filter(Boolean);
}
export function ResultPanel({ outputState }: ResultPanelProps): React.JSX.Element {
const statusClass = `status-${outputState.status}`;
if (outputState.kind === "react") {
const { submitFlags, error, parsedPayload, meta } = outputState;
const errorLines = error ? formatZodErrors(error) : [];
const metaEntries = meta ? Object.entries(meta) : [];
return (
Output
Submit state
{outputState.statusMessage}
isSubmitting
{String(submitFlags.isSubmitting)}
isError
{String(submitFlags.isError)}
isSuccess
{String(submitFlags.isSuccess)}
{metaEntries.length > 0 && (
{metaEntries.map(([key, value]) => (
- {key}
- {value === null ? "null" : typeof value === "boolean" ? String(value) : value}
))}
)}
{errorLines.length > 0 && (
{errorLines.map((line, i) => (
{line}
))}
)}
{parsedPayload !== null ? (
{JSON.stringify(parsedPayload, null, 2)}
) : (
{outputState.status === "idle" ? "Submit the form to see parsed output." : ""}
)}
);
}
// standard kind
const { errorMessage, parsedPayload, statusMessage } = outputState;
return (
Output
Parsed result
{statusMessage}
{errorMessage && (
)}
{parsedPayload !== null ? (
{JSON.stringify(parsedPayload, null, 2)}
) : (
{outputState.status === "idle" ? "Run the variant to see parsed output." : ""}
)}
);
}
================================================
FILE: apps/docs/src/components/playground/StandardResultPanel.tsx
================================================
import React from "react";
import type { StandardOutputState } from "./types";
interface StandardResultPanelProps {
outputState: StandardOutputState;
}
export function StandardResultPanel({ outputState }: StandardResultPanelProps): React.JSX.Element {
const hasParsedPayload = outputState.parsedPayload !== null;
return (
Parsed result
{outputState.statusMessage}
{outputState.errorMessage ? {outputState.errorMessage}
: null}
{hasParsedPayload ? {JSON.stringify(outputState.parsedPayload, null, 2)} : null}
);
}
================================================
FILE: apps/docs/src/components/playground/VariantHeader.tsx
================================================
// apps/docs/src/components/playground/VariantHeader.tsx
import React from "react";
import type { VariantDefinition, VariantId } from "./types";
interface VariantHeaderProps {
activeId: VariantId;
variants: VariantDefinition[];
onSelect: (variantId: VariantId) => void;
}
export function VariantHeader({ activeId, variants, onSelect }: VariantHeaderProps): React.JSX.Element {
return (
{variants.map((variant) => (
))}
);
}
================================================
FILE: apps/docs/src/components/playground/bootstrap/jquery-bootstrap.ts
================================================
import $ from "jquery";
import { installToObjectPlugin } from "@form2js/jquery";
type JQueryWithPlugin = typeof $ & {
fn: {
toObject?: unknown;
};
};
let installedPlugin: unknown = null;
export function ensureJqueryBootstrap(): unknown {
const jquery = $ as JQueryWithPlugin;
if (typeof jquery.fn.toObject !== "function") {
installToObjectPlugin(jquery);
}
installedPlugin = jquery.fn.toObject ?? null;
return installedPlugin;
}
================================================
FILE: apps/docs/src/components/playground/types.ts
================================================
import type { ReactNode } from "react";
export type VariantKind = "react" | "standard";
export type OutputStatus = "idle" | "running" | "success" | "error";
export type VariantId = "react" | "form" | "jquery" | "js2form" | "core" | "form-data";
export interface ErrorInfo {
message: string;
source: "render" | "effect" | "bootstrap" | "event" | "async";
detail?: string;
}
export interface ReactOutputState {
kind: "react";
status: OutputStatus;
statusMessage: string;
submitFlags: {
isSubmitting: boolean;
isError: boolean;
isSuccess: boolean;
};
error: { message: string; detail?: string } | null;
parsedPayload: unknown;
meta?: Record;
}
export interface StandardOutputState {
kind: "standard";
status: OutputStatus;
statusMessage: string;
errorMessage: string | null;
parsedPayload: unknown;
}
export type OutputState = ReactOutputState | StandardOutputState;
export interface VariantComponentProps {
isActive: boolean;
onOutputChange: (outputState: OutputState) => void;
reportFatalError: (errorInfo: ErrorInfo) => void;
}
export interface VariantDefinition {
id: VariantId;
kind: VariantKind;
label: string;
summary: string;
packages: string[];
createInitialOutputState: () => OutputState;
Component: (props: VariantComponentProps) => ReactNode;
}
================================================
FILE: apps/docs/src/components/playground/variant-registry.ts
================================================
import type { OutputState, VariantDefinition, VariantId } from "./types";
import { CoreVariant } from "./variants/core-variant";
import { FormDataVariant } from "./variants/form-data-variant";
import { FormVariant } from "./variants/form-variant";
import { JQueryVariant } from "./variants/jquery-variant";
import { Js2FormVariant } from "./variants/js2form-variant";
import { ReactVariant } from "./variants/react-variant";
function createStandardIdle(statusMessage: string): OutputState {
return {
kind: "standard",
status: "idle",
statusMessage,
errorMessage: null,
parsedPayload: null
};
}
function createReactIdle(statusMessage: string): OutputState {
return {
kind: "react",
status: "idle",
statusMessage,
submitFlags: {
isSubmitting: false,
isError: false,
isSuccess: false
},
error: null,
parsedPayload: null
};
}
export const variants = [
{
id: "react",
kind: "react",
label: "React",
summary: "Submit forms with schema-aware async state.",
packages: ["@form2js/react"],
createInitialOutputState: () => createReactIdle("Ready to submit.")
},
{
id: "form",
kind: "standard",
label: "Form",
summary: "Parse a plain browser form with @form2js/dom or form2js().",
packages: ["@form2js/dom"],
createInitialOutputState: () => createStandardIdle("Ready to parse the form.")
},
{
id: "jquery",
kind: "standard",
label: "jQuery",
summary: "Use the jQuery plugin adapter with selectable modes.",
packages: ["@form2js/jquery"],
createInitialOutputState: () => createStandardIdle("Ready to run the plugin.")
},
{
id: "js2form",
kind: "standard",
label: "js2form",
summary: "Apply object data back into form controls.",
packages: ["@form2js/js2form"],
createInitialOutputState: () => createStandardIdle("Ready to apply object data.")
},
{
id: "core",
kind: "standard",
label: "Core",
summary: "Parse raw key/value entries into nested objects.",
packages: ["@form2js/core"],
createInitialOutputState: () => createStandardIdle("Ready to parse entry data.")
},
{
id: "form-data",
kind: "standard",
label: "FormData",
summary: "Convert FormData-like entries into structured objects.",
packages: ["@form2js/form-data"],
createInitialOutputState: () => createStandardIdle("Ready to parse form data.")
}
] satisfies Omit[];
export const VARIANT_IDS = variants.map((variant) => variant.id) satisfies VariantId[];
const variantComponents: Record = {
react: ReactVariant,
form: FormVariant,
jquery: JQueryVariant,
js2form: Js2FormVariant,
core: CoreVariant,
"form-data": FormDataVariant
};
export const variantsById = Object.fromEntries(
variants.map((variant) => [
variant.id,
{
...variant,
Component: variantComponents[variant.id]
}
])
) as Record;
================================================
FILE: apps/docs/src/components/playground/variants/core-variant.tsx
================================================
// apps/docs/src/components/playground/variants/core-variant.tsx
import React, { useRef } from "react";
import { entriesToObject } from "@form2js/core";
import type { StandardOutputState, VariantComponentProps } from "../types";
const INITIAL_ENTRIES_JSON = `[
{ "key": "person.name.first", "value": "Moist" },
{ "key": "person.name.last", "value": "von Lipwig" },
{ "key": "person.city", "value": "ankh-morpork" },
{ "key": "person.guild", "value": "thieves" },
{ "key": "person.tags[]", "value": "crime" },
{ "key": "person.tags[]", "value": "banking" }
]`;
function createErrorState(message: string): StandardOutputState {
return { kind: "standard", status: "error", statusMessage: "core parse failed.", errorMessage: message, parsedPayload: null };
}
function createSuccessState(parsedPayload: unknown): StandardOutputState {
return { kind: "standard", status: "success", statusMessage: "@form2js/core -> entriesToObject(entry objects)", errorMessage: null, parsedPayload };
}
function formatVariantError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export function CoreVariant({ onOutputChange }: VariantComponentProps): React.JSX.Element {
const jsonInputRef = useRef(null);
function handleRun(): void {
const jsonInput = jsonInputRef.current;
if (!jsonInput) return;
let parsed: { key: string; value: unknown }[];
try {
parsed = JSON.parse(jsonInput.value) as { key: string; value: unknown }[];
} catch {
onOutputChange(createErrorState("JSON parse error: please provide valid entry-object JSON before parsing core entries."));
return;
}
try {
onOutputChange(createSuccessState(entriesToObject(parsed)));
} catch (error: unknown) {
onOutputChange(createErrorState(`Core conversion failed: ${formatVariantError(error)}`));
}
}
return (
);
}
================================================
FILE: apps/docs/src/components/playground/variants/form-data-variant.tsx
================================================
// apps/docs/src/components/playground/variants/form-data-variant.tsx
import React, { useRef } from "react";
import { formDataToObject } from "@form2js/form-data";
import type { StandardOutputState, VariantComponentProps } from "../types";
function createIdleState(): StandardOutputState {
return { kind: "standard", status: "idle", statusMessage: "Ready to parse form data.", errorMessage: null, parsedPayload: null };
}
function createSuccessState(parsedPayload: unknown): StandardOutputState {
return { kind: "standard", status: "success", statusMessage: "@form2js/form-data -> formDataToObject(form)", errorMessage: null, parsedPayload };
}
export function FormDataVariant({ onOutputChange }: VariantComponentProps): React.JSX.Element {
const formRef = useRef(null);
function handleSubmit(event: React.SyntheticEvent): void {
event.preventDefault();
const form = formRef.current;
if (!form) { onOutputChange(createIdleState()); return; }
onOutputChange(createSuccessState(formDataToObject(new FormData(form))));
}
function handleReset(): void {
formRef.current?.reset();
onOutputChange(createIdleState());
}
return (
);
}
================================================
FILE: apps/docs/src/components/playground/variants/form-variant.tsx
================================================
// apps/docs/src/components/playground/variants/form-variant.tsx
import React, { useRef } from "react";
import { form2js, formToObject } from "@form2js/dom";
import type { StandardOutputState, VariantComponentProps } from "../types";
function createIdleState(): StandardOutputState {
return { kind: "standard", status: "idle", statusMessage: "Ready to parse the form.", errorMessage: null, parsedPayload: null };
}
function createSuccessState(statusMessage: string, parsedPayload: unknown): StandardOutputState {
return { kind: "standard", status: "success", statusMessage, errorMessage: null, parsedPayload };
}
export function FormVariant({ onOutputChange }: VariantComponentProps): React.JSX.Element {
const formRef = useRef(null);
function handleSubmit(event: React.SyntheticEvent): void {
event.preventDefault();
const form = formRef.current;
if (!form) { onOutputChange(createIdleState()); return; }
onOutputChange(createSuccessState("@form2js/dom -> formToObject(form)", formToObject(form)));
}
function handleLegacyRun(): void {
const form = formRef.current;
if (!form) { onOutputChange(createIdleState()); return; }
onOutputChange(createSuccessState("@form2js/dom -> form2js(form)", form2js(form)));
}
function handleReset(): void {
formRef.current?.reset();
onOutputChange(createIdleState());
}
return (
);
}
================================================
FILE: apps/docs/src/components/playground/variants/jquery-variant.tsx
================================================
// apps/docs/src/components/playground/variants/jquery-variant.tsx
import React, { useEffect, useRef } from "react";
import $ from "jquery";
import { ensureJqueryBootstrap } from "../bootstrap/jquery-bootstrap";
import type { StandardOutputState, VariantComponentProps } from "../types";
type JQueryToObjectOptions = { mode?: "first" | "all" | "combine" };
type JQueryCollectionWithToObject = JQuery & {
toObject?: (options?: JQueryToObjectOptions) => unknown;
};
function createIdleState(): StandardOutputState {
return { kind: "standard", status: "idle", statusMessage: "Ready to run the plugin.", errorMessage: null, parsedPayload: null };
}
function createSuccessState(statusMessage: string, parsedPayload: unknown): StandardOutputState {
return { kind: "standard", status: "success", statusMessage, errorMessage: null, parsedPayload };
}
function getParsedPayload(
root: HTMLDivElement,
mode: "first" | "all" | "combine"
): unknown {
const collection = $(root).find(".jq-slice") as JQueryCollectionWithToObject;
return collection.toObject?.({ mode }) ?? null;
}
export function JQueryVariant({ onOutputChange }: VariantComponentProps): React.JSX.Element {
const sourceRef = useRef(null);
const modeRef = useRef(null);
useEffect(() => { ensureJqueryBootstrap(); }, []);
function handleRun(): void {
ensureJqueryBootstrap();
const root = sourceRef.current;
const mode = (modeRef.current?.value ?? "combine") as "first" | "all" | "combine";
if (!root) return;
const parsedPayload = getParsedPayload(root, mode);
onOutputChange(createSuccessState(`@form2js/jquery -> $(".jq-slice").toObject({ mode: "${mode}" })`, parsedPayload));
}
function handleReset(): void {
const root = sourceRef.current;
if (root) {
root.querySelectorAll("form").forEach((form) => {
form.reset();
});
}
onOutputChange(createIdleState());
}
return (
);
}
================================================
FILE: apps/docs/src/components/playground/variants/js2form-variant.tsx
================================================
// apps/docs/src/components/playground/variants/js2form-variant.tsx
import React, { useRef } from "react";
import { formToObject } from "@form2js/dom";
import { objectToForm } from "@form2js/js2form";
import type { StandardOutputState, VariantComponentProps } from "../types";
const INITIAL_JSON = `{
"person": {
"name": {
"first": "Tiffany",
"last": "Aching"
},
"city": "quirm",
"tags": ["witch"]
}
}`;
function createIdleState(): StandardOutputState {
return { kind: "standard", status: "idle", statusMessage: "Ready to apply object data.", errorMessage: null, parsedPayload: null };
}
function createErrorState(message: string): StandardOutputState {
return { kind: "standard", status: "error", statusMessage: "js2form apply failed.", errorMessage: message, parsedPayload: null };
}
function createSuccessState(parsedPayload: unknown): StandardOutputState {
return { kind: "standard", status: "success", statusMessage: "@form2js/js2form -> objectToForm(...), then formToObject(...)", errorMessage: null, parsedPayload };
}
function formatVariantError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export function Js2FormVariant({ onOutputChange }: VariantComponentProps): React.JSX.Element {
const formRef = useRef(null);
const jsonInputRef = useRef(null);
function handleApply(): void {
const form = formRef.current;
const jsonInput = jsonInputRef.current;
if (!form || !jsonInput) { onOutputChange(createIdleState()); return; }
let parsed: unknown;
try {
parsed = JSON.parse(jsonInput.value) as unknown;
} catch {
onOutputChange(createErrorState("JSON parse error: please provide valid JSON before applying js2form."));
return;
}
try {
objectToForm(form, parsed);
onOutputChange(createSuccessState(formToObject(form)));
} catch (error: unknown) {
onOutputChange(createErrorState(`js2form runtime error: ${formatVariantError(error)}`));
}
}
function handleReset(): void {
const form = formRef.current;
const jsonInput = jsonInputRef.current;
if (form) form.reset();
if (jsonInput) jsonInput.value = INITIAL_JSON;
onOutputChange(createIdleState());
}
return (
);
}
================================================
FILE: apps/docs/src/components/playground/variants/react-variant.tsx
================================================
// apps/docs/src/components/playground/variants/react-variant.tsx
import React, { useEffect, useRef, useState } from "react";
import { z } from "zod";
import { useForm2js } from "@form2js/react";
import type { ReactOutputState, VariantComponentProps } from "../types";
const SubmitPayloadSchema = z.object({
person: z.object({
name: z.object({
first: z.string().min(1, "First name is required."),
last: z.string().min(1, "Last name is required.")
}),
email: z.string().regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, "Email must be valid."),
age: z.coerce.number().int().min(18, "Age must be at least 18."),
guild: z.string().min(1, "Guild is required."),
interests: z.array(z.string()).default([])
})
});
type SubmitPayload = z.infer;
function sleep(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function formatError(error: unknown): string {
if (error instanceof z.ZodError) {
return error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`).join("\n");
}
if (error instanceof Error) return error.message;
return String(error);
}
function createOutputState(
isSubmitting: boolean,
isError: boolean,
isSuccess: boolean,
error: unknown,
lastSuccessfulPayload: SubmitPayload | null
): ReactOutputState {
let status: ReactOutputState["status"] = "idle";
let statusMessage = "Ready to submit.";
if (isSubmitting) { status = "running"; statusMessage = "Callback running"; }
else if (isError) { status = "error"; statusMessage = "Submit failed"; }
else if (isSuccess){ status = "success"; statusMessage = "Callback resolved"; }
return {
kind: "react",
status,
statusMessage,
submitFlags: { isSubmitting, isError, isSuccess },
error: isError ? { message: formatError(error) } : null,
parsedPayload: lastSuccessfulPayload,
meta: { submitMode: "onSubmit", validationEnabled: true }
};
}
export function ReactVariant({ onOutputChange }: VariantComponentProps): React.JSX.Element {
const [lastSuccessfulPayload, setLastSuccessfulPayload] = useState(null);
const onOutputChangeRef = useRef(onOutputChange);
const forceErrorRef = useRef(false);
const formRef = useRef(null);
const { onSubmit, isSubmitting, isError, error, isSuccess, reset } = useForm2js(
async (data: SubmitPayload) => {
await sleep(850);
if (forceErrorRef.current) {
forceErrorRef.current = false;
throw new Error("Simulated server error.");
}
setLastSuccessfulPayload(data);
},
{ schema: SubmitPayloadSchema }
);
useEffect(() => { onOutputChangeRef.current = onOutputChange; }, [onOutputChange]);
useEffect(() => {
onOutputChangeRef.current(createOutputState(isSubmitting, isError, isSuccess, error, lastSuccessfulPayload));
}, [error, isError, isSubmitting, isSuccess, lastSuccessfulPayload]);
useEffect(() => {
if (!isSubmitting && (isError || isSuccess)) {
forceErrorRef.current = false;
}
}, [isError, isSubmitting, isSuccess]);
function handleReset(): void {
forceErrorRef.current = false;
reset();
setLastSuccessfulPayload(null);
}
function handleForceError(): void {
const form = formRef.current;
if (!form) {
return;
}
if (!form.reportValidity()) {
forceErrorRef.current = false;
return;
}
forceErrorRef.current = true;
form.requestSubmit();
}
return (
);
}
================================================
FILE: apps/docs/src/env.d.ts
================================================
///
================================================
FILE: apps/docs/src/layouts/ApiDocsLayout.astro
================================================
---
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";
interface Props {
title: string;
introHtml: string;
bodyHtml: string;
headings: ApiHeading[];
packages: ApiPackageEntry[];
activePackageSlug?: ApiPackageEntry["slug"];
}
const { title, introHtml, bodyHtml, headings, packages, activePackageSlug } =
Astro.props;
const basePath = import.meta.env.BASE_URL;
---
{headings.length > 0 ? (
) : null}
================================================
FILE: apps/docs/src/layouts/DesignShell.astro
================================================
---
// apps/docs/src/layouts/DesignShell.astro
import { apiDocsPath } from "../lib/site-routes";
import "../styles/landing.css";
import "../styles/playground.css";
interface Props {
title?: string;
theme: "dark-brutalism" | "outrun-sunset" | "terminal-noir" | "linear-dark" | "editorial";
}
const { title = "form2js", theme } = Astro.props;
const basePath = import.meta.env.BASE_URL;
const THEME_FONTS: Record = {
"dark-brutalism":
"https://fonts.googleapis.com/css2?family=Syne:wght@400;700;800;900&family=DM+Mono:wght@300;400;500&display=swap",
"outrun-sunset":
"https://fonts.googleapis.com/css2?family=Dela+Gothic+One&family=Syne:wght@700;900&family=DM+Mono:wght@300;400;500&display=swap",
"terminal-noir":
"https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap",
"linear-dark":
"https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700;900&family=DM+Mono:wght@300;400;500&display=swap",
editorial:
"https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@300;400;500&display=swap",
};
const fontsHref = THEME_FONTS[theme];
---
{title} — form2js
{fontsHref && }
================================================
FILE: apps/docs/src/layouts/DocsShell.astro
================================================
---
import "../styles/global.css";
import { homepagePath, migrationGuidePath } from "../lib/site-routes";
interface Props {
title?: string;
}
const { title = "form2js" } = Astro.props;
const basePath = import.meta.env.BASE_URL;
---
{title}
================================================
FILE: apps/docs/src/lib/api-docs-source.ts
================================================
import { readFile } from "node:fs/promises";
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 {
apiIndexMarkdownPath,
getApiPackageByMarkdownBasename
} from "./api-packages";
import {
apiDocsPath,
apiPackageDocsPath,
homepagePath,
migrationGuidePath
} from "./site-routes";
export interface ApiHeading {
depth: 2 | 3;
slug: string;
text: string;
}
export interface ApiDocsSource {
title: string;
introMarkdown: string;
introHtml: string;
bodyMarkdown: string;
bodyHtml: string;
headings: ApiHeading[];
}
interface ParseOptions {
basePath: string;
}
type MarkdownNode = {
type: string;
url?: string;
depth?: number;
value?: string;
children?: MarkdownNode[];
position?: {
start?: { offset?: number };
end?: { offset?: number };
};
data?: Record;
};
function slugify(text: string): string {
const slug = text
.toLowerCase()
.replace(/[`"]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.replace(/-{2,}/g, "-");
return slug || "section";
}
function rewriteMarkdownLink(url: string, basePath: string): string {
if (
url.startsWith("#") ||
url.startsWith("http://") ||
url.startsWith("https://") ||
url.startsWith("mailto:")
) {
return url;
}
const [pathname, hash] = url.split("#");
const suffix = hash ? `#${hash}` : "";
const normalizedPathname = pathname
.replace(/^(?:\.\.\/)?docs\//, "")
.replace(/^\.\//, "");
if (
normalizedPathname === "README.md" ||
pathname === "../README.md"
) {
return `${homepagePath(basePath)}${suffix}`;
}
if (normalizedPathname === "migrate.md") {
return `${migrationGuidePath(basePath)}${suffix}`;
}
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;
}
function visit(
node: MarkdownNode,
callback: (currentNode: MarkdownNode) => void
): void {
callback(node);
for (const child of node.children ?? []) {
visit(child, callback);
}
}
function collectText(node: MarkdownNode): string {
if (node.type === "text" || node.type === "inlineCode") {
return node.value ?? "";
}
return (node.children ?? []).map((child) => collectText(child)).join("");
}
function renderMarkdownNodes(nodes: MarkdownNode[]): string {
if (nodes.length === 0) {
return "";
}
const processor = unified().use(remarkRehype).use(rehypeStringify);
const htmlTree = processor.runSync({
type: "root",
children: nodes,
} as never);
return processor.stringify(htmlTree).trim();
}
function extractMarkdownSlice(
markdown: string,
startOffset: number,
endOffset?: number
): string {
return markdown.slice(startOffset, endOffset).trim();
}
export function parseApiDocsMarkdown(
markdown: string,
options: ParseOptions
): ApiDocsSource {
const parser = unified().use(remarkParse).use(remarkGfm);
const tree = parser.parse(markdown) as MarkdownNode;
const rootChildren = tree.children ?? [];
const titleNode = rootChildren[0];
if (titleNode?.type !== "heading" || titleNode.depth !== 1) {
throw new Error("API docs markdown must start with an H1 heading.");
}
const title = collectText(titleNode);
const contentNodes = rootChildren.slice(1);
const firstBodyNodeIndex = contentNodes.findIndex(
(node) => node.type === "heading" && node.depth === 2
);
const introNodes =
firstBodyNodeIndex === -1
? contentNodes
: contentNodes.slice(0, firstBodyNodeIndex);
const bodyNodes =
firstBodyNodeIndex === -1 ? [] : contentNodes.slice(firstBodyNodeIndex);
const introStartOffset = titleNode.position?.end?.offset ?? 0;
const bodyStartOffset = bodyNodes[0]?.position?.start?.offset;
const introMarkdown = extractMarkdownSlice(
markdown,
introStartOffset,
bodyStartOffset
);
const bodyMarkdown =
bodyStartOffset === undefined
? ""
: extractMarkdownSlice(markdown, bodyStartOffset);
const headings: ApiHeading[] = [];
const slugCounts = new Map();
for (const node of [...introNodes, ...bodyNodes]) {
visit(node, (currentNode) => {
if (currentNode.type === "link" && currentNode.url) {
currentNode.url = rewriteMarkdownLink(
currentNode.url,
options.basePath
);
}
if (
currentNode.type !== "heading" ||
(currentNode.depth !== 2 && currentNode.depth !== 3)
) {
return;
}
if (!bodyNodes.includes(node)) {
return;
}
const text = collectText(currentNode);
const baseSlug = slugify(text);
const nextCount = (slugCounts.get(baseSlug) ?? 0) + 1;
slugCounts.set(baseSlug, nextCount);
const slug = nextCount === 1 ? baseSlug : `${baseSlug}-${nextCount}`;
currentNode.data = {
...(currentNode.data ?? {}),
hProperties: {
id: slug,
},
};
headings.push({
depth: currentNode.depth,
slug,
text,
});
});
}
return {
title,
introMarkdown,
introHtml: renderMarkdownNodes(introNodes),
bodyMarkdown,
bodyHtml: renderMarkdownNodes(bodyNodes),
headings,
};
}
export async function loadApiDocsSource(
options: { basePath?: string; markdownPath?: string } = {}
): Promise {
const markdownPath = options.markdownPath ?? apiIndexMarkdownPath;
const markdown = await readFile(markdownPath, "utf8");
return parseApiDocsMarkdown(markdown, {
basePath: options.basePath ?? "/",
});
}
================================================
FILE: apps/docs/src/lib/api-packages.ts
================================================
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
);
}
================================================
FILE: apps/docs/src/lib/site-routes.ts
================================================
function normalizeBase(basePath: string): string {
if (basePath === "/") {
return "/";
}
const withLeadingSlash = basePath.startsWith("/") ? basePath : `/${basePath}`;
return withLeadingSlash.endsWith("/") ? withLeadingSlash : `${withLeadingSlash}/`;
}
export function homepagePath(basePath: string): string {
return normalizeBase(basePath);
}
export function migrationGuidePath(basePath: string): string {
return `${normalizeBase(basePath)}migrate/`;
}
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)}`;
}
================================================
FILE: apps/docs/src/pages/api/[package].astro
================================================
---
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
});
---
================================================
FILE: apps/docs/src/pages/api/index.astro
================================================
---
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({
basePath: import.meta.env.BASE_URL
});
---
================================================
FILE: apps/docs/src/pages/index.astro
================================================
---
// apps/docs/src/pages/1.astro
import "../styles/themes/dark-brutalism.css";
import DesignShell from "../layouts/DesignShell.astro";
import Hero from "../components/landing/Hero.astro";
import InstallSection from "../components/landing/InstallSection.astro";
import ApiDocsCta from "../components/landing/ApiDocsCta.astro";
import { PlaygroundShell } from "../components/playground/PlaygroundShell";
---
================================================
FILE: apps/docs/src/pages/migrate.astro
================================================
---
import path from "node:path";
import { ApiToc } from "../components/api/ApiToc";
import { loadApiDocsSource } from "../lib/api-docs-source";
import DocsShell from "../layouts/DocsShell.astro";
const migrationSource = await loadApiDocsSource({
basePath: import.meta.env.BASE_URL,
markdownPath: path.resolve(process.cwd(), "..", "..", "docs", "migrate.md")
});
---
================================================
FILE: apps/docs/src/styles/global.css
================================================
:root {
color-scheme: dark;
--bg: #0c0e14;
--panel: #12151e;
--panel-2: #1a1d2b;
--text: #e4e6ef;
--muted: #8b8fa3;
--accent: #00e5c8;
--border: rgba(255, 255, 255, 0.08);
--mono: "JetBrains Mono", "Fira Code", monospace;
--ui: "Inter", "Segoe UI", sans-serif;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--ui);
}
a {
color: inherit;
}
.site-shell {
min-height: 100vh;
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.site-brand {
display: inline-flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
}
.site-brand__logo {
padding: 0.2rem 0.5rem;
border: 1px solid rgba(0, 229, 200, 0.2);
border-radius: 0.4rem;
color: var(--accent);
background: rgba(0, 229, 200, 0.08);
font-family: var(--mono);
}
.site-brand__name {
font-weight: 600;
}
.site-nav {
display: inline-flex;
gap: 1rem;
}
.site-nav a {
color: var(--muted);
text-decoration: none;
}
.site-nav a:hover,
.site-nav a:focus-visible {
color: var(--text);
}
.hero {
width: min(72rem, calc(100% - 3rem));
margin: 0 auto;
padding: 4rem 0;
}
.hero__eyebrow {
margin: 0 0 0.75rem;
color: var(--accent);
font-family: var(--mono);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.hero h1 {
margin: 0;
font-size: clamp(2.5rem, 8vw, 5rem);
}
.hero p:last-child {
max-width: 42rem;
color: var(--muted);
line-height: 1.6;
}
.api-docs {
width: min(96rem, calc(100% - 3rem));
margin: 0 auto;
padding: 3rem 0 4rem;
display: grid;
grid-template-columns: 16rem minmax(0, 1fr) 18rem;
gap: 2rem;
}
.api-docs--content-sidebar {
grid-template-columns: minmax(0, 1fr) 18rem;
}
.api-docs__nav,
.api-docs__sidebar {
position: sticky;
top: 1rem;
align-self: start;
}
.api-docs__content {
min-width: 0;
}
.api-docs__header h1 {
margin: 0;
font-size: clamp(2rem, 6vw, 3.75rem);
}
.api-docs__intro,
.api-docs__body {
color: var(--muted);
line-height: 1.75;
}
.api-docs__intro {
margin-top: 1.5rem;
}
.api-docs__body {
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);
scroll-margin-top: 6rem;
}
.api-docs__body pre {
overflow-x: auto;
padding: 1rem 1.25rem;
border: 1px solid var(--border);
border-radius: 1rem;
background: var(--panel);
}
.api-docs__body code {
font-family: var(--mono);
}
.api-docs__body table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
}
.api-docs__body th,
.api-docs__body td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.api-package-nav {
padding: 1rem;
}
.api-package-nav__eyebrow,
.api-toc__eyebrow {
margin: 0 0 0.75rem;
color: var(--accent);
font-family: var(--mono);
text-transform: uppercase;
letter-spacing: 0.08em;
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;
padding: 0;
list-style: none;
}
.api-toc__list {
display: grid;
gap: 0.75rem;
}
.api-toc__sublist {
margin-top: 0.5rem;
padding-left: 0.75rem;
display: grid;
gap: 0.4rem;
}
.api-toc__link,
.api-toc__sublink {
color: var(--muted);
text-decoration: none;
}
.api-toc__link[aria-current="true"],
.api-toc__sublink[aria-current="true"] {
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__nav {
position: static;
order: -1;
}
.api-docs__sidebar {
position: static;
}
}
================================================
FILE: apps/docs/src/styles/landing.css
================================================
/* apps/docs/src/styles/landing.css */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
min-height: 100%;
}
body {
background: var(--c-bg);
color: var(--c-text);
font-family: var(--f-ui);
}
a {
color: inherit;
}
/* ── Nav ── */
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2.5rem;
border-bottom: 1px solid var(--c-border);
}
.site-brand {
display: inline-flex;
align-items: center;
gap: 0.6rem;
text-decoration: none;
color: var(--c-text);
font-family: var(--f-display);
font-weight: 700;
font-size: 1rem;
}
.site-brand__badge {
padding: 0.15rem 0.5rem;
background: var(--c-accent-glow);
border: 1px solid color-mix(in srgb, var(--c-accent) 35%, transparent);
color: var(--c-accent);
font-family: var(--f-mono);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.03em;
}
.site-nav {
display: flex;
align-items: center;
gap: 1.5rem;
}
.site-nav a {
color: var(--c-muted);
text-decoration: none;
font-size: 0.85rem;
transition: color 0.15s;
}
.site-nav a:hover {
color: var(--c-text);
}
/* ── Hero ── */
.hero {
width: min(76rem, calc(100% - 3rem));
margin: 0 auto;
padding: 5rem 0 3.5rem;
}
.hero__eyebrow {
font-family: var(--f-mono);
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--c-accent);
margin-bottom: 1.25rem;
}
.hero__title {
font-family: var(--f-display);
font-size: clamp(3rem, 9vw, 6rem);
font-weight: 900;
line-height: 0.92;
letter-spacing: -0.02em;
color: var(--c-text);
margin-bottom: 1.25rem;
}
.hero__desc {
font-size: 0.95rem;
color: var(--c-muted);
line-height: 1.7;
max-width: 48ch;
margin-bottom: 2rem;
font-family: var(--f-ui);
}
.hero__actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
align-items: center;
}
.hero__install {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.9rem;
background: var(--c-panel);
border: 1px solid var(--c-border);
font-family: var(--f-mono);
font-size: 0.8rem;
color: var(--c-text);
}
.hero__install-prefix {
color: var(--c-muted);
}
.btn-primary {
display: inline-block;
padding: 0.55rem 1.5rem;
background: var(--c-accent);
color: var(--c-bg);
font-family: var(--f-ui);
font-size: 0.85rem;
font-weight: 700;
text-decoration: none;
border: none;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-primary:hover {
opacity: 0.85;
}
.btn-ghost {
display: inline-block;
padding: 0.55rem 1.5rem;
background: transparent;
color: var(--c-muted);
font-family: var(--f-ui);
font-size: 0.85rem;
font-weight: 600;
text-decoration: none;
border: 1px solid var(--c-border);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.btn-ghost:hover {
color: var(--c-text);
border-color: var(--c-text);
}
/* ── Page sections ── */
.content-wrap {
width: min(76rem, calc(100% - 3rem));
margin: 0 auto;
}
.section {
padding: 3rem 0;
border-top: 1px solid var(--c-border);
}
.section-eyebrow {
font-family: var(--f-mono);
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--c-accent);
margin-bottom: 0.75rem;
}
.section-title {
font-family: var(--f-display);
font-size: clamp(1.5rem, 4vw, 2.5rem);
font-weight: 900;
color: var(--c-text);
margin-bottom: 0.75rem;
}
.section-desc {
font-size: 0.9rem;
color: var(--c-muted);
line-height: 1.65;
max-width: 54ch;
margin-bottom: 1.5rem;
}
/* ── Install section ── */
.install-grid {
display: grid;
grid-template-columns: 1fr 1.4fr;
gap: 3rem;
align-items: start;
}
@media (max-width: 680px) {
.install-grid {
grid-template-columns: 1fr;
}
.hero {
padding: 3rem 0 2.5rem;
}
.site-header {
padding: 1rem 1.25rem;
}
}
.code-block {
background: var(--c-panel-2);
border: 1px solid var(--c-border);
padding: 1.25rem 1.5rem;
font-family: var(--f-mono);
font-size: 0.78rem;
line-height: 1.65;
overflow-x: auto;
color: var(--c-muted);
}
.code-block .kw {
color: var(--c-accent);
}
.code-block .fn {
color: var(--c-accent-2);
}
.code-block .str {
color: var(--c-text);
opacity: 0.7;
}
.code-block .cmt {
color: var(--c-muted);
font-style: italic;
}
.install-cmd {
font-family: var(--f-mono);
font-size: 0.82rem;
color: var(--c-accent);
margin-bottom: 0.5rem;
display: block;
}
.install-cmd::before {
content: "$ ";
color: var(--c-muted);
}
================================================
FILE: apps/docs/src/styles/playground.css
================================================
/* apps/docs/src/styles/playground.css */
/* ── Shell ── */
.pg-shell {
border: 1px solid var(--c-border);
overflow: hidden;
}
/* ── Tab bar ── */
.pg-tabs {
display: flex;
border-bottom: 1px solid var(--c-border);
background: var(--c-panel);
overflow-x: auto;
scrollbar-width: none;
}
.pg-tabs::-webkit-scrollbar { display: none; }
.pg-tab-btn {
padding: 0.6rem 1rem;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
color: var(--c-muted);
font-family: var(--f-mono);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.06em;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: color 0.15s;
}
.pg-tab-btn[aria-pressed="true"] {
color: var(--c-accent);
border-bottom-color: var(--c-accent);
}
.pg-tab-btn:hover:not([aria-pressed="true"]) { color: var(--c-text); }
/* ── Two-column split ── */
.pg-body {
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 340px;
}
@media (max-width: 760px) {
.pg-body { grid-template-columns: 1fr; }
.pg-form-col { border-right: none; }
}
.pg-form-col {
padding: 1.5rem;
border-right: 1px solid var(--c-border);
background: var(--c-panel);
overflow-y: auto;
}
.pg-output-col {
padding: 1.5rem;
background: var(--c-panel-2);
overflow-y: auto;
}
/* ── Form fields ── */
.pg-field { margin-bottom: 1rem; }
.pg-label {
display: block;
font-family: var(--f-mono);
font-size: 0.7rem;
color: var(--c-muted);
margin-bottom: 0.3rem;
letter-spacing: 0.05em;
}
.pg-input,
.pg-select,
.pg-textarea {
width: 100%;
padding: 0.4rem 0.6rem;
background: var(--c-bg);
border: 1px solid var(--c-border);
color: var(--c-text);
font-family: var(--f-mono);
font-size: 0.8rem;
outline: none;
transition: border-color 0.15s;
-webkit-appearance: none;
}
.pg-input:focus,
.pg-select:focus,
.pg-textarea:focus { border-color: var(--c-accent); }
.pg-textarea {
min-height: 220px;
resize: vertical;
line-height: 1.55;
}
.pg-fieldset {
border: 1px solid var(--c-border);
padding: 0.6rem 0.9rem 0.6rem;
margin-bottom: 1rem;
}
.pg-fieldset legend {
font-family: var(--f-mono);
font-size: 0.7rem;
color: var(--c-muted);
padding: 0 0.3rem;
letter-spacing: 0.05em;
}
.pg-check-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-family: var(--f-mono);
font-size: 0.78rem;
color: var(--c-text);
margin: 0.35rem 0;
cursor: pointer;
}
/* ── Buttons ── */
.pg-btns {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 1.25rem;
}
.pg-btn {
padding: 0.4rem 1rem;
background: var(--c-accent);
color: var(--c-bg);
font-family: var(--f-mono);
font-size: 0.73rem;
font-weight: 700;
letter-spacing: 0.06em;
border: none;
cursor: pointer;
transition: opacity 0.15s;
}
.pg-btn:hover { opacity: 0.82; }
.pg-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.pg-btn-secondary {
background: transparent;
color: var(--c-muted);
border: 1px solid var(--c-border);
}
.pg-btn-secondary:hover { color: var(--c-text); border-color: var(--c-text); opacity: 1; }
.pg-btn-danger {
background: transparent;
color: #ff5555;
border: 1px solid rgba(255, 85, 85, 0.3);
}
.pg-btn-danger:hover { border-color: #ff5555; opacity: 1; }
.pg-btn-danger:disabled { opacity: 0.35; cursor: not-allowed; }
/* ── Output panel (ResultPanel) ── */
.result-eyebrow {
font-family: var(--f-mono);
font-size: 0.65rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--c-muted);
margin-bottom: 1rem;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: var(--f-mono);
font-size: 0.73rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 1rem;
}
.status-badge::before {
content: '';
display: block;
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.status-idle { color: var(--c-muted); }
.status-running { color: var(--c-accent); animation: badge-pulse 1s steps(1) infinite; }
.status-success { color: #22c55e; }
.status-error { color: #ff5555; }
@keyframes badge-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.result-flags {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.2rem 0.75rem;
font-family: var(--f-mono);
font-size: 0.73rem;
margin-bottom: 1rem;
align-items: baseline;
}
.flag-key { color: var(--c-muted); }
.flag-true { color: var(--c-accent); }
.flag-false { color: var(--c-muted); opacity: 0.5; }
.result-errors {
margin-bottom: 1rem;
padding: 0.75rem 0.9rem;
background: rgba(255, 85, 85, 0.06);
border: 1px solid rgba(255, 85, 85, 0.2);
}
.result-error-item {
font-family: var(--f-mono);
font-size: 0.73rem;
color: #ff7777;
margin: 0.2rem 0;
}
.result-json {
font-family: var(--f-mono);
font-size: 0.76rem;
color: var(--c-text);
background: var(--c-panel);
border: 1px solid var(--c-border);
padding: 0.75rem 1rem;
overflow-x: auto;
white-space: pre;
line-height: 1.55;
}
.result-msg {
font-family: var(--f-mono);
font-size: 0.78rem;
color: var(--c-muted);
line-height: 1.5;
}
.result-empty {
font-family: var(--f-mono);
font-size: 0.73rem;
color: var(--c-muted);
opacity: 0.5;
font-style: italic;
}
.pg-variant-summary {
font-family: var(--f-mono);
font-size: 0.72rem;
color: var(--c-muted);
margin-bottom: 1rem;
line-height: 1.5;
}
================================================
FILE: apps/docs/src/styles/themes/dark-brutalism.css
================================================
/* apps/docs/src/styles/themes/dark-brutalism.css */
:root {
color-scheme: dark;
--c-bg: #0c0c0c;
--c-panel: #111111;
--c-panel-2: #0a0a0a;
--c-text: #ffffff;
--c-muted: #555555;
--c-accent: #c6ff00;
--c-accent-2: #c6ff00;
--c-accent-glow: rgba(198, 255, 0, 0.18);
--c-border: #1e1e1e;
--f-ui: 'Syne', sans-serif;
--f-mono: 'DM Mono', 'Fira Code', monospace;
--f-display: 'Syne', sans-serif;
}
================================================
FILE: apps/docs/src/styles/themes/editorial.css
================================================
/* apps/docs/src/styles/themes/editorial.css */
:root {
color-scheme: light;
--c-bg: #faf9f6;
--c-panel: #f0ece0;
--c-panel-2: #1a1a1a;
--c-text: #1a1a1a;
--c-muted: #888888;
--c-accent: #ff6600;
--c-accent-2: #1a1a1a;
--c-accent-glow: transparent;
--c-border: #cccccc;
--f-ui: 'Libre Baskerville', Georgia, serif;
--f-mono: 'DM Mono', 'Fira Code', monospace;
--f-display: 'Bebas Neue', 'Arial Black', sans-serif;
}
================================================
FILE: apps/docs/src/styles/themes/linear-dark.css
================================================
/* apps/docs/src/styles/themes/linear-dark.css */
:root {
color-scheme: dark;
--c-bg: #060609;
--c-panel: rgba(255, 255, 255, 0.025);
--c-panel-2: rgba(255, 255, 255, 0.04);
--c-text: #ffffff;
--c-muted: rgba(255, 255, 255, 0.4);
--c-accent: #5e56ff;
--c-accent-2: #9747ff;
--c-accent-glow: rgba(94, 86, 255, 0.22);
--c-border: rgba(255, 255, 255, 0.07);
--f-ui: 'Geist', 'Segoe UI', sans-serif;
--f-mono: 'DM Mono', 'Fira Code', monospace;
--f-display: 'Geist', 'Segoe UI', sans-serif;
}
================================================
FILE: apps/docs/src/styles/themes/outrun-sunset.css
================================================
/* apps/docs/src/styles/themes/outrun-sunset.css */
:root {
color-scheme: dark;
--c-bg: #050a1a;
--c-panel: #080f26;
--c-panel-2: #060c1e;
--c-text: #ffffff;
--c-muted: rgba(255, 255, 255, 0.35);
--c-accent: #ff8c00;
--c-accent-2: #ff2d55;
--c-accent-glow: rgba(255, 100, 0, 0.22);
--c-border: rgba(255, 100, 0, 0.2);
--f-ui: 'Syne', sans-serif;
--f-mono: 'DM Mono', 'Fira Code', monospace;
--f-display: 'Dela Gothic One', 'Syne', sans-serif;
}
================================================
FILE: apps/docs/src/styles/themes/terminal-noir.css
================================================
/* apps/docs/src/styles/themes/terminal-noir.css */
:root {
color-scheme: dark;
--c-bg: #030403;
--c-panel: #040c04;
--c-panel-2: #020802;
--c-text: #22ff44;
--c-muted: #2a6b2a;
--c-accent: #22ff44;
--c-accent-2: #22ff44;
--c-accent-glow: rgba(34, 255, 68, 0.18);
--c-border: #0f2e0f;
--f-ui: 'Space Mono', monospace;
--f-mono: 'Space Mono', monospace;
--f-display: 'Space Mono', monospace;
}
================================================
FILE: apps/docs/test/api-docs-page.test.tsx
================================================
// @vitest-environment jsdom
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(
);
expect(markup).toContain("On this page");
expect(markup).toContain('href="#package-index"');
expect(markup).toContain('href="#common-tasks"');
expect(markup).toContain('aria-current="true"');
});
});
================================================
FILE: apps/docs/test/api-docs-source.test.ts
================================================
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { parseApiDocsMarkdown } from "../src/lib/api-docs-source";
const testDir = path.dirname(fileURLToPath(import.meta.url));
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 migrationMarkdown = readFileSync(path.resolve(testDir, "../../../docs/migrate.md"), "utf8");
const readmeMarkdown = readFileSync(path.resolve(testDir, "../../../README.md"), "utf8");
describe("parseApiDocsMarkdown", () => {
it("extracts the H1 title, intro copy, headings, and rewrites package markdown links", () => {
const source = parseApiDocsMarkdown(
`# React API
Intro with [index](api-index.md).
## Installation
Text with [core](api-core.md).
### npm
More text.
`,
{ basePath: "/form2js/" }
);
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: "installation", text: "Installation" },
{ depth: 3, slug: "npm", text: "npm" }
]);
});
it("returns an empty intro when the first section starts immediately after the title", () => {
const source = parseApiDocsMarkdown(
`# API Title
## Section
Text.
`,
{ basePath: "/" }
);
expect(source.introMarkdown).toBe("");
expect(source.introHtml).toBe("");
});
it("deduplicates repeated heading slugs", () => {
const source = parseApiDocsMarkdown(
`# API Title
Intro
## Repeated Name
### Repeated Name
## Repeated Name
`,
{ basePath: "/" }
);
expect(source.headings).toEqual([
{ depth: 2, slug: "repeated-name", text: "Repeated Name" },
{ depth: 3, slug: "repeated-name-2", text: "Repeated Name" },
{ depth: 2, slug: "repeated-name-3", text: "Repeated Name" }
]);
});
it("throws when the markdown does not start with an H1", () => {
expect(() =>
parseApiDocsMarkdown(
`## Missing title
Text.
`,
{ basePath: "/" }
)
).toThrow("API docs markdown must start with an H1 heading.");
});
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)");
});
it("parses the migration guide markdown and rewrites package links", () => {
const source = parseApiDocsMarkdown(migrationMarkdown, {
basePath: "/form2js/"
});
expect(source.title).toBe("Migrate from Legacy form2js");
expect(source.introMarkdown).toContain("single `form2js` script");
expect(source.bodyHtml).toContain('href="/form2js/api/dom/"');
expect(source.bodyHtml).toContain('href="/form2js/api/jquery/"');
expect(source.bodyHtml).toContain('href="/form2js/api/form-data/"');
expect(source.headings).toEqual(
expect.arrayContaining([
expect.objectContaining({ slug: "quick-chooser", text: "Quick Chooser" }),
expect.objectContaining({ slug: "legacy-api-mapping", text: "Legacy API Mapping" }),
expect.objectContaining({ slug: "where-to-go-now", text: "Where To Go Now" })
])
);
});
});
================================================
FILE: apps/docs/test/data-variants.test.tsx
================================================
// apps/docs/test/data-variants.test.tsx
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PlaygroundShell } from "../src/components/playground/PlaygroundShell";
import type { OutputState, VariantComponentProps } from "../src/components/playground/types";
import { CoreVariant } from "../src/components/playground/variants/core-variant";
import { FormDataVariant } from "../src/components/playground/variants/form-data-variant";
declare global {
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
interface RenderResult {
container: HTMLDivElement;
root: ReturnType;
getLastOutputState: () => OutputState | null;
}
function renderVariant(Component: (props: VariantComponentProps) => React.ReactNode): RenderResult {
let lastOutputState: OutputState | null = null;
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
act(() => {
root.render(
{ lastOutputState = outputState; }}
reportFatalError={(errorInfo) => { throw new Error(`Unexpected fatal error: ${errorInfo.message}`); }}
/>
);
});
return { container, root, getLastOutputState: () => lastOutputState };
}
describe("parser playground variants", () => {
beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; });
afterEach(() => {
document.body.innerHTML = "";
vi.restoreAllMocks();
});
it("runs the core variant from seeded entry objects (Moist von Lipwig)", () => {
const view = renderVariant(CoreVariant);
const jsonInput = view.container.querySelector('textarea[name="core-entries-json"]');
const runButton = [...view.container.querySelectorAll("button")].find((button) =>
button.textContent?.includes("@form2js/core")
);
expect(jsonInput?.value).toContain('"key": "person.name.first"');
expect(jsonInput?.value).toContain("Moist");
act(() => {
runButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(view.getLastOutputState()).toEqual({
kind: "standard",
status: "success",
statusMessage: "@form2js/core -> entriesToObject(entry objects)",
errorMessage: null,
parsedPayload: {
person: {
city: "ankh-morpork",
guild: "thieves",
name: { first: "Moist", last: "von Lipwig" },
tags: ["crime", "banking"]
}
}
});
act(() => { view.root.unmount(); });
});
it("runs the form-data variant from a submitted form (Tiffany Aching)", () => {
const view = renderVariant(FormDataVariant);
const firstNameInput = view.container.querySelector('input[name="person.name.first"]');
const form = view.container.querySelector("form");
expect(firstNameInput?.value).toBe("Tiffany");
expect(form).not.toBeNull();
act(() => {
form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
expect(view.getLastOutputState()).toEqual({
kind: "standard",
status: "success",
statusMessage: "@form2js/form-data -> formDataToObject(form)",
errorMessage: null,
parsedPayload: {
person: {
city: "quirm",
guild: "witches",
name: { first: "Tiffany", last: "Aching" },
tags: ["witch"]
}
}
});
act(() => { view.root.unmount(); });
});
it("reports runtime merge failures from the core parser instead of labeling them as JSON parse errors", () => {
const view = renderVariant(CoreVariant);
const jsonInput = view.container.querySelector('textarea[name="core-entries-json"]');
const runButton = [...view.container.querySelectorAll("button")].find((button) =>
button.textContent?.includes("@form2js/core")
);
act(() => {
if (!jsonInput) throw new Error("Missing core JSON input.");
jsonInput.value = JSON.stringify([
{ key: "person", value: "watch" },
{ key: "person.name.first", value: "Sam" }
]);
jsonInput.dispatchEvent(new Event("input", { bubbles: true }));
runButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(view.getLastOutputState()).toEqual({
kind: "standard",
status: "error",
statusMessage: "core parse failed.",
errorMessage: "Core conversion failed: Expected object-like container while setting nested path",
parsedPayload: null
});
act(() => { view.root.unmount(); });
});
it("preserves core output when switching away and back through the shell", () => {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
act(() => { root.render(); });
const coreButton = container.querySelector('button[data-variant-id="core"]');
act(() => { coreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
const runButton = [...container.querySelectorAll("button")].find((button) =>
button.textContent?.includes("@form2js/core")
);
act(() => { runButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
expect(container.textContent).toContain("@form2js/core -> entriesToObject(entry objects)");
expect(container.textContent).toContain("von Lipwig");
const formButton = container.querySelector('button[data-variant-id="form"]');
act(() => { formButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
act(() => { coreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
expect(container.textContent).toContain("@form2js/core -> entriesToObject(entry objects)");
expect(container.textContent).toContain("von Lipwig");
act(() => { root.unmount(); });
});
});
================================================
FILE: apps/docs/test/docs-pipeline.test.ts
================================================
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const testDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(testDir, "../../..");
const turboConfig = JSON.parse(readFileSync(path.join(repoRoot, "turbo.json"), "utf8")) as {
tasks?: Record;
};
describe("docs CI task graph", () => {
it("builds workspace package dependencies before docs lint and typecheck", () => {
expect(turboConfig.tasks?.lint?.dependsOn).toEqual(expect.arrayContaining(["^build"]));
expect(turboConfig.tasks?.typecheck?.dependsOn).toEqual(expect.arrayContaining(["^build"]));
});
});
================================================
FILE: apps/docs/test/docs-root-scripts.test.ts
================================================
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const testDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(testDir, "../../..");
const rootPackageJson = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8")) as {
scripts?: Record;
};
describe("root docs scripts", () => {
it("builds workspace packages before local docs commands that depend on package dist output", () => {
expect(rootPackageJson.scripts?.docs).toContain("npm run build:packages");
expect(rootPackageJson.scripts?.["docs:build"]).toContain("npm run build:packages");
expect(rootPackageJson.scripts?.["test:docs"]).toContain("npm run build:packages");
});
});
================================================
FILE: apps/docs/test/homepage-shell.test.ts
================================================
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const testDir = path.dirname(fileURLToPath(import.meta.url));
const homepageSource = readFileSync(path.resolve(testDir, "../src/pages/index.astro"), "utf8");
const installSectionSource = readFileSync(
path.resolve(testDir, "../src/components/landing/InstallSection.astro"),
"utf8"
);
const docsShellSource = readFileSync(path.resolve(testDir, "../src/layouts/DocsShell.astro"), "utf8");
const readmeSource = readFileSync(path.resolve(testDir, "../../../README.md"), "utf8");
describe("docs homepage shell", () => {
it("wires the landing page sections together", () => {
expect(homepageSource).toContain("");
expect(homepageSource).toContain('id="playground"');
expect(homepageSource).toContain("");
expect(homepageSource).toContain("");
expect(readFileSync(path.resolve(testDir, "../src/components/landing/Hero.astro"), "utf8")).toContain(
"npm install @form2js/react react"
);
});
it("includes npm and standalone install guidance for supported variants", () => {
expect(installSectionSource).toContain("npm install @form2js/react react");
expect(installSectionSource).toContain("https://unpkg.com/@form2js/dom/dist/standalone.global.js");
expect(installSectionSource).toContain("https://unpkg.com/@form2js/jquery/dist/standalone.global.js");
});
it("surfaces the migration guide in the shared docs chrome and the README", () => {
expect(docsShellSource).toContain("migrationGuidePath");
expect(docsShellSource).toContain(">Migration<");
expect(readmeSource).toContain("Migrating from legacy form2js?");
expect(readmeSource).toContain("https://maxatwork.github.io/form2js/migrate/");
expect(readmeSource).toContain("## Migration from Legacy");
});
});
================================================
FILE: apps/docs/test/playground-shell.test.tsx
================================================
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OutputState, VariantComponentProps, VariantDefinition, VariantId } from "../src/components/playground/types";
function createReactOutput(statusMessage: string): OutputState {
return {
kind: "react",
status: "success",
statusMessage,
submitFlags: {
isSubmitting: false,
isError: false,
isSuccess: true
},
error: null,
parsedPayload: { ok: true },
meta: {
submitMode: "onSubmit",
validationEnabled: true
}
};
}
function createStandardOutput(statusMessage: string): OutputState {
return {
kind: "standard",
status: "success",
statusMessage,
errorMessage: null,
parsedPayload: { ok: true }
};
}
function makeVariant(
id: VariantId,
kind: VariantDefinition["kind"],
label: string,
statusMessage: string,
options?: {
throwWhenActive?: boolean;
}
): VariantDefinition {
function Component(props: VariantComponentProps) {
if (options?.throwWhenActive && props.isActive) {
throw new Error(`${label} render exploded`);
}
return (
{props.isActive ? `${label} active` : `${label} hidden`}
);
}
return {
id,
kind,
label,
summary: `${label} summary`,
packages: [`@form2js/${id}`],
createInitialOutputState: () =>
kind === "react"
? {
kind: "react",
status: "idle",
statusMessage: `Ready for ${label}`,
submitFlags: {
isSubmitting: false,
isError: false,
isSuccess: false
},
error: null,
parsedPayload: null
}
: {
kind: "standard",
status: "idle",
statusMessage: `Ready for ${label}`,
errorMessage: null,
parsedPayload: null
},
Component
};
}
function resetMockVariants(): void {
mockVariantsById.react = makeVariant("react", "react", "React", "React complete");
}
const { mockVariantsById } = vi.hoisted(() => ({
mockVariantsById: {
react: makeVariant("react", "react", "React", "React complete"),
form: makeVariant("form", "standard", "Form", "Form complete"),
jquery: makeVariant("jquery", "standard", "jQuery", "jQuery complete"),
js2form: makeVariant("js2form", "standard", "js2form", "js2form complete"),
core: makeVariant("core", "standard", "Core", "Core complete"),
"form-data": makeVariant("form-data", "standard", "FormData", "FormData complete")
} satisfies Record
}));
vi.mock("../src/components/playground/variant-registry", () => ({
VARIANT_IDS: ["react", "form", "jquery", "js2form", "core", "form-data"],
variantsById: mockVariantsById
}));
import { PlaygroundShell } from "../src/components/playground/PlaygroundShell";
declare global {
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
let container: HTMLDivElement;
let root: ReturnType;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
resetMockVariants();
vi.spyOn(console, "error").mockImplementation(() => undefined);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
window.history.replaceState({}, "", "/");
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
resetMockVariants();
vi.restoreAllMocks();
});
function renderShell(): void {
act(() => {
root.render();
});
}
describe("PlaygroundShell", () => {
it("renders the active variant and chooses the output panel by kind", () => {
window.history.replaceState({}, "", "/?variant=react");
renderShell();
expect(container.textContent).toContain("React summary");
expect(container.textContent).toContain("React active");
expect(container.textContent).toContain("Submit state");
expect(container.textContent).toContain("Ready for React");
});
it("updates the query string when switching variants", () => {
window.history.replaceState({}, "", "/docs/start?variant=react#top");
renderShell();
const formButton = container.querySelector('button[data-variant-id="form"]');
expect(formButton).not.toBeNull();
act(() => {
formButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(window.location.pathname).toBe("/docs/start");
expect(window.location.search).toBe("?variant=form");
expect(window.location.hash).toBe("");
expect(container.textContent).toContain("Form active");
expect(container.textContent).toContain("Parsed result");
});
it("does not render placeholder payload output while a variant is idle", () => {
window.history.replaceState({}, "", "/?variant=react");
renderShell();
expect(container.textContent).toContain("Ready for React");
expect(container.textContent).not.toContain("null");
const formButton = container.querySelector('button[data-variant-id="form"]');
act(() => {
formButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("Ready for Form");
expect(container.textContent).not.toContain("null");
});
it("preserves emitted output state when switching away and back", () => {
renderShell();
const emitReact = [...container.querySelectorAll("button")].find((button) => button.textContent === "Emit React");
expect(emitReact).not.toBeUndefined();
act(() => {
emitReact?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("React complete");
expect(container.textContent).toContain("submitMode");
expect(container.textContent).toContain("onSubmit");
expect(container.textContent).toContain("validationEnabled");
expect(container.textContent).toContain("true");
const formButton = container.querySelector('button[data-variant-id="form"]');
act(() => {
formButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("React hidden");
const reactButton = container.querySelector('button[data-variant-id="react"]');
act(() => {
reactButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("React complete");
});
it("renders a failed-state fallback and keeps it when revisiting the variant", () => {
renderShell();
const emitReact = [...container.querySelectorAll("button")].find((button) => button.textContent === "Emit React");
const failReact = [...container.querySelectorAll("button")].find((button) => button.textContent === "Fail React");
expect(emitReact).not.toBeUndefined();
expect(failReact).not.toBeUndefined();
act(() => {
emitReact?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("React complete");
act(() => {
failReact?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("React crashed");
expect(container.textContent).not.toContain("React complete");
expect(container.textContent).not.toContain("submitMode");
expect(container.textContent).not.toContain("React active");
const formButton = container.querySelector('button[data-variant-id="form"]');
act(() => {
formButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const reactButton = container.querySelector('button[data-variant-id="react"]');
act(() => {
reactButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("React crashed");
expect(container.textContent).not.toContain("React complete");
expect(container.textContent).not.toContain("submitMode");
});
it("isolates a thrown active-variant render failure and keeps the switcher usable", async () => {
mockVariantsById.react = makeVariant("react", "react", "React", "React complete", {
throwWhenActive: true
});
renderShell();
await act(async () => {
await Promise.resolve();
});
expect(container.textContent).toContain("React failed");
expect(container.textContent).toContain("React render exploded");
expect(container.textContent).not.toContain("Form failed");
const formButton = container.querySelector('button[data-variant-id="form"]');
expect(formButton).not.toBeNull();
act(() => {
formButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("Form active");
expect(container.textContent).toContain("Parsed result");
const reactButton = container.querySelector('button[data-variant-id="react"]');
act(() => {
reactButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => {
await Promise.resolve();
});
expect(container.textContent).toContain("React failed");
expect(container.textContent).toContain("React render exploded");
});
});
================================================
FILE: apps/docs/test/playground-styles.test.ts
================================================
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const stylesheetPath = path.resolve(import.meta.dirname, "../src/styles/playground.css");
const stylesheet = readFileSync(stylesheetPath, "utf8");
describe("playground responsive styles", () => {
it("drops the desktop form-column divider in the one-column mobile layout", () => {
expect(stylesheet).toContain("@media (max-width: 760px)");
expect(stylesheet).toContain(".pg-form-col { border-right: none; }");
});
});
================================================
FILE: apps/docs/test/react-variant.test.tsx
================================================
// apps/docs/test/react-variant.test.tsx
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OutputState, VariantComponentProps } from "../src/components/playground/types";
import { ReactVariant } from "../src/components/playground/variants/react-variant";
declare global {
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
interface RenderResult {
container: HTMLDivElement;
root: ReturnType;
getLastOutputState: () => OutputState | null;
}
function renderVariant(Component: (props: VariantComponentProps) => React.ReactNode): RenderResult {
let lastOutputState: OutputState | null = null;
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
act(() => {
root.render(
{ lastOutputState = outputState; }}
reportFatalError={(errorInfo) => { throw new Error(`Unexpected fatal error: ${errorInfo.message}`); }}
/>
);
});
return { container, root, getLastOutputState: () => lastOutputState };
}
async function submitForm(container: HTMLDivElement): Promise {
const form = container.querySelector("form");
if (!form) throw new Error("Missing React variant form.");
await act(async () => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await Promise.resolve();
});
}
describe("ReactVariant", () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
vi.useFakeTimers();
});
afterEach(() => {
document.body.innerHTML = "";
vi.useRealTimers();
vi.restoreAllMocks();
});
it("submits the seeded valid form and emits react output states", async () => {
const view = renderVariant(ReactVariant);
const firstNameInput = view.container.querySelector('input[name="person.name.first"]');
const emailInput = view.container.querySelector('input[name="person.email"]');
expect(firstNameInput?.value).toBe("Sam");
expect(emailInput?.value).toBe("sam.vimes@ankh-morpork.gov");
await submitForm(view.container);
expect(view.getLastOutputState()).toMatchObject({
kind: "react",
status: "running",
submitFlags: { isSubmitting: true, isError: false, isSuccess: false },
parsedPayload: null,
meta: { submitMode: "onSubmit", validationEnabled: true }
});
await act(async () => {
vi.advanceTimersByTime(900);
await Promise.resolve();
});
expect(view.getLastOutputState()).toEqual({
kind: "react",
status: "success",
statusMessage: "Callback resolved",
submitFlags: { isSubmitting: false, isError: false, isSuccess: true },
error: null,
parsedPayload: {
person: {
age: 45,
email: "sam.vimes@ankh-morpork.gov",
guild: "watchman",
interests: ["city-watch", "cigars"],
name: { first: "Sam", last: "Vimes" }
}
},
meta: { submitMode: "onSubmit", validationEnabled: true }
});
act(() => { view.root.unmount(); });
});
it("emits validation failure output when seeded fields become invalid", async () => {
const view = renderVariant(ReactVariant);
const emailInput = view.container.querySelector('input[name="person.email"]');
act(() => {
if (!emailInput) throw new Error("Missing email input.");
emailInput.value = "bad-email";
emailInput.dispatchEvent(new Event("input", { bubbles: true }));
});
await submitForm(view.container);
expect(view.getLastOutputState()).toEqual({
kind: "react",
status: "error",
statusMessage: "Submit failed",
submitFlags: { isSubmitting: false, isError: true, isSuccess: false },
error: { message: "person.email: Email must be valid." },
parsedPayload: null,
meta: { submitMode: "onSubmit", validationEnabled: true }
});
act(() => { view.root.unmount(); });
});
it("emits callback failure output when the Force Error button is clicked", async () => {
const view = renderVariant(ReactVariant);
const forceErrorButton = [...view.container.querySelectorAll("button")].find((button) =>
button.textContent?.includes("Force Error")
);
expect(forceErrorButton).toBeDefined();
act(() => {
forceErrorButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(view.getLastOutputState()).toMatchObject({
kind: "react",
status: "running"
});
await act(async () => {
vi.advanceTimersByTime(900);
await Promise.resolve();
});
expect(view.getLastOutputState()).toEqual({
kind: "react",
status: "error",
statusMessage: "Submit failed",
submitFlags: { isSubmitting: false, isError: true, isSuccess: false },
error: { message: "Simulated server error." },
parsedPayload: null,
meta: { submitMode: "onSubmit", validationEnabled: true }
});
act(() => { view.root.unmount(); });
});
it("clears a queued force-error after a validation failure so the next valid submit succeeds", async () => {
const view = renderVariant(ReactVariant);
const forceErrorButton = [...view.container.querySelectorAll("button")].find((button) =>
button.textContent?.includes("Force Error")
);
const emailInput = view.container.querySelector('input[name="person.email"]');
expect(forceErrorButton).toBeDefined();
act(() => {
if (!emailInput) throw new Error("Missing email input.");
emailInput.value = "bad-email";
emailInput.dispatchEvent(new Event("input", { bubbles: true }));
forceErrorButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
act(() => {
if (!emailInput) throw new Error("Missing email input.");
emailInput.value = "sam.vimes@ankh-morpork.gov";
emailInput.dispatchEvent(new Event("input", { bubbles: true }));
});
await submitForm(view.container);
expect(view.getLastOutputState()).toMatchObject({
kind: "react",
status: "running"
});
await act(async () => {
vi.advanceTimersByTime(900);
await Promise.resolve();
});
expect(view.getLastOutputState()).toMatchObject({
kind: "react",
status: "success",
statusMessage: "Callback resolved",
submitFlags: { isSubmitting: false, isError: false, isSuccess: true },
error: null,
meta: { submitMode: "onSubmit", validationEnabled: true }
});
act(() => { view.root.unmount(); });
});
it("resets back to idle and clears the last successful payload", async () => {
const view = renderVariant(ReactVariant);
const resetButton = [...view.container.querySelectorAll("button")].find((button) =>
button.textContent?.includes("Reset state")
);
await submitForm(view.container);
await act(async () => {
vi.advanceTimersByTime(900);
await Promise.resolve();
});
expect(view.getLastOutputState()).toMatchObject({ kind: "react", status: "success" });
act(() => {
resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(view.getLastOutputState()).toEqual({
kind: "react",
status: "idle",
statusMessage: "Ready to submit.",
submitFlags: { isSubmitting: false, isError: false, isSuccess: false },
error: null,
parsedPayload: null,
meta: { submitMode: "onSubmit", validationEnabled: true }
});
act(() => { view.root.unmount(); });
});
});
================================================
FILE: apps/docs/test/site-routes.test.ts
================================================
import { describe, expect, it } from "vitest";
import {
apiDocsPath,
apiPackageDocsPath,
homepagePath,
homepageVariantPath,
migrationGuidePath
} from "../src/lib/site-routes";
describe("site routes", () => {
it("builds homepage, migration, and api paths under a base path", () => {
expect(homepagePath("/form2js/")).toBe("/form2js/");
expect(migrationGuidePath("/form2js/")).toBe("/form2js/migrate/");
expect(migrationGuidePath("/")).toBe("/migrate/");
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", () => {
expect(homepageVariantPath("/form2js/", "react")).toBe("/form2js/?variant=react");
expect(homepageVariantPath("/", "form-data")).toBe("/?variant=form-data");
});
});
================================================
FILE: apps/docs/test/standard-variants.test.tsx
================================================
// apps/docs/test/standard-variants.test.tsx
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OutputState, VariantComponentProps } from "../src/components/playground/types";
import { ensureJqueryBootstrap } from "../src/components/playground/bootstrap/jquery-bootstrap";
import { FormVariant } from "../src/components/playground/variants/form-variant";
import { JQueryVariant } from "../src/components/playground/variants/jquery-variant";
import { Js2FormVariant } from "../src/components/playground/variants/js2form-variant";
declare global {
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
interface RenderResult {
container: HTMLDivElement;
root: ReturnType;
getLastOutputState: () => OutputState | null;
render: () => void;
}
function renderVariant(Component: (props: VariantComponentProps) => React.ReactNode): RenderResult {
let lastOutputState: OutputState | null = null;
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
function render(): void {
act(() => {
root.render(
{ lastOutputState = outputState; }}
reportFatalError={(errorInfo) => { throw new Error(`Unexpected fatal error: ${errorInfo.message}`); }}
/>
);
});
}
render();
return { container, root, render, getLastOutputState: () => lastOutputState };
}
describe("standard playground variants", () => {
beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; });
afterEach(() => {
document.body.innerHTML = "";
vi.restoreAllMocks();
});
it("runs the form variant with seeded controls via form submit", () => {
const view = renderVariant(FormVariant);
const firstNameInput = view.container.querySelector('input[name="person.name.first"]');
const lastNameInput = view.container.querySelector('input[name="person.name.last"]');
const form = view.container.querySelector("form");
expect(firstNameInput?.value).toBe("Esme");
expect(lastNameInput?.value).toBe("Weatherwax");
expect(form).not.toBeNull();
act(() => {
form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
expect(view.getLastOutputState()).toEqual({
kind: "standard",
status: "success",
statusMessage: "@form2js/dom -> formToObject(form)",
errorMessage: null,
parsedPayload: {
person: {
city: "lancre",
guild: "witches",
name: { first: "Esme", last: "Weatherwax" },
tags: ["witch", "headology"]
}
}
});
act(() => { view.root.unmount(); });
});
it("runs the jquery variant in combine mode and installs the plugin idempotently", () => {
const beforeInstall = ensureJqueryBootstrap();
const secondInstall = ensureJqueryBootstrap();
expect(secondInstall).toBe(beforeInstall);
const view = renderVariant(JQueryVariant);
const modeSelect = view.container.querySelector('select[name="jquery-mode"]');
const runButton = [...view.container.querySelectorAll("button")].find((button) =>
button.textContent?.includes("@form2js/jquery")
);
expect(modeSelect?.value).toBe("combine");
act(() => {
runButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(view.getLastOutputState()).toEqual({
kind: "standard",
status: "success",
statusMessage: '@form2js/jquery -> $(".jq-slice").toObject({ mode: "combine" })',
errorMessage: null,
parsedPayload: {
person: {
city: "lancre",
first: "Gytha",
guild: "witches",
last: "Ogg"
}
}
});
act(() => { view.root.unmount(); });
});
it("applies js2form data, reports invalid JSON, and resets back to idle", () => {
const view = renderVariant(Js2FormVariant);
const jsonInput = view.container.querySelector('textarea[name="js2form-json"]');
const firstNameInput = view.container.querySelector('input[name="person.name.first"]');
const applyButton = [...view.container.querySelectorAll("button")].find((button) =>
button.textContent?.includes("Apply js2form")
);
const resetButton = [...view.container.querySelectorAll("button")].find((button) =>
button.textContent?.includes("Reset form")
);
expect(jsonInput?.value).toContain('"first": "Tiffany"');
expect(firstNameInput?.value).toBe("Esme");
act(() => {
if (!jsonInput) throw new Error("Missing js2form JSON input.");
jsonInput.value = "{";
jsonInput.dispatchEvent(new Event("input", { bubbles: true }));
applyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(view.getLastOutputState()).toEqual({
kind: "standard",
status: "error",
statusMessage: "js2form apply failed.",
errorMessage: "JSON parse error: please provide valid JSON before applying js2form.",
parsedPayload: null
});
act(() => {
if (!jsonInput) throw new Error("Missing js2form JSON input.");
jsonInput.value = `{
"person": {
"name": { "first": "Tiffany", "last": "Aching" },
"city": "quirm",
"tags": ["witch"]
}
}`;
jsonInput.dispatchEvent(new Event("input", { bubbles: true }));
applyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(view.getLastOutputState()).toEqual({
kind: "standard",
status: "success",
statusMessage: "@form2js/js2form -> objectToForm(...), then formToObject(...)",
errorMessage: null,
parsedPayload: {
person: { city: "quirm", name: { first: "Tiffany", last: "Aching" }, tags: ["witch"] }
}
});
expect(firstNameInput?.value).toBe("Tiffany");
act(() => {
resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(firstNameInput?.value).toBe("Esme");
expect(view.getLastOutputState()).toEqual({
kind: "standard",
status: "idle",
statusMessage: "Ready to apply object data.",
errorMessage: null,
parsedPayload: null
});
act(() => { view.root.unmount(); });
});
});
================================================
FILE: apps/docs/test/variant-registry.test.ts
================================================
import { describe, expect, it } from "vitest";
import { VARIANT_IDS, variantsById } from "../src/components/playground/variant-registry";
describe("variant registry", () => {
it("registers all expected variants", () => {
expect(VARIANT_IDS).toEqual([
"react",
"form",
"jquery",
"js2form",
"core",
"form-data"
]);
});
it("provides seeded idle output state for every variant", () => {
for (const id of VARIANT_IDS) {
const variant = variantsById[id];
expect(variant.id).toBe(id);
expect(variant.label.length).toBeGreaterThan(0);
expect(variant.summary.length).toBeGreaterThan(0);
expect(variant.packages.length).toBeGreaterThan(0);
expect(variant.createInitialOutputState().status).toBe("idle");
expect(variant.createInitialOutputState().kind).toBe(variant.kind);
}
});
});
================================================
FILE: apps/docs/test-e2e/api-docs.spec.ts
================================================
import { expect, test } from "@playwright/test";
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.getByLabel("API packages").getByRole("link", { name: "@form2js/react" })
).toBeVisible();
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$/);
});
================================================
FILE: apps/docs/test-e2e/homepage.spec.ts
================================================
import { expect, test } from "@playwright/test";
test("homepage switcher supports keyboard navigation and preserves parser output", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("link", { name: "API Docs", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Core" })).toBeVisible();
const coreButton = page.locator('button[data-variant-id="core"]');
await coreButton.focus();
await coreButton.press("Space");
await expect(coreButton).toHaveAttribute("aria-pressed", "true");
await expect(page.locator("#install-cmd")).toContainText("npm install @form2js/core");
await expect(page.locator("#install-snippet")).toContainText("entriesToObject");
await page.getByRole("button", { name: "Run @form2js/core" }).click();
const resultJson = page.locator(".result-json");
await expect(page.getByText("@form2js/core -> entriesToObject(entry objects)")).toBeVisible();
await expect(resultJson).toContainText("von Lipwig");
await page.locator('button[data-variant-id="form"]').click();
await page.locator('button[data-variant-id="core"]').click();
await expect(page.getByText("@form2js/core -> entriesToObject(entry objects)")).toBeVisible();
await expect(resultJson).toContainText("von Lipwig");
});
test("fault-injected variant failure keeps the switcher usable", async ({ page }) => {
await page.goto("/?variant=react&__fault=react:render");
await expect(page.getByText("React failed to load.")).toBeVisible();
await expect(page.getByText("Injected render fault for React")).toBeVisible();
await page.locator('button[data-variant-id="form"]').click();
await expect(page.getByRole("button", { name: "Run @form2js/dom" })).toBeVisible();
});
================================================
FILE: apps/docs/tsconfig.json
================================================
{
"extends": "astro/tsconfigs/strict",
"include": [
".astro/types.d.ts",
"src/**/*",
"test/**/*",
"test-e2e/**/*"
]
}
================================================
FILE: apps/docs/vitest.config.ts
================================================
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.{test,spec}.{ts,tsx}"],
exclude: ["test-e2e/**"]
}
});
================================================
FILE: changeset/config.json
================================================
{
"note": "Canonical changesets config lives at .changeset/config.json"
}
================================================
FILE: docs/api-core.md
================================================
# @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.
================================================
FILE: docs/api-dom.md
================================================
# @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.
================================================
FILE: docs/api-form-data.md
================================================
# @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.
================================================
FILE: docs/api-index.md
================================================
# 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
================================================
FILE: docs/api-jquery.md
================================================
# @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.
================================================
FILE: docs/api-js2form.md
================================================
# @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.
================================================
FILE: docs/api-react.md
================================================
# @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 (
);
}
```
## 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`.
================================================
FILE: docs/migrate.md
================================================
# Migrate from Legacy form2js
If you built around the old single `form2js` script or the archived jQuery plugin flow, the main change is that modern form2js is now a small package family. You install only the part you need instead of pulling one browser-era bundle into every environment.
The legacy code and historical examples still live in the [legacy branch](https://github.com/maxatwork/form2js/tree/legacy), but new work should move to the current packages and docs.
## Quick Chooser
| If your legacy code does this | Use now | Notes |
| --- | --- | --- |
| `form2js(form)` in the browser | [`@form2js/dom`](api-dom.md) | Closest direct replacement. Exports both `formToObject()` and a compatibility `form2js()` wrapper. |
| `$("#form").toObject()` in jQuery | [`@form2js/jquery`](api-jquery.md) | Keeps the plugin shape while using the modern DOM parser underneath. |
| Parse `FormData` on the server or in browser pipelines | [`@form2js/form-data`](api-form-data.md) | Best fit for fetch actions, loaders, workers, and Node. |
| Handle submit state in React | [`@form2js/react`](api-react.md) | Wraps form parsing in a hook with async submit state and optional schema validation. |
| Push object data back into a form | [`@form2js/js2form`](api-js2form.md) | Modern replacement for the old "object back into fields" helpers around the ecosystem. |
| Work directly with path/value entries | [`@form2js/core`](api-core.md) | Lowest-level parser and formatter. |
## What Changed
- The archived project exposed one browser-oriented `form2js(rootNode, delimiter, skipEmpty, nodeCallback, useIdIfEmptyName)` entry point.
- The current project splits that behavior by environment and responsibility.
- Browser DOM extraction lives in `@form2js/dom`.
- jQuery compatibility lives in `@form2js/jquery`.
- `FormData`, React, object-to-form, and low-level entry parsing each have their own package.
That split is the point of the rewrite: smaller installs, clearer environment boundaries, and first-class TypeScript/ESM support without making every user drag along legacy browser assumptions.
## Legacy API Mapping
Legacy browser code usually looked like this:
```js
var data = form2js(rootNode, ".", true, nodeCallback, false);
```
Modern browser code should usually look like this:
```ts
import { formToObject } from "@form2js/dom";
const data = formToObject(rootNode, {
delimiter: ".",
skipEmpty: true,
nodeCallback,
useIdIfEmptyName: false
});
```
If you want the smallest possible migration diff, `@form2js/dom` also exports a compatibility wrapper:
```ts
import { form2js } from "@form2js/dom";
const data = form2js(rootNode, ".", true, nodeCallback, false);
```
Parameter mapping:
| Legacy parameter | Modern equivalent |
| --- | --- |
| `rootNode` | `rootNode` |
| `delimiter` | `options.delimiter` |
| `skipEmpty` | `options.skipEmpty` |
| `nodeCallback` | `options.nodeCallback` |
| `useIdIfEmptyName` | `options.useIdIfEmptyName` |
The main migration decision is not the parameter mapping. It is choosing the right package for the environment where parsing now happens.
## Browser Migration
For plain browser forms, install `@form2js/dom`:
```bash
npm install @form2js/dom
```
Module usage:
```ts
import { formToObject } from "@form2js/dom";
const data = formToObject(document.getElementById("profileForm"));
```
Standalone usage is still available for the DOM package:
```html
```
## jQuery Migration
If your codebase still expects `$.fn.toObject()`, move to `@form2js/jquery` instead of rebuilding that glue yourself.
```bash
npm install @form2js/jquery jquery
```
```ts
import $ from "jquery";
import { installToObjectPlugin } from "@form2js/jquery";
installToObjectPlugin($);
const data = $("#profileForm").toObject({ mode: "first" });
```
Standalone usage is also available:
```html
```
## Behavior Differences To Check
- `skipEmpty` still defaults to `true`, so empty strings and `null` values are skipped unless you opt out.
- Disabled controls are ignored by default. Set `getDisabled: true` only if you really want them parsed.
- Unsafe path segments such as `__proto__`, `prototype`, and `constructor` are rejected by default in the modern parser.
- Only `@form2js/dom` and `@form2js/jquery` ship standalone browser globals. The other packages are module-only.
- React and `FormData` use cases now have dedicated packages instead of being squeezed through the DOM entry point.
## Where To Go Now
If the legacy code used browser DOM access only because that was the only option at the time, this is the modern package map:
- Use [`@form2js/form-data`](api-form-data.md) when your app already has `FormData`, request entries, or server-side action handlers.
- Use [`@form2js/react`](api-react.md) when you want submit-state handling around parsing in React.
- Use [`@form2js/js2form`](api-js2form.md) when you need to populate forms from nested objects.
- Use [`@form2js/core`](api-core.md) when you already have raw key/value pairs and just need the parser rules.
## Migration Checklist
1. Identify whether the old code is DOM-based, jQuery-based, React-based, or really just `FormData` processing.
2. Swap the legacy package or script include for the specific current package.
3. Move old positional arguments to an options object where appropriate.
4. Re-test any custom `nodeCallback` logic and any flows that depend on disabled or empty fields.
5. Replace browser-only parsing with `@form2js/form-data` or `@form2js/react` when the parsing no longer needs direct DOM traversal.
================================================
FILE: eslint.config.js
================================================
import path from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import tseslint from "typescript-eslint";
const tsconfigRootDir = path.dirname(fileURLToPath(import.meta.url));
export default tseslint.config(
{
ignores: [
"**/dist/**",
"**/coverage/**",
"**/node_modules/**",
".turbo/**",
"package-lock.json"
]
},
js.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
files: ["**/*.ts", "**/*.tsx", "**/*.cts", "**/*.mts"],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir
}
},
rules: {
"@typescript-eslint/consistent-type-imports": [
"error",
{
"prefer": "type-imports"
}
],
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-empty-object-type": "off",
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/no-unsafe-call": "error",
"@typescript-eslint/no-unsafe-member-access": "error",
"@typescript-eslint/no-unsafe-argument": "error",
"@typescript-eslint/no-unsafe-return": "error",
"@typescript-eslint/prefer-regexp-exec": "off",
"@typescript-eslint/prefer-nullish-coalescing": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/consistent-type-definitions": "off",
"@typescript-eslint/no-unnecessary-condition": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "off",
"@typescript-eslint/prefer-for-of": "off",
"@typescript-eslint/consistent-indexed-object-style": "off",
"@typescript-eslint/no-base-to-string": "off",
"@typescript-eslint/non-nullable-type-assertion-style": "off"
}
}
);
================================================
FILE: package.json
================================================
{
"name": "form2js-monorepo",
"private": true,
"type": "module",
"packageManager": "npm@11.6.2",
"workspaces": [
"packages/*",
"apps/*"
],
"engines": {
"node": ">=18"
},
"scripts": {
"build": "turbo run build",
"build:packages": "turbo run build --filter=@form2js/*",
"docs": "npm run build:packages && npm -w @form2js/docs run dev",
"docs:build": "npm run build:packages && npm -w @form2js/docs run build",
"test": "npm run test:packages && npm run test:integration && npm run test:docs",
"test:docs": "npm run build:packages && DOCS_E2E_PORT=4329 npm -w @form2js/docs run test:e2e",
"test:packages": "turbo run test",
"test:integration": "vitest run test/integration",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"clean": "turbo run clean && rimraf node_modules .turbo",
"pack:dry-run": "npm_config_cache=/tmp/form2js-npm-cache npm pack --dry-run --workspace @form2js/core && npm_config_cache=/tmp/form2js-npm-cache npm pack --dry-run --workspace @form2js/dom && npm_config_cache=/tmp/form2js-npm-cache npm pack --dry-run --workspace @form2js/form-data && npm_config_cache=/tmp/form2js-npm-cache npm pack --dry-run --workspace @form2js/js2form && npm_config_cache=/tmp/form2js-npm-cache npm pack --dry-run --workspace @form2js/jquery && npm_config_cache=/tmp/form2js-npm-cache npm pack --dry-run --workspace @form2js/react",
"changeset": "changeset",
"release": "changeset publish",
"scope:rewrite": "node scripts/rewrite-scope.mjs",
"bump-version": "node scripts/bump-version.mjs"
},
"overrides": {
"minimatch": {
"brace-expansion": "1.1.13"
},
"@typescript-eslint/typescript-estree": {
"minimatch": {
"brace-expansion": "2.0.3"
}
},
"micromatch": {
"picomatch": "2.3.2"
},
"astro": {
"@rollup/pluginutils": {
"picomatch": "4.0.4"
}
},
"vite": {
"picomatch": "4.0.4"
},
"@rollup/pluginutils": {
"picomatch": "4.0.4"
},
"tinyglobby": {
"picomatch": "4.0.4"
},
"vitest": {
"picomatch": "4.0.4"
},
"smol-toml": "1.6.1",
"yaml": "2.8.3"
},
"devDependencies": {
"@changesets/cli": "^2.29.7",
"@eslint/js": "^9.22.0",
"@types/jsdom": "^21.1.7",
"eslint": "^9.22.0",
"jsdom": "^26.0.0",
"picomatch": "^4.0.4",
"rimraf": "^6.0.1",
"tsup": "^8.5.0",
"tsx": "^4.20.5",
"turbo": "^2.5.6",
"typescript": "^5.9.2",
"typescript-eslint": "^8.35.1",
"vitest": "^4.1.2"
}
}
================================================
FILE: packages/core/CHANGELOG.md
================================================
# @form2js/core
## 3.3.0
### Minor Changes
- Restore Rails-style bracket path compatibility across parsing and form population, align DOM checkbox and radio handling with native browser submission behavior, and add explicit `SKIP_NODE` callback support for DOM extraction.
## 3.2.2
### Patch Changes
- Republish the package metadata and package-level README improvements across all public packages after fixing publish configuration.
## 3.2.1
### Patch Changes
- Add npm package metadata (`license`, `bugs`, `keywords`) and publish package-level README files for all public packages.
## 3.2.0
### Minor Changes
- 4d2f923: Added optional schema validation support (structural `parse`) to parsing APIs in core and form-data, plus a new `@form2js/react` package with `useForm2js` for async submit state management in React forms.
## 3.1.1
### Patch Changes
- 77b8543: Hardened parser security by rejecting unsafe key path segments by default and aligned DOM extraction with HTML semantics (disabled fieldsets, no button-like inputs). Added a writer hook, improved multi-select mapping, tightened ESLint, added a version bump script, and aligned packages to 3.1.0 with updated docs, tests, and repo/website links.
New Features
Added allowUnsafePathSegments option across core, dom, form-data, and jQuery plugin to explicitly allow trusted unsafe segments.
Added nodeCallback to objectToForm/js2form; returning false skips default assignment for that node.
Improved multi-select handling: matches names with [] and bare-name fallbacks without per-option keys.
Added bump-version script to sync workspace versions and ranges; bumped packages to 3.1.0.
Enabled vitest for examples and added smoke tests; updated tsconfig and lint script.
Bug Fixes
Prevented prototype pollution by blocking "proto", "prototype", and "constructor" path tokens by default.
objectToEntries now serializes only own enumerable properties.
DOM extraction respects disabled fieldset rules (legend exception) and excludes button-like inputs even when skipEmpty is false.
ESLint config now errors on unsafe TypeScript operations (no-unsafe-* rules).
================================================
FILE: packages/core/README.md
================================================
# @form2js/core
Core path parsing and object transformation logic for form-shaped data.
## Install
```bash
npm install @form2js/core
```
## Minimal usage
```ts
import { entriesToObject } from "@form2js/core";
const data = entriesToObject([
{ key: "person.name.first", value: "Vimes" },
{ key: "person.tags[]", value: "watch" },
]);
```
For guides, playground examples, and API details, see the docs site:
https://maxatwork.github.io/form2js/?variant=core
License: MIT
================================================
FILE: packages/core/package.json
================================================
{
"name": "@form2js/core",
"version": "3.4.0",
"description": "Core path parsing and object transformation logic for form2js.",
"license": "MIT",
"repository": "https://github.com/maxatwork/form2js",
"homepage": "https://maxatwork.github.io/form2js/",
"bugs": {
"url": "https://github.com/maxatwork/form2js/issues"
},
"keywords": [
"form",
"forms",
"serialization",
"deserialization",
"form-data",
"json",
"parser",
"object-transform"
],
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": [
"dist"
],
"peerDependencies": {
"zod": "^3.22.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
},
"engines": {
"node": ">=18"
},
"scripts": {
"build": "tsup --config tsup.config.ts",
"test": "vitest run",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "rimraf dist"
}
}
================================================
FILE: packages/core/src/index.ts
================================================
import type {
Entry,
EntryInput,
EntryValue,
InferSchemaOutput,
MergeContext,
MergeOptions,
NameValuePair,
ObjectTree,
ParseOptions,
SchemaValidator,
ValidationOptions
} from "./types";
const SUB_ARRAY_REGEXP = /^\[\d+?\]/;
const SUB_OBJECT_REGEXP = /^[a-zA-Z_][a-zA-Z_0-9]*/;
const PATH_TOKEN_REGEXP = /[a-zA-Z_][a-zA-Z0-9_]*/g;
const UNSAFE_PATH_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
interface BracketMatch {
content: string;
index: number;
text: string;
}
function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null;
}
function createIndexRecord(): Record {
return Object.create(null) as Record;
}
function hasOwnRecordValue(record: Record, key: string): boolean {
return Object.prototype.hasOwnProperty.call(record, key);
}
function getOwnRecordValue(record: Record, key: string): unknown {
return hasOwnRecordValue(record, key) ? record[key] : undefined;
}
function setOwnRecordValue(record: Record, key: string, value: unknown): void {
Object.defineProperty(record, key, {
configurable: true,
enumerable: true,
value,
writable: true
});
}
function findBracketMatches(input: string): BracketMatch[] {
const matches: BracketMatch[] = [];
let cursor = 0;
while (cursor < input.length) {
const startIndex = input.indexOf("[", cursor);
if (startIndex === -1) {
break;
}
const endIndex = input.indexOf("]", startIndex + 1);
if (endIndex === -1) {
break;
}
matches.push({
content: input.slice(startIndex + 1, endIndex),
index: startIndex,
text: input.slice(startIndex, endIndex + 1)
});
cursor = endIndex + 1;
}
return matches;
}
function normalizeEntry(entry: EntryInput): Entry {
if (Array.isArray(entry) && typeof entry[0] === "string") {
const tupleEntry = entry as readonly [string, EntryValue];
return { key: tupleEntry[0], value: tupleEntry[1] };
}
if ("key" in entry && typeof entry.key === "string") {
return { key: entry.key, value: entry.value };
}
if ("name" in entry && typeof entry.name === "string") {
return { key: entry.name, value: entry.value };
}
throw new TypeError("Invalid entry. Expected [key, value], { key, value }, or { name, value }.");
}
function shouldSkipValue(value: EntryValue, skipEmpty: boolean): boolean {
return skipEmpty && (value === "" || value === null);
}
function findUnsafePathToken(part: string): string | null {
const tokens = part.match(PATH_TOKEN_REGEXP);
if (!tokens) {
return null;
}
for (const token of tokens) {
if (UNSAFE_PATH_SEGMENTS.has(token)) {
return token;
}
}
return null;
}
function assertPathIsSafe(nameParts: string[], allowUnsafePathSegments: boolean): void {
if (allowUnsafePathSegments) {
return;
}
for (const namePart of nameParts) {
const unsafeToken = findUnsafePathToken(namePart);
if (unsafeToken) {
throw new TypeError(
`Unsafe path segment "${unsafeToken}" is not allowed. ` +
"Pass allowUnsafePathSegments: true only for trusted input."
);
}
}
}
function splitNameIntoParts(name: string, delimiter: string): string[] {
const rawParts = name.split(delimiter);
const nameParts: string[] = [];
for (const rawPart of rawParts) {
const bracketMatches = findBracketMatches(rawPart);
if (bracketMatches.length === 0) {
nameParts.push(rawPart);
continue;
}
let currentPart = "";
let cursor = 0;
for (const match of bracketMatches) {
const literalText = rawPart.slice(cursor, match.index ?? cursor);
if (literalText !== "") {
currentPart += literalText;
}
const bracketContent = match.content;
const isArraySegment = bracketContent === "" || /^\d+$/.test(bracketContent);
if (isArraySegment) {
if (currentPart !== "" && currentPart.endsWith("]")) {
nameParts.push(currentPart);
currentPart = "";
}
currentPart = `${currentPart}[${bracketContent}]`;
} else {
if (currentPart !== "") {
nameParts.push(currentPart);
}
currentPart = bracketContent;
}
cursor = match.index + match.text.length;
}
const trailingText = rawPart.slice(cursor);
if (trailingText !== "") {
currentPart += trailingText;
}
if (currentPart !== "") {
nameParts.push(currentPart);
}
}
return nameParts;
}
function ensureNamedArray(container: unknown, arrayName: string): unknown[] {
if (arrayName === "" && Array.isArray(container)) {
return container;
}
if (!isRecord(container)) {
throw new TypeError("Expected object-like container when creating array path");
}
const existingValue = getOwnRecordValue(container, arrayName);
if (Array.isArray(existingValue)) {
return existingValue;
}
const newArray: unknown[] = [];
setOwnRecordValue(container, arrayName, newArray);
return newArray;
}
function pushToNamedArray(container: unknown, arrayName: string, value: unknown): unknown {
const targetArray = ensureNamedArray(container, arrayName);
targetArray.push(value);
return targetArray[targetArray.length - 1];
}
export function createMergeContext(): MergeContext {
return { arrays: createIndexRecord() as MergeContext["arrays"] };
}
export function setPathValue(
target: ObjectTree,
path: string,
value: EntryValue,
options: MergeOptions = {}
): ObjectTree {
const delimiter = options.delimiter ?? ".";
const context = options.context ?? createMergeContext();
const allowUnsafePathSegments = options.allowUnsafePathSegments ?? false;
const nameParts = splitNameIntoParts(path, delimiter);
assertPathIsSafe(nameParts, allowUnsafePathSegments);
let currResult: unknown = target;
let arrayNameFull = "";
for (let partIndex = 0; partIndex < nameParts.length; partIndex += 1) {
const namePart = nameParts[partIndex] ?? "";
const isLast = partIndex === nameParts.length - 1;
if (namePart.includes("[]") && isLast) {
const arrayName = namePart.slice(0, namePart.indexOf("["));
arrayNameFull += arrayName;
pushToNamedArray(currResult, arrayName, value);
continue;
}
if (namePart.includes("[")) {
const arrayName = namePart.slice(0, namePart.indexOf("["));
const arrayIndex = namePart.replace(/(^([a-z_]+)?\[)|(\]$)/gi, "");
arrayNameFull += `_${arrayName}_${arrayIndex}`;
if (arrayName !== "") {
ensureNamedArray(currResult, arrayName);
}
const existingArrayMap = getOwnRecordValue(context.arrays, arrayNameFull);
const arrayMap = isRecord(existingArrayMap) ? existingArrayMap : createIndexRecord();
if (!isRecord(existingArrayMap)) {
setOwnRecordValue(context.arrays, arrayNameFull, arrayMap);
}
if (isLast) {
const inserted = pushToNamedArray(currResult, arrayName, value);
setOwnRecordValue(arrayMap, arrayIndex, inserted);
} else if (getOwnRecordValue(arrayMap, arrayIndex) === undefined) {
const nextNamePart = nameParts[partIndex + 1] ?? "";
const nextContainer = /^[0-9a-z_]+\[?/i.test(nextNamePart) ? {} : [];
const inserted = pushToNamedArray(currResult, arrayName, nextContainer);
setOwnRecordValue(arrayMap, arrayIndex, inserted);
}
currResult = getOwnRecordValue(arrayMap, arrayIndex);
continue;
}
arrayNameFull += namePart;
if (!isRecord(currResult)) {
throw new TypeError("Expected object-like container while setting nested path");
}
if (!isLast) {
if (getOwnRecordValue(currResult, namePart) === undefined) {
setOwnRecordValue(currResult, namePart, {});
}
currResult = getOwnRecordValue(currResult, namePart);
} else {
setOwnRecordValue(currResult, namePart, value);
}
}
return target;
}
export function entriesToObject(entries: Iterable, options?: ParseOptions): ObjectTree;
export function entriesToObject(
entries: Iterable,
options: ParseOptions & { schema: TSchema }
): InferSchemaOutput;
export function entriesToObject(
entries: Iterable,
options: ParseOptions & ValidationOptions = {}
): unknown {
const delimiter = options.delimiter ?? ".";
const skipEmpty = options.skipEmpty ?? true;
const allowUnsafePathSegments = options.allowUnsafePathSegments ?? false;
const context = createMergeContext();
const result: ObjectTree = {};
for (const rawEntry of entries) {
const entry = normalizeEntry(rawEntry);
if (shouldSkipValue(entry.value, skipEmpty)) {
continue;
}
setPathValue(result, entry.key, entry.value, {
delimiter,
context,
allowUnsafePathSegments
});
}
if (options.schema) {
return options.schema.parse(result);
}
return result;
}
function objectToNameValues(obj: unknown): NameValuePair[] {
const result: NameValuePair[] = [];
if (obj === null || obj === undefined) {
result.push({ name: "", value: null });
return result;
}
if (typeof obj === "string" || typeof obj === "number" || typeof obj === "boolean") {
result.push({ name: "", value: obj });
return result;
}
if (Array.isArray(obj)) {
for (let index = 0; index < obj.length; index += 1) {
const name = `[${index}]`;
result.push(...getSubValues(obj[index], name));
}
return result;
}
if (isRecord(obj)) {
for (const key of Object.keys(obj)) {
result.push(...getSubValues(obj[key], key));
}
}
return result;
}
function getSubValues(subObject: unknown, name: string): NameValuePair[] {
const result: NameValuePair[] = [];
const tempResult = objectToNameValues(subObject);
for (const item of tempResult) {
let itemName = name;
if (SUB_ARRAY_REGEXP.test(item.name)) {
itemName += item.name;
} else if (SUB_OBJECT_REGEXP.test(item.name)) {
itemName += `.${item.name}`;
}
result.push({ name: itemName, value: item.value });
}
return result;
}
export function objectToEntries(value: unknown): Entry[] {
return objectToNameValues(value).map((item) => ({
key: item.name,
value: item.value
}));
}
export function processNameValues(
nameValues: Iterable,
skipEmpty = true,
delimiter = "."
): ObjectTree {
const entries: Entry[] = [];
for (const pair of nameValues) {
entries.push({ key: pair.name, value: pair.value });
}
return entriesToObject(entries, { skipEmpty, delimiter });
}
export type {
Entry,
EntryInput,
EntryValue,
InferSchemaOutput,
MergeContext,
MergeOptions,
NameValuePair,
ObjectTree,
ParseOptions,
SchemaValidator,
ValidationOptions
} from "./types";
================================================
FILE: packages/core/src/types.ts
================================================
export type EntryValue = unknown;
export interface Entry {
key: string;
value: EntryValue;
}
export interface NameValuePair {
name: string;
value: EntryValue;
}
export interface ParseOptions {
delimiter?: string;
skipEmpty?: boolean;
allowUnsafePathSegments?: boolean;
}
export interface SchemaValidator {
parse(input: unknown): TOutput;
}
export type InferSchemaOutput = TSchema extends SchemaValidator
? TOutput
: never;
export interface ValidationOptions {
schema?: TSchema;
}
export interface MergeContext {
arrays: Record>;
}
export interface MergeOptions {
delimiter?: string;
context?: MergeContext;
allowUnsafePathSegments?: boolean;
}
export type ObjectTree = Record;
export type EntryInput =
| Entry
| NameValuePair
| readonly [string, EntryValue]
| {
key: string;
value: EntryValue;
}
| {
name: string;
value: EntryValue;
};
================================================
FILE: packages/core/test/core.test.ts
================================================
import { describe, expect, it } from "vitest";
import { entriesToObject, objectToEntries, processNameValues, setPathValue } from "../src/index";
describe("entriesToObject", () => {
it("accepts tuple entries directly", () => {
const result = entriesToObject([
["person.name.first", "John"],
["person.name.last", "Doe"]
]);
expect(result).toEqual({
person: {
name: {
first: "John",
last: "Doe"
}
}
});
});
it("accepts name/value object entries directly", () => {
const result = entriesToObject([
{ name: "person.name.first", value: "John" },
{ name: "person.name.last", value: "Doe" }
]);
expect(result).toEqual({
person: {
name: {
first: "John",
last: "Doe"
}
}
});
});
it("builds nested objects with dot notation", () => {
const result = entriesToObject([
{ key: "person.name.first", value: "John" },
{ key: "person.name.last", value: "Doe" }
]);
expect(result).toEqual({
person: {
name: {
first: "John",
last: "Doe"
}
}
});
});
it("builds array values with [] syntax", () => {
const result = entriesToObject([
{ key: "person.favFood[]", value: "steak" },
{ key: "person.favFood[]", value: "chicken" }
]);
expect(result).toEqual({
person: {
favFood: ["steak", "chicken"]
}
});
});
it("keeps indexed arrays in first-seen order", () => {
const result = entriesToObject([
{ key: "person.friends[5].name", value: "Neo" },
{ key: "person.friends[3].name", value: "Smith" }
]);
expect(result).toEqual({
person: {
friends: [{ name: "Neo" }, { name: "Smith" }]
}
});
});
it("supports rails style object paths", () => {
const result = entriesToObject([
{ key: "rails[field1][foo]", value: "baz" },
{ key: "rails[field1][bar]", value: "qux" }
]);
expect(result).toEqual({
rails: {
field1: {
foo: "baz",
bar: "qux"
}
}
});
});
it("supports rails style keys with underscores and one-character names", () => {
const result = entriesToObject(
[
{ key: "data[Topic][topic_id]", value: "1" },
{ key: "person.ruby[field2][f]", value: "baz" }
],
{ skipEmpty: false }
);
expect(result).toEqual({
data: {
Topic: {
topic_id: "1"
}
},
person: {
ruby: {
field2: {
f: "baz"
}
}
}
});
});
it("supports single-bracket rails object segments at the root", () => {
const result = entriesToObject([{ key: "testitem[test_property]", value: "ok" }], {
skipEmpty: false
});
expect(result).toEqual({
testitem: {
test_property: "ok"
}
});
});
it("supports mixed indexed rails arrays and nested object traversal", () => {
const result = entriesToObject(
[
{ key: "tables[1][features][0][title]", value: "Feature A" },
{ key: "something[something][title]", value: "Nested" },
{ key: "something[description]", value: "Test" }
],
{ skipEmpty: false }
);
expect(result).toEqual({
tables: [
{
features: [
{
title: "Feature A"
}
]
}
],
something: {
something: {
title: "Nested"
},
description: "Test"
}
});
});
it("supports consecutive indexed segments for nested arrays", () => {
const result = entriesToObject([{ key: "foo[0][1][bar]", value: "baz" }], {
skipEmpty: false
});
expect(result).toEqual({
foo: [
[
{
bar: "baz"
}
]
]
});
});
it("skips empty and null values by default", () => {
const result = entriesToObject([
{ key: "a", value: "" },
{ key: "b", value: null },
{ key: "c", value: "ok" }
]);
expect(result).toEqual({ c: "ok" });
});
it("can keep empty values", () => {
const result = entriesToObject(
[
{ key: "a", value: "" },
{ key: "b", value: null },
{ key: "c", value: "ok" }
],
{ skipEmpty: false }
);
expect(result).toEqual({
a: "",
b: null,
c: "ok"
});
});
it("rejects unsafe path segments by default", () => {
expect(() =>
entriesToObject([{ key: "__proto__.polluted", value: "yes" }], {
skipEmpty: false
})
).toThrow(/Unsafe path segment/);
});
it("can opt into unsafe path segments for trusted inputs", () => {
const target = Object.create(null) as Record;
setPathValue(target, "__proto__.polluted", "yes", {
allowUnsafePathSegments: true
});
expect(target.__proto__).toEqual({ polluted: "yes" });
});
it("stores trusted unsafe path segments as own properties on plain objects", () => {
const target = {} as Record;
setPathValue(target, "__proto__.polluted", "yes", {
allowUnsafePathSegments: true
});
expect(Object.getPrototypeOf(target)).toBe(Object.prototype);
expect(Object.prototype.hasOwnProperty.call(target, "__proto__")).toBe(true);
expect(target.__proto__).toEqual({ polluted: "yes" });
expect(({} as Record).polluted).toBeUndefined();
});
it("does not treat trusted array index markers as prototype access", () => {
const target = {} as Record;
setPathValue(target, "items[__proto__].title", "safe", {
allowUnsafePathSegments: true
});
expect(Object.getPrototypeOf(target)).toBe(Object.prototype);
expect(({} as Record).title).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(target.items as Record, "__proto__")).toBe(true);
expect((target.items as Record).__proto__).toEqual({ title: "safe" });
});
it("validates and transforms using schema.parse when schema is provided", () => {
const schema = {
parse(value: unknown) {
const record = value as { person?: { age?: string } };
return {
person: {
age: Number(record.person?.age ?? "0")
}
};
}
};
const result = entriesToObject([{ key: "person.age", value: "42" }], { schema });
expect(result).toEqual({
person: {
age: 42
}
});
});
it("throws validation errors from schema.parse", () => {
const schema = {
parse() {
throw new Error("Validation failed");
}
};
expect(() =>
entriesToObject([{ key: "person.name", value: "Neo" }], {
schema
})
).toThrow("Validation failed");
});
it("throws for invalid entry shapes", () => {
expect(() =>
entriesToObject([{ value: "missing-key" } as unknown as { key: string; value: unknown }])
).toThrow("Invalid entry");
});
});
describe("processNameValues", () => {
it("keeps compatibility name field", () => {
const result = processNameValues([
{ name: "person.name.first", value: "John" },
{ name: "person.name.last", value: "Doe" }
]);
expect(result).toEqual({
person: {
name: {
first: "John",
last: "Doe"
}
}
});
});
});
describe("objectToEntries", () => {
it("flattens nested objects and arrays", () => {
const result = objectToEntries({
person: {
name: {
first: "John"
},
emails: ["a@example.com", "b@example.com"]
}
});
expect(result).toContainEqual({ key: "person.name.first", value: "John" });
expect(result).toContainEqual({ key: "person.emails[0]", value: "a@example.com" });
expect(result).toContainEqual({ key: "person.emails[1]", value: "b@example.com" });
});
it("only serializes own enumerable properties", () => {
const value = Object.create({ leaked: "secret" }) as Record;
value.safe = "ok";
const result = objectToEntries(value);
expect(result).toEqual([{ key: "safe", value: "ok" }]);
});
});
================================================
FILE: packages/core/tsconfig.json
================================================
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2020"]
},
"include": ["src/**/*.ts", "test/**/*.ts", "tsup.config.ts"]
}
================================================
FILE: packages/core/tsup.config.ts
================================================
import { defineConfig } from "tsup";
export default defineConfig({
entry: {
index: "src/index.ts"
},
format: ["esm", "cjs"],
dts: true,
sourcemap: true,
clean: true,
target: "es2020"
});
================================================
FILE: packages/dom/CHANGELOG.md
================================================
# @form2js/dom
## 3.2.0
### Minor Changes
- Restore Rails-style bracket path compatibility across parsing and form population, align DOM checkbox and radio handling with native browser submission behavior, and add explicit `SKIP_NODE` callback support for DOM extraction.
### Patch Changes
- Updated dependencies
- @form2js/core@3.3.0
## 3.1.4
### Patch Changes
- Republish the package metadata and package-level README improvements across all public packages after fixing publish configuration.
- Updated dependencies
- @form2js/core@3.2.2
## 3.1.3
### Patch Changes
- Add npm package metadata (`license`, `bugs`, `keywords`) and publish package-level README files for all public packages.
- Updated dependencies
- @form2js/core@3.2.1
## 3.1.2
### Patch Changes
- Updated dependencies [4d2f923]
- @form2js/core@3.2.0
## 3.1.1
### Patch Changes
- 77b8543: Hardened parser security by rejecting unsafe key path segments by default and aligned DOM extraction with HTML semantics (disabled fieldsets, no button-like inputs). Added a writer hook, improved multi-select mapping, tightened ESLint, added a version bump script, and aligned packages to 3.1.0 with updated docs, tests, and repo/website links.
New Features
Added allowUnsafePathSegments option across core, dom, form-data, and jQuery plugin to explicitly allow trusted unsafe segments.
Added nodeCallback to objectToForm/js2form; returning false skips default assignment for that node.
Improved multi-select handling: matches names with [] and bare-name fallbacks without per-option keys.
Added bump-version script to sync workspace versions and ranges; bumped packages to 3.1.0.
Enabled vitest for examples and added smoke tests; updated tsconfig and lint script.
Bug Fixes
Prevented prototype pollution by blocking "proto", "prototype", and "constructor" path tokens by default.
objectToEntries now serializes only own enumerable properties.
DOM extraction respects disabled fieldset rules (legend exception) and excludes button-like inputs even when skipEmpty is false.
ESLint config now errors on unsafe TypeScript operations (no-unsafe-* rules).
- Updated dependencies [77b8543]
- @form2js/core@3.1.1
================================================
FILE: packages/dom/README.md
================================================
# @form2js/dom
Parse browser form controls into structured objects.
## Install
```bash
npm install @form2js/dom
```
## Minimal usage
```ts
import { formToObject } from "@form2js/dom";
const result = formToObject(document.getElementById("profileForm"));
```
For guides, playground examples, and API details, see the docs site:
https://maxatwork.github.io/form2js/?variant=form
License: MIT
================================================
FILE: packages/dom/package.json
================================================
{
"name": "@form2js/dom",
"version": "3.4.0",
"description": "DOM extraction adapters for form2js.",
"license": "MIT",
"repository": "https://github.com/maxatwork/form2js",
"homepage": "https://maxatwork.github.io/form2js/",
"bugs": {
"url": "https://github.com/maxatwork/form2js/issues"
},
"keywords": [
"form",
"forms",
"serialization",
"deserialization",
"form-data",
"json",
"dom",
"form-to-object",
"browser"
],
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"browser": "./dist/standalone.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./standalone": {
"default": "./dist/standalone.js"
}
},
"files": [
"dist"
],
"engines": {
"node": ">=18"
},
"dependencies": {
"@form2js/core": "3.4.0"
},
"scripts": {
"build": "tsup --config tsup.config.ts",
"test": "vitest run",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "rimraf dist"
}
}
================================================
FILE: packages/dom/src/index.ts
================================================
import { entriesToObject, type Entry, type ObjectTree, type ParseOptions } from "@form2js/core";
export interface NodeCallbackResult {
name?: string;
key?: string;
value: unknown;
}
export const SKIP_NODE = Symbol("form2js.skipNode");
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;
function isNodeObject(value: unknown): value is Node {
return (
typeof value === "object" &&
value !== null &&
"nodeType" in value &&
"nodeName" in value
);
}
function isElementNode(node: Node): node is Element {
return node.nodeType === 1;
}
function nodeNameIs(node: Node, expected: string): boolean {
return node.nodeName.toUpperCase() === expected;
}
function isInputNode(node: Node): node is HTMLInputElement {
return nodeNameIs(node, "INPUT");
}
function isTextareaNode(node: Node): node is HTMLTextAreaElement {
return nodeNameIs(node, "TEXTAREA");
}
function isSelectNode(node: Node): node is HTMLSelectElement {
return nodeNameIs(node, "SELECT");
}
function isNodeDisabled(node: Node): boolean {
return "disabled" in node && Boolean((node as { disabled?: boolean }).disabled);
}
function isFormControlNode(node: Node): node is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement {
return isInputNode(node) || isTextareaNode(node) || isSelectNode(node);
}
function getFirstLegendChild(fieldset: Element): Element | null {
for (let index = 0; index < fieldset.children.length; index += 1) {
const child = fieldset.children[index];
if (child?.nodeName.toUpperCase() === "LEGEND") {
return child;
}
}
return null;
}
function isDisabledByAncestorFieldset(node: Element): boolean {
let ancestor: Element | null = node.parentElement;
while (ancestor) {
const isDisabledFieldset =
nodeNameIs(ancestor, "FIELDSET") && "disabled" in ancestor && Boolean((ancestor as { disabled?: boolean }).disabled);
if (isDisabledFieldset) {
const firstLegend = getFirstLegendChild(ancestor);
if (firstLegend?.contains(node)) {
ancestor = ancestor.parentElement;
continue;
}
return true;
}
ancestor = ancestor.parentElement;
}
return false;
}
function isEffectivelyDisabledControl(node: Node): boolean {
if (!isFormControlNode(node)) {
return false;
}
if (isNodeDisabled(node)) {
return true;
}
return isDisabledByAncestorFieldset(node);
}
function isButtonLikeInputType(inputType: string): boolean {
return /^(button|reset|submit|image)$/i.test(inputType);
}
function isNodeListLike(value: RootNodeInput): value is Node[] | NodeListOf | HTMLCollection {
if (!value || typeof value === "string") {
return false;
}
return !isNodeObject(value) && typeof value === "object" && "length" in value;
}
function getDocumentFromRoot(rootNode: RootNodeInput, fallback?: Document): Document {
if (fallback) {
return fallback;
}
if (typeof document !== "undefined") {
return document;
}
if (isNodeObject(rootNode) && rootNode.ownerDocument) {
return rootNode.ownerDocument;
}
if (isNodeListLike(rootNode) && rootNode.length > 0) {
const firstNode = rootNode[0];
if (isNodeObject(firstNode) && firstNode.ownerDocument) {
return firstNode.ownerDocument;
}
}
throw new Error("No document available. Provide options.document when running outside a browser.");
}
function resolveRootNode(rootNode: RootNodeInput, options: ExtractOptions): RootNodeInput {
if (typeof rootNode !== "string") {
return rootNode;
}
const doc = getDocumentFromRoot(rootNode, options.document);
return doc.getElementById(rootNode);
}
function getFieldName(node: Node, useIdIfEmptyName: boolean): string {
if (!isElementNode(node)) {
return "";
}
const namedNode = node as Element & { name?: string; id?: string };
if (namedNode.name && namedNode.name !== "") {
return namedNode.name;
}
if (useIdIfEmptyName && namedNode.id && namedNode.id !== "") {
return namedNode.id;
}
return "";
}
function getSelectedOptionValue(selectNode: HTMLSelectElement): string | string[] {
if (!selectNode.multiple) {
return selectNode.value;
}
const result: string[] = [];
const options = selectNode.getElementsByTagName("option");
for (let index = 0; index < options.length; index += 1) {
const option = options[index];
if (option?.selected) {
result.push(option.value);
}
}
return result;
}
function getFieldValue(fieldNode: Node, getDisabled: boolean): unknown {
if (isInputNode(fieldNode) || isTextareaNode(fieldNode)) {
const textLikeNode = fieldNode as HTMLInputElement | HTMLTextAreaElement;
if (isNodeDisabled(fieldNode) && !getDisabled) {
return null;
}
if (isInputNode(fieldNode)) {
const inputType = fieldNode.type.toLowerCase();
if (isButtonLikeInputType(inputType)) {
return null;
}
switch (inputType) {
case "radio":
case "checkbox":
if (fieldNode.checked) {
return fieldNode.value;
}
break;
default:
return fieldNode.value;
}
return null;
}
return textLikeNode.value;
}
if (isSelectNode(fieldNode)) {
if (isNodeDisabled(fieldNode) && !getDisabled) {
return null;
}
return getSelectedOptionValue(fieldNode);
}
return null;
}
function getSubFormValues(rootNode: Node, options: ExtractOptions): Entry[] {
const result: Entry[] = [];
let currentNode: ChildNode | null = rootNode.firstChild;
while (currentNode) {
const extractedValues = extractNodeValues(currentNode, options);
if (extractedValues !== SKIP_NODE) {
result.push(...extractedValues);
}
currentNode = currentNode.nextSibling;
}
return result;
}
function extractNodeValues(node: Node, options: ExtractOptions): Entry[] | typeof SKIP_NODE {
if (isEffectivelyDisabledControl(node) && !options.getDisabled) {
return [];
}
const fieldName = getFieldName(node, options.useIdIfEmptyName ?? false);
const callbackResult = options.nodeCallback?.(node);
if (callbackResult === SKIP_NODE) {
return SKIP_NODE;
}
if (callbackResult && (callbackResult.name || callbackResult.key)) {
const key = callbackResult.key ?? callbackResult.name ?? "";
if (key !== "") {
return [{ key, value: callbackResult.value }];
}
}
if (fieldName !== "" && (isInputNode(node) || isTextareaNode(node))) {
const fieldValue = getFieldValue(node, options.getDisabled ?? false);
if (fieldValue === null) {
return [];
}
return [{ key: fieldName, value: fieldValue }];
}
if (fieldName !== "" && isSelectNode(node)) {
const fieldValue = getFieldValue(node, options.getDisabled ?? false);
return [{ key: fieldName.replace(/\[\]$/, ""), value: fieldValue }];
}
return getSubFormValues(node, options);
}
function getFormValues(rootNode: Node, options: ExtractOptions): Entry[] {
const directResult = extractNodeValues(rootNode, options);
if (directResult === SKIP_NODE) {
return [];
}
if (directResult.length > 0) {
return directResult;
}
return getSubFormValues(rootNode, options);
}
export function extractPairs(rootNode: RootNodeInput, options: ExtractOptions = {}): Entry[] {
const resolvedRoot = resolveRootNode(rootNode, options);
if (!resolvedRoot) {
return [];
}
if (isNodeListLike(resolvedRoot)) {
const result: Entry[] = [];
for (let index = 0; index < resolvedRoot.length; index += 1) {
const currentNode = resolvedRoot[index];
if (isNodeObject(currentNode)) {
result.push(...getFormValues(currentNode, options));
}
}
return result;
}
if (isNodeObject(resolvedRoot)) {
return getFormValues(resolvedRoot, options);
}
return [];
}
export function formToObject(rootNode: RootNodeInput, options: FormToObjectOptions = {}): ObjectTree {
const pairs = extractPairs(rootNode, options);
const parseOptions: ParseOptions = {};
if (options.delimiter !== undefined) {
parseOptions.delimiter = options.delimiter;
}
if (options.skipEmpty !== undefined) {
parseOptions.skipEmpty = options.skipEmpty;
}
if (options.allowUnsafePathSegments !== undefined) {
parseOptions.allowUnsafePathSegments = options.allowUnsafePathSegments;
}
return entriesToObject(pairs, parseOptions);
}
export function form2js(
rootNode: RootNodeInput,
delimiter?: string,
skipEmpty?: boolean,
nodeCallback?: FormToObjectNodeCallback,
useIdIfEmptyName = false,
getDisabled = false,
allowUnsafePathSegments = false
): ObjectTree {
const normalizedOptions: FormToObjectOptions = {
useIdIfEmptyName,
getDisabled,
allowUnsafePathSegments
};
if (delimiter !== undefined) {
normalizedOptions.delimiter = delimiter;
}
if (skipEmpty !== undefined) {
normalizedOptions.skipEmpty = skipEmpty;
}
if (nodeCallback !== undefined) {
normalizedOptions.nodeCallback = nodeCallback;
}
return formToObject(rootNode, normalizedOptions);
}
================================================
FILE: packages/dom/src/standalone.ts
================================================
import { SKIP_NODE, form2js, formToObject } from "./index";
interface DomStandaloneGlobals {
formToObject?: typeof formToObject;
form2js?: typeof form2js;
SKIP_NODE?: typeof SKIP_NODE;
}
const scope = globalThis as typeof globalThis & DomStandaloneGlobals;
scope.formToObject = formToObject;
scope.form2js = form2js;
scope.SKIP_NODE = SKIP_NODE;
export { SKIP_NODE, form2js, formToObject };
================================================
FILE: packages/dom/test/dom.test.ts
================================================
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { SKIP_NODE, extractPairs, form2js, formToObject } from "../src/index";
describe("extractPairs", () => {
it("extracts input/select values", () => {
document.body.innerHTML = `
`;
const form = document.getElementById("testForm") as HTMLFormElement;
const result = extractPairs(form);
expect(result).toContainEqual({ key: "person.name.first", value: "John" });
expect(result).toContainEqual({ key: "person.name.last", value: "Doe" });
expect(result).toContainEqual({ key: "person.colors", value: ["red", "green"] });
});
it("extracts nested form controls inside arbitrary container markup", () => {
document.body.innerHTML = `
`;
const form = document.getElementById("testForm") as HTMLFormElement;
const result = extractPairs(form);
expect(result).toEqual([{ key: "person.name.first", value: "John" }]);
});
it("supports callback extraction", () => {
document.body.innerHTML = `
`;
const form = document.getElementById("testForm") as HTMLFormElement;
const result = extractPairs(form, {
nodeCallback(node) {
if (node instanceof HTMLDivElement && node.id === "person.callbackTest") {
return { name: node.id, value: node.textContent };
}
return false;
}
});
expect(result).toEqual([{ key: "person.callbackTest", value: "hello world" }]);
});
it("supports explicit callback exclusion without overloading falsy returns", () => {
document.body.innerHTML = `
`;
const form = document.getElementById("testForm") as HTMLFormElement;
const result = extractPairs(form, {
nodeCallback(node) {
if (!(node instanceof HTMLInputElement)) {
return false;
}
if (node.dataset.changed === "true") {
return null;
}
return SKIP_NODE;
}
});
expect(result).toEqual([{ key: "person.changed", value: "yes" }]);
});
it("excludes an entire supplied root when the callback returns SKIP_NODE for that root", () => {
document.body.innerHTML = `
`;
const wrapper = document.getElementById("wrapper") as HTMLElement;
const result = extractPairs(wrapper, {
nodeCallback(node) {
if (node === wrapper) {
return SKIP_NODE;
}
return false;
}
});
expect(result).toEqual([]);
});
it("returns empty pairs when the root cannot be resolved", () => {
expect(extractPairs("missing-form")).toEqual([]);
});
});
describe("formToObject", () => {
it("keeps checkbox and radio values aligned with native form submission", () => {
document.body.innerHTML = `
`;
const form = document.getElementById("testForm") as HTMLFormElement;
const result = formToObject(form);
expect(result).toEqual({
person: {
checkboxTrueChecked: "true",
checkboxFalseChecked: "false",
radio: "false"
}
});
});
it("uses the browser default on value for checked checkboxes without an explicit value", () => {
document.body.innerHTML = `
`;
const form = document.getElementById("testForm") as HTMLFormElement;
expect(extractPairs(form)).toEqual([{ key: "enabled", value: "on" }]);
expect(formToObject(form)).toEqual({
enabled: "on"
});
});
it("compacts sparse indexed checkbox rows after omitting unchecked controls", () => {
document.body.innerHTML = `
`;
const form = document.getElementById("testForm") as HTMLFormElement;
expect(formToObject(form)).toEqual({
items: [{ selected: "true" }]
});
});
it("does not coerce an empty checked radio option to false when true and false siblings exist", () => {
document.body.innerHTML = `
`;
const form = document.getElementById("testForm") as HTMLFormElement;
const result = formToObject(form, { skipEmpty: false });
expect(result).toEqual({
state: ""
});
});
it("supports id fallback and disabled field extraction", () => {
document.body.innerHTML = `
`;
const form = document.getElementById("testForm") as HTMLFormElement;
const withoutDisabled = formToObject(form, {
useIdIfEmptyName: true
});
expect(withoutDisabled).toEqual({
person: {
name: {
first: "John"
}
}
});
const withDisabled = formToObject(form, {
useIdIfEmptyName: true,
getDisabled: true
});
expect(withDisabled).toEqual({
person: {
name: {
first: "John",
last: "Doe"
}
}
});
});
it("respects disabled fieldset semantics", () => {
document.body.innerHTML = `
`;
const form = document.getElementById("testForm") as HTMLFormElement;
const withoutDisabled = formToObject(form);
expect(withoutDisabled).toEqual({
insideLegend: "legend-value"
});
const withDisabled = formToObject(form, { getDisabled: true });
expect(withDisabled).toEqual({
insideLegend: "legend-value",
outsideLegend: "blocked-value"
});
});
it("skips button-like input fields even when skipEmpty is false", () => {
document.body.innerHTML = `
`;
const form = document.getElementById("testForm") as HTMLFormElement;
const result = formToObject(form, { skipEmpty: false });
expect(result).toEqual({
person: {
name: "Trinity"
}
});
});
it("rejects unsafe path segments from field names", () => {
document.body.innerHTML = `
`;
const form = document.getElementById("testForm") as HTMLFormElement;
expect(() => formToObject(form)).toThrow(/Unsafe path segment/);
});
it("can combine NodeList roots", () => {
document.body.innerHTML = `
`;
const roots = document.querySelectorAll(".part");
const result = formToObject(roots);
expect(result).toEqual({
person: {
first: "Neo",
last: "Anderson"
}
});
});
it("can combine HTMLCollection roots", () => {
document.body.innerHTML = `
`;
const roots = document.getElementsByClassName("part");
const result = formToObject(roots);
expect(result).toEqual({
person: {
first: "Neo",
last: "Anderson"
}
});
});
it("resolves string roots against an explicit document", () => {
const customDocument = document.implementation.createHTMLDocument("custom");
customDocument.body.innerHTML = `
`;
const result = formToObject("testForm", {
document: customDocument,
delimiter: "/"
});
expect(result).toEqual({
person: {
name: "Sam"
}
});
});
it("keeps compatibility wrapper", () => {
document.body.innerHTML = `
`;
const result = form2js("testForm");
expect(result).toEqual({
person: {
name: "Trinity"
}
});
});
it("returns an empty object when the root cannot be resolved", () => {
expect(formToObject("missing-form")).toEqual({});
});
});
describe("standalone entry", () => {
it("attaches browser globals", async () => {
const scope = globalThis as typeof globalThis & {
formToObject?: unknown;
form2js?: unknown;
SKIP_NODE?: unknown;
};
Reflect.deleteProperty(scope, "formToObject");
Reflect.deleteProperty(scope, "form2js");
Reflect.deleteProperty(scope, "SKIP_NODE");
await import("../src/standalone");
expect(typeof scope.formToObject).toBe("function");
expect(typeof scope.form2js).toBe("function");
expect(typeof scope.SKIP_NODE).toBe("symbol");
});
});
================================================
FILE: packages/dom/tsconfig.json
================================================
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2020", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*.ts", "test/**/*.ts", "tsup.config.ts"]
}
================================================
FILE: packages/dom/tsup.config.ts
================================================
import { defineConfig } from "tsup";
export default defineConfig([
{
entry: {
index: "src/index.ts"
},
format: ["esm", "cjs"],
dts: true,
sourcemap: true,
clean: true,
target: "es2020"
},
{
entry: {
standalone: "src/standalone.ts"
},
format: ["iife"],
globalName: "form2jsDom",
sourcemap: true,
clean: false,
target: "es2020"
}
]);
================================================
FILE: packages/form-data/CHANGELOG.md
================================================
# @form2js/form-data
## 3.2.3
### Patch Changes
- Updated dependencies
- @form2js/core@3.3.0
## 3.2.2
### Patch Changes
- Republish the package metadata and package-level README improvements across all public packages after fixing publish configuration.
- Updated dependencies
- @form2js/core@3.2.2
## 3.2.1
### Patch Changes
- Add npm package metadata (`license`, `bugs`, `keywords`) and publish package-level README files for all public packages.
- Updated dependencies
- @form2js/core@3.2.1
## 3.2.0
### Minor Changes
- 4d2f923: Added optional schema validation support (structural `parse`) to parsing APIs in core and form-data, plus a new `@form2js/react` package with `useForm2js` for async submit state management in React forms.
### Patch Changes
- Updated dependencies [4d2f923]
- @form2js/core@3.2.0
## 3.1.1
### Patch Changes
- 77b8543: Hardened parser security by rejecting unsafe key path segments by default and aligned DOM extraction with HTML semantics (disabled fieldsets, no button-like inputs). Added a writer hook, improved multi-select mapping, tightened ESLint, added a version bump script, and aligned packages to 3.1.0 with updated docs, tests, and repo/website links.
New Features
Added allowUnsafePathSegments option across core, dom, form-data, and jQuery plugin to explicitly allow trusted unsafe segments.
Added nodeCallback to objectToForm/js2form; returning false skips default assignment for that node.
Improved multi-select handling: matches names with [] and bare-name fallbacks without per-option keys.
Added bump-version script to sync workspace versions and ranges; bumped packages to 3.1.0.
Enabled vitest for examples and added smoke tests; updated tsconfig and lint script.
Bug Fixes
Prevented prototype pollution by blocking "proto", "prototype", and "constructor" path tokens by default.
objectToEntries now serializes only own enumerable properties.
DOM extraction respects disabled fieldset rules (legend exception) and excludes button-like inputs even when skipEmpty is false.
ESLint config now errors on unsafe TypeScript operations (no-unsafe-* rules).
- Updated dependencies [77b8543]
- @form2js/core@3.1.1
================================================
FILE: packages/form-data/README.md
================================================
# @form2js/form-data
Convert `FormData` and entry lists into structured objects.
## Install
```bash
npm install @form2js/form-data
```
## Minimal usage
```ts
import { formDataToObject } from "@form2js/form-data";
const result = formDataToObject([
["person.name.first", "Sam"],
["person.roles[]", "captain"],
]);
```
For guides, playground examples, and API details, see the docs site:
https://maxatwork.github.io/form2js/?variant=form-data
License: MIT
================================================
FILE: packages/form-data/package.json
================================================
{
"name": "@form2js/form-data",
"version": "3.4.0",
"description": "FormData adapters for form2js.",
"license": "MIT",
"repository": "https://github.com/maxatwork/form2js",
"homepage": "https://maxatwork.github.io/form2js/",
"bugs": {
"url": "https://github.com/maxatwork/form2js/issues"
},
"keywords": [
"form",
"forms",
"serialization",
"deserialization",
"form-data",
"json",
"formdata",
"entries",
"server"
],
"type": "module",
"sideEffects": false,
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": [
"dist"
],
"engines": {
"node": ">=18"
},
"dependencies": {
"@form2js/core": "3.4.0"
},
"scripts": {
"build": "tsup --config tsup.config.ts",
"test": "vitest run",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "rimraf dist"
}
}
================================================
FILE: packages/form-data/src/index.ts
================================================
import {
entriesToObject as coreEntriesToObject,
type EntryInput,
type InferSchemaOutput,
type ObjectTree,
type ParseOptions,
type SchemaValidator,
type ValidationOptions
} from "@form2js/core";
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 entriesToObject(
entries: Iterable,
options: ParseOptions & ValidationOptions = {}
): unknown {
return coreEntriesToObject(entries, options);
}
export function formDataToObject(
formData: FormData | Iterable,
options?: FormDataToObjectOptions
): ObjectTree;
export function formDataToObject(
formData: FormData | Iterable,
options: FormDataToObjectOptions & { schema: TSchema }
): InferSchemaOutput;
export function formDataToObject(
formData: FormData | Iterable,
options: FormDataToObjectOptions & ValidationOptions = {}
): unknown {
const entries =
formData instanceof FormData ? formData.entries() : formData;
return coreEntriesToObject(entries, options);
}
export type {
EntryInput,
InferSchemaOutput,
ObjectTree,
ParseOptions,
SchemaValidator,
ValidationOptions
} from "@form2js/core";
================================================
FILE: packages/form-data/test/form-data.test.ts
================================================
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { entriesToObject, formDataToObject } from "../src/index";
describe("formDataToObject", () => {
it("converts FormData entries into structured objects", () => {
const formData = new FormData();
formData.append("person.name.first", "John");
formData.append("person.name.last", "Doe");
formData.append("person.colors[]", "red");
formData.append("person.colors[]", "blue");
const result = formDataToObject(formData);
expect(result).toEqual({
person: {
name: {
first: "John",
last: "Doe"
},
colors: ["red", "blue"]
}
});
});
it("supports generic iterable entries", () => {
const result = formDataToObject([
["user.id", "1"],
["user.roles[]", "admin"],
["user.roles[]", "editor"]
]);
expect(result).toEqual({
user: {
id: "1",
roles: ["admin", "editor"]
}
});
});
it("rejects unsafe path segments by default", () => {
expect(() =>
formDataToObject([["__proto__.polluted", "yes"]], {
skipEmpty: false
})
).toThrow(/Unsafe path segment/);
});
it("validates and transforms parsed form data with schema.parse", () => {
const schema = {
parse(value: unknown) {
const record = value as { user?: { id?: string } };
return {
user: {
id: Number(record.user?.id ?? "0")
}
};
}
};
const result = formDataToObject([["user.id", "7"]], { schema });
expect(result).toEqual({
user: {
id: 7
}
});
});
it("propagates schema.parse validation errors", () => {
const schema = {
parse() {
throw new Error("Invalid payload");
}
};
expect(() =>
formDataToObject([["user.id", "7"]], {
schema
})
).toThrow("Invalid payload");
});
});
describe("entriesToObject adapter", () => {
it("accepts key/value object entries", () => {
const result = entriesToObject([
{ key: "profile.email", value: "neo@example.com" },
{ key: "profile.active", value: true }
]);
expect(result).toEqual({
profile: {
email: "neo@example.com",
active: true
}
});
});
});
================================================
FILE: packages/form-data/tsconfig.json
================================================
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2020", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*.ts", "test/**/*.ts", "tsup.config.ts"]
}
================================================
FILE: packages/form-data/tsup.config.ts
================================================
import { defineConfig } from "tsup";
export default defineConfig({
entry: {
index: "src/index.ts"
},
format: ["esm", "cjs"],
dts: true,
sourcemap: true,
clean: true,
target: "es2020"
});
================================================
FILE: packages/jquery/CHANGELOG.md
================================================
# @form2js/jquery
## 3.1.5
### Patch Changes
- Updated dependencies
- @form2js/dom@3.2.0
## 3.1.4
### Patch Changes
- Republish the package metadata and package-level README improvements across all public packages after fixing publish configuration.
- Updated dependencies
- @form2js/dom@3.1.4
## 3.1.3
### Patch Changes
- Add npm package metadata (`license`, `bugs`, `keywords`) and publish package-level README files for all public packages.
- Updated dependencies
- @form2js/dom@3.1.3
## 3.1.2
### Patch Changes
- @form2js/dom@3.1.2
## 3.1.1
### Patch Changes
- 77b8543: Hardened parser security by rejecting unsafe key path segments by default and aligned DOM extraction with HTML semantics (disabled fieldsets, no button-like inputs). Added a writer hook, improved multi-select mapping, tightened ESLint, added a version bump script, and aligned packages to 3.1.0 with updated docs, tests, and repo/website links.
New Features
Added allowUnsafePathSegments option across core, dom, form-data, and jQuery plugin to explicitly allow trusted unsafe segments.
Added nodeCallback to objectToForm/js2form; returning false skips default assignment for that node.
Improved multi-select handling: matches names with [] and bare-name fallbacks without per-option keys.
Added bump-version script to sync workspace versions and ranges; bumped packages to 3.1.0.
Enabled vitest for examples and added smoke tests; updated tsconfig and lint script.
Bug Fixes
Prevented prototype pollution by blocking "proto", "prototype", and "constructor" path tokens by default.
objectToEntries now serializes only own enumerable properties.
DOM extraction respects disabled fieldset rules (legend exception) and excludes button-like inputs even when skipEmpty is false.
ESLint config now errors on unsafe TypeScript operations (no-unsafe-* rules).
- Updated dependencies [77b8543]
- @form2js/dom@3.1.1
================================================
FILE: packages/jquery/README.md
================================================
# @form2js/jquery
Use form2js through a jQuery plugin adapter.
## Install
```bash
npm install @form2js/jquery jquery
```
## Minimal usage
```ts
import $ from "jquery";
import { installToObjectPlugin } from "@form2js/jquery";
installToObjectPlugin($);
const data = $("#profileForm").toObject({ mode: "first" });
```
For guides, playground examples, and API details, see the docs site:
https://maxatwork.github.io/form2js/?variant=jquery
License: MIT
================================================
FILE: packages/jquery/package.json
================================================
{
"name": "@form2js/jquery",
"version": "3.4.0",
"description": "jQuery plugin adapter for form2js.",
"license": "MIT",
"repository": "https://github.com/maxatwork/form2js",
"homepage": "https://maxatwork.github.io/form2js/",
"bugs": {
"url": "https://github.com/maxatwork/form2js/issues"
},
"keywords": [
"form",
"forms",
"serialization",
"deserialization",
"form-data",
"json",
"jquery",
"jquery-plugin"
],
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"browser": "./dist/standalone.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./standalone": {
"default": "./dist/standalone.js"
}
},
"files": [
"dist"
],
"engines": {
"node": ">=18"
},
"peerDependencies": {
"jquery": ">=3.0.0"
},
"dependencies": {
"@form2js/dom": "3.4.0"
},
"devDependencies": {
"@types/jquery": "^3.5.32",
"jquery": "^3.7.1"
},
"scripts": {
"build": "tsup --config tsup.config.ts",
"test": "vitest run",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "rimraf dist"
}
}
================================================
FILE: packages/jquery/src/index.ts
================================================
import { form2js, type FormToObjectNodeCallback, type RootNodeInput } from "@form2js/dom";
export type ToObjectMode = "first" | "all" | "combine";
export interface ToObjectOptions {
mode?: ToObjectMode;
delimiter?: string;
skipEmpty?: boolean;
allowUnsafePathSegments?: boolean;
nodeCallback?: FormToObjectNodeCallback;
useIdIfEmptyName?: boolean;
getDisabled?: boolean;
}
interface JQueryCollectionLike {
get(index: number): unknown;
each(callback: (this: unknown, index: number, element: unknown) => void): unknown;
}
interface JQueryLike {
fn: object;
extend?: (target: object, ...sources: object[]) => object;
}
function isNodeObject(value: unknown): value is Node {
return typeof value === "object" && value !== null && "nodeType" in value && "nodeName" in value;
}
function getFnObject($: JQueryLike): Record {
return $.fn as Record;
}
interface ResolvedToObjectOptions {
mode: ToObjectMode;
delimiter: string;
skipEmpty: boolean;
allowUnsafePathSegments: boolean;
nodeCallback?: FormToObjectNodeCallback;
useIdIfEmptyName: boolean;
getDisabled: boolean;
}
function applySettings(options?: ToObjectOptions): ResolvedToObjectOptions {
const settings: ResolvedToObjectOptions = {
mode: options?.mode ?? "first",
delimiter: options?.delimiter ?? ".",
skipEmpty: options?.skipEmpty ?? true,
allowUnsafePathSegments: options?.allowUnsafePathSegments ?? false,
useIdIfEmptyName: options?.useIdIfEmptyName ?? false,
getDisabled: options?.getDisabled ?? false
};
if (options?.nodeCallback) {
settings.nodeCallback = options.nodeCallback;
}
return settings;
}
function isJQueryLike(value: unknown): value is JQueryLike {
return typeof value === "function" || (typeof value === "object" && value !== null && "fn" in value);
}
export function installToObjectPlugin($: JQueryLike): void {
if (!$.fn) {
throw new TypeError("jQuery-like object with fn is required");
}
const fnObject = getFnObject($);
if (typeof fnObject.toObject === "function") {
return;
}
fnObject.toObject = function toObject(this: JQueryCollectionLike, options?: ToObjectOptions): unknown {
const settings = applySettings(options);
switch (settings.mode) {
case "all": {
const result: unknown[] = [];
this.each(function eachMatched() {
result.push(
form2js(
this as RootNodeInput,
settings.delimiter,
settings.skipEmpty,
settings.nodeCallback,
settings.useIdIfEmptyName,
settings.getDisabled,
settings.allowUnsafePathSegments
)
);
});
return result;
}
case "combine": {
const roots: Node[] = [];
this.each(function eachMatched() {
if (isNodeObject(this)) {
roots.push(this);
}
});
return form2js(
roots,
settings.delimiter,
settings.skipEmpty,
settings.nodeCallback,
settings.useIdIfEmptyName,
settings.getDisabled,
settings.allowUnsafePathSegments
);
}
case "first":
default:
return form2js(
this.get(0) as RootNodeInput,
settings.delimiter,
settings.skipEmpty,
settings.nodeCallback,
settings.useIdIfEmptyName,
settings.getDisabled,
settings.allowUnsafePathSegments
);
}
};
}
export function maybeAutoInstallPlugin(scope: unknown = globalThis): void {
if (!isJQueryLike(scope)) {
return;
}
const jqueryLike = scope as JQueryLike;
if (jqueryLike.fn) {
installToObjectPlugin(jqueryLike);
}
}
================================================
FILE: packages/jquery/src/standalone.ts
================================================
import { maybeAutoInstallPlugin } from "./index";
interface JQueryGlobal {
jQuery?: unknown;
}
const scope = globalThis as typeof globalThis & JQueryGlobal;
if (scope.jQuery) {
maybeAutoInstallPlugin(scope.jQuery);
}
export { installToObjectPlugin, maybeAutoInstallPlugin } from "./index";
================================================
FILE: packages/jquery/test/jquery.test.ts
================================================
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { installToObjectPlugin, maybeAutoInstallPlugin } from "../src/index";
type ToObjectOptions = {
mode?: "first" | "all" | "combine";
delimiter?: string;
skipEmpty?: boolean;
allowUnsafePathSegments?: boolean;
useIdIfEmptyName?: boolean;
getDisabled?: boolean;
nodeCallback?: (node: Node) => { name?: string; key?: string; value: unknown } | false;
};
type ToObjectResult = unknown;
type StubCollection = {
length: number;
get(index: number): Element | undefined;
each(callback: (this: Element, index: number, element: Element) => void): StubCollection;
toObject?: (options?: ToObjectOptions) => ToObjectResult;
};
type SelectorInput = string | Element | Element[];
type StubJQuery = ((input: SelectorInput) => StubCollection) & {
fn: Record;
extend(target: object, ...sources: object[]): object;
};
function createCollection(elements: Element[], fn: Record): StubCollection {
const collection: StubCollection = {
length: elements.length,
get(index: number): Element | undefined {
return elements[index];
},
each(callback) {
for (let index = 0; index < elements.length; index += 1) {
const element = elements[index];
if (element) {
callback.call(element, index, element);
}
}
return this;
}
};
Object.setPrototypeOf(collection, fn);
return collection;
}
function createStubJQuery(): StubJQuery {
const fn: Record = {};
const $ = ((input: SelectorInput): StubCollection => {
if (typeof input === "string") {
return createCollection(Array.from(document.querySelectorAll(input)), fn);
}
if (Array.isArray(input)) {
return createCollection(input, fn);
}
return createCollection([input], fn);
}) as StubJQuery;
$.fn = fn;
$.extend = (target, ...sources) => {
Object.assign(target, ...sources);
return target;
};
return $;
}
describe("installToObjectPlugin", () => {
it("supports legacy first/all/combine modes", () => {
document.body.innerHTML = `
`;
const $ = createStubJQuery();
installToObjectPlugin($);
const first = $(".part").toObject?.({ mode: "first" });
const all = $(".part").toObject?.({ mode: "all" });
const combine = $(".part").toObject?.({ mode: "combine" });
expect(first).toEqual({ person: { first: "Neo" } });
expect(all).toEqual([{ person: { first: "Neo" } }, { person: { last: "Anderson" } }]);
expect(combine).toEqual({ person: { first: "Neo", last: "Anderson" } });
});
it("can be auto-installed", () => {
const $ = createStubJQuery();
maybeAutoInstallPlugin($);
expect(typeof $.fn.toObject).toBe("function");
});
it("forwards parsing options through the plugin", () => {
document.body.innerHTML = `
`;
const $ = createStubJQuery();
installToObjectPlugin($);
const result = $("#profile").toObject?.({
delimiter: "/",
skipEmpty: false,
useIdIfEmptyName: true,
getDisabled: true
});
expect(result).toEqual({
person: {
name: "Neo",
nickname: "",
role: "captain"
}
});
});
it("forwards nodeCallback and allowUnsafePathSegments through the plugin", () => {
document.body.innerHTML = `
`;
const $ = createStubJQuery();
installToObjectPlugin($);
const result = $("#unsafe").toObject?.({
allowUnsafePathSegments: true,
nodeCallback(node) {
if (node instanceof HTMLDivElement && node.id === "profile.callback") {
return { name: node.id, value: node.textContent };
}
return false;
}
}) as { profile: { callback: string } } & Record;
expect(result.profile.callback).toBe("hello world");
expect(result.__proto__).toEqual({ polluted: "yes" });
});
});
describe("standalone entry", () => {
it("auto-installs plugin from global jQuery", async () => {
const $ = createStubJQuery();
const scope = globalThis as typeof globalThis & { jQuery?: StubJQuery };
scope.jQuery = $;
await import("../src/standalone");
expect(typeof $.fn.toObject).toBe("function");
});
});
================================================
FILE: packages/jquery/tsconfig.json
================================================
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vitest/globals", "jquery"]
},
"include": ["src/**/*.ts", "test/**/*.ts", "tsup.config.ts"]
}
================================================
FILE: packages/jquery/tsup.config.ts
================================================
import { defineConfig } from "tsup";
export default defineConfig([
{
entry: {
index: "src/index.ts"
},
format: ["esm", "cjs"],
dts: true,
sourcemap: true,
clean: true,
target: "es2020"
},
{
entry: {
standalone: "src/standalone.ts"
},
format: ["iife"],
globalName: "form2jsJquery",
sourcemap: true,
clean: false,
target: "es2020",
external: ["jquery"]
}
]);
================================================
FILE: packages/js2form/CHANGELOG.md
================================================
# @form2js/js2form
## 3.2.0
### Minor Changes
- Restore Rails-style bracket path compatibility across parsing and form population, align DOM checkbox and radio handling with native browser submission behavior, and add explicit `SKIP_NODE` callback support for DOM extraction.
### Patch Changes
- Updated dependencies
- @form2js/core@3.3.0
## 3.1.4
### Patch Changes
- Republish the package metadata and package-level README improvements across all public packages after fixing publish configuration.
- Updated dependencies
- @form2js/core@3.2.2
## 3.1.3
### Patch Changes
- Add npm package metadata (`license`, `bugs`, `keywords`) and publish package-level README files for all public packages.
- Updated dependencies
- @form2js/core@3.2.1
## 3.1.2
### Patch Changes
- Updated dependencies [4d2f923]
- @form2js/core@3.2.0
## 3.1.1
### Patch Changes
- 77b8543: Hardened parser security by rejecting unsafe key path segments by default and aligned DOM extraction with HTML semantics (disabled fieldsets, no button-like inputs). Added a writer hook, improved multi-select mapping, tightened ESLint, added a version bump script, and aligned packages to 3.1.0 with updated docs, tests, and repo/website links.
New Features
Added allowUnsafePathSegments option across core, dom, form-data, and jQuery plugin to explicitly allow trusted unsafe segments.
Added nodeCallback to objectToForm/js2form; returning false skips default assignment for that node.
Improved multi-select handling: matches names with [] and bare-name fallbacks without per-option keys.
Added bump-version script to sync workspace versions and ranges; bumped packages to 3.1.0.
Enabled vitest for examples and added smoke tests; updated tsconfig and lint script.
Bug Fixes
Prevented prototype pollution by blocking "proto", "prototype", and "constructor" path tokens by default.
objectToEntries now serializes only own enumerable properties.
DOM extraction respects disabled fieldset rules (legend exception) and excludes button-like inputs even when skipEmpty is false.
ESLint config now errors on unsafe TypeScript operations (no-unsafe-* rules).
- Updated dependencies [77b8543]
- @form2js/core@3.1.1
================================================
FILE: packages/js2form/README.md
================================================
# @form2js/js2form
Populate form controls from plain object data.
## Install
```bash
npm install @form2js/js2form
```
## Minimal usage
```ts
import { objectToForm } from "@form2js/js2form";
objectToForm(document.getElementById("profileForm"), {
person: { name: { first: "Tiffany", last: "Aching" } },
});
```
For guides, playground examples, and API details, see the docs site:
https://maxatwork.github.io/form2js/?variant=js2form
License: MIT
================================================
FILE: packages/js2form/package.json
================================================
{
"name": "@form2js/js2form",
"version": "3.4.0",
"description": "Populate DOM forms from plain objects.",
"license": "MIT",
"repository": "https://github.com/maxatwork/form2js",
"homepage": "https://maxatwork.github.io/form2js/",
"bugs": {
"url": "https://github.com/maxatwork/form2js/issues"
},
"keywords": [
"form",
"forms",
"serialization",
"deserialization",
"form-data",
"json",
"object-to-form",
"populate-form"
],
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": [
"dist"
],
"engines": {
"node": ">=18"
},
"dependencies": {
"@form2js/core": "3.4.0"
},
"scripts": {
"build": "tsup --config tsup.config.ts",
"test": "vitest run",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "rimraf dist"
}
}
================================================
FILE: packages/js2form/src/index.ts
================================================
import { objectToEntries, type Entry } from "@form2js/core";
const ARRAY_ITEM_REGEXP = /\[[0-9]+?\]$/;
const LAST_INDEXED_ARRAY_REGEXP = /(.*)(\[)([0-9]*)(\])$/;
const ARRAY_OF_ARRAYS_REGEXP = /\[([0-9]+)\]\[([0-9]+)\]/g;
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;
type ArrayIndexesMap = Record<
string,
{
lastIndex: number;
indexes: Record;
emptyIndexGroup?: {
index: number;
seenSuffixes: Set;
};
}
>;
interface BracketMatch {
content: string;
index: number;
text: string;
}
function findBracketMatches(input: string): BracketMatch[] {
const matches: BracketMatch[] = [];
let cursor = 0;
while (cursor < input.length) {
const startIndex = input.indexOf("[", cursor);
if (startIndex === -1) {
break;
}
const endIndex = input.indexOf("]", startIndex + 1);
if (endIndex === -1) {
break;
}
matches.push({
content: input.slice(startIndex + 1, endIndex),
index: startIndex,
text: input.slice(startIndex, endIndex + 1)
});
cursor = endIndex + 1;
}
return matches;
}
function isNodeObject(value: unknown): value is Node {
return typeof value === "object" && value !== null && "nodeType" in value && "nodeName" in value;
}
function isElementNode(node: Node): node is Element {
return node.nodeType === 1;
}
function nodeNameIs(node: Node, expected: string): boolean {
return node.nodeName.toUpperCase() === expected;
}
function isInputNode(node: Node): node is HTMLInputElement {
return nodeNameIs(node, "INPUT");
}
function isTextareaNode(node: Node): node is HTMLTextAreaElement {
return nodeNameIs(node, "TEXTAREA");
}
function isSelectNode(node: Node): node is HTMLSelectElement {
return nodeNameIs(node, "SELECT");
}
function getDocumentFromRoot(rootNode: RootNodeInput, fallback?: Document): Document {
if (fallback) {
return fallback;
}
if (typeof document !== "undefined") {
return document;
}
if (isNodeObject(rootNode) && rootNode.ownerDocument) {
return rootNode.ownerDocument;
}
throw new Error("No document available. Provide options.document when running outside a browser.");
}
function resolveRootNode(rootNode: RootNodeInput, options: ObjectToFormOptions): Node | null {
if (!rootNode) {
return null;
}
if (typeof rootNode !== "string") {
return isNodeObject(rootNode) ? rootNode : null;
}
const doc = getDocumentFromRoot(rootNode, options.document);
return doc.getElementById(rootNode);
}
function isSupportedField(node: Node): node is SupportedField {
return isInputNode(node) || isTextareaNode(node) || isSelectNode(node);
}
function shouldSkipNodeAssignment(node: Node, nodeCallback: ObjectToFormNodeCallback): boolean {
return Boolean(nodeCallback && nodeCallback(node) === false);
}
function normalizeName(name: string, delimiter: string, arrayIndexes: ArrayIndexesMap): string {
let nameToNormalize = name;
const rawChunks = name.split(delimiter);
const normalizedRawChunks: string[] = [];
for (const rawChunk of rawChunks) {
const bracketMatches = findBracketMatches(rawChunk);
if (bracketMatches.length === 0) {
normalizedRawChunks.push(rawChunk);
continue;
}
let currentChunk = "";
let cursor = 0;
for (const match of bracketMatches) {
const literalText = rawChunk.slice(cursor, match.index ?? cursor);
if (literalText !== "") {
currentChunk += literalText;
}
const bracketContent = match.content;
const isArraySegment = bracketContent === "" || /^\d+$/.test(bracketContent);
if (isArraySegment) {
if (currentChunk !== "" && currentChunk.endsWith("]")) {
normalizedRawChunks.push(currentChunk);
currentChunk = "";
}
currentChunk = `${currentChunk}[${bracketContent}]`;
} else {
if (currentChunk !== "") {
normalizedRawChunks.push(currentChunk);
}
currentChunk = bracketContent;
}
cursor = match.index + match.text.length;
}
const trailingText = rawChunk.slice(cursor);
if (trailingText !== "") {
currentChunk += trailingText;
}
if (currentChunk !== "") {
normalizedRawChunks.push(currentChunk);
}
}
if (normalizedRawChunks.length > 0) {
nameToNormalize = normalizedRawChunks.join(delimiter);
}
const normalizedNameChunks: string[] = [];
const chunks = nameToNormalize.replace(ARRAY_OF_ARRAYS_REGEXP, "[$1].[$2]").split(delimiter);
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
const currentChunk = chunks[chunkIndex] ?? "";
normalizedNameChunks.push(currentChunk);
const nameMatches = currentChunk.match(LAST_INDEXED_ARRAY_REGEXP);
if (!nameMatches) {
continue;
}
let currentNormalizedName = normalizedNameChunks.join(delimiter);
const currentIndex = currentNormalizedName.replace(LAST_INDEXED_ARRAY_REGEXP, "$3");
currentNormalizedName = currentNormalizedName.replace(LAST_INDEXED_ARRAY_REGEXP, "$1");
const arrayIndexInfo = (arrayIndexes[currentNormalizedName] ??= {
lastIndex: -1,
indexes: {}
});
if (currentIndex === "") {
const remainingPath = chunks.slice(chunkIndex + 1).join(delimiter);
const currentGroup = arrayIndexInfo.emptyIndexGroup;
if (
!currentGroup ||
remainingPath === "" ||
currentGroup.seenSuffixes.has(remainingPath)
) {
arrayIndexInfo.lastIndex += 1;
arrayIndexInfo.emptyIndexGroup = {
index: arrayIndexInfo.lastIndex,
seenSuffixes: new Set(remainingPath === "" ? [] : [remainingPath])
};
} else {
currentGroup.seenSuffixes.add(remainingPath);
}
} else if (arrayIndexInfo.indexes[currentIndex] === undefined) {
arrayIndexInfo.lastIndex += 1;
arrayIndexInfo.indexes[currentIndex] = arrayIndexInfo.lastIndex;
}
const newIndex =
currentIndex === ""
? (arrayIndexInfo.emptyIndexGroup?.index ?? 0)
: arrayIndexInfo.indexes[currentIndex];
normalizedNameChunks[normalizedNameChunks.length - 1] = currentChunk.replace(
LAST_INDEXED_ARRAY_REGEXP,
`$1$2${newIndex}$4`
);
}
return normalizedNameChunks.join(delimiter).replace("].[", "][");
}
function mergeField(result: FieldMap, key: string, value: SupportedFieldCollection): void {
const existing = result[key];
if (!existing) {
result[key] = value;
return;
}
if (Array.isArray(existing)) {
if (Array.isArray(value)) {
existing.push(...value);
} else {
existing.push(value);
}
return;
}
if (Array.isArray(value)) {
result[key] = [existing, ...value];
return;
}
result[key] = value;
}
function getFields(
rootNode: Node,
useIdIfEmptyName: boolean,
delimiter: string,
arrayIndexes: ArrayIndexesMap,
shouldClean: boolean
): FieldMap {
const result: FieldMap = {};
let currentNode: ChildNode | null = rootNode.firstChild;
while (currentNode) {
let name = "";
if (isElementNode(currentNode)) {
const namedNode = currentNode as Element & { name?: string; id?: string };
if (namedNode.name && namedNode.name !== "") {
name = namedNode.name;
} else if (useIdIfEmptyName && namedNode.id && namedNode.id !== "") {
name = namedNode.id;
}
}
if (name === "") {
const subFields = getFields(currentNode, useIdIfEmptyName, delimiter, arrayIndexes, shouldClean);
for (const [subFieldName, subFieldValue] of Object.entries(subFields)) {
mergeField(result, subFieldName, subFieldValue);
}
} else if (isSelectNode(currentNode)) {
const options = currentNode.getElementsByTagName("option");
for (let optionIndex = 0; optionIndex < options.length; optionIndex += 1) {
if (shouldClean) {
const option = options[optionIndex];
if (option) {
option.selected = false;
}
}
}
const normalizedName = normalizeName(name, delimiter, arrayIndexes);
result[normalizedName] = currentNode;
const arraySyntaxName = normalizedName.replace(ARRAY_ITEM_REGEXP, "[]");
if (arraySyntaxName !== normalizedName) {
result[arraySyntaxName] = currentNode;
}
const bareArrayName = normalizedName.replace(ARRAY_ITEM_REGEXP, "");
if (bareArrayName !== normalizedName) {
result[bareArrayName] = currentNode;
}
} else if (isInputNode(currentNode) && /CHECKBOX|RADIO/i.test(currentNode.type)) {
if (shouldClean) {
currentNode.checked = false;
}
const normalizedName = normalizeName(name, delimiter, arrayIndexes).replace(ARRAY_ITEM_REGEXP, "[]");
if (!result[normalizedName]) {
result[normalizedName] = [];
}
const existing = result[normalizedName];
if (Array.isArray(existing)) {
existing.push(currentNode);
} else {
result[normalizedName] = [existing, currentNode];
}
} else if (isSupportedField(currentNode)) {
if (shouldClean) {
currentNode.value = "";
}
const normalizedName = normalizeName(name, delimiter, arrayIndexes);
result[normalizedName] = currentNode;
}
currentNode = currentNode.nextSibling;
}
return result;
}
function setValue(
field: SupportedFieldCollection,
value: unknown,
nodeCallback: ObjectToFormNodeCallback
): void {
if (Array.isArray(field)) {
for (const inputNode of field) {
if (shouldSkipNodeAssignment(inputNode, nodeCallback)) {
continue;
}
if (isInputNode(inputNode) && (inputNode.value === String(value) || value === true)) {
inputNode.checked = true;
}
}
return;
}
if (shouldSkipNodeAssignment(field, nodeCallback)) {
return;
}
if (isInputNode(field) || isTextareaNode(field)) {
field.value = String(value ?? "");
return;
}
if (isSelectNode(field)) {
const options = field.getElementsByTagName("option");
for (let index = 0; index < options.length; index += 1) {
const option = options[index];
if (!option) {
continue;
}
if (option.value === String(value)) {
option.selected = true;
if (field.multiple) {
break;
}
} else if (!field.multiple) {
option.selected = false;
}
}
}
}
function toPathEntries(data: unknown): Entry[] {
return objectToEntries(data).map((entry) => ({
key: entry.key,
value: entry.value
}));
}
export function flattenDataForForm(data: unknown): Entry[] {
return toPathEntries(data);
}
export function mapFieldsByName(
rootNode: RootNodeInput,
options: Pick = {}
): FieldMap {
const resolvedRoot = resolveRootNode(rootNode, options);
if (!resolvedRoot) {
return {};
}
return getFields(
resolvedRoot,
options.useIdIfEmptyName ?? false,
options.delimiter ?? ".",
{},
options.shouldClean ?? true
);
}
export function objectToForm(rootNode: RootNodeInput, data: unknown, options: ObjectToFormOptions = {}): void {
const resolvedRoot = resolveRootNode(rootNode, options);
if (!resolvedRoot) {
return;
}
const delimiter = options.delimiter ?? ".";
const nodeCallback = options.nodeCallback ?? null;
const fieldValues = toPathEntries(data);
const formFieldsByName = getFields(
resolvedRoot,
options.useIdIfEmptyName ?? false,
delimiter,
{},
options.shouldClean ?? true
);
for (const fieldValue of fieldValues) {
const fieldName = fieldValue.key;
const value = fieldValue.value;
if (formFieldsByName[fieldName]) {
setValue(formFieldsByName[fieldName], value, nodeCallback);
continue;
}
const arraySyntaxName = fieldName.replace(ARRAY_ITEM_REGEXP, "[]");
if (formFieldsByName[arraySyntaxName]) {
setValue(formFieldsByName[arraySyntaxName], value, nodeCallback);
continue;
}
const bareArrayName = fieldName.replace(ARRAY_ITEM_REGEXP, "");
if (formFieldsByName[bareArrayName]) {
setValue(formFieldsByName[bareArrayName], value, nodeCallback);
}
}
}
export function js2form(
rootNode: RootNodeInput,
data: unknown,
delimiter = ".",
nodeCallback: ObjectToFormNodeCallback = null,
useIdIfEmptyName = false
): void {
objectToForm(rootNode, data, {
delimiter,
nodeCallback,
useIdIfEmptyName
});
}
export { normalizeName };
export type { Entry } from "@form2js/core";
================================================
FILE: packages/js2form/test/js2form.test.ts
================================================
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import {
flattenDataForForm,
js2form,
mapFieldsByName,
objectToForm,
} from "../src/index";
describe("objectToForm", () => {
it("populates text, checkbox, radio and select fields", () => {
document.body.innerHTML = `
`;
objectToForm("testForm", {
person: {
name: { first: "Jane" },
favFood: ["steak"],
gender: "female",
city: "paris",
},
});
const firstName = document.querySelector(
"input[name='person.name.first']"
) as HTMLInputElement;
const steak = document.querySelector(
"input[name='person.favFood[]'][value='steak']"
) as HTMLInputElement;
const pizza = document.querySelector(
"input[name='person.favFood[]'][value='pizza']"
) as HTMLInputElement;
const female = document.querySelector(
"input[name='person.gender'][value='female']"
) as HTMLInputElement;
const city = document.querySelector(
"select[name='person.city']"
) as HTMLSelectElement;
expect(firstName.value).toBe("Jane");
expect(steak.checked).toBe(true);
expect(pizza.checked).toBe(false);
expect(female.checked).toBe(true);
expect(city.value).toBe("paris");
});
it("keeps compatibility wrapper", () => {
document.body.innerHTML = `
`;
js2form("testForm", { foo: { name: { first: "Neo" } } });
const input = document.querySelector(
"input[name='foo.name.first']"
) as HTMLInputElement;
expect(input.value).toBe("Neo");
});
it("supports nodeCallback by skipping default assignment when callback returns false", () => {
document.body.innerHTML = `
`;
objectToForm(
"testForm",
{ person: { name: { first: "Jane" } } },
{
nodeCallback(node) {
if (node instanceof HTMLInputElement && node.name === "person.name.first") {
node.value = "from-callback";
return false;
}
return null;
}
}
);
const input = document.querySelector(
"input[name='person.name.first']"
) as HTMLInputElement;
expect(input.value).toBe("from-callback");
});
it("populates multi-select values from arrays", () => {
document.body.innerHTML = `
`;
objectToForm("testForm", {
person: {
colors: ["red", "blue"]
}
});
const red = document.querySelector("option[value='red']") as HTMLOptionElement;
const blue = document.querySelector("option[value='blue']") as HTMLOptionElement;
const green = document.querySelector("option[value='green']") as HTMLOptionElement;
expect(red.selected).toBe(true);
expect(blue.selected).toBe(true);
expect(green.selected).toBe(false);
});
it("clears existing values by default and preserves them when shouldClean is false", () => {
document.body.innerHTML = `
`;
objectToForm("testForm", {
person: {
name: { last: "Vimes" },
favFood: ["pizza"]
}
});
const firstAfterClean = document.querySelector("input[name='person.name.first']") as HTMLInputElement;
const lastAfterClean = document.querySelector("input[name='person.name.last']") as HTMLInputElement;
const steakAfterClean = document.querySelector(
"input[name='person.favFood[]'][value='steak']"
) as HTMLInputElement;
const pizzaAfterClean = document.querySelector(
"input[name='person.favFood[]'][value='pizza']"
) as HTMLInputElement;
expect(firstAfterClean.value).toBe("");
expect(lastAfterClean.value).toBe("Vimes");
expect(steakAfterClean.checked).toBe(false);
expect(pizzaAfterClean.checked).toBe(true);
document.body.innerHTML = `
`;
objectToForm(
"testForm",
{
person: {
name: { last: "Vimes" },
favFood: ["pizza"]
}
},
{ shouldClean: false }
);
const firstPreserved = document.querySelector("input[name='person.name.first']") as HTMLInputElement;
const lastPreserved = document.querySelector("input[name='person.name.last']") as HTMLInputElement;
const steakPreserved = document.querySelector(
"input[name='person.favFood[]'][value='steak']"
) as HTMLInputElement;
const pizzaPreserved = document.querySelector(
"input[name='person.favFood[]'][value='pizza']"
) as HTMLInputElement;
expect(firstPreserved.value).toBe("Sam");
expect(lastPreserved.value).toBe("Vimes");
expect(steakPreserved.checked).toBe(true);
expect(pizzaPreserved.checked).toBe(true);
});
it("populates nested table fields (issue #23 regression)", () => {
document.body.innerHTML = `
`;
objectToForm("testForm", {
phone: "555-0123",
address: {
street1: "123 Test St",
street2: "Suite 5",
city: "Portland",
state: "OR",
zip: "97201",
country: "US"
}
});
expect((document.querySelector("input[name='phone']") as HTMLInputElement).value).toBe("555-0123");
expect((document.querySelector("input[name='address.street1']") as HTMLInputElement).value).toBe(
"123 Test St"
);
expect((document.querySelector("input[name='address.street2']") as HTMLInputElement).value).toBe("Suite 5");
expect((document.querySelector("input[name='address.city']") as HTMLInputElement).value).toBe("Portland");
expect((document.querySelector("input[name='address.state']") as HTMLInputElement).value).toBe("OR");
expect((document.querySelector("input[name='address.zip']") as HTMLInputElement).value).toBe("97201");
expect((document.querySelector("input[name='address.country']") as HTMLInputElement).value).toBe("US");
});
it("supports id fallback and explicit document resolution", () => {
const customDocument = document.implementation.createHTMLDocument("custom");
customDocument.body.innerHTML = `
`;
objectToForm(
"testForm",
{
person: {
name: {
first: "Neo"
}
}
},
{
useIdIfEmptyName: true,
document: customDocument
}
);
const input = customDocument.getElementById("person.name.first") as HTMLInputElement;
expect(input.value).toBe("Neo");
});
it("handles literal name and id field names", () => {
document.body.innerHTML = `
`;
objectToForm("testForm", {
name: "Form Name",
id: "form-id"
});
expect((document.querySelector("input[name='name']") as HTMLInputElement).value).toBe("Form Name");
expect((document.querySelector("input[name='id']") as HTMLInputElement).value).toBe("form-id");
});
it("writes empty strings for null values instead of the literal null string", () => {
document.body.innerHTML = `
`;
objectToForm("testForm", {
person: {
nickname: null
}
});
expect((document.querySelector("input[name='person.nickname']") as HTMLInputElement).value).toBe("");
});
it("populates rails-style field names", () => {
document.body.innerHTML = `
`;
objectToForm("testForm", {
a: {
b: {
c: "value"
}
}
});
const input = document.querySelector("input[name='a[b][c]']") as HTMLInputElement;
expect(input.value).toBe("value");
});
it("keeps sibling rails array object fields on the same synthetic index", () => {
document.body.innerHTML = `
`;
objectToForm("testForm", {
items: [
{
title: "First",
description: "Desc"
}
]
});
expect((document.querySelector("input[name='items[][title]']") as HTMLInputElement).value).toBe("First");
expect((document.querySelector("input[name='items[][description]']") as HTMLInputElement).value).toBe(
"Desc"
);
});
});
describe("low-level helpers", () => {
it("maps fields by normalized names", () => {
document.body.innerHTML = `
`;
const fields = mapFieldsByName("testForm", { shouldClean: false });
expect(Object.keys(fields)).toContain("items[0].name");
expect(Object.keys(fields)).toContain("items[1].name");
});
it("flattens object data to path entries", () => {
const entries = flattenDataForForm({ foo: { bar: ["a", "b"] } });
expect(entries).toContainEqual({ key: "foo.bar[0]", value: "a" });
expect(entries).toContainEqual({ key: "foo.bar[1]", value: "b" });
});
it("maps multi-select names without creating one key per option", () => {
document.body.innerHTML = `
`;
const fields = mapFieldsByName("testForm", { shouldClean: false });
expect(Object.keys(fields)).toContain("person.colors[]");
expect(Object.keys(fields)).not.toContain("person.colors[1]");
expect(Object.keys(fields)).not.toContain("person.colors[2]");
});
it("preserves literal suffixes around indexed bracket groups", () => {
document.body.innerHTML = `
`;
const fields = mapFieldsByName("testForm", { shouldClean: false });
expect(Object.keys(fields)).toContain("items[5]_id");
});
it("preserves unmatched bracket-heavy field names", () => {
document.body.innerHTML = `
`;
const fields = mapFieldsByName("testForm", { shouldClean: false });
expect(Object.keys(fields)).toContain("filters[[[[value");
});
});
================================================
FILE: packages/js2form/tsconfig.json
================================================
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2020", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*.ts", "test/**/*.ts", "tsup.config.ts"]
}
================================================
FILE: packages/js2form/tsup.config.ts
================================================
import { defineConfig } from "tsup";
export default defineConfig({
entry: {
index: "src/index.ts"
},
format: ["esm", "cjs"],
dts: true,
sourcemap: true,
clean: true,
target: "es2020"
});
================================================
FILE: packages/react/CHANGELOG.md
================================================
# @form2js/react
## 3.2.3
### Patch Changes
- @form2js/form-data@3.2.3
## 3.2.2
### Patch Changes
- Republish the package metadata and package-level README improvements across all public packages after fixing publish configuration.
- Updated dependencies
- @form2js/form-data@3.2.2
## 3.2.1
### Patch Changes
- Add npm package metadata (`license`, `bugs`, `keywords`) and publish package-level README files for all public packages.
- Updated dependencies
- @form2js/form-data@3.2.1
## 3.2.0
### Minor Changes
- 4d2f923: Added optional schema validation support (structural `parse`) to parsing APIs in core and form-data, plus a new `@form2js/react` package with `useForm2js` for async submit state management in React forms.
### Patch Changes
- Updated dependencies [4d2f923]
- @form2js/form-data@3.2.0
## 3.1.1
### Patch Changes
- Initial package scaffold.
================================================
FILE: packages/react/README.md
================================================
# @form2js/react
Handle form submission with a small React hook built on form2js parsing.
## Install
```bash
npm install @form2js/react react
```
## Minimal usage
```tsx
import { useForm2js } from "@form2js/react";
export function ProfileForm(): React.JSX.Element {
const { onSubmit, isSubmitting } = useForm2js(async (data) => {
await saveProfile(data);
});
return (
);
}
```
For guides, playground examples, and API details, see the docs site:
https://maxatwork.github.io/form2js/?variant=react
License: MIT
================================================
FILE: packages/react/package.json
================================================
{
"name": "@form2js/react",
"version": "3.4.0",
"description": "React hook adapter for form2js.",
"license": "MIT",
"repository": "https://github.com/maxatwork/form2js",
"homepage": "https://maxatwork.github.io/form2js/",
"bugs": {
"url": "https://github.com/maxatwork/form2js/issues"
},
"keywords": [
"form",
"forms",
"serialization",
"deserialization",
"form-data",
"json",
"react",
"hook",
"submit"
],
"type": "module",
"sideEffects": false,
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": [
"dist"
],
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": ">=18.0.0"
},
"dependencies": {
"@form2js/form-data": "3.4.0"
},
"devDependencies": {
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"scripts": {
"build": "tsup --config tsup.config.ts",
"test": "vitest run",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "rimraf dist"
}
}
================================================
FILE: packages/react/src/index.ts
================================================
import { formDataToObject, type InferSchemaOutput, type ObjectTree, type ParseOptions, type SchemaValidator } from "@form2js/form-data";
import { useCallback, useRef, useState, type SyntheticEvent } from "react";
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;
}
function buildParseOptions(options: ParseOptions): ParseOptions {
const parseOptions: ParseOptions = {};
if (options.delimiter !== undefined) {
parseOptions.delimiter = options.delimiter;
}
if (options.skipEmpty !== undefined) {
parseOptions.skipEmpty = options.skipEmpty;
}
if (options.allowUnsafePathSegments !== undefined) {
parseOptions.allowUnsafePathSegments = options.allowUnsafePathSegments;
}
return parseOptions;
}
export function useForm2js(
submit: UseForm2jsSubmit,
options: UseForm2jsOptions = {}
): UseForm2jsResult {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isError, setIsError] = useState(false);
const [error, setError] = useState(null);
const [isSuccess, setIsSuccess] = useState(false);
const isSubmittingRef = useRef(false);
const { allowUnsafePathSegments, delimiter, schema, skipEmpty } = options;
const reset = useCallback(() => {
setIsError(false);
setError(null);
setIsSuccess(false);
}, []);
const onSubmit = useCallback(
async (event: SyntheticEvent) => {
event.preventDefault();
if (isSubmittingRef.current) {
return;
}
isSubmittingRef.current = true;
setIsSubmitting(true);
setIsError(false);
setError(null);
setIsSuccess(false);
try {
const parseOptions = buildParseOptions(options);
const formData = new FormData(event.currentTarget);
const data = schema
? formDataToObject(formData, { ...parseOptions, schema })
: formDataToObject(formData, parseOptions);
await submit(data as UseForm2jsData);
setIsSuccess(true);
} catch (submitError: unknown) {
setIsError(true);
setError(submitError);
} finally {
setIsSubmitting(false);
isSubmittingRef.current = false;
}
},
[allowUnsafePathSegments, delimiter, schema, skipEmpty, submit]
);
return {
onSubmit,
isSubmitting,
isError,
error,
isSuccess,
reset
};
}
================================================
FILE: packages/react/test/react.test.ts
================================================
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SchemaValidator } from "@form2js/form-data";
import { useForm2js, type UseForm2jsOptions, type UseForm2jsResult, type UseForm2jsSubmit } from "../src/index";
const reactActScope = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
reactActScope.IS_REACT_ACT_ENVIRONMENT = true;
interface HarnessProps {
submit: UseForm2jsSubmit;
options?: UseForm2jsOptions;
onSnapshot: (state: UseForm2jsResult) => void;
renderFields?: () => React.ReactNode;
}
function Harness(
props: HarnessProps
): React.ReactElement {
const state = useForm2js(props.submit, props.options);
props.onSnapshot(state);
const fields =
props.renderFields?.() ??
React.createElement("input", { name: "person.name", defaultValue: "Neo" });
return React.createElement(
"form",
{ onSubmit: state.onSubmit },
fields,
React.createElement("button", { type: "submit" }, "Submit")
);
}
interface MountedHarness {
root: Root;
container: HTMLDivElement;
}
const mountedHarnesses: MountedHarness[] = [];
afterEach(() => {
for (const mounted of mountedHarnesses) {
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
mountedHarnesses.length = 0;
});
function renderHarness(
submit: UseForm2jsSubmit,
options?: UseForm2jsOptions,
renderFields?: () => React.ReactNode
): {
form: HTMLFormElement;
getState: () => UseForm2jsResult;
} {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
mountedHarnesses.push({ root, container });
let latestState: UseForm2jsResult | null = null;
const harnessProps: HarnessProps = {
submit,
onSnapshot(state) {
latestState = state;
}
};
if (options !== undefined) {
harnessProps.options = options;
}
if (renderFields !== undefined) {
harnessProps.renderFields = renderFields;
}
act(() => {
root.render(React.createElement(Harness, harnessProps));
});
const form = container.querySelector("form");
if (!(form instanceof HTMLFormElement)) {
throw new Error("Harness form was not rendered");
}
return {
form,
getState() {
if (!latestState) {
throw new Error("Hook state snapshot is not available");
}
return latestState;
}
};
}
function dispatchSubmit(form: HTMLFormElement): void {
act(() => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
}
async function submitAndFlush(form: HTMLFormElement): Promise {
await act(async () => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await Promise.resolve();
});
}
function createDeferred(): {
promise: Promise;
resolve: (value: TValue) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value: TValue) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise((innerResolve, innerReject) => {
resolve = innerResolve;
reject = innerReject;
});
return { promise, resolve, reject };
}
describe("useForm2js", () => {
it("submits validated data and marks submit as successful", async () => {
const schema = {
parse(value: unknown) {
const record = value as { person?: { name?: string } };
return {
profileName: (record.person?.name ?? "").toUpperCase()
};
}
};
const received: unknown[] = [];
const submit = vi.fn((data: { profileName: string }) => {
received.push(data);
});
const { form, getState } = renderHarness(submit, { schema });
await submitAndFlush(form);
expect(submit).toHaveBeenCalledTimes(1);
expect(received).toEqual([{ profileName: "NEO" }]);
expect(getState().isSubmitting).toBe(false);
expect(getState().isError).toBe(false);
expect(getState().error).toBeNull();
expect(getState().isSuccess).toBe(true);
});
it("captures validation errors and does not call submit callback", async () => {
const schema = {
parse() {
throw new Error("Invalid payload");
}
};
const submit = vi.fn(() => Promise.resolve());
const { form, getState } = renderHarness(submit, { schema });
await submitAndFlush(form);
expect(submit).not.toHaveBeenCalled();
expect(getState().isSubmitting).toBe(false);
expect(getState().isError).toBe(true);
expect(getState().isSuccess).toBe(false);
expect(getState().error).toBeInstanceOf(Error);
});
it("captures submit errors from async callback", async () => {
const submit = vi.fn(() => Promise.reject(new Error("Network failed")));
const { form, getState } = renderHarness(submit);
await submitAndFlush(form);
expect(submit).toHaveBeenCalledTimes(1);
expect(getState().isError).toBe(true);
expect(getState().isSuccess).toBe(false);
expect(getState().error).toBeInstanceOf(Error);
});
it("ignores duplicate submit attempts while request is in flight", async () => {
const deferred = createDeferred();
const submit = vi.fn(() => deferred.promise);
const { form, getState } = renderHarness(submit);
dispatchSubmit(form);
dispatchSubmit(form);
expect(submit).toHaveBeenCalledTimes(1);
expect(getState().isSubmitting).toBe(true);
deferred.resolve(undefined);
await act(async () => {
await deferred.promise;
await Promise.resolve();
});
expect(getState().isSubmitting).toBe(false);
});
it("reset clears error and success flags", async () => {
const submit = vi.fn(() => Promise.reject(new Error("submit failed")));
const { form, getState } = renderHarness(submit);
await submitAndFlush(form);
expect(getState().isError).toBe(true);
act(() => {
getState().reset();
});
expect(getState().isError).toBe(false);
expect(getState().error).toBeNull();
expect(getState().isSuccess).toBe(false);
});
it("submits parsed object when schema is omitted", async () => {
const received: unknown[] = [];
const submit = vi.fn((data: unknown) => {
received.push(data);
});
const { form, getState } = renderHarness(submit);
await submitAndFlush(form);
expect(submit).toHaveBeenCalledTimes(1);
expect(received).toEqual([
{
person: {
name: "Neo"
}
}
]);
expect(getState().isSuccess).toBe(true);
});
it("forwards delimiter and skipEmpty options to the parser", async () => {
const received: unknown[] = [];
const submit = vi.fn((data: unknown) => {
received.push(data);
});
const { form } = renderHarness(
submit,
{
delimiter: "/",
skipEmpty: false
},
() =>
React.createElement(
React.Fragment,
null,
React.createElement("input", { name: "person/name", defaultValue: "Neo" }),
React.createElement("input", { name: "person/alias", defaultValue: "" })
)
);
await submitAndFlush(form);
expect(received).toEqual([
{
person: {
name: "Neo",
alias: ""
}
}
]);
});
it("forwards allowUnsafePathSegments to the parser", async () => {
const submit = vi.fn(() => Promise.resolve());
const { form, getState } = renderHarness(
submit,
{
allowUnsafePathSegments: true
},
() => React.createElement("input", { name: "__proto__.polluted", defaultValue: "yes" })
);
await submitAndFlush(form);
expect(submit).toHaveBeenCalledTimes(1);
expect(getState().isError).toBe(false);
expect(getState().isSuccess).toBe(true);
});
});
================================================
FILE: packages/react/tsconfig.json
================================================
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2020", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*.ts", "test/**/*.ts", "tsup.config.ts"]
}
================================================
FILE: packages/react/tsup.config.ts
================================================
import { defineConfig } from "tsup";
export default defineConfig({
entry: {
index: "src/index.ts"
},
format: ["esm", "cjs"],
dts: true,
sourcemap: true,
clean: true,
target: "es2020"
});
================================================
FILE: scripts/bump-version.mjs
================================================
#!/usr/bin/env node
import { readFile, writeFile, readdir } from "node:fs/promises";
import path from "node:path";
const VERSION_PARTS_RE = /^(\d+)\.(\d+)\.(\d+)$/;
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
const BUMP_TYPES = new Set(["patch", "minor", "major"]);
const DEP_FIELDS = [
"dependencies",
"devDependencies",
"peerDependencies",
"optionalDependencies",
];
function usage(exitCode = 1) {
console.error(
"Usage: npm run bump-version -- [patch|minor|major|]"
);
process.exit(exitCode);
}
function nextVersion(currentVersion, bumpType) {
const parts = VERSION_PARTS_RE.exec(currentVersion);
if (!parts) {
throw new Error(
`Version ${JSON.stringify(
currentVersion
)} is not x.y.z; use an exact version instead.`
);
}
let major = Number(parts[1]);
let minor = Number(parts[2]);
let patch = Number(parts[3]);
if (bumpType === "major") {
major += 1;
minor = 0;
patch = 0;
} else if (bumpType === "minor") {
minor += 1;
patch = 0;
} else {
patch += 1;
}
return `${major}.${minor}.${patch}`;
}
async function expandWorkspacePattern(pattern) {
if (!pattern.endsWith("/*")) {
throw new Error(
`Unsupported workspace pattern ${JSON.stringify(
pattern
)}; only trailing /* patterns are supported.`
);
}
const baseDir = pattern.slice(0, -2);
const absoluteBaseDir = path.resolve(baseDir);
const entries = await readdir(absoluteBaseDir, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(baseDir, entry.name));
}
async function readJson(filePath) {
const raw = await readFile(filePath, "utf8");
return JSON.parse(raw);
}
async function readJsonIfExists(filePath) {
try {
return await readJson(filePath);
} catch (error) {
if (error && typeof error === "object" && error.code === "ENOENT") {
return null;
}
throw error;
}
}
function rewriteDependencyRange(currentRange, targetVersion) {
const clean = currentRange.trim();
if (clean.startsWith("workspace:")) {
const workspaceRange = clean.slice("workspace:".length);
if (workspaceRange.startsWith("^")) return `workspace:^${targetVersion}`;
if (workspaceRange.startsWith("~")) return `workspace:~${targetVersion}`;
if (workspaceRange === "*" || workspaceRange === "^" || workspaceRange === "~") {
return clean;
}
return `workspace:${targetVersion}`;
}
if (clean.startsWith("^")) return `^${targetVersion}`;
if (clean.startsWith("~")) return `~${targetVersion}`;
return targetVersion;
}
async function main() {
const input = process.argv[2];
if (!input || input === "--help" || input === "-h") {
usage(input ? 0 : 1);
}
const rootManifest = await readJson(path.resolve("package.json"));
const workspacePatterns = Array.isArray(rootManifest.workspaces)
? rootManifest.workspaces
: rootManifest.workspaces?.packages;
if (!Array.isArray(workspacePatterns) || workspacePatterns.length === 0) {
throw new Error("No workspaces found in root package.json.");
}
const workspaceDirs = (
await Promise.all(workspacePatterns.map((pattern) => expandWorkspacePattern(pattern)))
).flat();
const packagePaths = workspaceDirs.map((dir) => path.resolve(dir, "package.json"));
const manifests = (
await Promise.all(
packagePaths.map(async (packagePath) => {
const data = await readJsonIfExists(packagePath);
return data ? { packagePath, data } : null;
})
)
).filter(Boolean);
const workspacePackageNames = new Set(
manifests.map((manifest) => manifest.data.name).filter(Boolean)
);
const versionedManifests = manifests.filter(
(manifest) => typeof manifest.data.version === "string"
);
if (versionedManifests.length === 0) {
throw new Error("No versioned workspace packages were found.");
}
const uniqueVersions = [...new Set(versionedManifests.map((m) => m.data.version))];
const currentVersion = uniqueVersions[0];
if (BUMP_TYPES.has(input) && uniqueVersions.length !== 1) {
throw new Error(
`Workspace versions are not aligned: ${uniqueVersions.join(
", "
)}. Use an exact version to set all packages explicitly.`
);
}
const targetVersion = BUMP_TYPES.has(input) ? nextVersion(currentVersion, input) : input;
if (!SEMVER_RE.test(targetVersion)) {
throw new Error(
`Invalid version input ${JSON.stringify(
input
)}. Use patch/minor/major or an exact semver version.`
);
}
for (const manifest of manifests) {
if (typeof manifest.data.version === "string") {
manifest.data.version = targetVersion;
}
for (const field of DEP_FIELDS) {
const deps = manifest.data[field];
if (!deps || typeof deps !== "object") continue;
for (const [depName, depRange] of Object.entries(deps)) {
if (
workspacePackageNames.has(depName) &&
typeof depRange === "string"
) {
deps[depName] = rewriteDependencyRange(depRange, targetVersion);
}
}
}
}
await Promise.all(
manifests.map(({ packagePath, data }) =>
writeFile(packagePath, `${JSON.stringify(data, null, 2)}\n`, "utf8")
)
);
console.log(`Bumped workspace package versions to ${targetVersion}`);
}
main().catch((error) => {
console.error(`bump-version failed: ${error.message}`);
process.exit(1);
});
================================================
FILE: scripts/rewrite-scope.mjs
================================================
#!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
const ROOT = process.cwd();
const SOURCE_SCOPE = "@form2js";
const SKIP_DIRS = new Set([".git", "node_modules", "dist", "coverage", ".turbo"]);
const TEXT_EXTENSIONS = new Set([
".ts",
".mts",
".cts",
".js",
".mjs",
".cjs",
".json",
".md",
".html"
]);
function parseArgs(argv) {
const args = { scope: "", dryRun: false };
for (let i = 0; i < argv.length; i += 1) {
const part = argv[i];
if (part === "--dry-run") {
args.dryRun = true;
continue;
}
if (part === "--scope") {
args.scope = argv[i + 1] ?? "";
i += 1;
continue;
}
if (part.startsWith("--scope=")) {
args.scope = part.slice("--scope=".length);
continue;
}
}
return args;
}
function assertScope(scope) {
if (!scope.startsWith("@") || scope.includes("/")) {
throw new Error("Scope must look like @your-scope");
}
if (scope === SOURCE_SCOPE) {
throw new Error("New scope is the same as current scope @form2js");
}
}
async function walk(dir, files = []) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) {
continue;
}
await walk(fullPath, files);
continue;
}
files.push(fullPath);
}
return files;
}
function rewriteDependencyMap(map, targetScope) {
if (!map) {
return { changed: false, value: map };
}
let changed = false;
const next = {};
for (const [name, version] of Object.entries(map)) {
if (name.startsWith(`${SOURCE_SCOPE}/`)) {
const newName = name.replace(`${SOURCE_SCOPE}/`, `${targetScope}/`);
next[newName] = version;
changed = true;
} else {
next[name] = version;
}
}
return { changed, value: next };
}
async function rewritePackageJson(filePath, targetScope, dryRun) {
const source = await fs.readFile(filePath, "utf8");
const parsed = JSON.parse(source);
let changed = false;
if (typeof parsed.name === "string" && parsed.name.startsWith(`${SOURCE_SCOPE}/`)) {
parsed.name = parsed.name.replace(`${SOURCE_SCOPE}/`, `${targetScope}/`);
changed = true;
}
for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
const result = rewriteDependencyMap(parsed[field], targetScope);
if (result.changed) {
parsed[field] = result.value;
changed = true;
}
}
if (!changed) {
return false;
}
if (!dryRun) {
await fs.writeFile(filePath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
}
return true;
}
async function rewriteTextFile(filePath, targetScope, dryRun) {
const source = await fs.readFile(filePath, "utf8");
const updated = source.replaceAll(`${SOURCE_SCOPE}/`, `${targetScope}/`);
if (source === updated) {
return false;
}
if (!dryRun) {
await fs.writeFile(filePath, updated, "utf8");
}
return true;
}
async function main() {
const { scope, dryRun } = parseArgs(process.argv.slice(2));
if (!scope) {
throw new Error("Usage: node scripts/rewrite-scope.mjs --scope @your-scope [--dry-run]");
}
assertScope(scope);
const files = await walk(ROOT);
const changedFiles = [];
for (const filePath of files) {
if (filePath.endsWith("package-lock.json")) {
continue;
}
const relative = path.relative(ROOT, filePath);
if (path.basename(filePath) === "package.json") {
if (await rewritePackageJson(filePath, scope, dryRun)) {
changedFiles.push(relative);
}
continue;
}
const extension = path.extname(filePath);
if (!TEXT_EXTENSIONS.has(extension)) {
continue;
}
if (await rewriteTextFile(filePath, scope, dryRun)) {
changedFiles.push(relative);
}
}
if (changedFiles.length === 0) {
console.log("No files required changes.");
return;
}
console.log(`${dryRun ? "[dry-run] " : ""}Updated ${changedFiles.length} file(s):`);
for (const file of changedFiles) {
console.log(`- ${file}`);
}
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
================================================
FILE: test/integration/bump-version.test.ts
================================================
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { describe, expect, it } from "vitest";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const scriptPath = path.join(repoRoot, "scripts", "bump-version.mjs");
function writeJson(filePath: string, value: unknown) {
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
describe("bump-version script", () => {
it("skips workspace directories that do not contain a package manifest", () => {
const tempDir = mkdtempSync(path.join(os.tmpdir(), "form2js-bump-version-"));
mkdirSync(path.join(tempDir, "packages", "core"), { recursive: true });
mkdirSync(path.join(tempDir, "apps", "docs"), { recursive: true });
mkdirSync(path.join(tempDir, "apps", "examples"), { recursive: true });
writeJson(path.join(tempDir, "package.json"), {
name: "fixture-monorepo",
private: true,
workspaces: ["packages/*", "apps/*"],
});
writeJson(path.join(tempDir, "packages", "core", "package.json"), {
name: "@form2js/core",
version: "1.0.0",
});
writeJson(path.join(tempDir, "apps", "docs", "package.json"), {
name: "@form2js/docs",
dependencies: {
"@form2js/core": "1.0.0",
},
});
const result = spawnSync(process.execPath, [scriptPath, "1.2.3"], {
cwd: tempDir,
encoding: "utf8",
});
expect(result.status).toBe(0);
const bumpedCore = JSON.parse(
readFileSync(path.join(tempDir, "packages", "core", "package.json"), "utf8")
) as { version: string };
const docsManifest = JSON.parse(
readFileSync(path.join(tempDir, "apps", "docs", "package.json"), "utf8")
) as { dependencies: { "@form2js/core": string } };
expect(bumpedCore.version).toBe("1.2.3");
expect(docsManifest.dependencies["@form2js/core"]).toBe("1.2.3");
});
});
================================================
FILE: test/integration/dependency-security.test.ts
================================================
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
type Lockfile = {
packages?: Record;
};
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const lockfilePath = path.join(repoRoot, "package-lock.json");
function readLockfile(): Lockfile {
return JSON.parse(readFileSync(lockfilePath, "utf8")) as Lockfile;
}
function compareSemver(left: string, right: string): number {
const leftParts = left.split("-", 1)[0].split(".").map(Number);
const rightParts = right.split("-", 1)[0].split(".").map(Number);
const length = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < length; index += 1) {
const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
if (difference !== 0) {
return difference;
}
}
return 0;
}
function isVulnerableVersion(name: string, version: string): boolean {
const [major = 0] = version.split(".", 1).map(Number);
if (name === "brace-expansion") {
if (major === 1) {
return compareSemver(version, "1.1.13") < 0;
}
if (major === 2) {
return compareSemver(version, "2.0.3") < 0;
}
if (major === 4) {
return true;
}
if (major === 5) {
return compareSemver(version, "5.0.5") < 0;
}
return false;
}
if (name === "picomatch") {
if (major === 2) {
return compareSemver(version, "2.3.2") < 0;
}
if (major === 4) {
return compareSemver(version, "4.0.4") < 0;
}
return false;
}
if (name === "smol-toml") {
return compareSemver(version, "1.6.1") < 0;
}
if (name === "vite") {
if (major === 6) {
return compareSemver(version, "6.4.2") < 0;
}
if (major === 7) {
return compareSemver(version, "7.3.2") < 0;
}
if (major === 8) {
return compareSemver(version, "8.0.5") < 0;
}
return false;
}
if (name === "defu") {
if (major === 6) {
return compareSemver(version, "6.1.5") < 0;
}
return false;
}
if (name === "yaml" && major === 2) {
return compareSemver(version, "2.8.3") < 0;
}
return false;
}
describe("dependency security", () => {
it("does not leave known vulnerable dependency versions in the lockfile", () => {
const lockfile = readLockfile();
const packages = lockfile.packages ?? {};
const vulnerablePackages = ["brace-expansion", "defu", "picomatch", "smol-toml", "vite", "yaml"];
for (const [packagePath, packageInfo] of Object.entries(packages)) {
const packageName = packagePath.split("/").at(-1);
const version = packageInfo.version;
if (!packageName || !version || !vulnerablePackages.includes(packageName)) {
continue;
}
expect(
isVulnerableVersion(packageName, version),
`${packagePath} resolves to vulnerable ${packageName}@${version}`
).toBe(false);
}
});
});
================================================
FILE: test/integration/form-flow.test.ts
================================================
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { formToObject } from "@form2js/dom";
import { formDataToObject } from "@form2js/form-data";
import { objectToForm } from "@form2js/js2form";
describe("integration: full data flow", () => {
it("supports form -> object -> form roundtrip", () => {
document.body.innerHTML = `
`;
const source = document.getElementById("source");
const target = document.getElementById("target");
const sourceObject = formToObject(source);
objectToForm(target, sourceObject);
const roundTripped = formToObject(target);
expect(roundTripped).toEqual(sourceObject);
});
it("accepts FormData with same path semantics", () => {
const formData = new FormData();
formData.append("items[8].name", "A");
formData.append("items[5].name", "B");
const result = formDataToObject(formData);
expect(result).toEqual({
items: [{ name: "A" }, { name: "B" }]
});
});
});
================================================
FILE: test/integration/package-metadata.test.ts
================================================
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
type PackageManifest = {
bugs?: { url?: string } | string;
homepage?: string;
keywords?: string[];
license?: string;
repository?: { url?: string } | string;
};
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const packages = [
"packages/core",
"packages/dom",
"packages/form-data",
"packages/js2form",
"packages/jquery",
"packages/react",
] as const;
function readManifest(packageDir: string): PackageManifest {
const manifestPath = path.join(repoRoot, packageDir, "package.json");
return JSON.parse(readFileSync(manifestPath, "utf8")) as PackageManifest;
}
describe("published package metadata", () => {
for (const packageDir of packages) {
it(`${packageDir} includes npm-facing metadata and a package README`, () => {
const manifest = readManifest(packageDir);
const readmePath = path.join(repoRoot, packageDir, "README.md");
expect(existsSync(readmePath), `${packageDir} is missing README.md`).toBe(true);
expect(manifest.license, `${packageDir} is missing license`).toBeTruthy();
expect(manifest.homepage, `${packageDir} is missing homepage`).toBeTruthy();
expect(manifest.repository, `${packageDir} is missing repository`).toBeTruthy();
expect(manifest.bugs, `${packageDir} is missing bugs`).toBeTruthy();
expect(manifest.keywords, `${packageDir} is missing keywords`).toBeInstanceOf(Array);
expect(manifest.keywords?.length ?? 0, `${packageDir} has no keywords`).toBeGreaterThan(0);
});
}
});
================================================
FILE: test/integration/workflow-node-version.test.ts
================================================
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
function readWorkflow(relativePath: string): string {
return readFileSync(path.join(repoRoot, relativePath), "utf8");
}
describe("workflow node versions", () => {
it("runs docs-related GitHub workflows on a supported Node.js version", () => {
const ciWorkflow = readWorkflow(".github/workflows/ci.yml");
const pagesWorkflow = readWorkflow(".github/workflows/pages.yml");
expect(ciWorkflow).toContain("node-version: [22.14.0]");
expect(ciWorkflow).toContain("name: docs-e2e (node 22.14.0)");
expect(ciWorkflow).toContain("node-version: 22.14.0");
expect(pagesWorkflow).toContain("node-version: 22.14.0");
});
});
================================================
FILE: tsconfig.base.json
================================================
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@form2js/core": ["packages/core/src/index.ts"],
"@form2js/dom": ["packages/dom/src/index.ts"],
"@form2js/form-data": ["packages/form-data/src/index.ts"],
"@form2js/js2form": ["packages/js2form/src/index.ts"],
"@form2js/jquery": ["packages/jquery/src/index.ts"],
"@form2js/react": ["packages/react/src/index.ts"]
},
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"types": [
"vitest/globals"
]
}
}
================================================
FILE: turbo.json
================================================
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": [
"^build"
],
"outputs": [
"dist/**"
]
},
"test": {
"dependsOn": [
"^build"
],
"outputs": []
},
"lint": {
"dependsOn": [
"^build"
],
"outputs": []
},
"typecheck": {
"dependsOn": [
"^build",
"^typecheck"
],
"outputs": []
},
"clean": {
"cache": false
}
}
}