@k8ordo/form 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/async-check.d.mts +22 -0
- package/dist/async-check.mjs +49 -0
- package/dist/derive/attributes.d.mts +32 -0
- package/dist/derive/attributes.mjs +130 -0
- package/dist/derive/form-fields.d.mts +15 -0
- package/dist/derive/form-fields.mjs +83 -0
- package/dist/derive/messages.d.mts +12 -0
- package/dist/derive/messages.mjs +80 -0
- package/dist/hidden-value.d.mts +25 -0
- package/dist/hidden-value.mjs +41 -0
- package/dist/index.d.mts +6 -0
- package/dist/index.mjs +4 -0
- package/dist/parse/parse-form.d.mts +26 -0
- package/dist/parse/parse-form.mjs +118 -0
- package/dist/parse/paths.d.mts +20 -0
- package/dist/parse/paths.mjs +52 -0
- package/dist/paths.d.mts +56 -0
- package/dist/paths.mjs +1 -0
- package/dist/rules/define-form.d.mts +21 -0
- package/dist/rules/define-form.mjs +12 -0
- package/dist/rules/rules.d.mts +41 -0
- package/dist/rules/rules.mjs +35 -0
- package/dist/schema/object-schema.d.mts +33 -0
- package/dist/schema/object-schema.mjs +4 -0
- package/dist/schema/walk.d.mts +48 -0
- package/dist/schema/walk.mjs +128 -0
- package/dist/server.d.mts +6 -0
- package/dist/server.mjs +5 -0
- package/dist/types.d.mts +76 -0
- package/dist/types.mjs +1 -0
- package/dist/use-form.d.mts +44 -0
- package/dist/use-form.mjs +309 -0
- package/docs/GUIDE.md +362 -0
- package/docs/llms.txt +32 -0
- package/package.json +76 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { FormState } from "../types.mjs";
|
|
2
|
+
import { ObjectSchema } from "../schema/object-schema.mjs";
|
|
3
|
+
import { FormDefinition } from "../rules/define-form.mjs";
|
|
4
|
+
import { output } from "zod/v4/core";
|
|
5
|
+
//#region src/parse/parse-form.d.ts
|
|
6
|
+
type ParseResult<Output> = {
|
|
7
|
+
success: true;
|
|
8
|
+
data: Output;
|
|
9
|
+
state: FormState;
|
|
10
|
+
} | {
|
|
11
|
+
success: false;
|
|
12
|
+
data?: undefined;
|
|
13
|
+
state: FormState;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Turn FormData into the shape the schema expects, then validate it.
|
|
17
|
+
*
|
|
18
|
+
* Only the structural part happens here — an unchecked checkbox arrives as a
|
|
19
|
+
* missing key, a repeated name arrives as several entries, names carry their
|
|
20
|
+
* nesting, and everything is a string. Converting `'42'` to `42` stays the
|
|
21
|
+
* schema's job via `z.coerce`, so the schema keeps describing what it
|
|
22
|
+
* actually validates.
|
|
23
|
+
*/
|
|
24
|
+
declare const parseForm: <Shape extends ObjectSchema>(input: FormDefinition<Shape> | Shape, formData: FormData) => ParseResult<output<Shape>>;
|
|
25
|
+
//#endregion
|
|
26
|
+
export { ParseResult, parseForm };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { breachOf } from "../rules/rules.mjs";
|
|
2
|
+
import { asDefinition } from "../rules/define-form.mjs";
|
|
3
|
+
import { asProbe } from "../schema/object-schema.mjs";
|
|
4
|
+
import { INDEX, schemaMap } from "../schema/walk.mjs";
|
|
5
|
+
import { nameOf, setPath } from "./paths.mjs";
|
|
6
|
+
//#region src/parse/parse-form.ts
|
|
7
|
+
/**
|
|
8
|
+
* The submitted names are the only source for row counts, and anyone can POST
|
|
9
|
+
* anything: without a ceiling, one forged `items[99999999].name` key makes the
|
|
10
|
+
* reconstruction allocate an array that long before the schema ever runs.
|
|
11
|
+
* Arrays with a `.max()` are capped there; this bounds the rest.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_ROWS = 1e3;
|
|
14
|
+
let parseSeq = 0;
|
|
15
|
+
/** How many rows each array actually received, read from the submitted names. */
|
|
16
|
+
const rowCounts = (formData, arrays) => {
|
|
17
|
+
const counts = {};
|
|
18
|
+
for (const array of arrays) {
|
|
19
|
+
const cap = Math.min(array.maxItems ?? MAX_ROWS, MAX_ROWS);
|
|
20
|
+
let highest = -1;
|
|
21
|
+
for (const key of formData.keys()) {
|
|
22
|
+
if (!key.startsWith(`${array.path}[`)) continue;
|
|
23
|
+
const index = Number(key.slice(array.path.length + 1, key.indexOf("]", array.path.length)));
|
|
24
|
+
if (Number.isInteger(index) && index > highest) highest = index;
|
|
25
|
+
}
|
|
26
|
+
counts[array.path] = Math.min(highest + 1, cap);
|
|
27
|
+
}
|
|
28
|
+
return counts;
|
|
29
|
+
};
|
|
30
|
+
const describeMissing = (missing) => {
|
|
31
|
+
const shown = missing.slice(0, 5).join(", ");
|
|
32
|
+
return missing.length > 5 ? `${shown} … ほか${String(missing.length - 5)}件` : shown;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Turn FormData into the shape the schema expects, then validate it.
|
|
36
|
+
*
|
|
37
|
+
* Only the structural part happens here — an unchecked checkbox arrives as a
|
|
38
|
+
* missing key, a repeated name arrives as several entries, names carry their
|
|
39
|
+
* nesting, and everything is a string. Converting `'42'` to `42` stays the
|
|
40
|
+
* schema's job via `z.coerce`, so the schema keeps describing what it
|
|
41
|
+
* actually validates.
|
|
42
|
+
*/
|
|
43
|
+
const parseForm = (input, formData) => {
|
|
44
|
+
const { schema, rules } = asDefinition(input);
|
|
45
|
+
const map = schemaMap(schema);
|
|
46
|
+
const raw = {};
|
|
47
|
+
const missing = [];
|
|
48
|
+
const secrets = /* @__PURE__ */ new Set();
|
|
49
|
+
const rows = rowCounts(formData, map.arrays);
|
|
50
|
+
for (const array of map.arrays) setPath(raw, array.path, Array.from({ length: rows[array.path] ?? 0 }));
|
|
51
|
+
const strings = (name) => formData.getAll(name).filter((value) => typeof value === "string");
|
|
52
|
+
for (const leaf of map.leaves) {
|
|
53
|
+
const names = leaf.arrayPath === null ? [leaf.name] : Array.from({ length: rows[leaf.arrayPath] ?? 0 }, (_, index) => leaf.name.replace(INDEX, String(index)));
|
|
54
|
+
for (const name of names) {
|
|
55
|
+
if (leaf.json.input === "password") secrets.add(name);
|
|
56
|
+
if (leaf.group !== void 0) {
|
|
57
|
+
setPath(raw, name, strings(name));
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (leaf.json.type === "boolean") {
|
|
61
|
+
setPath(raw, name, formData.has(name));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const entries = formData.getAll(name);
|
|
65
|
+
if (entries.length === 0) {
|
|
66
|
+
if (leaf.json.enum !== void 0) continue;
|
|
67
|
+
missing.push(name);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
setPath(raw, name, entries.length === 1 ? entries[0] : entries);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (missing.length > 0) throw new Error(`[@k8ordo/form] スキーマにあるフィールドが送信されていません: ${describeMissing(missing)}\ninput に name が付いていないか、その入力欄が描画されていない可能性があります。`);
|
|
74
|
+
const result = asProbe(schema).safeParse(raw);
|
|
75
|
+
const breaches = {};
|
|
76
|
+
for (const rule of rules) {
|
|
77
|
+
const message = breachOf(rule, strings);
|
|
78
|
+
if (message !== void 0) breaches[rule.field] = message;
|
|
79
|
+
}
|
|
80
|
+
const values = {};
|
|
81
|
+
for (const [name, value] of formData.entries()) {
|
|
82
|
+
if (typeof value !== "string" || secrets.has(name)) continue;
|
|
83
|
+
const existing = values[name];
|
|
84
|
+
if (existing === void 0) values[name] = value;
|
|
85
|
+
else if (typeof existing === "string") values[name] = [existing, value];
|
|
86
|
+
else existing.push(value);
|
|
87
|
+
}
|
|
88
|
+
parseSeq += 1;
|
|
89
|
+
const token = `${String(Date.now())}.${String(parseSeq)}`;
|
|
90
|
+
if (result.success && Object.keys(breaches).length === 0) return {
|
|
91
|
+
success: true,
|
|
92
|
+
data: result.data,
|
|
93
|
+
state: {
|
|
94
|
+
values,
|
|
95
|
+
rows,
|
|
96
|
+
token
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const errors = { ...breaches };
|
|
100
|
+
let formError;
|
|
101
|
+
for (const issue of result.error?.issues ?? []) {
|
|
102
|
+
const name = nameOf(issue.path);
|
|
103
|
+
if (name === "") formError ??= issue.message;
|
|
104
|
+
else errors[name] ??= issue.message;
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
success: false,
|
|
108
|
+
state: {
|
|
109
|
+
errors,
|
|
110
|
+
values,
|
|
111
|
+
rows,
|
|
112
|
+
formError,
|
|
113
|
+
token
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
//#endregion
|
|
118
|
+
export { parseForm };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/parse/paths.d.ts
|
|
2
|
+
/** One step of a name: an object key, or an array index. */
|
|
3
|
+
type Step = {
|
|
4
|
+
key: string;
|
|
5
|
+
} | {
|
|
6
|
+
index: number;
|
|
7
|
+
};
|
|
8
|
+
/** Split `items[1].name` into its steps. */
|
|
9
|
+
declare const stepsOf: (name: string) => Step[];
|
|
10
|
+
/**
|
|
11
|
+
* Write a value at a dotted/indexed path, creating the objects and arrays the
|
|
12
|
+
* path implies. Gaps in an array stay holes rather than shifting later rows
|
|
13
|
+
* onto earlier indexes, so a row removed on the client cannot silently
|
|
14
|
+
* renumber the rows that follow.
|
|
15
|
+
*/
|
|
16
|
+
declare const setPath: (root: Record<string, unknown>, name: string, value: unknown) => void;
|
|
17
|
+
/** Turn a zod issue path into the `name` of the input that produced it. */
|
|
18
|
+
declare const nameOf: (path: readonly PropertyKey[]) => string;
|
|
19
|
+
//#endregion
|
|
20
|
+
export { nameOf, setPath, stepsOf };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
//#region src/parse/paths.ts
|
|
2
|
+
const STEP = /([^.[\]]+)|\[(\d+)\]/gu;
|
|
3
|
+
/** Split `items[1].name` into its steps. */
|
|
4
|
+
const stepsOf = (name) => {
|
|
5
|
+
const steps = [];
|
|
6
|
+
for (const match of name.matchAll(STEP)) {
|
|
7
|
+
const [, key, index] = match;
|
|
8
|
+
if (key !== void 0) steps.push({ key });
|
|
9
|
+
else if (index !== void 0) steps.push({ index: Number(index) });
|
|
10
|
+
}
|
|
11
|
+
return steps;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Write a value at a dotted/indexed path, creating the objects and arrays the
|
|
15
|
+
* path implies. Gaps in an array stay holes rather than shifting later rows
|
|
16
|
+
* onto earlier indexes, so a row removed on the client cannot silently
|
|
17
|
+
* renumber the rows that follow.
|
|
18
|
+
*/
|
|
19
|
+
const setPath = (root, name, value) => {
|
|
20
|
+
const steps = stepsOf(name);
|
|
21
|
+
if (steps.length === 0) return;
|
|
22
|
+
let cursor = root;
|
|
23
|
+
for (const [position, step] of steps.entries()) {
|
|
24
|
+
const last = position === steps.length - 1;
|
|
25
|
+
const next = steps[position + 1];
|
|
26
|
+
const container = next !== void 0 && "index" in next ? [] : {};
|
|
27
|
+
if ("index" in step) {
|
|
28
|
+
const list = cursor;
|
|
29
|
+
if (last) list[step.index] = value;
|
|
30
|
+
else {
|
|
31
|
+
list[step.index] ??= container;
|
|
32
|
+
cursor = list[step.index];
|
|
33
|
+
}
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const record = cursor;
|
|
37
|
+
if (last) record[step.key] = value;
|
|
38
|
+
else {
|
|
39
|
+
record[step.key] ??= container;
|
|
40
|
+
cursor = record[step.key];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
/** Turn a zod issue path into the `name` of the input that produced it. */
|
|
45
|
+
const nameOf = (path) => {
|
|
46
|
+
let name = "";
|
|
47
|
+
for (const segment of path) if (typeof segment === "number") name += `[${String(segment)}]`;
|
|
48
|
+
else name += name === "" ? String(segment) : `.${String(segment)}`;
|
|
49
|
+
return name;
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
export { nameOf, setPath, stepsOf };
|
package/dist/paths.d.mts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
//#region src/paths.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Dotted paths derived from a zod object type, so `field('titel')` fails to
|
|
4
|
+
* compile rather than at runtime. Arrays of objects are reached through
|
|
5
|
+
* `array()`, so they are excluded from the field paths and listed separately —
|
|
6
|
+
* except an array of enums, which is a checkbox group: one shared name, so it
|
|
7
|
+
* is a field.
|
|
8
|
+
*
|
|
9
|
+
* Wrappers (`.optional()`, `.nullable()`, `.default()`, …) are peeled the same
|
|
10
|
+
* way the runtime walk peels them, so the two advertise the same paths.
|
|
11
|
+
*/
|
|
12
|
+
type Join<Prefix extends string, Key extends string> = Prefix extends '' ? Key : `${Prefix}.${Key}`;
|
|
13
|
+
/**
|
|
14
|
+
* Wrappers keep their inner schema on the shared core definition; following it
|
|
15
|
+
* here mirrors what the runtime walk does with `_zod.def.innerType`.
|
|
16
|
+
*/
|
|
17
|
+
type Unwrap<Schema> = Schema extends {
|
|
18
|
+
_zod: {
|
|
19
|
+
def: {
|
|
20
|
+
innerType: infer Inner;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
} ? Unwrap<Inner> : Schema;
|
|
24
|
+
/**
|
|
25
|
+
* `zod/mini` arrays carry the element only on the shared core definition, so
|
|
26
|
+
* this is the one shape that matches both entries. Objects expose `shape`
|
|
27
|
+
* publicly on both and are matched there.
|
|
28
|
+
*/
|
|
29
|
+
type ArrayElement<Schema> = Schema extends {
|
|
30
|
+
_zod: {
|
|
31
|
+
def: {
|
|
32
|
+
element: infer Element;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
} ? Element : never;
|
|
36
|
+
type IsArray<Schema> = [ArrayElement<Schema>] extends [never] ? false : true;
|
|
37
|
+
/** Enums carry `entries` on the core definition, in both zod entries. */
|
|
38
|
+
type IsEnum<Schema> = Unwrap<Schema> extends {
|
|
39
|
+
_zod: {
|
|
40
|
+
def: {
|
|
41
|
+
entries: infer _Entries;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
} ? true : false;
|
|
45
|
+
type FieldPathsOf<Schema, Prefix extends string = ''> = Unwrap<Schema> extends (infer Bare) ? IsArray<Bare> extends true ? IsEnum<ArrayElement<Bare>> extends true ? Prefix extends '' ? never : Prefix : never : Bare extends {
|
|
46
|
+
shape: infer Shape;
|
|
47
|
+
} ? { [Key in keyof Shape & string]: FieldPathsOf<Shape[Key], Join<Prefix, Key>>; }[keyof Shape & string] : Prefix extends '' ? never : Prefix : never;
|
|
48
|
+
/**
|
|
49
|
+
* No recursion into an array's element: a repeat inside a repeat has no
|
|
50
|
+
* unambiguous name, and the runtime walk refuses it too.
|
|
51
|
+
*/
|
|
52
|
+
type ArrayPathsOf<Schema, Prefix extends string = ''> = Unwrap<Schema> extends (infer Bare) ? IsArray<Bare> extends true ? IsEnum<ArrayElement<Bare>> extends true ? never : Prefix extends '' ? never : Prefix : Bare extends {
|
|
53
|
+
shape: infer Shape;
|
|
54
|
+
} ? { [Key in keyof Shape & string]: ArrayPathsOf<Shape[Key], Join<Prefix, Key>>; }[keyof Shape & string] : never : never;
|
|
55
|
+
//#endregion
|
|
56
|
+
export { ArrayPathsOf, FieldPathsOf };
|
package/dist/paths.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Rule } from "./rules.mjs";
|
|
2
|
+
import { FieldPathsOf } from "../paths.mjs";
|
|
3
|
+
import { ObjectSchema } from "../schema/object-schema.mjs";
|
|
4
|
+
//#region src/rules/define-form.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* A schema plus the checks HTML cannot express. Declaring them together is
|
|
7
|
+
* what keeps the client and the server working from one source: `formFields`
|
|
8
|
+
* serializes the rules for the browser, `parseForm` runs them on submit.
|
|
9
|
+
*
|
|
10
|
+
* The rules are typed against the schema's own paths, so
|
|
11
|
+
* `sameAs('confrim', …)` is a compile error, not a rule that never fires.
|
|
12
|
+
*/
|
|
13
|
+
type FormDefinition<Schema extends ObjectSchema = ObjectSchema> = {
|
|
14
|
+
schema: Schema;
|
|
15
|
+
rules: Array<Rule<FieldPathsOf<Schema>>>;
|
|
16
|
+
};
|
|
17
|
+
declare const defineForm: <Schema extends ObjectSchema>(schema: Schema, rules?: Array<Rule<FieldPathsOf<Schema>>>) => FormDefinition<Schema>;
|
|
18
|
+
/** Accept either a bare schema or a definition, so rules stay optional. */
|
|
19
|
+
declare const asDefinition: <Schema extends ObjectSchema>(input: FormDefinition<Schema> | Schema) => FormDefinition<Schema>;
|
|
20
|
+
//#endregion
|
|
21
|
+
export { FormDefinition, asDefinition, defineForm };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/rules/define-form.ts
|
|
2
|
+
const defineForm = (schema, rules = []) => ({
|
|
3
|
+
schema,
|
|
4
|
+
rules
|
|
5
|
+
});
|
|
6
|
+
/** Accept either a bare schema or a definition, so rules stay optional. */
|
|
7
|
+
const asDefinition = (input) => "schema" in input ? input : {
|
|
8
|
+
schema: input,
|
|
9
|
+
rules: []
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { asDefinition, defineForm };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
//#region src/rules/rules.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Checks HTML has no attribute for. They are plain data so they cross to the
|
|
4
|
+
* client with everything else, and both sides run the same evaluator against
|
|
5
|
+
* the same values — the client reading the live form, the server reading the
|
|
6
|
+
* submission. One declaration, two consumers, no second implementation to
|
|
7
|
+
* drift from the first.
|
|
8
|
+
*
|
|
9
|
+
* `Field` is the set of paths the schema actually has: `defineForm` supplies
|
|
10
|
+
* it, so a typo in a field name fails to compile instead of producing a rule
|
|
11
|
+
* that never fires.
|
|
12
|
+
*/
|
|
13
|
+
type Rule<Field extends string = string> = {
|
|
14
|
+
kind: 'sameAs';
|
|
15
|
+
field: Field;
|
|
16
|
+
other: Field;
|
|
17
|
+
message: string;
|
|
18
|
+
} | {
|
|
19
|
+
kind: 'minChecked';
|
|
20
|
+
field: Field;
|
|
21
|
+
min: number;
|
|
22
|
+
message: string;
|
|
23
|
+
} | {
|
|
24
|
+
kind: 'requiredWhen';
|
|
25
|
+
field: Field;
|
|
26
|
+
when: Field;
|
|
27
|
+
equals: string;
|
|
28
|
+
message: string;
|
|
29
|
+
};
|
|
30
|
+
/** Reads the current values of a name, however many controls carry it. */
|
|
31
|
+
type Values = (name: string) => string[];
|
|
32
|
+
/** The value must equal another field's. Password confirmation, and the like. */
|
|
33
|
+
declare const sameAs: <Field extends string>(field: Field, other: Field, message: string) => Rule<Field>;
|
|
34
|
+
/** At least `min` boxes sharing this name must be checked. */
|
|
35
|
+
declare const minChecked: <Field extends string>(field: Field, min: number, message: string) => Rule<Field>;
|
|
36
|
+
/** Required only while another field holds a particular value. */
|
|
37
|
+
declare const requiredWhen: <Field extends string>(field: Field, when: Field, equals: string, message: string) => Rule<Field>;
|
|
38
|
+
/** The message when the rule is broken, or undefined when it holds. */
|
|
39
|
+
declare const breachOf: (rule: Rule, values: Values) => string | undefined;
|
|
40
|
+
//#endregion
|
|
41
|
+
export { Rule, Values, breachOf, minChecked, requiredWhen, sameAs };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/rules/rules.ts
|
|
2
|
+
/** The value must equal another field's. Password confirmation, and the like. */
|
|
3
|
+
const sameAs = (field, other, message) => ({
|
|
4
|
+
kind: "sameAs",
|
|
5
|
+
field,
|
|
6
|
+
other,
|
|
7
|
+
message
|
|
8
|
+
});
|
|
9
|
+
/** At least `min` boxes sharing this name must be checked. */
|
|
10
|
+
const minChecked = (field, min, message) => ({
|
|
11
|
+
kind: "minChecked",
|
|
12
|
+
field,
|
|
13
|
+
min,
|
|
14
|
+
message
|
|
15
|
+
});
|
|
16
|
+
/** Required only while another field holds a particular value. */
|
|
17
|
+
const requiredWhen = (field, when, equals, message) => ({
|
|
18
|
+
kind: "requiredWhen",
|
|
19
|
+
field,
|
|
20
|
+
when,
|
|
21
|
+
equals,
|
|
22
|
+
message
|
|
23
|
+
});
|
|
24
|
+
const first = (values, name) => values(name)[0] ?? "";
|
|
25
|
+
/** The message when the rule is broken, or undefined when it holds. */
|
|
26
|
+
const breachOf = (rule, values) => {
|
|
27
|
+
switch (rule.kind) {
|
|
28
|
+
case "sameAs": return first(values, rule.field) === first(values, rule.other) ? void 0 : rule.message;
|
|
29
|
+
case "minChecked": return values(rule.field).length >= rule.min ? void 0 : rule.message;
|
|
30
|
+
case "requiredWhen": return first(values, rule.when) === rule.equals && first(values, rule.field) === "" ? rule.message : void 0;
|
|
31
|
+
default: return;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
export { breachOf, minChecked, requiredWhen, sameAs };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { $ZodObject, $ZodShape, $ZodType } from "zod/v4/core";
|
|
2
|
+
//#region src/schema/object-schema.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The common ground between `zod` and `zod/mini`: both build on the shared core
|
|
5
|
+
* object and both expose `shape`. Accepting this instead of the classic
|
|
6
|
+
* `ZodObject` is what lets a caller pick the seven-times-smaller entry without
|
|
7
|
+
* losing the types.
|
|
8
|
+
*/
|
|
9
|
+
type ObjectSchema<Shape extends $ZodShape = $ZodShape> = $ZodObject<Shape> & {
|
|
10
|
+
shape: Shape;
|
|
11
|
+
};
|
|
12
|
+
type ProbeIssue = {
|
|
13
|
+
code: string;
|
|
14
|
+
message: string;
|
|
15
|
+
path: readonly PropertyKey[];
|
|
16
|
+
};
|
|
17
|
+
/** What probing needs, present on both entries but not on the core type. */
|
|
18
|
+
type ProbeSchema = $ZodType & {
|
|
19
|
+
safeParse: (value: unknown) => {
|
|
20
|
+
success: true;
|
|
21
|
+
data: unknown;
|
|
22
|
+
error?: undefined;
|
|
23
|
+
} | {
|
|
24
|
+
success: false;
|
|
25
|
+
data?: undefined;
|
|
26
|
+
error: {
|
|
27
|
+
issues: readonly ProbeIssue[];
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
declare const asProbe: (schema: $ZodType) => ProbeSchema;
|
|
32
|
+
//#endregion
|
|
33
|
+
export { ObjectSchema, ProbeIssue, ProbeSchema, asProbe };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { LeafSchema } from "../derive/attributes.mjs";
|
|
2
|
+
import { ObjectSchema } from "./object-schema.mjs";
|
|
3
|
+
import { $ZodType } from "zod/v4/core";
|
|
4
|
+
//#region src/schema/walk.d.ts
|
|
5
|
+
/** Placeholder an array item's name carries until a row index is known. */
|
|
6
|
+
declare const INDEX = "{index}";
|
|
7
|
+
type LeafNode = {
|
|
8
|
+
/** Dotted path within the parsed object, e.g. `user.email`. */
|
|
9
|
+
path: string;
|
|
10
|
+
/** The `name` attribute, with {@link INDEX} still in it for array items. */
|
|
11
|
+
name: string;
|
|
12
|
+
json: LeafSchema & {
|
|
13
|
+
input?: string;
|
|
14
|
+
};
|
|
15
|
+
zod: $ZodType;
|
|
16
|
+
required: boolean;
|
|
17
|
+
/** The array this leaf repeats inside, or null. */
|
|
18
|
+
arrayPath: string | null;
|
|
19
|
+
/** Key within the array item; '' when the array holds scalars. */
|
|
20
|
+
itemKey: string | null;
|
|
21
|
+
/**
|
|
22
|
+
* Present when several same-named controls submit together — a checkbox
|
|
23
|
+
* group, derived from an array of enums. Carries the bounds the group's
|
|
24
|
+
* array schema declared, which HTML has no attribute for.
|
|
25
|
+
*/
|
|
26
|
+
group?: {
|
|
27
|
+
minItems?: number;
|
|
28
|
+
maxItems?: number;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
type ArrayNode = {
|
|
32
|
+
path: string;
|
|
33
|
+
name: string;
|
|
34
|
+
minItems?: number;
|
|
35
|
+
maxItems?: number;
|
|
36
|
+
};
|
|
37
|
+
type SchemaMap = {
|
|
38
|
+
leaves: LeafNode[];
|
|
39
|
+
arrays: ArrayNode[];
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Describe a schema once: every leaf that becomes an input, and every array
|
|
43
|
+
* that becomes a repeatable group. Both the derivation and the parse read this,
|
|
44
|
+
* so the two can never disagree about what the form contains.
|
|
45
|
+
*/
|
|
46
|
+
declare const schemaMap: (schema: ObjectSchema) => SchemaMap;
|
|
47
|
+
//#endregion
|
|
48
|
+
export { ArrayNode, INDEX, LeafNode, SchemaMap, schemaMap };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { asProbe } from "./object-schema.mjs";
|
|
2
|
+
import { toJSONSchema } from "zod/v4/core";
|
|
3
|
+
//#region src/schema/walk.ts
|
|
4
|
+
/** Placeholder an array item's name carries until a row index is known. */
|
|
5
|
+
const INDEX = "{index}";
|
|
6
|
+
/**
|
|
7
|
+
* Peel wrapper schemas until the node that owns the structure is reached.
|
|
8
|
+
* `toJSONSchema` flattens `.optional()` / `.default()` into the plain node, so
|
|
9
|
+
* pairing the two trees requires flattening the zod side the same way.
|
|
10
|
+
*/
|
|
11
|
+
const unwrap = ($schema) => {
|
|
12
|
+
let current = $schema;
|
|
13
|
+
let inner = current._zod?.def?.innerType;
|
|
14
|
+
while (inner !== void 0) {
|
|
15
|
+
current = inner;
|
|
16
|
+
inner = current._zod?.def?.innerType;
|
|
17
|
+
}
|
|
18
|
+
return current;
|
|
19
|
+
};
|
|
20
|
+
const shapeOf = ($schema) => unwrap($schema).shape;
|
|
21
|
+
const elementOf = ($schema) => {
|
|
22
|
+
const candidate = unwrap($schema);
|
|
23
|
+
return candidate.element ?? candidate._zod?.def?.element;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* `required` in JSON Schema means "the key is present", but a form always
|
|
27
|
+
* submits something for every control. What it submits depends on the control:
|
|
28
|
+
* a text field sends `''`, an unchecked checkbox maps to `false` in the parse.
|
|
29
|
+
* Asking the schema what it does with that empty submission is what makes the
|
|
30
|
+
* attribute mean the same on both sides.
|
|
31
|
+
*/
|
|
32
|
+
const rejectsEmptySubmission = (json, $schema) => {
|
|
33
|
+
const empty = json.type === "boolean" ? false : "";
|
|
34
|
+
return !asProbe($schema).safeParse(empty).success;
|
|
35
|
+
};
|
|
36
|
+
const fail = (path, reason) => {
|
|
37
|
+
throw new Error(`[@k8ordo/form] '${path === "" ? "(schema)" : path}': ${reason}`);
|
|
38
|
+
};
|
|
39
|
+
const walkNode = (json, zod, path, name, context, out) => {
|
|
40
|
+
if (json.type === "object") {
|
|
41
|
+
const shape = shapeOf(zod);
|
|
42
|
+
if (json.properties === void 0 || shape === void 0) fail(path, "オブジェクトのキー構成を列挙できないため、フォームに展開できません(z.record などは表現できません)");
|
|
43
|
+
for (const [key, child] of Object.entries(json.properties)) {
|
|
44
|
+
if (/[.[\]]/u.test(key)) fail(path === "" ? key : `${path}.${key}`, "キーに '.' や '[' ']' を含むスキーマは、入力の name と経路の区切りが衝突するため使えません");
|
|
45
|
+
const childZod = shape[key];
|
|
46
|
+
if (childZod === void 0) fail(path === "" ? key : `${path}.${key}`, "JSON Schema 側にだけ現れるキーです。zod 側と対応が取れないため、フォームに展開できません");
|
|
47
|
+
walkNode(child, childZod, path === "" ? key : `${path}.${key}`, name === "" ? key : `${name}.${key}`, {
|
|
48
|
+
arrayPath: context.arrayPath,
|
|
49
|
+
itemKey: context.arrayPath === null ? null : context.itemKey === null || context.itemKey === "" ? key : `${context.itemKey}.${key}`
|
|
50
|
+
}, out);
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (json.type === "array") {
|
|
55
|
+
if (json.items === void 0) fail(path, "タプルなど要素型が一様でない配列は、繰り返し行として表現できません");
|
|
56
|
+
if (json.items.enum !== void 0) {
|
|
57
|
+
const element = elementOf(zod);
|
|
58
|
+
if (element === void 0) fail(path, "配列要素のスキーマを取り出せませんでした");
|
|
59
|
+
out.leaves.push({
|
|
60
|
+
path,
|
|
61
|
+
name,
|
|
62
|
+
json: json.items,
|
|
63
|
+
zod: element,
|
|
64
|
+
required: false,
|
|
65
|
+
arrayPath: context.arrayPath,
|
|
66
|
+
itemKey: context.itemKey,
|
|
67
|
+
group: {
|
|
68
|
+
minItems: json.minItems,
|
|
69
|
+
maxItems: json.maxItems
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (context.arrayPath !== null) fail(path, "繰り返しの中の繰り返しは name の添字が一意に決まらないため表現できません");
|
|
75
|
+
const element = elementOf(zod);
|
|
76
|
+
if (element === void 0) fail(path, "配列要素のスキーマを取り出せませんでした");
|
|
77
|
+
out.arrays.push({
|
|
78
|
+
path,
|
|
79
|
+
name,
|
|
80
|
+
minItems: json.minItems,
|
|
81
|
+
maxItems: json.maxItems
|
|
82
|
+
});
|
|
83
|
+
walkNode(json.items, element, path, `${name}[${INDEX}]`, {
|
|
84
|
+
arrayPath: path,
|
|
85
|
+
itemKey: ""
|
|
86
|
+
}, out);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (json.anyOf !== void 0) {
|
|
90
|
+
for (const branch of json.anyOf) if (branch.type === "object" || branch.type === "array") fail(path, "nullable / union のオブジェクトや配列は、どちらの枝を描画すべきか決まらないため表現できません");
|
|
91
|
+
}
|
|
92
|
+
out.leaves.push({
|
|
93
|
+
path,
|
|
94
|
+
name,
|
|
95
|
+
json,
|
|
96
|
+
zod,
|
|
97
|
+
required: rejectsEmptySubmission(json, zod),
|
|
98
|
+
arrayPath: context.arrayPath,
|
|
99
|
+
itemKey: context.itemKey
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
const cache = /* @__PURE__ */ new WeakMap();
|
|
103
|
+
/**
|
|
104
|
+
* Describe a schema once: every leaf that becomes an input, and every array
|
|
105
|
+
* that becomes a repeatable group. Both the derivation and the parse read this,
|
|
106
|
+
* so the two can never disagree about what the form contains.
|
|
107
|
+
*/
|
|
108
|
+
const schemaMap = (schema) => {
|
|
109
|
+
const cached = cache.get(schema);
|
|
110
|
+
if (cached !== void 0) return cached;
|
|
111
|
+
const json = toJSONSchema(schema, {
|
|
112
|
+
io: "output",
|
|
113
|
+
reused: "inline",
|
|
114
|
+
unrepresentable: "any"
|
|
115
|
+
});
|
|
116
|
+
const out = {
|
|
117
|
+
leaves: [],
|
|
118
|
+
arrays: []
|
|
119
|
+
};
|
|
120
|
+
walkNode(json, schema, "", "", {
|
|
121
|
+
arrayPath: null,
|
|
122
|
+
itemKey: null
|
|
123
|
+
}, out);
|
|
124
|
+
cache.set(schema, out);
|
|
125
|
+
return out;
|
|
126
|
+
};
|
|
127
|
+
//#endregion
|
|
128
|
+
export { INDEX, schemaMap };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Rule, minChecked, requiredWhen, sameAs } from "./rules/rules.mjs";
|
|
2
|
+
import { DerivedArray, DerivedField, DroppedCheck, FieldInput, FormFields, FormState, ValidityFlag } from "./types.mjs";
|
|
3
|
+
import { FormDefinition, defineForm } from "./rules/define-form.mjs";
|
|
4
|
+
import { formFields } from "./derive/form-fields.mjs";
|
|
5
|
+
import { ParseResult, parseForm } from "./parse/parse-form.mjs";
|
|
6
|
+
export { type DerivedArray, type DerivedField, type DroppedCheck, type FieldInput, type FormDefinition, type FormFields, type FormState, type ParseResult, type Rule, type ValidityFlag, defineForm, formFields, minChecked, parseForm, requiredWhen, sameAs };
|
package/dist/server.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { minChecked, requiredWhen, sameAs } from "./rules/rules.mjs";
|
|
2
|
+
import { defineForm } from "./rules/define-form.mjs";
|
|
3
|
+
import { formFields } from "./derive/form-fields.mjs";
|
|
4
|
+
import { parseForm } from "./parse/parse-form.mjs";
|
|
5
|
+
export { defineForm, formFields, minChecked, parseForm, requiredWhen, sameAs };
|