astro-lyra 0.0.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/README.md +40 -0
- package/dist/components/Form.astro +114 -0
- package/dist/integration.js +13 -0
- package/dist/server/index.js +37 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Lyra
|
|
2
|
+
|
|
3
|
+
> Astro-native forms: one Zod schema for client + server, View Transitions safe, and post-submit UX that doesn't fight you.
|
|
4
|
+
|
|
5
|
+
`astro-lyra` is an Astro integration that handles the hard parts of forms in Astro so you don't have to:
|
|
6
|
+
|
|
7
|
+
- **One schema, both sides** — define your form with [Zod](https://zod.dev) once; validate on the client and the server with the same source of truth.
|
|
8
|
+
- **View Transitions safe** — the client runtime re-initializes correctly across Astro navigations. No broken scripts, no lost state.
|
|
9
|
+
- **Post-submit UX, solved** — inline success/error messages, no jarring scroll-to-top, optional redirect — with or without JavaScript (progressive enhancement).
|
|
10
|
+
- **Bring your own handler** — submit to your own Astro Action or endpoint. No backend lock-in.
|
|
11
|
+
- **Spam honeypot built in** — basic bot protection with zero external services.
|
|
12
|
+
|
|
13
|
+
> Status: planning. Implementation is spec-driven — see [`openspec/`](./openspec) for the proposal, specs, design, and tasks.
|
|
14
|
+
|
|
15
|
+
## Install (planned)
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npx astro add astro-lyra
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Form schema contract
|
|
22
|
+
|
|
23
|
+
Every form submission arrives as `FormData`, where every value is a string. Use
|
|
24
|
+
`z.coerce.*` for any field that isn't a string so the value is converted to the
|
|
25
|
+
right type during validation:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
const schema = z.object({
|
|
29
|
+
email: z.string().email(), // string — no coercion needed
|
|
30
|
+
age: z.coerce.number().min(18), // number — coerce the FormData string
|
|
31
|
+
subscribe: z.coerce.boolean(), // boolean — coerce "true"/"on"
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
A plain `z.number()` reports a validation error for that field (not a crash),
|
|
36
|
+
pointing you back to `z.coerce.number()`.
|
|
37
|
+
|
|
38
|
+
## License
|
|
39
|
+
|
|
40
|
+
MIT
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { defineForm } from "../define-form.js";
|
|
3
|
+
import { HONEY_FIELD } from "../shared/constants.js";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/** defineForm() result — supplies schema and descriptor for field rendering */
|
|
7
|
+
form: ReturnType<typeof defineForm>;
|
|
8
|
+
/** Native form action URL or Astro Action name */
|
|
9
|
+
action?: string;
|
|
10
|
+
/** HTTP method — always POST for mutation forms */
|
|
11
|
+
method?: "POST";
|
|
12
|
+
/** Set false to opt-out of JS enhancement (native POST only) */
|
|
13
|
+
enhance?: boolean;
|
|
14
|
+
/** Client-side redirect target on successful submission */
|
|
15
|
+
redirect?: string;
|
|
16
|
+
/** Field-level errors for SSR re-render without JS */
|
|
17
|
+
errors?: Record<string, string[]>;
|
|
18
|
+
/** Pre-populated field values for no-JS re-render after server validation */
|
|
19
|
+
values?: Record<string, string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
form: _form,
|
|
24
|
+
action,
|
|
25
|
+
method = "POST",
|
|
26
|
+
enhance = true,
|
|
27
|
+
redirect,
|
|
28
|
+
errors = {},
|
|
29
|
+
values = {},
|
|
30
|
+
} = Astro.props;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Map a descriptor field type to the appropriate HTML input type.
|
|
34
|
+
* email and other strings map to "text" — the descriptor only knows the
|
|
35
|
+
* JS type (string/number/boolean), not the semantic format.
|
|
36
|
+
*/
|
|
37
|
+
function mapInputType(descriptorType: string): string {
|
|
38
|
+
if (descriptorType === "number") return "number";
|
|
39
|
+
if (descriptorType === "boolean") return "checkbox";
|
|
40
|
+
return "text";
|
|
41
|
+
}
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
<form
|
|
45
|
+
method={method}
|
|
46
|
+
action={action}
|
|
47
|
+
data-lyra-redirect={redirect || undefined}
|
|
48
|
+
>
|
|
49
|
+
{/* Honeypot: off-screen, invisible to humans, reachable by bots that fill all fields.
|
|
50
|
+
NOT display:none — that hides it from bots too.
|
|
51
|
+
aria-hidden prevents screen readers from announcing it.
|
|
52
|
+
tabindex="-1" prevents keyboard focus. */}
|
|
53
|
+
<span
|
|
54
|
+
aria-hidden="true"
|
|
55
|
+
style="position:absolute;opacity:0;pointer-events:none;width:0;height:0;overflow:hidden"
|
|
56
|
+
>
|
|
57
|
+
<input
|
|
58
|
+
type="text"
|
|
59
|
+
name={HONEY_FIELD}
|
|
60
|
+
tabindex="-1"
|
|
61
|
+
autocomplete="off"
|
|
62
|
+
/>
|
|
63
|
+
</span>
|
|
64
|
+
|
|
65
|
+
{/* Inline feedback region — client runtime writes success/error messages here.
|
|
66
|
+
role="status" + aria-live="polite" announces content changes to screen readers.
|
|
67
|
+
Left empty at SSR; field-level errors appear adjacent to each input below. */}
|
|
68
|
+
<div data-lyra-status role="status" aria-live="polite"></div>
|
|
69
|
+
|
|
70
|
+
{/* Descriptor-driven fields — rendered from form.descriptor.fields so that
|
|
71
|
+
aria-describedby can be wired at SSR time (impossible with slot-only approach).
|
|
72
|
+
Each field gets:
|
|
73
|
+
- a <label> linked to the input via for/id
|
|
74
|
+
- an <input> with the appropriate type and required attribute
|
|
75
|
+
- when errors[name] present: aria-describedby pointing to the error span
|
|
76
|
+
- when errors[name] present: a <span role="alert"> adjacent to the input */}
|
|
77
|
+
{_form.descriptor.fields.map((field) => {
|
|
78
|
+
const fieldErrors = errors[field.name];
|
|
79
|
+
const hasError = !!fieldErrors && fieldErrors.length > 0;
|
|
80
|
+
const inputId = `lyra-field-${field.name}`;
|
|
81
|
+
const errorId = `lyra-err-${field.name}`;
|
|
82
|
+
return (
|
|
83
|
+
<div>
|
|
84
|
+
<label for={inputId}>{field.name}</label>
|
|
85
|
+
<input
|
|
86
|
+
id={inputId}
|
|
87
|
+
name={field.name}
|
|
88
|
+
type={mapInputType(field.type)}
|
|
89
|
+
required={field.required || undefined}
|
|
90
|
+
aria-describedby={hasError ? errorId : undefined}
|
|
91
|
+
value={values[field.name] ?? undefined}
|
|
92
|
+
/>
|
|
93
|
+
{hasError && (
|
|
94
|
+
<span id={errorId} role="alert">
|
|
95
|
+
{fieldErrors!.join(", ")}
|
|
96
|
+
</span>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
})}
|
|
101
|
+
|
|
102
|
+
{/* Custom content — submit button, extra inputs, or any user markup.
|
|
103
|
+
Rendered after the auto-generated fields so layout flows naturally. */}
|
|
104
|
+
<slot />
|
|
105
|
+
</form>
|
|
106
|
+
|
|
107
|
+
{
|
|
108
|
+
enhance && (
|
|
109
|
+
<script data-astro-rerun>
|
|
110
|
+
import { init } from "../runtime/enhance.js";
|
|
111
|
+
init();
|
|
112
|
+
</script>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// src/server/index.ts
|
|
2
|
+
import "zod";
|
|
3
|
+
|
|
4
|
+
// src/shared/constants.ts
|
|
5
|
+
var HONEY_FIELD = "_honey";
|
|
6
|
+
|
|
7
|
+
// src/server/index.ts
|
|
8
|
+
function validateForm(data, form, opts) {
|
|
9
|
+
if (opts?.honeypot !== false) {
|
|
10
|
+
const honeyValue = data.get(HONEY_FIELD);
|
|
11
|
+
if (honeyValue !== null && honeyValue !== "") {
|
|
12
|
+
return { ok: false, spam: true };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const formData = {};
|
|
16
|
+
data.forEach((value, key) => {
|
|
17
|
+
if (key !== HONEY_FIELD && typeof value === "string") {
|
|
18
|
+
formData[key] = value;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
const parsed = form.schema.safeParse(Object.fromEntries(data));
|
|
22
|
+
if (parsed.success) {
|
|
23
|
+
return { ok: true, data: parsed.data };
|
|
24
|
+
}
|
|
25
|
+
const fieldErrors = {};
|
|
26
|
+
for (const issue of parsed.error.issues) {
|
|
27
|
+
const fieldName = issue.path.length > 0 ? String(issue.path[0]) : "_form";
|
|
28
|
+
if (!fieldErrors[fieldName]) {
|
|
29
|
+
fieldErrors[fieldName] = [];
|
|
30
|
+
}
|
|
31
|
+
fieldErrors[fieldName].push(issue.message);
|
|
32
|
+
}
|
|
33
|
+
return { ok: false, fieldErrors, formData };
|
|
34
|
+
}
|
|
35
|
+
export {
|
|
36
|
+
validateForm
|
|
37
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "astro-lyra",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Astro-native form integration: Zod-typed validation, View Transitions safe, and post-submit UX done right.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"astro",
|
|
8
|
+
"astro-integration",
|
|
9
|
+
"astro-component",
|
|
10
|
+
"forms",
|
|
11
|
+
"zod",
|
|
12
|
+
"validation",
|
|
13
|
+
"view-transitions"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./dist/integration.js",
|
|
18
|
+
"./Form.astro": "./dist/components/Form.astro",
|
|
19
|
+
"./server": "./dist/server/index.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"astro": "^4.0.0 || ^5.0.0",
|
|
26
|
+
"zod": "^3.23.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@astrojs/compiler": "2.13.1",
|
|
30
|
+
"@playwright/test": "^1.48.0",
|
|
31
|
+
"@types/jsdom": "^28.0.3",
|
|
32
|
+
"@types/node": "^25.9.3",
|
|
33
|
+
"astro": "^5.0.0",
|
|
34
|
+
"esbuild": "0.27.7",
|
|
35
|
+
"jsdom": "^29.1.1",
|
|
36
|
+
"tsup": "^8.3.0",
|
|
37
|
+
"typescript": "^5.6.0",
|
|
38
|
+
"vitest": "^2.1.0",
|
|
39
|
+
"zod": "^3.23.0"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsup",
|
|
43
|
+
"dev": "pnpm --filter playground dev",
|
|
44
|
+
"test": "vitest run && vitest run --config vitest.component.config.ts",
|
|
45
|
+
"test:watch": "vitest",
|
|
46
|
+
"test:e2e": "playwright test",
|
|
47
|
+
"typecheck": "tsc --noEmit"
|
|
48
|
+
}
|
|
49
|
+
}
|