first commit
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* @wrnexus/validation — one schema, validated on the server (API) and the browser
|
||||
* (forms). A schema is a fluent builder; `.parse()` runs server-side and returns
|
||||
* coerced values + field errors, while `.describe()` emits a JSON descriptor the
|
||||
* eval-free client validator interprets. Define schemas once in `app/schemas/`.
|
||||
*/
|
||||
|
||||
// --- Descriptor (the JSON bridge between server and client) ----------------
|
||||
|
||||
export type RuleDescriptor =
|
||||
| { kind: "min"; n: number; message?: string }
|
||||
| { kind: "max"; n: number; message?: string }
|
||||
| { kind: "length"; n: number; message?: string }
|
||||
| { kind: "email"; message?: string }
|
||||
| { kind: "url"; message?: string }
|
||||
| { kind: "uuid"; message?: string }
|
||||
| { kind: "date"; message?: string }
|
||||
| { kind: "oneOf"; values: (string | number)[]; message?: string }
|
||||
| { kind: "pattern"; source: string; flags?: string; message?: string }
|
||||
| { kind: "integer"; message?: string };
|
||||
|
||||
export interface FieldDescriptor {
|
||||
type: "string" | "number" | "boolean";
|
||||
optional?: boolean;
|
||||
label?: string;
|
||||
/** Trim string input before validating. */
|
||||
trim?: boolean;
|
||||
rules: RuleDescriptor[];
|
||||
}
|
||||
|
||||
export interface SchemaDescriptor {
|
||||
type: "object";
|
||||
fields: Record<string, FieldDescriptor>;
|
||||
}
|
||||
|
||||
export interface ParseResult<T = Record<string, unknown>> {
|
||||
ok: boolean;
|
||||
/** Coerced values (present whether or not validation passed). */
|
||||
value: T;
|
||||
/** Field name → message, only for fields that failed. */
|
||||
errors: Record<string, string>;
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const URL_RE = /^https?:\/\/[^\s/$.?#][^\s]*$/i;
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Apply one rule to an already-coerced value. Shared by the server; the client
|
||||
* runtime (runtime.ts) mirrors this exactly. Returns an error message or null.
|
||||
*/
|
||||
export function applyRule(type: string, rule: RuleDescriptor, value: unknown): string | null {
|
||||
switch (rule.kind) {
|
||||
case "min":
|
||||
if (type === "string")
|
||||
return String(value).length < rule.n
|
||||
? (rule.message ?? `Must be at least ${rule.n} characters`)
|
||||
: null;
|
||||
return (value as number) < rule.n ? (rule.message ?? `Must be at least ${rule.n}`) : null;
|
||||
case "max":
|
||||
if (type === "string")
|
||||
return String(value).length > rule.n
|
||||
? (rule.message ?? `Must be at most ${rule.n} characters`)
|
||||
: null;
|
||||
return (value as number) > rule.n ? (rule.message ?? `Must be at most ${rule.n}`) : null;
|
||||
case "length":
|
||||
return String(value).length !== rule.n
|
||||
? (rule.message ?? `Must be exactly ${rule.n} characters`)
|
||||
: null;
|
||||
case "email":
|
||||
return EMAIL_RE.test(String(value)) ? null : (rule.message ?? "Must be a valid email");
|
||||
case "url":
|
||||
return URL_RE.test(String(value)) ? null : (rule.message ?? "Must be a valid URL");
|
||||
case "uuid":
|
||||
return UUID_RE.test(String(value)) ? null : (rule.message ?? "Must be a valid UUID");
|
||||
case "date":
|
||||
return Number.isNaN(Date.parse(String(value)))
|
||||
? (rule.message ?? "Must be a valid date")
|
||||
: null;
|
||||
case "oneOf":
|
||||
return rule.values.includes(value as string | number)
|
||||
? null
|
||||
: (rule.message ?? `Must be one of: ${rule.values.join(", ")}`);
|
||||
case "pattern":
|
||||
try {
|
||||
return new RegExp(rule.source, rule.flags ?? "").test(String(value))
|
||||
? null
|
||||
: (rule.message ?? "Invalid format");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
case "integer":
|
||||
return Number.isInteger(value) ? null : (rule.message ?? "Must be a whole number");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Coerce + validate one field against its descriptor. */
|
||||
export function checkField(
|
||||
desc: FieldDescriptor,
|
||||
raw: unknown,
|
||||
): { value: unknown; error: string | null } {
|
||||
if (desc.type === "boolean") {
|
||||
const value = raw === true || raw === "true" || raw === "on";
|
||||
if (!desc.optional && !value) return { value, error: "Required" };
|
||||
return { value, error: null };
|
||||
}
|
||||
|
||||
const pre = desc.trim && typeof raw === "string" ? raw.trim() : raw;
|
||||
const empty = pre === undefined || pre === null || pre === "";
|
||||
if (empty) return { value: undefined, error: desc.optional ? null : "Required" };
|
||||
|
||||
let value: unknown;
|
||||
if (desc.type === "number") {
|
||||
value = Number(pre);
|
||||
if (Number.isNaN(value)) return { value, error: "Must be a number" };
|
||||
} else {
|
||||
value = String(pre);
|
||||
}
|
||||
for (const rule of desc.rules) {
|
||||
const error = applyRule(desc.type, rule, value);
|
||||
if (error) return { value, error };
|
||||
}
|
||||
return { value, error: null };
|
||||
}
|
||||
|
||||
// --- Fluent builder --------------------------------------------------------
|
||||
|
||||
/** A server-only refinement (a predicate that can't be serialized to the client). */
|
||||
type Refinement = { fn: (value: unknown) => boolean | string; message?: string };
|
||||
|
||||
abstract class FieldSchema {
|
||||
abstract readonly type: "string" | "number" | "boolean";
|
||||
protected _optional = false;
|
||||
protected _label?: string;
|
||||
protected _default?: unknown;
|
||||
protected rules: RuleDescriptor[] = [];
|
||||
protected refinements: Refinement[] = [];
|
||||
|
||||
optional(): this {
|
||||
this._optional = true;
|
||||
return this;
|
||||
}
|
||||
label(label: string): this {
|
||||
this._label = label;
|
||||
return this;
|
||||
}
|
||||
/** Value used when the field is absent (implies optional). */
|
||||
default(value: unknown): this {
|
||||
this._default = value;
|
||||
this._optional = true;
|
||||
return this;
|
||||
}
|
||||
min(n: number, message?: string): this {
|
||||
this.rules.push({ kind: "min", n, message });
|
||||
return this;
|
||||
}
|
||||
max(n: number, message?: string): this {
|
||||
this.rules.push({ kind: "max", n, message });
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Custom SERVER-side validation. `fn` returns true (ok), false (use `message`),
|
||||
* or a string (that error). Not mirrored to the client validator.
|
||||
*/
|
||||
refine(fn: (value: unknown) => boolean | string, message?: string): this {
|
||||
this.refinements.push({ fn, message });
|
||||
return this;
|
||||
}
|
||||
getDefault(): unknown {
|
||||
return this._default;
|
||||
}
|
||||
runRefinements(value: unknown): string | null {
|
||||
for (const r of this.refinements) {
|
||||
const result = r.fn(value);
|
||||
if (result === false) return r.message ?? "Invalid value";
|
||||
if (typeof result === "string") return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
describe(): FieldDescriptor {
|
||||
return {
|
||||
type: this.type,
|
||||
optional: this._optional || undefined,
|
||||
label: this._label,
|
||||
rules: this.rules,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class StringSchema extends FieldSchema {
|
||||
readonly type = "string" as const;
|
||||
private _trim = false;
|
||||
email(message?: string): this {
|
||||
this.rules.push({ kind: "email", message });
|
||||
return this;
|
||||
}
|
||||
url(message?: string): this {
|
||||
this.rules.push({ kind: "url", message });
|
||||
return this;
|
||||
}
|
||||
uuid(message?: string): this {
|
||||
this.rules.push({ kind: "uuid", message });
|
||||
return this;
|
||||
}
|
||||
date(message?: string): this {
|
||||
this.rules.push({ kind: "date", message });
|
||||
return this;
|
||||
}
|
||||
length(n: number, message?: string): this {
|
||||
this.rules.push({ kind: "length", n, message });
|
||||
return this;
|
||||
}
|
||||
oneOf(values: string[], message?: string): this {
|
||||
this.rules.push({ kind: "oneOf", values, message });
|
||||
return this;
|
||||
}
|
||||
trim(): this {
|
||||
this._trim = true;
|
||||
return this;
|
||||
}
|
||||
pattern(re: RegExp, message?: string): this {
|
||||
this.rules.push({ kind: "pattern", source: re.source, flags: re.flags, message });
|
||||
return this;
|
||||
}
|
||||
describe(): FieldDescriptor {
|
||||
return { ...super.describe(), trim: this._trim || undefined };
|
||||
}
|
||||
}
|
||||
|
||||
class NumberSchema extends FieldSchema {
|
||||
readonly type = "number" as const;
|
||||
integer(message?: string): this {
|
||||
this.rules.push({ kind: "integer", message });
|
||||
return this;
|
||||
}
|
||||
positive(message?: string): this {
|
||||
this.rules.push({ kind: "min", n: Number.MIN_VALUE, message: message ?? "Must be positive" });
|
||||
return this;
|
||||
}
|
||||
oneOf(values: number[], message?: string): this {
|
||||
this.rules.push({ kind: "oneOf", values, message });
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class BooleanSchema extends FieldSchema {
|
||||
readonly type = "boolean" as const;
|
||||
}
|
||||
|
||||
export class ObjectSchema {
|
||||
constructor(private readonly fields: Record<string, FieldSchema>) {}
|
||||
|
||||
/** Validate an input object; returns coerced values + per-field errors. */
|
||||
parse(input: unknown): ParseResult {
|
||||
const source = (input ?? {}) as Record<string, unknown>;
|
||||
const value: Record<string, unknown> = {};
|
||||
const errors: Record<string, string> = {};
|
||||
for (const [name, field] of Object.entries(this.fields)) {
|
||||
const { value: coerced, error } = checkField(field.describe(), source[name]);
|
||||
if (error) {
|
||||
errors[name] = error;
|
||||
continue;
|
||||
}
|
||||
let finalValue = coerced;
|
||||
if (finalValue === undefined) {
|
||||
const fallback = field.getDefault();
|
||||
if (fallback !== undefined) finalValue = fallback;
|
||||
}
|
||||
if (finalValue !== undefined) {
|
||||
const refineError = field.runRefinements(finalValue);
|
||||
if (refineError) {
|
||||
errors[name] = refineError;
|
||||
continue;
|
||||
}
|
||||
value[name] = finalValue;
|
||||
}
|
||||
}
|
||||
return { ok: Object.keys(errors).length === 0, value, errors };
|
||||
}
|
||||
|
||||
describe(): SchemaDescriptor {
|
||||
const fields: Record<string, FieldDescriptor> = {};
|
||||
for (const [name, field] of Object.entries(this.fields)) fields[name] = field.describe();
|
||||
return { type: "object", fields };
|
||||
}
|
||||
}
|
||||
|
||||
/** The fluent schema builder. */
|
||||
export const v = {
|
||||
string: () => new StringSchema(),
|
||||
number: () => new NumberSchema(),
|
||||
boolean: () => new BooleanSchema(),
|
||||
object: (fields: Record<string, FieldSchema>) => new ObjectSchema(fields),
|
||||
};
|
||||
|
||||
// --- Environment configuration --------------------------------------------
|
||||
|
||||
/** Read the ambient environment (Bun.env, falling back to process.env). */
|
||||
function readEnv(): Record<string, string | undefined> {
|
||||
const bun = (globalThis as { Bun?: { env?: Record<string, string | undefined> } }).Bun;
|
||||
if (bun?.env) return bun.env;
|
||||
const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;
|
||||
return proc?.env ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate environment variables against a schema at startup. Values are read
|
||||
* from `Bun.env` / `process.env` by default and coerced by the schema (so
|
||||
* `PORT` becomes a number, `DEBUG` a boolean). On any problem it throws ONE
|
||||
* readable error listing every offending variable, so misconfiguration fails
|
||||
* fast with an actionable message instead of surfacing deep inside the app.
|
||||
*
|
||||
* export const env = parseEnv(v.object({
|
||||
* DATABASE_URL: v.string().min(1),
|
||||
* PORT: v.number(),
|
||||
* }));
|
||||
*/
|
||||
export function parseEnv<T = Record<string, unknown>>(
|
||||
schema: ObjectSchema,
|
||||
source: Record<string, string | undefined> = readEnv(),
|
||||
): T {
|
||||
const result = schema.parse(source);
|
||||
if (!result.ok) {
|
||||
const lines = Object.entries(result.errors).map(([name, message]) => ` • ${name}: ${message}`);
|
||||
throw new Error(`Invalid environment variables:\n${lines.join("\n")}`);
|
||||
}
|
||||
return result.value as T;
|
||||
}
|
||||
|
||||
// --- API helpers -----------------------------------------------------------
|
||||
|
||||
/** A 400 response carrying field errors, for API routes. */
|
||||
export function invalid(errors: Record<string, string>): Response {
|
||||
return Response.json({ ok: false, errors }, { status: 400 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a request's JSON body against a schema. On failure returns
|
||||
* `{ ok: false, response }` (a ready 400); on success `{ ok: true, value }`.
|
||||
*/
|
||||
export async function parseBody<T = Record<string, unknown>>(
|
||||
schema: ObjectSchema,
|
||||
req: Request,
|
||||
): Promise<{ ok: true; value: T } | { ok: false; response: Response }> {
|
||||
const body = await readBody(req);
|
||||
const result = schema.parse(body);
|
||||
if (!result.ok) return { ok: false, response: invalid(result.errors) };
|
||||
return { ok: true, value: result.value as T };
|
||||
}
|
||||
|
||||
/** Read a request body as an object from JSON, form-urlencoded, or multipart. */
|
||||
async function readBody(req: Request): Promise<Record<string, unknown>> {
|
||||
const contentType = req.headers.get("content-type") ?? "";
|
||||
try {
|
||||
if (contentType.includes("application/json")) {
|
||||
return (await req.json()) as Record<string, unknown>;
|
||||
}
|
||||
if (
|
||||
contentType.includes("application/x-www-form-urlencoded") ||
|
||||
contentType.includes("multipart/form-data")
|
||||
) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of await req.formData()) out[key] = value;
|
||||
return out;
|
||||
}
|
||||
// Best effort: try JSON, else treat as empty.
|
||||
return (await req.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export { renderSchemasScript, VALIDATE_RUNTIME } from "./runtime.ts";
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Client-side validation. `renderSchemasScript` bakes the discovered schema
|
||||
* descriptors into `window.__wireSchemas`; `VALIDATE_RUNTIME` is a generic,
|
||||
* eval-free validator that reads them and validates every `form[data-schema]`
|
||||
* on submit and blur, writing messages into `[data-error="<field>"]` elements.
|
||||
* The rule logic mirrors `checkField`/`applyRule` in index.ts.
|
||||
*/
|
||||
|
||||
import type { SchemaDescriptor } from "./index.ts";
|
||||
|
||||
/** `window.__wireSchemas = { name: descriptor, ... }` for the client validator. */
|
||||
export function renderSchemasScript(descriptors: Record<string, SchemaDescriptor>): string {
|
||||
return `window.__wireSchemas=${JSON.stringify(descriptors)};`;
|
||||
}
|
||||
|
||||
export const VALIDATE_RUNTIME = String.raw`
|
||||
(function () {
|
||||
var EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
var URL_RE = /^https?:\/\/[^\s/$.?#][^\s]*$/i;
|
||||
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function applyRule(type, r, value) {
|
||||
if (r.kind === "min") {
|
||||
if (type === "string") return String(value).length < r.n ? (r.message || ("Must be at least " + r.n + " characters")) : null;
|
||||
return value < r.n ? (r.message || ("Must be at least " + r.n)) : null;
|
||||
}
|
||||
if (r.kind === "max") {
|
||||
if (type === "string") return String(value).length > r.n ? (r.message || ("Must be at most " + r.n + " characters")) : null;
|
||||
return value > r.n ? (r.message || ("Must be at most " + r.n)) : null;
|
||||
}
|
||||
if (r.kind === "length") return String(value).length !== r.n ? (r.message || ("Must be exactly " + r.n + " characters")) : null;
|
||||
if (r.kind === "email") return EMAIL.test(String(value)) ? null : (r.message || "Must be a valid email");
|
||||
if (r.kind === "url") return URL_RE.test(String(value)) ? null : (r.message || "Must be a valid URL");
|
||||
if (r.kind === "uuid") return UUID_RE.test(String(value)) ? null : (r.message || "Must be a valid UUID");
|
||||
if (r.kind === "date") return isNaN(Date.parse(String(value))) ? (r.message || "Must be a valid date") : null;
|
||||
if (r.kind === "oneOf") return r.values.indexOf(value) !== -1 ? null : (r.message || ("Must be one of: " + r.values.join(", ")));
|
||||
if (r.kind === "pattern") { try { return new RegExp(r.source, r.flags || "").test(String(value)) ? null : (r.message || "Invalid format"); } catch (e) { return null; } }
|
||||
if (r.kind === "integer") return Number.isInteger(value) ? null : (r.message || "Must be a whole number");
|
||||
return null;
|
||||
}
|
||||
|
||||
function checkField(desc, raw) {
|
||||
if (desc.type === "boolean") {
|
||||
var b = raw === true || raw === "true" || raw === "on";
|
||||
return (!desc.optional && !b) ? "Required" : null;
|
||||
}
|
||||
var pre = desc.trim && typeof raw === "string" ? raw.trim() : raw;
|
||||
var empty = pre === undefined || pre === null || pre === "";
|
||||
if (empty) return desc.optional ? null : "Required";
|
||||
var value;
|
||||
if (desc.type === "number") { value = Number(pre); if (isNaN(value)) return "Must be a number"; }
|
||||
else value = String(pre);
|
||||
for (var i = 0; i < desc.rules.length; i++) {
|
||||
var err = applyRule(desc.type, desc.rules[i], value);
|
||||
if (err) return err;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rawValue(form, name) {
|
||||
var el = form.elements[name];
|
||||
if (!el) return undefined;
|
||||
return el.type === "checkbox" ? el.checked : el.value;
|
||||
}
|
||||
|
||||
function showError(form, name, err) {
|
||||
var box = form.querySelector('[data-error="' + name + '"]');
|
||||
if (box) box.textContent = err || "";
|
||||
var el = form.elements[name];
|
||||
if (el && el.setAttribute) {
|
||||
if (err) { el.setAttribute("aria-invalid", "true"); if (el.classList) el.classList.add("wire-invalid"); }
|
||||
else { el.removeAttribute("aria-invalid"); if (el.classList) el.classList.remove("wire-invalid"); }
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm(form, schema) {
|
||||
var errors = 0;
|
||||
Object.keys(schema.fields).forEach(function (name) {
|
||||
var err = checkField(schema.fields[name], rawValue(form, name));
|
||||
if (err) errors++;
|
||||
showError(form, name, err);
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
function collect(form) {
|
||||
var out = {};
|
||||
for (var i = 0; i < form.elements.length; i++) {
|
||||
var el = form.elements[i];
|
||||
if (!el.name) continue;
|
||||
if (el.type === "checkbox") out[el.name] = el.checked;
|
||||
else if (el.type === "radio") { if (el.checked) out[el.name] = el.value; }
|
||||
else out[el.name] = el.value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function onSuccess(form, data) {
|
||||
var redirect = form.getAttribute("data-redirect") || (data && data.redirect);
|
||||
if (redirect) {
|
||||
// Prefer client-side navigation (no full reload) when it is available.
|
||||
if (window.__wrnexusNavigate) window.__wrnexusNavigate(redirect);
|
||||
else location.assign(redirect);
|
||||
return;
|
||||
}
|
||||
var box = form.querySelector("[data-success]");
|
||||
if (box) { box.textContent = box.getAttribute("data-success") || "Success"; box.hidden = false; }
|
||||
form.reset();
|
||||
form.dispatchEvent(new CustomEvent("wire:success", { detail: data, bubbles: true }));
|
||||
}
|
||||
|
||||
function csrfHeader() {
|
||||
var m = document.cookie.match(/(?:^|;\s*)wire-csrf=([^;]+)/);
|
||||
return m ? { "x-csrf-token": decodeURIComponent(m[1]) } : {};
|
||||
}
|
||||
|
||||
function submitForm(form, schema) {
|
||||
var method = (form.getAttribute("method") || "post").toUpperCase();
|
||||
var action = form.getAttribute("action") || location.pathname;
|
||||
var btns = form.querySelectorAll("[type=submit]");
|
||||
btns.forEach(function (b) { b.disabled = true; });
|
||||
var headers = { "content-type": "application/json", accept: "application/json" };
|
||||
var csrf = csrfHeader();
|
||||
for (var k in csrf) headers[k] = csrf[k];
|
||||
fetch(action, {
|
||||
method: method,
|
||||
headers: headers,
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify(collect(form)),
|
||||
})
|
||||
.then(function (res) {
|
||||
return res.json().catch(function () { return {}; }).then(function (data) { return { res: res, data: data }; });
|
||||
})
|
||||
.then(function (r) {
|
||||
if (r.res.ok) { onSuccess(form, r.data); return; }
|
||||
// Surface server-side field errors (e.g. "email already taken").
|
||||
var errors = r.data && r.data.errors;
|
||||
if (errors) Object.keys(errors).forEach(function (f) { showError(form, f, errors[f]); });
|
||||
form.dispatchEvent(new CustomEvent("wire:error", { detail: r.data, bubbles: true }));
|
||||
})
|
||||
.catch(function () {
|
||||
form.dispatchEvent(new CustomEvent("wire:error", { detail: { network: true }, bubbles: true }));
|
||||
})
|
||||
.then(function () { btns.forEach(function (b) { b.disabled = false; }); });
|
||||
}
|
||||
|
||||
function bind(form) {
|
||||
if (form.__wireValidateBound) return;
|
||||
form.__wireValidateBound = 1;
|
||||
var name = form.getAttribute("data-schema");
|
||||
var schema = (window.__wireSchemas || {})[name];
|
||||
if (!schema) return;
|
||||
form.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
if (validateForm(form, schema) > 0) return; // client-invalid: stay put, errors shown
|
||||
submitForm(form, schema);
|
||||
});
|
||||
form.addEventListener("blur", function (e) {
|
||||
var t = e.target;
|
||||
if (t && t.name && schema.fields[t.name]) {
|
||||
showError(form, t.name, checkField(schema.fields[t.name], t.type === "checkbox" ? t.checked : t.value));
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
function init(root) {
|
||||
(root || document).querySelectorAll("form[data-schema]").forEach(bind);
|
||||
}
|
||||
|
||||
window.__wireValidate = { init: init };
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", function () { init(document); });
|
||||
else init(document);
|
||||
})();
|
||||
`.trim();
|
||||
Reference in New Issue
Block a user