workers-mini-admin 0.1.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jaredcat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # `workers-mini-admin`
2
+
3
+ Tiny single-user admin for Cloudflare Workers: password cookie / Bearer auth, HTML chrome, declarative forms, and a configurable mount path.
4
+
5
+ Declare fields (or pass your own HTML). Domain save/load stays in your Worker.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add workers-mini-admin
11
+ ```
12
+
13
+ ## Local preview
14
+
15
+ From a clone of this repo:
16
+
17
+ ```bash
18
+ cp playground/.dev.vars.example playground/.dev.vars
19
+ pnpm dev
20
+ ```
21
+
22
+ Open the URL Wrangler prints, then `/admin`. Login password is `ADMIN_SECRET` from `playground/.dev.vars` (default `dev`). See [playground/README.md](playground/README.md).
23
+
24
+ ## Usage
25
+
26
+ ```ts
27
+ import { createAdmin, parseAdminForm, type Field } from 'workers-mini-admin';
28
+
29
+ type Env = { ADMIN_SECRET?: string; SETTINGS: KVNamespace };
30
+
31
+ const admin = createAdmin<Env>({
32
+ basePath: '/admin',
33
+ title: 'My Admin',
34
+ getSecret: (env) => env.ADMIN_SECRET,
35
+ async render({ env }) {
36
+ const note = (await env.SETTINGS.get('note')) ?? '';
37
+ const fields: Field[] = [
38
+ {
39
+ type: 'textarea',
40
+ name: 'note',
41
+ label: 'Note',
42
+ value: note,
43
+ },
44
+ ];
45
+ return {
46
+ hint: 'Signed in. Edit settings below.',
47
+ fields,
48
+ submitLabel: 'Save',
49
+ };
50
+ },
51
+ async save({ form, env }) {
52
+ const values = parseAdminForm(form, [
53
+ { type: 'textarea', name: 'note', label: 'Note' },
54
+ ]);
55
+ await env.SETTINGS.put('note', String(values.note ?? ''));
56
+ return { flash: 'Saved.' };
57
+ },
58
+ });
59
+
60
+ export default {
61
+ async fetch(request: Request, env: Env): Promise<Response> {
62
+ const res = await admin.fetch(request, env);
63
+ if (res) return res;
64
+ return new Response('Not found', { status: 404 });
65
+ },
66
+ };
67
+ ```
68
+
69
+ ### Mount path / reverse proxy
70
+
71
+ `basePath` defaults to `"/admin"`. Use `""` or `"/"` when the admin is the site root (e.g. `admin.example.com`):
72
+
73
+ ```ts
74
+ createAdmin({
75
+ basePath: '',
76
+ title: 'My Admin',
77
+ // login at /login, logout at /logout, form at /
78
+ getSecret: (env) => env.ADMIN_SECRET,
79
+ render: async () => ({ fields: [] }),
80
+ save: async () => ({ flash: 'Saved.' }),
81
+ });
82
+ ```
83
+
84
+ Routes, form `action`s, redirects, and the auth cookie `Path` are derived from `basePath`. The Worker must see the same pathname the browser uses (normal on Cloudflare Workers). Path-stripping reverse proxies are not supported.
85
+
86
+ ### Bring your own HTML
87
+
88
+ Return `body` instead of `fields`, or mix in `{ type: "html", html: "…" }` among fields:
89
+
90
+ ```ts
91
+ async render() {
92
+ return {
93
+ body: `<form method="post" action="/admin">…</form>`,
94
+ };
95
+ }
96
+ ```
97
+
98
+ For multi-panel UIs (wide layout, own cards, extra CSS):
99
+
100
+ ```ts
101
+ async render() {
102
+ return {
103
+ shellMaxWidth: '1200px',
104
+ wrapBody: false,
105
+ extraCss: `.layout { display: grid; … }`,
106
+ bodyAttrs: { 'data-deployed-origin': origin },
107
+ body: `…`,
108
+ };
109
+ }
110
+ ```
111
+
112
+ - `shellMaxWidth` — simple CSS length for `.page-shell` (e.g. `1200px`)
113
+ - `wrapBody: false` — skip the default `.panel-card` wrapper
114
+ - `extraCss` — appended after shared styles
115
+ - `bodyAttrs` — attributes on `<body>` (escaped)
116
+ - `flashTone` — `'ok' | 'warn' | 'error'` (auto: `Error:` → error, `failed` → warn)
117
+
118
+ ### Extra routes
119
+
120
+ For preview/upload endpoints under the mount:
121
+
122
+ ```ts
123
+ createAdmin({
124
+ basePath: '/admin',
125
+ title: 'My Admin',
126
+ getSecret: (env) => env.ADMIN_SECRET,
127
+ render: async () => ({ fields: [] }),
128
+ save: async () => ({}),
129
+ routes: {
130
+ 'POST /preview': async (ctx) => {
131
+ const unauth = await ctx.requireAuth();
132
+ if (unauth) return unauth;
133
+ return Response.json({ ok: true });
134
+ },
135
+ },
136
+ });
137
+ ```
138
+
139
+ Unknown paths under a non-root mount return `404`. Paths outside the mount return `undefined` so your Worker can handle them. On a root mount (`basePath: ""`), unknown paths also return `undefined`.
140
+
141
+ ### Low-level helpers
142
+
143
+ You can still wire auth and chrome yourself (`isAuthenticated`, `loginResponse`, `adminPage`, `adminForm`, …). Omitting `basePath` on those helpers keeps the legacy `/admin` defaults.
144
+
145
+ ### Options
146
+
147
+ - `adminDisabledResponse(message?)` — optional custom 503 body (default mentions `ADMIN_SECRET`)
148
+ - `cookieName` on auth helpers and `createAdmin` — defaults to `admin_auth`
149
+ - `cookiePath` on `loginResponse` / `logoutResponse` / `createAdmin` — defaults to `/`. Logout clears `/`, the configured path, and the mount path so leftover cookies cannot stick around.
150
+
151
+ ## API
152
+
153
+ | Export | Role |
154
+ | ------------------------------------ | ---------------------------------------------------- |
155
+ | `createAdmin` | Auth router + form page with configurable `basePath` |
156
+ | `adminForm` / `Field` | Declarative form HTML |
157
+ | `formText` / `parseAdminForm` | FormData helpers |
158
+ | `escapeHtml` | Safe HTML text interpolation |
159
+ | `hexSha256` / `timingSafeEqual` | Cookie token helpers |
160
+ | `isAuthenticated` | Cookie or `Authorization: Bearer` check |
161
+ | `adminAuthCookie` | `Set-Cookie` value builder |
162
+ | `loginResponse` / `logoutResponse` | Login POST / logout POST handlers |
163
+ | `html` / `redirect` | Small response helpers |
164
+ | `adminDisabledResponse` | 503 when admin secret is unset |
165
+ | `loginPageHtml` / `adminPage` | HTML shells |
166
+ | `joinBasePath` / `normalizeBasePath` | Mount path helpers |
167
+
168
+ ## License
169
+
170
+ MIT
package/dist/auth.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ export declare function hexSha256(secret: string): Promise<string>;
2
+ export declare function timingSafeEqual(a: string, b: string): boolean;
3
+ export declare const DEFAULT_COOKIE_NAME = "admin_auth";
4
+ export declare function adminAuthCookie(token: string, isSecure: boolean, maxAge: number, cookieName?: string, path?: string): string;
5
+ export declare function isAuthenticated(request: Request, secret: string, cookieName?: string): Promise<boolean>;
6
+ export declare function loginResponse(options: {
7
+ secret: string;
8
+ password: string;
9
+ secureCookie: boolean;
10
+ successRedirect?: string;
11
+ loginHtml: (error?: string) => string;
12
+ cookieName?: string;
13
+ cookiePath?: string;
14
+ basePath?: string;
15
+ }): Promise<Response>;
16
+ export declare function logoutResponse(options: {
17
+ secureCookie: boolean;
18
+ redirectTo?: string;
19
+ cookieName?: string;
20
+ cookiePath?: string;
21
+ basePath?: string;
22
+ }): Response;
23
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAGA,wBAAsB,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAQ/D;AAED,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAS7D;AAED,eAAO,MAAM,mBAAmB,eAAe,CAAC;AAEhD,wBAAgB,eAAe,CAC7B,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,OAAO,EACjB,MAAM,EAAE,MAAM,EACd,UAAU,GAAE,MAA4B,EACxC,IAAI,GAAE,MAAY,GACjB,MAAM,CAGR;AAED,wBAAsB,eAAe,CACnC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,EACd,UAAU,GAAE,MAA4B,GACvC,OAAO,CAAC,OAAO,CAAC,CAkBlB;AAMD,wBAAsB,aAAa,CAAC,OAAO,EAAE;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,OAAO,CAAC;IACtB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IACtC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAoBpB;AAED,wBAAgB,cAAc,CAAC,OAAO,EAAE;IACtC,YAAY,EAAE,OAAO,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,QAAQ,CAoBX"}
package/dist/auth.js ADDED
@@ -0,0 +1,66 @@
1
+ import { html, redirect } from './html.js';
2
+ import { cookiePathForBase, joinBasePath, resolveBasePath } from './path.js';
3
+ export async function hexSha256(secret) {
4
+ const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));
5
+ return [...new Uint8Array(buffer)]
6
+ .map((b) => b.toString(16).padStart(2, '0'))
7
+ .join('');
8
+ }
9
+ export function timingSafeEqual(a, b) {
10
+ if (a.length !== b.length) {
11
+ return false;
12
+ }
13
+ let out = 0;
14
+ for (let index = 0; index < a.length; index++) {
15
+ out |= (a.codePointAt(index) ?? 0) ^ (b.codePointAt(index) ?? 0);
16
+ }
17
+ return out === 0;
18
+ }
19
+ export const DEFAULT_COOKIE_NAME = 'admin_auth';
20
+ export function adminAuthCookie(token, isSecure, maxAge, cookieName = DEFAULT_COOKIE_NAME, path = '/') {
21
+ const secureFlag = isSecure ? 'Secure; ' : '';
22
+ return `${cookieName}=${token}; HttpOnly; ${secureFlag}SameSite=Lax; Max-Age=${maxAge}; Path=${path}`;
23
+ }
24
+ export async function isAuthenticated(request, secret, cookieName = DEFAULT_COOKIE_NAME) {
25
+ const auth = request.headers.get('Authorization');
26
+ if (auth?.startsWith('Bearer ')) {
27
+ const token = auth.slice(7);
28
+ if (timingSafeEqual(token, secret)) {
29
+ return true;
30
+ }
31
+ }
32
+ const cookie = request.headers.get('Cookie') || '';
33
+ const match = new RegExp(String.raw `(?:^|;\s*)${escapeRegExp(cookieName)}=([^;]+)`).exec(cookie);
34
+ if (!match) {
35
+ return false;
36
+ }
37
+ const expected = await hexSha256(secret);
38
+ return timingSafeEqual(match[1], expected);
39
+ }
40
+ function escapeRegExp(s) {
41
+ return s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
42
+ }
43
+ export async function loginResponse(options) {
44
+ const { secret, password, secureCookie, loginHtml: renderLogin, cookieName = DEFAULT_COOKIE_NAME, cookiePath = '/', basePath, } = options;
45
+ if (!timingSafeEqual(password, secret)) {
46
+ return html(renderLogin('Invalid password'), 401);
47
+ }
48
+ const successRedirect = options.successRedirect ?? joinBasePath(resolveBasePath(basePath));
49
+ const token = await hexSha256(secret);
50
+ return redirect(successRedirect, adminAuthCookie(token, secureCookie, 86_400, cookieName, cookiePath));
51
+ }
52
+ export function logoutResponse(options) {
53
+ const { secureCookie, cookieName = DEFAULT_COOKIE_NAME, cookiePath = '/', basePath, } = options;
54
+ const resolvedBase = resolveBasePath(basePath);
55
+ const redirectTo = options.redirectTo ?? joinBasePath(resolvedBase, 'login');
56
+ // Browsers only drop a cookie when Path matches exactly, so clear the
57
+ // configured path, `/`, and the mount-scoped path (e.g. `/admin`).
58
+ const paths = new Set([
59
+ cookiePath,
60
+ '/',
61
+ cookiePathForBase(resolvedBase),
62
+ ]);
63
+ const cookies = [...paths].map((path) => adminAuthCookie('', secureCookie, 0, cookieName, path));
64
+ return redirect(redirectTo, cookies);
65
+ }
66
+ //# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,EAAE,iBAAiB,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAE7E,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,MAAc;IAC5C,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,MAAM,CACvC,SAAS,EACT,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,CACjC,CAAC;IACF,OAAO,CAAC,GAAG,IAAI,UAAU,CAAC,MAAM,CAAC,CAAC;SAC/B,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;SAC3C,IAAI,CAAC,EAAE,CAAC,CAAC;AACd,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,CAAS,EAAE,CAAS;IAClD,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM,EAAE,CAAC;QAC1B,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,GAAG,GAAG,CAAC,CAAC;IACZ,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,CAAC,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,CAAC;QAC9C,GAAG,IAAI,CAAC,CAAC,CAAC,WAAW,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,WAAW,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;IACnE,CAAC;IACD,OAAO,GAAG,KAAK,CAAC,CAAC;AACnB,CAAC;AAED,MAAM,CAAC,MAAM,mBAAmB,GAAG,YAAY,CAAC;AAEhD,MAAM,UAAU,eAAe,CAC7B,KAAa,EACb,QAAiB,EACjB,MAAc,EACd,aAAqB,mBAAmB,EACxC,OAAe,GAAG;IAElB,MAAM,UAAU,GAAG,QAAQ,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC;IAC9C,OAAO,GAAG,UAAU,IAAI,KAAK,eAAe,UAAU,yBAAyB,MAAM,UAAU,IAAI,EAAE,CAAC;AACxG,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,OAAgB,EAChB,MAAc,EACd,aAAqB,mBAAmB;IAExC,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC;IAClD,IAAI,IAAI,EAAE,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QAChC,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QAC5B,IAAI,eAAe,CAAC,KAAK,EAAE,MAAM,CAAC,EAAE,CAAC;YACnC,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IAED,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;IACnD,MAAM,KAAK,GAAG,IAAI,MAAM,CACtB,MAAM,CAAC,GAAG,CAAA,aAAa,YAAY,CAAC,UAAU,CAAC,UAAU,CAC1D,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACf,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,OAAO,KAAK,CAAC;IACf,CAAC;IACD,MAAM,QAAQ,GAAG,MAAM,SAAS,CAAC,MAAM,CAAC,CAAC;IACzC,OAAO,eAAe,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;AAC7C,CAAC;AAED,SAAS,YAAY,CAAC,CAAS;IAC7B,OAAO,CAAC,CAAC,UAAU,CAAC,qBAAqB,EAAE,MAAM,CAAC,GAAG,CAAA,KAAK,CAAC,CAAC;AAC9D,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,OASnC;IACC,MAAM,EACJ,MAAM,EACN,QAAQ,EACR,YAAY,EACZ,SAAS,EAAE,WAAW,EACtB,UAAU,GAAG,mBAAmB,EAChC,UAAU,GAAG,GAAG,EAChB,QAAQ,GACT,GAAG,OAAO,CAAC;IACZ,IAAI,CAAC,eAAe,CAAC,QAAQ,EAAE,MAAM,CAAC,EAAE,CAAC;QACvC,OAAO,IAAI,CAAC,WAAW,CAAC,kBAAkB,CAAC,EAAE,GAAG,CAAC,CAAC;IACpD,CAAC;IACD,MAAM,eAAe,GACnB,OAAO,CAAC,eAAe,IAAI,YAAY,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAC,CAAC;IACrE,MAAM,KAAK,GAAG,MAAM,SAAS,CAAC,MAAM,CAAC,CAAC;IACtC,OAAO,QAAQ,CACb,eAAe,EACf,eAAe,CAAC,KAAK,EAAE,YAAY,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,CAAC,CACrE,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,OAM9B;IACC,MAAM,EACJ,YAAY,EACZ,UAAU,GAAG,mBAAmB,EAChC,UAAU,GAAG,GAAG,EAChB,QAAQ,GACT,GAAG,OAAO,CAAC;IACZ,MAAM,YAAY,GAAG,eAAe,CAAC,QAAQ,CAAC,CAAC;IAC/C,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,YAAY,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC;IAC7E,sEAAsE;IACtE,mEAAmE;IACnE,MAAM,KAAK,GAAG,IAAI,GAAG,CAAS;QAC5B,UAAU;QACV,GAAG;QACH,iBAAiB,CAAC,YAAY,CAAC;KAChC,CAAC,CAAC;IACH,MAAM,OAAO,GAAG,CAAC,GAAG,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CACtC,eAAe,CAAC,EAAE,EAAE,YAAY,EAAE,CAAC,EAAE,UAAU,EAAE,IAAI,CAAC,CACvD,CAAC;IACF,OAAO,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;AACvC,CAAC"}
package/dist/css.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ /** Generated by scripts/embed-css.mjs — edit src/shared.css instead. */
2
+ export declare const SHARED_CSS = "* {\n box-sizing: border-box;\n}\n\n.admin-body {\n font-family:\n system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n margin: 0;\n line-height: 1.45;\n color: #1a1a1a;\n background: #e4e6eb;\n min-height: 100vh;\n text-size-adjust: 100%;\n}\n\n.page-shell {\n max-width: 40rem;\n margin: 0 auto;\n padding: 0.75rem 1rem 2rem;\n}\n\n@media (width >= 640px) {\n .page-shell {\n padding: 1rem 1.25rem 2rem;\n }\n}\n\n.page-header {\n margin-bottom: 1rem;\n}\n\n.page-header h1 {\n font-size: clamp(1.25rem, 4vw, 1.5rem);\n margin: 0 0 0.5rem;\n}\n\n.panel-card {\n background: #fff;\n border-radius: 12px;\n padding: 1rem 1rem 1.25rem;\n box-shadow: 0 1px 3px rgb(0 0 0 / 7%);\n margin-bottom: 1rem;\n}\n\nlabel {\n display: block;\n margin-top: 1rem;\n font-weight: 600;\n font-size: 0.95rem;\n}\n\n.mode-row label {\n margin-top: 0;\n font-weight: normal;\n display: flex;\n align-items: center;\n gap: 0.35rem;\n}\n\n.row {\n display: flex;\n gap: 0.75rem;\n flex-wrap: wrap;\n}\n\n.row label {\n flex: 1;\n min-width: min(100%, 5.5rem);\n margin-top: 0;\n}\n\n@media (width <= 480px) {\n .row {\n flex-direction: column;\n }\n\n .row label {\n min-width: 100%;\n }\n}\n\nlabel:first-of-type,\n.panel-card > label:first-child {\n margin-top: 0;\n}\n\ninput[type='text'],\ninput[type='password'],\ninput[type='url'],\ninput[type='number'],\ntextarea,\nselect {\n width: 100%;\n padding: 0.55rem 0.65rem;\n font: inherit;\n border: 1px solid #c5c9d0;\n border-radius: 8px;\n background: #fafbfc;\n}\n\ntextarea {\n min-height: 5.5rem;\n resize: vertical;\n}\n\ninput:focus,\ntextarea:focus,\nselect:focus {\n outline: 2px solid #90cdf4;\n outline-offset: 1px;\n border-color: #3182ce;\n}\n\n.hint {\n font-size: 0.82rem;\n color: #4a5568;\n font-weight: normal;\n}\n\n.actions {\n margin-top: 1.5rem;\n display: flex;\n gap: 0.75rem;\n align-items: center;\n flex-wrap: wrap;\n}\n\n.btn-primary {\n padding: 0.6rem 1.1rem;\n border: none;\n border-radius: 8px;\n background: #2d3748;\n color: #fff;\n font: inherit;\n font-weight: 600;\n cursor: pointer;\n min-height: 44px;\n}\n\n.btn-primary:active {\n opacity: 0.94;\n}\n\n.btn-ghost {\n padding: 0.5rem 0.75rem;\n border: none;\n background: transparent;\n color: #4a5568;\n font: inherit;\n cursor: pointer;\n text-decoration: underline;\n min-height: 44px;\n}\n\n.ok {\n color: #276749;\n margin: 0 0 0.75rem;\n font-weight: 600;\n}\n\n.flash-err {\n color: #c53030;\n margin: 0 0 0.75rem;\n font-weight: 600;\n}\n\n.warn {\n color: #c05600;\n margin: 0 0 0.75rem;\n font-weight: 600;\n}\n\n.err {\n color: #b00;\n margin-bottom: 1rem;\n font-size: 0.95rem;\n}\n\n.btn-secondary {\n padding: 0.5rem 0.85rem;\n border: 1px solid #cbd5e0;\n border-radius: 8px;\n background: #f7fafc;\n font: inherit;\n cursor: pointer;\n min-height: 44px;\n font-size: 0.9rem;\n}\n\n.btn-secondary:active {\n background: #edf2f7;\n}\n\nfieldset {\n border: 1px solid #d8dee6;\n padding: 0.75rem 1rem;\n margin-top: 1rem;\n border-radius: 8px;\n background: #fafbfc;\n}\n\nlegend {\n padding: 0 0.35rem;\n font-weight: 600;\n}\n\n.mode-row {\n display: flex;\n flex-wrap: wrap;\n gap: 0.75rem 1.25rem;\n margin-top: 0.5rem;\n}\n\n.mode-row input {\n width: auto;\n margin: 0;\n}\n\n.checkbox-row {\n margin-top: 1rem;\n font-weight: normal;\n display: flex;\n align-items: center;\n gap: 0.35rem;\n}\n\n.checkbox-row input {\n width: auto;\n margin: 0;\n}\n\n.login-shell {\n max-width: 24rem;\n margin: 0 auto;\n padding: 1.25rem;\n min-height: 100vh;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.login-shell .panel-card {\n width: 100%;\n margin: 0;\n}\n\n.login-shell button {\n width: 100%;\n}";
3
+ //# sourceMappingURL=css.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"css.d.ts","sourceRoot":"","sources":["../src/css.ts"],"names":[],"mappings":"AAAA,wEAAwE;AACxE,eAAO,MAAM,UAAU,+uHAA+uH,CAAC"}
package/dist/css.js ADDED
@@ -0,0 +1,3 @@
1
+ /** Generated by scripts/embed-css.mjs — edit src/shared.css instead. */
2
+ export const SHARED_CSS = "* {\n box-sizing: border-box;\n}\n\n.admin-body {\n font-family:\n system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n margin: 0;\n line-height: 1.45;\n color: #1a1a1a;\n background: #e4e6eb;\n min-height: 100vh;\n text-size-adjust: 100%;\n}\n\n.page-shell {\n max-width: 40rem;\n margin: 0 auto;\n padding: 0.75rem 1rem 2rem;\n}\n\n@media (width >= 640px) {\n .page-shell {\n padding: 1rem 1.25rem 2rem;\n }\n}\n\n.page-header {\n margin-bottom: 1rem;\n}\n\n.page-header h1 {\n font-size: clamp(1.25rem, 4vw, 1.5rem);\n margin: 0 0 0.5rem;\n}\n\n.panel-card {\n background: #fff;\n border-radius: 12px;\n padding: 1rem 1rem 1.25rem;\n box-shadow: 0 1px 3px rgb(0 0 0 / 7%);\n margin-bottom: 1rem;\n}\n\nlabel {\n display: block;\n margin-top: 1rem;\n font-weight: 600;\n font-size: 0.95rem;\n}\n\n.mode-row label {\n margin-top: 0;\n font-weight: normal;\n display: flex;\n align-items: center;\n gap: 0.35rem;\n}\n\n.row {\n display: flex;\n gap: 0.75rem;\n flex-wrap: wrap;\n}\n\n.row label {\n flex: 1;\n min-width: min(100%, 5.5rem);\n margin-top: 0;\n}\n\n@media (width <= 480px) {\n .row {\n flex-direction: column;\n }\n\n .row label {\n min-width: 100%;\n }\n}\n\nlabel:first-of-type,\n.panel-card > label:first-child {\n margin-top: 0;\n}\n\ninput[type='text'],\ninput[type='password'],\ninput[type='url'],\ninput[type='number'],\ntextarea,\nselect {\n width: 100%;\n padding: 0.55rem 0.65rem;\n font: inherit;\n border: 1px solid #c5c9d0;\n border-radius: 8px;\n background: #fafbfc;\n}\n\ntextarea {\n min-height: 5.5rem;\n resize: vertical;\n}\n\ninput:focus,\ntextarea:focus,\nselect:focus {\n outline: 2px solid #90cdf4;\n outline-offset: 1px;\n border-color: #3182ce;\n}\n\n.hint {\n font-size: 0.82rem;\n color: #4a5568;\n font-weight: normal;\n}\n\n.actions {\n margin-top: 1.5rem;\n display: flex;\n gap: 0.75rem;\n align-items: center;\n flex-wrap: wrap;\n}\n\n.btn-primary {\n padding: 0.6rem 1.1rem;\n border: none;\n border-radius: 8px;\n background: #2d3748;\n color: #fff;\n font: inherit;\n font-weight: 600;\n cursor: pointer;\n min-height: 44px;\n}\n\n.btn-primary:active {\n opacity: 0.94;\n}\n\n.btn-ghost {\n padding: 0.5rem 0.75rem;\n border: none;\n background: transparent;\n color: #4a5568;\n font: inherit;\n cursor: pointer;\n text-decoration: underline;\n min-height: 44px;\n}\n\n.ok {\n color: #276749;\n margin: 0 0 0.75rem;\n font-weight: 600;\n}\n\n.flash-err {\n color: #c53030;\n margin: 0 0 0.75rem;\n font-weight: 600;\n}\n\n.warn {\n color: #c05600;\n margin: 0 0 0.75rem;\n font-weight: 600;\n}\n\n.err {\n color: #b00;\n margin-bottom: 1rem;\n font-size: 0.95rem;\n}\n\n.btn-secondary {\n padding: 0.5rem 0.85rem;\n border: 1px solid #cbd5e0;\n border-radius: 8px;\n background: #f7fafc;\n font: inherit;\n cursor: pointer;\n min-height: 44px;\n font-size: 0.9rem;\n}\n\n.btn-secondary:active {\n background: #edf2f7;\n}\n\nfieldset {\n border: 1px solid #d8dee6;\n padding: 0.75rem 1rem;\n margin-top: 1rem;\n border-radius: 8px;\n background: #fafbfc;\n}\n\nlegend {\n padding: 0 0.35rem;\n font-weight: 600;\n}\n\n.mode-row {\n display: flex;\n flex-wrap: wrap;\n gap: 0.75rem 1.25rem;\n margin-top: 0.5rem;\n}\n\n.mode-row input {\n width: auto;\n margin: 0;\n}\n\n.checkbox-row {\n margin-top: 1rem;\n font-weight: normal;\n display: flex;\n align-items: center;\n gap: 0.35rem;\n}\n\n.checkbox-row input {\n width: auto;\n margin: 0;\n}\n\n.login-shell {\n max-width: 24rem;\n margin: 0 auto;\n padding: 1.25rem;\n min-height: 100vh;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.login-shell .panel-card {\n width: 100%;\n margin: 0;\n}\n\n.login-shell button {\n width: 100%;\n}";
3
+ //# sourceMappingURL=css.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"css.js","sourceRoot":"","sources":["../src/css.ts"],"names":[],"mappings":"AAAA,wEAAwE;AACxE,MAAM,CAAC,MAAM,UAAU,GAAG,4uHAA4uH,CAAC"}
@@ -0,0 +1,5 @@
1
+ /**
2
+ Escape text for safe interpolation into HTML.
3
+ */
4
+ export declare function escapeHtml(s: string): string;
5
+ //# sourceMappingURL=escape.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"escape.d.ts","sourceRoot":"","sources":["../src/escape.ts"],"names":[],"mappings":"AAAA;;EAEE;AACF,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAM5C"}
package/dist/escape.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ Escape text for safe interpolation into HTML.
3
+ */
4
+ export function escapeHtml(s) {
5
+ return s
6
+ .replaceAll('&', '&amp;')
7
+ .replaceAll('<', '&lt;')
8
+ .replaceAll('>', '&gt;')
9
+ .replaceAll('"', '&quot;');
10
+ }
11
+ //# sourceMappingURL=escape.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"escape.js","sourceRoot":"","sources":["../src/escape.ts"],"names":[],"mappings":"AAAA;;EAEE;AACF,MAAM,UAAU,UAAU,CAAC,CAAS;IAClC,OAAO,CAAC;SACL,UAAU,CAAC,GAAG,EAAE,OAAO,CAAC;SACxB,UAAU,CAAC,GAAG,EAAE,MAAM,CAAC;SACvB,UAAU,CAAC,GAAG,EAAE,MAAM,CAAC;SACvB,UAAU,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;AAC/B,CAAC"}
package/dist/form.d.ts ADDED
@@ -0,0 +1,74 @@
1
+ export type FieldOption = {
2
+ value: string;
3
+ label: string;
4
+ };
5
+ type FieldBase = {
6
+ label?: string;
7
+ hint?: string;
8
+ required?: boolean;
9
+ placeholder?: string;
10
+ };
11
+ export type TextField = FieldBase & {
12
+ type: 'text' | 'url' | 'password';
13
+ name: string;
14
+ value?: string;
15
+ };
16
+ export type NumberField = FieldBase & {
17
+ type: 'number';
18
+ name: string;
19
+ value?: string | number;
20
+ min?: number;
21
+ max?: number;
22
+ step?: number | string;
23
+ };
24
+ export type TextareaField = FieldBase & {
25
+ type: 'textarea';
26
+ name: string;
27
+ value?: string;
28
+ };
29
+ export type SelectField = FieldBase & {
30
+ type: 'select';
31
+ name: string;
32
+ value?: string;
33
+ options: FieldOption[];
34
+ };
35
+ export type RadioField = FieldBase & {
36
+ type: 'radio';
37
+ name: string;
38
+ value?: string;
39
+ options: FieldOption[];
40
+ };
41
+ export type CheckboxField = FieldBase & {
42
+ type: 'checkbox';
43
+ name: string;
44
+ /**
45
+ Whether the checkbox is checked when rendering.
46
+ */
47
+ checked?: boolean;
48
+ value?: string;
49
+ };
50
+ export type FieldsetField = {
51
+ type: 'fieldset';
52
+ legend: string;
53
+ hint?: string;
54
+ fields: Field[];
55
+ };
56
+ export type HtmlField = {
57
+ type: 'html';
58
+ html: string;
59
+ };
60
+ export type Field = TextField | NumberField | TextareaField | SelectField | RadioField | CheckboxField | FieldsetField | HtmlField;
61
+ export declare function adminForm(options: {
62
+ action: string;
63
+ fields: Field[];
64
+ method?: 'get' | 'post';
65
+ submitLabel?: string;
66
+ id?: string;
67
+ }): string;
68
+ export declare function formText(form: FormData, name: string): string;
69
+ /**
70
+ Named scalar values from a form matching the field schema (checkbox → boolean).
71
+ */
72
+ export declare function parseAdminForm(form: FormData, fields: Field[]): Record<string, string | boolean>;
73
+ export {};
74
+ //# sourceMappingURL=form.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"form.d.ts","sourceRoot":"","sources":["../src/form.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,WAAW,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAE3D,KAAK,SAAS,GAAG;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG;IAClC,IAAI,EAAE,MAAM,GAAG,KAAK,GAAG,UAAU,CAAC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG;IACpC,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG,SAAS,GAAG;IACtC,IAAI,EAAE,UAAU,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG;IACpC,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,WAAW,EAAE,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG;IACnC,IAAI,EAAE,OAAO,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,WAAW,EAAE,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG,SAAS,GAAG;IACtC,IAAI,EAAE,UAAU,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb;;QAEC;IACD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,EAAE,UAAU,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,KAAK,EAAE,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,MAAM,MAAM,KAAK,GACb,SAAS,GACT,WAAW,GACX,aAAa,GACb,WAAW,GACX,UAAU,GACV,aAAa,GACb,aAAa,GACb,SAAS,CAAC;AA2Id,wBAAgB,SAAS,CAAC,OAAO,EAAE;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,MAAM,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,EAAE,CAAC,EAAE,MAAM,CAAC;CACb,GAAG,MAAM,CAUT;AAED,wBAAgB,QAAQ,CAAC,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAG7D;AAgCD;;EAEE;AACF,wBAAgB,cAAc,CAC5B,IAAI,EAAE,QAAQ,EACd,MAAM,EAAE,KAAK,EAAE,GACd,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAgBlC"}
package/dist/form.js ADDED
@@ -0,0 +1,184 @@
1
+ import { escapeHtml } from './escape.js';
2
+ function optionalHint(hint) {
3
+ return hint ? ` <span class="hint">${escapeHtml(hint)}</span>` : '';
4
+ }
5
+ function labelHtml(id, label, hint) {
6
+ return `<label for="${escapeHtml(id)}">${escapeHtml(label)}${optionalHint(hint)}</label>`;
7
+ }
8
+ function requiredAttribute(required) {
9
+ return required ? ' required' : '';
10
+ }
11
+ function renderField(field) {
12
+ switch (field.type) {
13
+ case 'html': {
14
+ return field.html;
15
+ }
16
+ case 'fieldset': {
17
+ return renderFieldset(field);
18
+ }
19
+ case 'radio': {
20
+ return renderRadio(field);
21
+ }
22
+ case 'checkbox': {
23
+ return renderCheckbox(field);
24
+ }
25
+ case 'textarea': {
26
+ return renderTextarea(field);
27
+ }
28
+ case 'select': {
29
+ return renderSelect(field);
30
+ }
31
+ case 'number': {
32
+ return renderNumber(field);
33
+ }
34
+ default: {
35
+ return renderTextInput(field);
36
+ }
37
+ }
38
+ }
39
+ function renderFieldset(field) {
40
+ const hint = field.hint
41
+ ? `<p class="hint">${escapeHtml(field.hint)}</p>`
42
+ : '';
43
+ const inner = field.fields.map((child) => renderField(child)).join('\n');
44
+ return `<fieldset>
45
+ <legend>${escapeHtml(field.legend)}</legend>
46
+ ${hint}
47
+ ${inner}
48
+ </fieldset>`;
49
+ }
50
+ function renderRadio(field) {
51
+ const legend = field.label
52
+ ? `<legend>${escapeHtml(field.label)}${optionalHint(field.hint)}</legend>`
53
+ : '';
54
+ const options = field.options
55
+ .map((option) => {
56
+ const id = `${field.name}_${option.value}`;
57
+ const checked = option.value === (field.value ?? '') ? ' checked' : '';
58
+ return `<label for="${escapeHtml(id)}"><input id="${escapeHtml(id)}" type="radio" name="${escapeHtml(field.name)}" value="${escapeHtml(option.value)}"${checked}${requiredAttribute(field.required)}> ${escapeHtml(option.label)}</label>`;
59
+ })
60
+ .join('\n ');
61
+ return `<fieldset>
62
+ ${legend}
63
+ <div class="mode-row">
64
+ ${options}
65
+ </div>
66
+ </fieldset>`;
67
+ }
68
+ function renderCheckbox(field) {
69
+ const id = field.name;
70
+ const checked = field.checked ? ' checked' : '';
71
+ const value = field.value ?? 'on';
72
+ return `<label class="checkbox-row" for="${escapeHtml(id)}">
73
+ <input id="${escapeHtml(id)}" type="checkbox" name="${escapeHtml(field.name)}" value="${escapeHtml(value)}"${checked}>
74
+ ${escapeHtml(field.label ?? field.name)}${optionalHint(field.hint)}
75
+ </label>`;
76
+ }
77
+ function renderTextarea(field) {
78
+ const id = field.name;
79
+ const label = field.label ? labelHtml(id, field.label, field.hint) : '';
80
+ const placeholder = field.placeholder
81
+ ? ` placeholder="${escapeHtml(field.placeholder)}"`
82
+ : '';
83
+ return `${label}
84
+ <textarea id="${escapeHtml(id)}" name="${escapeHtml(field.name)}"${placeholder}${requiredAttribute(field.required)}>${escapeHtml(field.value ?? '')}</textarea>`;
85
+ }
86
+ function renderSelect(field) {
87
+ const id = field.name;
88
+ const label = field.label ? labelHtml(id, field.label, field.hint) : '';
89
+ const options = field.options
90
+ .map((option) => {
91
+ const selected = option.value === (field.value ?? '') ? ' selected' : '';
92
+ return `<option value="${escapeHtml(option.value)}"${selected}>${escapeHtml(option.label)}</option>`;
93
+ })
94
+ .join('\n ');
95
+ return `${label}
96
+ <select id="${escapeHtml(id)}" name="${escapeHtml(field.name)}"${requiredAttribute(field.required)}>
97
+ ${options}
98
+ </select>`;
99
+ }
100
+ function renderNumber(field) {
101
+ const id = field.name;
102
+ const label = field.label ? labelHtml(id, field.label, field.hint) : '';
103
+ const min = field.min === undefined ? '' : ` min="${field.min}"`;
104
+ const max = field.max === undefined ? '' : ` max="${field.max}"`;
105
+ const step = field.step === undefined ? '' : ` step="${escapeHtml(String(field.step))}"`;
106
+ const placeholder = field.placeholder
107
+ ? ` placeholder="${escapeHtml(field.placeholder)}"`
108
+ : '';
109
+ const value = field.value !== undefined && field.value !== ''
110
+ ? ` value="${escapeHtml(String(field.value))}"`
111
+ : '';
112
+ return `${label}
113
+ <input id="${escapeHtml(id)}" name="${escapeHtml(field.name)}" type="number"${value}${min}${max}${step}${placeholder}${requiredAttribute(field.required)}>`;
114
+ }
115
+ function renderTextInput(field) {
116
+ const id = field.name;
117
+ const label = field.label ? labelHtml(id, field.label, field.hint) : '';
118
+ const placeholder = field.placeholder
119
+ ? ` placeholder="${escapeHtml(field.placeholder)}"`
120
+ : '';
121
+ const value = field.value === undefined ? '' : ` value="${escapeHtml(field.value)}"`;
122
+ return `${label}
123
+ <input id="${escapeHtml(id)}" name="${escapeHtml(field.name)}" type="${field.type}"${value}${placeholder}${requiredAttribute(field.required)}>`;
124
+ }
125
+ export function adminForm(options) {
126
+ const { action, fields, method = 'post', submitLabel = 'Save', id } = options;
127
+ const idAttribute = id ? ` id="${escapeHtml(id)}"` : '';
128
+ const body = fields.map((field) => renderField(field)).join('\n');
129
+ return `<form method="${method}" action="${escapeHtml(action)}"${idAttribute}>
130
+ ${body}
131
+ <div class="actions">
132
+ <button type="submit" class="btn-primary">${escapeHtml(submitLabel)}</button>
133
+ </div>
134
+ </form>`;
135
+ }
136
+ export function formText(form, name) {
137
+ const value = form.get(name);
138
+ return typeof value === 'string' ? value : '';
139
+ }
140
+ const VALUE_FIELD_TYPES = new Set([
141
+ 'text',
142
+ 'url',
143
+ 'password',
144
+ 'number',
145
+ 'textarea',
146
+ 'select',
147
+ 'radio',
148
+ ]);
149
+ function isValueField(field) {
150
+ return VALUE_FIELD_TYPES.has(field.type);
151
+ }
152
+ function walkScalarFields(fields, out) {
153
+ for (const field of fields) {
154
+ if (field.type === 'html') {
155
+ continue;
156
+ }
157
+ if (field.type === 'fieldset') {
158
+ walkScalarFields(field.fields, out);
159
+ continue;
160
+ }
161
+ out.push(field);
162
+ }
163
+ }
164
+ /**
165
+ Named scalar values from a form matching the field schema (checkbox → boolean).
166
+ */
167
+ export function parseAdminForm(form, fields) {
168
+ const scalars = [];
169
+ walkScalarFields(fields, scalars);
170
+ const result = {};
171
+ for (const field of scalars) {
172
+ if (field.type === 'checkbox') {
173
+ const raw = form.get(field.name);
174
+ const expected = field.value ?? 'on';
175
+ result[field.name] = typeof raw === 'string' && raw === expected;
176
+ continue;
177
+ }
178
+ if (isValueField(field)) {
179
+ result[field.name] = formText(form, field.name);
180
+ }
181
+ }
182
+ return result;
183
+ }
184
+ //# sourceMappingURL=form.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"form.js","sourceRoot":"","sources":["../src/form.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AA8EzC,SAAS,YAAY,CAAC,IAAwB;IAC5C,OAAO,IAAI,CAAC,CAAC,CAAC,uBAAuB,UAAU,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC;AACtE,CAAC;AAED,SAAS,SAAS,CAAC,EAAU,EAAE,KAAa,EAAE,IAAa;IACzD,OAAO,eAAe,UAAU,CAAC,EAAE,CAAC,KAAK,UAAU,CAAC,KAAK,CAAC,GAAG,YAAY,CAAC,IAAI,CAAC,UAAU,CAAC;AAC5F,CAAC;AAED,SAAS,iBAAiB,CAAC,QAA6B;IACtD,OAAO,QAAQ,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,CAAC;AACrC,CAAC;AAED,SAAS,WAAW,CAAC,KAAY;IAC/B,QAAQ,KAAK,CAAC,IAAI,EAAE,CAAC;QACnB,KAAK,MAAM,CAAC,CAAC,CAAC;YACZ,OAAO,KAAK,CAAC,IAAI,CAAC;QACpB,CAAC;QACD,KAAK,UAAU,CAAC,CAAC,CAAC;YAChB,OAAO,cAAc,CAAC,KAAK,CAAC,CAAC;QAC/B,CAAC;QACD,KAAK,OAAO,CAAC,CAAC,CAAC;YACb,OAAO,WAAW,CAAC,KAAK,CAAC,CAAC;QAC5B,CAAC;QACD,KAAK,UAAU,CAAC,CAAC,CAAC;YAChB,OAAO,cAAc,CAAC,KAAK,CAAC,CAAC;QAC/B,CAAC;QACD,KAAK,UAAU,CAAC,CAAC,CAAC;YAChB,OAAO,cAAc,CAAC,KAAK,CAAC,CAAC;QAC/B,CAAC;QACD,KAAK,QAAQ,CAAC,CAAC,CAAC;YACd,OAAO,YAAY,CAAC,KAAK,CAAC,CAAC;QAC7B,CAAC;QACD,KAAK,QAAQ,CAAC,CAAC,CAAC;YACd,OAAO,YAAY,CAAC,KAAK,CAAC,CAAC;QAC7B,CAAC;QACD,OAAO,CAAC,CAAC,CAAC;YACR,OAAO,eAAe,CAAC,KAAK,CAAC,CAAC;QAChC,CAAC;IACH,CAAC;AACH,CAAC;AAED,SAAS,cAAc,CAAC,KAAoB;IAC1C,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI;QACrB,CAAC,CAAC,mBAAmB,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM;QACjD,CAAC,CAAC,EAAE,CAAC;IACP,MAAM,KAAK,GAAG,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACzE,OAAO;YACG,UAAU,CAAC,KAAK,CAAC,MAAM,CAAC;IAChC,IAAI;IACJ,KAAK;YACG,CAAC;AACb,CAAC;AAED,SAAS,WAAW,CAAC,KAAiB;IACpC,MAAM,MAAM,GAAG,KAAK,CAAC,KAAK;QACxB,CAAC,CAAC,WAAW,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,YAAY,CAAC,KAAK,CAAC,IAAI,CAAC,WAAW;QAC1E,CAAC,CAAC,EAAE,CAAC;IACP,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO;SAC1B,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE;QACd,MAAM,EAAE,GAAG,GAAG,KAAK,CAAC,IAAI,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;QAC3C,MAAM,OAAO,GAAG,MAAM,CAAC,KAAK,KAAK,CAAC,KAAK,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC;QACvE,OAAO,eAAe,UAAU,CAAC,EAAE,CAAC,gBAAgB,UAAU,CAAC,EAAE,CAAC,wBAAwB,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,YAAY,UAAU,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,OAAO,GAAG,iBAAiB,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,UAAU,CAAC,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC;IAC7O,CAAC,CAAC;SACD,IAAI,CAAC,QAAQ,CAAC,CAAC;IAClB,OAAO;IACL,MAAM;;MAEJ,OAAO;;YAED,CAAC;AACb,CAAC;AAED,SAAS,cAAc,CAAC,KAAoB;IAC1C,MAAM,EAAE,GAAG,KAAK,CAAC,IAAI,CAAC;IACtB,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC;IAChD,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,IAAI,IAAI,CAAC;IAClC,OAAO,oCAAoC,UAAU,CAAC,EAAE,CAAC;eAC5C,UAAU,CAAC,EAAE,CAAC,2BAA2B,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,YAAY,UAAU,CAAC,KAAK,CAAC,IAAI,OAAO;IAClH,UAAU,CAAC,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,IAAI,CAAC,GAAG,YAAY,CAAC,KAAK,CAAC,IAAI,CAAC;SAC3D,CAAC;AACV,CAAC;AAED,SAAS,cAAc,CAAC,KAAoB;IAC1C,MAAM,EAAE,GAAG,KAAK,CAAC,IAAI,CAAC;IACtB,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,EAAE,EAAE,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IACxE,MAAM,WAAW,GAAG,KAAK,CAAC,WAAW;QACnC,CAAC,CAAC,iBAAiB,UAAU,CAAC,KAAK,CAAC,WAAW,CAAC,GAAG;QACnD,CAAC,CAAC,EAAE,CAAC;IACP,OAAO,GAAG,KAAK;gBACD,UAAU,CAAC,EAAE,CAAC,WAAW,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,WAAW,GAAG,iBAAiB,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,UAAU,CAAC,KAAK,CAAC,KAAK,IAAI,EAAE,CAAC,aAAa,CAAC;AACjK,CAAC;AAED,SAAS,YAAY,CAAC,KAAkB;IACtC,MAAM,EAAE,GAAG,KAAK,CAAC,IAAI,CAAC;IACtB,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,EAAE,EAAE,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IACxE,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO;SAC1B,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE;QACd,MAAM,QAAQ,GAAG,MAAM,CAAC,KAAK,KAAK,CAAC,KAAK,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,CAAC;QACzE,OAAO,kBAAkB,UAAU,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,QAAQ,IAAI,UAAU,CAAC,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC;IACvG,CAAC,CAAC;SACD,IAAI,CAAC,QAAQ,CAAC,CAAC;IAClB,OAAO,GAAG,KAAK;cACH,UAAU,CAAC,EAAE,CAAC,WAAW,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,iBAAiB,CAAC,KAAK,CAAC,QAAQ,CAAC;MAC5F,OAAO;YACD,CAAC;AACb,CAAC;AAED,SAAS,YAAY,CAAC,KAAkB;IACtC,MAAM,EAAE,GAAG,KAAK,CAAC,IAAI,CAAC;IACtB,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,EAAE,EAAE,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IACxE,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,KAAK,CAAC,GAAG,GAAG,CAAC;IACjE,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,KAAK,CAAC,GAAG,GAAG,CAAC;IACjE,MAAM,IAAI,GACR,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,UAAU,UAAU,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC;IAC9E,MAAM,WAAW,GAAG,KAAK,CAAC,WAAW;QACnC,CAAC,CAAC,iBAAiB,UAAU,CAAC,KAAK,CAAC,WAAW,CAAC,GAAG;QACnD,CAAC,CAAC,EAAE,CAAC;IACP,MAAM,KAAK,GACT,KAAK,CAAC,KAAK,KAAK,SAAS,IAAI,KAAK,CAAC,KAAK,KAAK,EAAE;QAC7C,CAAC,CAAC,WAAW,UAAU,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,GAAG;QAC/C,CAAC,CAAC,EAAE,CAAC;IACT,OAAO,GAAG,KAAK;aACJ,UAAU,CAAC,EAAE,CAAC,WAAW,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,kBAAkB,KAAK,GAAG,GAAG,GAAG,GAAG,GAAG,IAAI,GAAG,WAAW,GAAG,iBAAiB,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC;AAC5J,CAAC;AAED,SAAS,eAAe,CAAC,KAAgB;IACvC,MAAM,EAAE,GAAG,KAAK,CAAC,IAAI,CAAC;IACtB,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,EAAE,EAAE,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IACxE,MAAM,WAAW,GAAG,KAAK,CAAC,WAAW;QACnC,CAAC,CAAC,iBAAiB,UAAU,CAAC,KAAK,CAAC,WAAW,CAAC,GAAG;QACnD,CAAC,CAAC,EAAE,CAAC;IACP,MAAM,KAAK,GACT,KAAK,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,WAAW,UAAU,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC;IACzE,OAAO,GAAG,KAAK;aACJ,UAAU,CAAC,EAAE,CAAC,WAAW,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,WAAW,KAAK,CAAC,IAAI,IAAI,KAAK,GAAG,WAAW,GAAG,iBAAiB,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC;AAChJ,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,OAMzB;IACC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,WAAW,GAAG,MAAM,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC;IAC9E,MAAM,WAAW,GAAG,EAAE,CAAC,CAAC,CAAC,QAAQ,UAAU,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;IACxD,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAClE,OAAO,iBAAiB,MAAM,aAAa,UAAU,CAAC,MAAM,CAAC,IAAI,WAAW;EAC5E,IAAI;;gDAE0C,UAAU,CAAC,WAAW,CAAC;;QAE/D,CAAC;AACT,CAAC;AAED,MAAM,UAAU,QAAQ,CAAC,IAAc,EAAE,IAAY;IACnD,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAC7B,OAAO,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC;AAChD,CAAC;AAKD,MAAM,iBAAiB,GAAG,IAAI,GAAG,CAAgB;IAC/C,MAAM;IACN,KAAK;IACL,UAAU;IACV,QAAQ;IACR,UAAU;IACV,QAAQ;IACR,OAAO;CACR,CAAC,CAAC;AAEH,SAAS,YAAY,CAAC,KAAY;IAChC,OAAO,iBAAiB,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;AAC3C,CAAC;AAED,SAAS,gBAAgB,CAAC,MAAe,EAAE,GAAY;IACrD,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,IAAI,KAAK,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;YAC1B,SAAS;QACX,CAAC;QACD,IAAI,KAAK,CAAC,IAAI,KAAK,UAAU,EAAE,CAAC;YAC9B,gBAAgB,CAAC,KAAK,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;YACpC,SAAS;QACX,CAAC;QACD,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAClB,CAAC;AACH,CAAC;AAED;;EAEE;AACF,MAAM,UAAU,cAAc,CAC5B,IAAc,EACd,MAAe;IAEf,MAAM,OAAO,GAAY,EAAE,CAAC;IAC5B,gBAAgB,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,MAAM,MAAM,GAAqC,EAAE,CAAC;IACpD,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;QAC5B,IAAI,KAAK,CAAC,IAAI,KAAK,UAAU,EAAE,CAAC;YAC9B,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YACjC,MAAM,QAAQ,GAAG,KAAK,CAAC,KAAK,IAAI,IAAI,CAAC;YACrC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,QAAQ,CAAC;YACjE,SAAS;QACX,CAAC;QACD,IAAI,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC;YACxB,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;QAClD,CAAC;IACH,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}