svelte-specma 1.1.6 → 2.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 +863 -193
- package/dist/svelte-specma.modern.js +490 -144
- package/dist/svelte-specma.modern.js.map +1 -1
- package/dist/svelte-specma.umd.js +551 -222
- package/dist/svelte-specma.umd.js.map +1 -1
- package/package.json +24 -7
- package/CHANGELOG.md +0 -202
package/README.md
CHANGED
|
@@ -1,298 +1,968 @@
|
|
|
1
1
|
# Svelte-Specma
|
|
2
2
|
|
|
3
|
-
Svelte-Specma
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
|
|
3
|
+
Svelte-Specma connects Specma predicate specs to Svelte stores to provide small, composable, and predictable client-side validation for forms and arbitrary state. It supports nested/collection shapes, synchronous and asynchronous predicates, custom error messages and easy binding to HTML inputs.
|
|
4
|
+
|
|
5
|
+
Goals
|
|
6
|
+
|
|
7
|
+
- Minimal API that maps Specma specs to reactive Svelte stores
|
|
8
|
+
- Compose nested validators for objects, arrays and Maps
|
|
9
|
+
- Allow cross-field and async validation via Specma
|
|
10
|
+
- Small, framework-friendly building blocks (stores + a Svelte action)
|
|
11
|
+
- **Share validation specs between client and server** — define once, use everywhere
|
|
12
|
+
|
|
13
|
+
## Table of Contents
|
|
14
|
+
|
|
15
|
+
- [Svelte-Specma](#svelte-specma)
|
|
16
|
+
- [Table of Contents](#table-of-contents)
|
|
17
|
+
- [Installation](#installation)
|
|
18
|
+
- [Quick setup](#quick-setup)
|
|
19
|
+
- [Concepts in two lines](#concepts-in-two-lines)
|
|
20
|
+
- [Create a store (convenience)](#create-a-store-convenience)
|
|
21
|
+
- [Primitive example (single field)](#primitive-example-single-field)
|
|
22
|
+
- [Collection example (form with nested list)](#collection-example-form-with-nested-list)
|
|
23
|
+
- [Svelte input binding (register)](#svelte-input-binding-register)
|
|
24
|
+
- [Shared Specs (Client \& Server)](#shared-specs-client--server)
|
|
25
|
+
- [Basic Pattern](#basic-pattern)
|
|
26
|
+
- [Client-Side Usage (Svelte)](#client-side-usage-svelte)
|
|
27
|
+
- [Server-Side Usage (Node.js/SvelteKit)](#server-side-usage-nodejssveltekit)
|
|
28
|
+
- [Complex Nested Specs](#complex-nested-specs)
|
|
29
|
+
- [Benefits of Shared Specs](#benefits-of-shared-specs)
|
|
30
|
+
- [API summary](#api-summary)
|
|
31
|
+
- [Real-world patterns](#real-world-patterns)
|
|
32
|
+
- [Tips \& best practices](#tips--best-practices)
|
|
33
|
+
- [Comprehensive Examples](#comprehensive-examples)
|
|
34
|
+
- [Example 1: Simple Login Form with Email and Password Validation](#example-1-simple-login-form-with-email-and-password-validation)
|
|
35
|
+
- [Example 2: User Profile Form with Nested Validation](#example-2-user-profile-form-with-nested-validation)
|
|
36
|
+
- [Example 3: Dynamic List Management (Shopping Cart)](#example-3-dynamic-list-management-shopping-cart)
|
|
37
|
+
- [Example 4: Async Validation (Username Availability Check)](#example-4-async-validation-username-availability-check)
|
|
38
|
+
- [Example 5: Shared Validation (Full Stack)](#example-5-shared-validation-full-stack)
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install svelte-specma
|
|
44
|
+
# or
|
|
45
|
+
yarn add svelte-specma
|
|
46
|
+
```
|
|
14
47
|
|
|
15
|
-
##
|
|
48
|
+
## Quick setup
|
|
16
49
|
|
|
17
|
-
|
|
50
|
+
Svelte-Specma delegates predicate operations to a Specma-compatible implementation. Configure the library once at app startup:
|
|
18
51
|
|
|
19
|
-
|
|
52
|
+
```js
|
|
53
|
+
import * as specma from "specma"; // or another Specma-compatible lib
|
|
54
|
+
import { configure } from "svelte-specma";
|
|
20
55
|
|
|
21
|
-
|
|
56
|
+
configure(specma);
|
|
57
|
+
```
|
|
22
58
|
|
|
23
|
-
|
|
59
|
+
This must run before creating any specable stores.
|
|
24
60
|
|
|
25
|
-
|
|
61
|
+
## Concepts in two lines
|
|
26
62
|
|
|
27
|
-
|
|
63
|
+
- `predSpecable`: single-value store that validates a primitive/non-collection value.
|
|
64
|
+
- `collSpecable`: collection-aware store (object / array / Map) that composes child specable stores.
|
|
28
65
|
|
|
29
|
-
|
|
66
|
+
## Create a store (convenience)
|
|
30
67
|
|
|
31
|
-
|
|
68
|
+
Use `specable()` — it chooses `predSpecable` or `collSpecable` automatically.
|
|
32
69
|
|
|
33
|
-
|
|
70
|
+
### Primitive example (single field)
|
|
34
71
|
|
|
35
72
|
```js
|
|
36
|
-
import
|
|
37
|
-
import { configure } from "svelte-specma";
|
|
38
|
-
|
|
39
|
-
configure(specma);
|
|
40
|
-
```
|
|
73
|
+
import { specable } from "svelte-specma";
|
|
41
74
|
|
|
42
|
-
|
|
75
|
+
const age = specable(0, {
|
|
76
|
+
id: "age",
|
|
77
|
+
required: true,
|
|
78
|
+
spec: (v) =>
|
|
79
|
+
typeof v === "number" && v >= 0 ? true : "must be a non-negative number",
|
|
80
|
+
});
|
|
43
81
|
|
|
44
|
-
|
|
82
|
+
// subscribe for UI binding:
|
|
83
|
+
age.subscribe((s) => {
|
|
84
|
+
// s.value, s.valid, s.validating, s.error, s.changed, s.active, s.promise, ...
|
|
85
|
+
console.log(s);
|
|
86
|
+
});
|
|
87
|
+
```
|
|
45
88
|
|
|
46
|
-
|
|
89
|
+
### Collection example (form with nested list)
|
|
47
90
|
|
|
48
91
|
```js
|
|
49
|
-
|
|
50
|
-
|
|
92
|
+
import { specable } from "svelte-specma";
|
|
93
|
+
import { spread } from "specma"; // example usage of Specma helpers
|
|
51
94
|
|
|
52
|
-
|
|
95
|
+
const productSpec = {
|
|
96
|
+
name: (v = "") => (v.length ? true : "required"),
|
|
97
|
+
price: (v) => (typeof v === "number" && v >= 0 ? true : "invalid price"),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const catalog = specable(
|
|
101
|
+
{ title: "My shop", items: [{ id: "1", name: "Pen", price: 1.5 }] },
|
|
102
|
+
{
|
|
103
|
+
spec: { title: (v) => true, items: spread(productSpec) },
|
|
104
|
+
getId: { items: (item) => item.id }, // id strategy for collection children
|
|
105
|
+
required: { title: 1, items: spread({ name: 1 }) },
|
|
106
|
+
}
|
|
107
|
+
);
|
|
53
108
|
|
|
54
|
-
|
|
109
|
+
// add an item programmatically:
|
|
110
|
+
catalog.add([{ id: "2", name: "", price: null }]);
|
|
111
|
+
```
|
|
55
112
|
|
|
56
|
-
|
|
113
|
+
## Svelte input binding (register)
|
|
57
114
|
|
|
58
|
-
|
|
115
|
+
Use the `register` action to bind a `predSpecable` to an input with optional converters:
|
|
59
116
|
|
|
60
|
-
|
|
117
|
+
Basic usage:
|
|
61
118
|
|
|
62
|
-
|
|
119
|
+
```svelte
|
|
120
|
+
<script>
|
|
121
|
+
import { specable, register } from "svelte-specma";
|
|
122
|
+
const name = specable("", { id: "name", spec: (v) => v ? true : "required" });
|
|
123
|
+
</script>
|
|
63
124
|
|
|
64
|
-
|
|
125
|
+
<input use:register="{name}" />
|
|
126
|
+
```
|
|
65
127
|
|
|
66
|
-
|
|
67
|
-
/* Will create a `predSpecable` store, since initial value is not a collection */
|
|
68
|
-
const store = specable(
|
|
69
|
-
30, // Initial value
|
|
70
|
-
{
|
|
71
|
-
spec: (v) => v === 42 || "is not the answer",
|
|
72
|
-
required: false,
|
|
73
|
-
id: "someId",
|
|
74
|
-
}
|
|
75
|
-
);
|
|
128
|
+
With converters (e.g. numeric input):
|
|
76
129
|
|
|
77
|
-
|
|
78
|
-
|
|
130
|
+
```svelte
|
|
131
|
+
<script>
|
|
132
|
+
const age = specable(0, { id: "age", spec: v => typeof v === 'number' || "must be a number" });
|
|
133
|
+
const conv = { toInput: (v) => v == null ? "" : String(v), toValue: (s) => s === "" ? undefined : Number(s) };
|
|
134
|
+
</script>
|
|
79
135
|
|
|
80
|
-
|
|
81
|
-
$: ({ value, active, changed, valid, validating, error, promise, id } = $store);
|
|
136
|
+
<input use:register="{[age, conv]}" inputmode="numeric" />
|
|
82
137
|
```
|
|
83
138
|
|
|
84
|
-
##
|
|
139
|
+
## Shared Specs (Client & Server)
|
|
85
140
|
|
|
86
|
-
|
|
87
|
-
- `spec`: Any. The Specma spec. Only the predicate function part of the spec is used in a `predSpecable`, so a compound value (object, array, etc.) won't validate its children spec. To do so, use a `collSpecable` instead.
|
|
88
|
-
- `required`: Boolean. If a value is required, must be different than `undefined`, `null` or `""`; will return the Specma `isRequired` message otherwise. False by default.
|
|
89
|
-
- `changePred`: Function. `(a, b) => boolean`. Function to evaluate if value is different from initial value. By default, will do a deep object value equality and compare dates by value.
|
|
90
|
-
- `id`: Any. Used to uniquely identify the store. Mainly useful for values part of a list.
|
|
141
|
+
One of the key advantages of using Specma with Svelte-Specma is the ability to **define validation specs once and reuse them across both client and server**. This ensures consistency, reduces duplication, and makes your codebase more maintainable.
|
|
91
142
|
|
|
92
|
-
|
|
143
|
+
### Basic Pattern
|
|
93
144
|
|
|
94
|
-
|
|
145
|
+
Define your specs in a shared module that can be imported by both client and server code:
|
|
95
146
|
|
|
96
|
-
|
|
147
|
+
```js
|
|
148
|
+
// filepath: shared/specs/userSpecs.js
|
|
149
|
+
// Shared validation specs - works in both browser and Node.js
|
|
97
150
|
|
|
98
|
-
|
|
151
|
+
export const emailSpec = (v) => {
|
|
152
|
+
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
153
|
+
return emailPattern.test(v) || "Invalid email address";
|
|
154
|
+
};
|
|
99
155
|
|
|
100
|
-
|
|
156
|
+
export const passwordSpec = (v) =>
|
|
157
|
+
v.length >= 8 ? true : "Password must be at least 8 characters";
|
|
101
158
|
|
|
102
|
-
|
|
159
|
+
export const usernameSpec = (v) =>
|
|
160
|
+
v && v.length >= 3 ? true : "Username must be at least 3 characters";
|
|
103
161
|
|
|
104
|
-
|
|
162
|
+
export const ageSpec = (v) =>
|
|
163
|
+
typeof v === "number" && v >= 18 && v <= 120
|
|
164
|
+
? true
|
|
165
|
+
: "Age must be between 18 and 120";
|
|
105
166
|
|
|
106
|
-
|
|
167
|
+
export const phoneSpec = (v) => /^\d{10}$/.test(v) || "Phone must be 10 digits";
|
|
168
|
+
```
|
|
107
169
|
|
|
108
|
-
-
|
|
170
|
+
### Client-Side Usage (Svelte)
|
|
109
171
|
|
|
110
|
-
|
|
172
|
+
```svelte
|
|
173
|
+
<!-- filepath: src/routes/register/+page.svelte -->
|
|
174
|
+
<script>
|
|
175
|
+
import { specable, register } from "svelte-specma";
|
|
176
|
+
import { emailSpec, passwordSpec, usernameSpec } from "$lib/shared/specs/userSpecs";
|
|
177
|
+
|
|
178
|
+
const email = specable("", { id: "email", required: true, spec: emailSpec });
|
|
179
|
+
const password = specable("", { id: "password", required: true, spec: passwordSpec });
|
|
180
|
+
const username = specable("", { id: "username", required: true, spec: usernameSpec });
|
|
181
|
+
|
|
182
|
+
async function handleSubmit() {
|
|
183
|
+
// Activate all fields
|
|
184
|
+
email.activate();
|
|
185
|
+
password.activate();
|
|
186
|
+
username.activate();
|
|
187
|
+
|
|
188
|
+
if ($email.valid && $password.valid && $username.valid) {
|
|
189
|
+
// Submit to server
|
|
190
|
+
const response = await fetch("/api/register", {
|
|
191
|
+
method: "POST",
|
|
192
|
+
headers: { "Content-Type": "application/json" },
|
|
193
|
+
body: JSON.stringify({
|
|
194
|
+
email: $email.value,
|
|
195
|
+
password: $password.value,
|
|
196
|
+
username: $username.value
|
|
197
|
+
})
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (response.ok) {
|
|
201
|
+
console.log("Registration successful!");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
</script>
|
|
111
206
|
|
|
112
|
-
|
|
207
|
+
<form on:submit|preventDefault={handleSubmit}>
|
|
208
|
+
<input use:register={username} placeholder="Username" />
|
|
209
|
+
{#if $username.active && $username.error}<p class="error">{$username.error}</p>{/if}
|
|
113
210
|
|
|
114
|
-
|
|
211
|
+
<input use:register={email} type="email" placeholder="Email" />
|
|
212
|
+
{#if $email.active && $email.error}<p class="error">{$email.error}</p>{/if}
|
|
115
213
|
|
|
116
|
-
|
|
214
|
+
<input use:register={password} type="password" placeholder="Password" />
|
|
215
|
+
{#if $password.active && $password.error}<p class="error">{$password.error}</p>{/if}
|
|
117
216
|
|
|
118
|
-
|
|
217
|
+
<button type="submit">Register</button>
|
|
218
|
+
</form>
|
|
219
|
+
```
|
|
119
220
|
|
|
120
|
-
|
|
221
|
+
### Server-Side Usage (Node.js/SvelteKit)
|
|
121
222
|
|
|
122
|
-
|
|
223
|
+
```js
|
|
224
|
+
// filepath: src/routes/api/register/+server.js
|
|
225
|
+
import { json } from "@sveltejs/kit";
|
|
226
|
+
import { conform } from "specma";
|
|
227
|
+
import {
|
|
228
|
+
emailSpec,
|
|
229
|
+
passwordSpec,
|
|
230
|
+
usernameSpec,
|
|
231
|
+
} from "$lib/shared/specs/userSpecs";
|
|
232
|
+
|
|
233
|
+
const registrationSpec = {
|
|
234
|
+
email: emailSpec,
|
|
235
|
+
password: passwordSpec,
|
|
236
|
+
username: usernameSpec,
|
|
237
|
+
};
|
|
123
238
|
|
|
124
|
-
|
|
239
|
+
export async function POST({ request }) {
|
|
240
|
+
const data = await request.json();
|
|
125
241
|
|
|
126
|
-
|
|
242
|
+
// Validate using the same specs as the client
|
|
243
|
+
const result = conform(data, registrationSpec);
|
|
127
244
|
|
|
128
|
-
|
|
245
|
+
if (!result.valid) {
|
|
246
|
+
return json(
|
|
247
|
+
{
|
|
248
|
+
success: false,
|
|
249
|
+
errors: result.problems,
|
|
250
|
+
},
|
|
251
|
+
{ status: 400 }
|
|
252
|
+
);
|
|
253
|
+
}
|
|
129
254
|
|
|
130
|
-
|
|
255
|
+
// Proceed with registration (save to database, etc.)
|
|
256
|
+
// ...
|
|
131
257
|
|
|
132
|
-
|
|
258
|
+
return json({ success: true });
|
|
259
|
+
}
|
|
260
|
+
```
|
|
133
261
|
|
|
134
|
-
|
|
262
|
+
### Complex Nested Specs
|
|
135
263
|
|
|
136
|
-
|
|
264
|
+
For more complex scenarios with nested objects and arrays:
|
|
137
265
|
|
|
138
266
|
```js
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
export const
|
|
143
|
-
name: (v
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
267
|
+
// filepath: shared/specs/orderSpecs.js
|
|
268
|
+
import { spread } from "specma";
|
|
269
|
+
|
|
270
|
+
export const orderItemSpec = {
|
|
271
|
+
name: (v) => (v && v.length > 0) || "Product name is required",
|
|
272
|
+
quantity: (v) =>
|
|
273
|
+
(typeof v === "number" && v > 0) || "Quantity must be positive",
|
|
274
|
+
price: (v) => (typeof v === "number" && v >= 0) || "Invalid price",
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
export const shippingAddressSpec = {
|
|
278
|
+
street: (v) => (v && v.length > 0) || "Street is required",
|
|
279
|
+
city: (v) => (v && v.length > 0) || "City is required",
|
|
280
|
+
zipCode: (v) => /^\d{5}$/.test(v) || "Invalid ZIP code",
|
|
281
|
+
country: (v) => (v && v.length > 0) || "Country is required",
|
|
151
282
|
};
|
|
283
|
+
|
|
284
|
+
export const orderSpec = {
|
|
285
|
+
customerEmail: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || "Invalid email",
|
|
286
|
+
items: spread(orderItemSpec),
|
|
287
|
+
shippingAddress: shippingAddressSpec,
|
|
288
|
+
};
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
**Client usage:**
|
|
292
|
+
|
|
293
|
+
```svelte
|
|
294
|
+
<script>
|
|
295
|
+
import { specable } from "svelte-specma";
|
|
296
|
+
import { orderSpec } from "$lib/shared/specs/orderSpecs";
|
|
297
|
+
|
|
298
|
+
const order = specable(
|
|
299
|
+
{
|
|
300
|
+
customerEmail: "",
|
|
301
|
+
items: [],
|
|
302
|
+
shippingAddress: { street: "", city: "", zipCode: "", country: "" }
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
spec: orderSpec,
|
|
306
|
+
getId: { items: (item) => item.id },
|
|
307
|
+
required: {
|
|
308
|
+
customerEmail: true,
|
|
309
|
+
items: spread({ name: true, quantity: true, price: true }),
|
|
310
|
+
shippingAddress: { street: true, city: true, zipCode: true, country: true }
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const priceConverter = {
|
|
316
|
+
toInput: (v) => v == null ? "" : String(v),
|
|
317
|
+
toValue: (s) => s === "" ? null : Number(s)
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
async function handleSubmit() {
|
|
321
|
+
await order.submit();
|
|
322
|
+
|
|
323
|
+
if ($order.valid) {
|
|
324
|
+
console.log("Order submitted:", $order.value);
|
|
325
|
+
// Perform API call here
|
|
326
|
+
} else {
|
|
327
|
+
console.log("Validation errors:", $order.errors);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
</script>
|
|
331
|
+
|
|
332
|
+
<form on:submit|preventDefault={handleSubmit}>
|
|
333
|
+
<div>
|
|
334
|
+
<label for="customerEmail">Email:</label>
|
|
335
|
+
<input id="customerEmail" type="email" use:register={order.getChild(["customerEmail"])} />
|
|
336
|
+
{#if $order.errors.customerEmail}
|
|
337
|
+
<p class="error">{$order.errors.customerEmail}</p>
|
|
338
|
+
{/if}
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
<div>
|
|
342
|
+
<label>Items:</label>
|
|
343
|
+
{#each $order.children as itemStore, index}
|
|
344
|
+
{@const item = $order.value[index]}
|
|
345
|
+
<div>
|
|
346
|
+
<input
|
|
347
|
+
placeholder="Product name"
|
|
348
|
+
bind:value={item.name}
|
|
349
|
+
on:blur={() => itemStore.activate()}
|
|
350
|
+
/>
|
|
351
|
+
|
|
352
|
+
<input
|
|
353
|
+
type="number"
|
|
354
|
+
placeholder="Qty"
|
|
355
|
+
bind:value={item.quantity}
|
|
356
|
+
on:blur={() => itemStore.activate()}
|
|
357
|
+
/>
|
|
358
|
+
|
|
359
|
+
<input
|
|
360
|
+
type="number"
|
|
361
|
+
step="0.01"
|
|
362
|
+
placeholder="Price"
|
|
363
|
+
bind:value={item.price}
|
|
364
|
+
on:blur={() => itemStore.activate()}
|
|
365
|
+
/>
|
|
366
|
+
|
|
367
|
+
<button type="button" on:click={() => order.remove([index])}>Remove</button>
|
|
368
|
+
|
|
369
|
+
{#if $order.errors[index]}
|
|
370
|
+
<p class="error">{Object.values($order.errors[index]).join(", ")}</p>
|
|
371
|
+
{/if}
|
|
372
|
+
</div>
|
|
373
|
+
{/each}
|
|
374
|
+
|
|
375
|
+
<button type="button" on:click={() => order.add([{ id: Date.now(), name: "", quantity: 1, price: 0 }])}>
|
|
376
|
+
Add Item
|
|
377
|
+
</button>
|
|
378
|
+
</div>
|
|
379
|
+
|
|
380
|
+
<div>
|
|
381
|
+
<label>Shipping Address:</label>
|
|
382
|
+
<input
|
|
383
|
+
placeholder="Street"
|
|
384
|
+
use:register={order.getChild(["shippingAddress", "street"])}
|
|
385
|
+
/>
|
|
386
|
+
{#if $order.errors["shippingAddress.street"]}
|
|
387
|
+
<p class="error">{$order.errors["shippingAddress.street"]}</p>
|
|
388
|
+
{/if}
|
|
389
|
+
|
|
390
|
+
<input
|
|
391
|
+
placeholder="City"
|
|
392
|
+
use:register={order.getChild(["shippingAddress", "city"])}
|
|
393
|
+
/>
|
|
394
|
+
{#if $order.errors["shippingAddress.city"]}
|
|
395
|
+
<p class="error">{$order.errors["shippingAddress.city"]}</p>
|
|
396
|
+
{/if}
|
|
397
|
+
|
|
398
|
+
<input
|
|
399
|
+
placeholder="ZIP Code"
|
|
400
|
+
use:register={order.getChild(["shippingAddress", "zipCode"])}
|
|
401
|
+
/>
|
|
402
|
+
{#if $order.errors["shippingAddress.zipCode"]}
|
|
403
|
+
<p class="error">{$order.errors["shippingAddress.zipCode"]}</p>
|
|
404
|
+
{/if}
|
|
405
|
+
|
|
406
|
+
<input
|
|
407
|
+
placeholder="Country"
|
|
408
|
+
use:register={order.getChild(["shippingAddress", "country"])}
|
|
409
|
+
/>
|
|
410
|
+
{#if $order.errors["shippingAddress.country"]}
|
|
411
|
+
<p class="error">{$order.errors["shippingAddress.country"]}</p>
|
|
412
|
+
{/if}
|
|
413
|
+
</div>
|
|
414
|
+
|
|
415
|
+
<button type="submit" disabled={$order.submitting}>
|
|
416
|
+
{$order.submitting ? "Submitting..." : "Submit Order"}
|
|
417
|
+
</button>
|
|
418
|
+
</form>
|
|
152
419
|
```
|
|
153
420
|
|
|
154
|
-
|
|
421
|
+
**Server usage:**
|
|
155
422
|
|
|
156
423
|
```js
|
|
157
|
-
|
|
158
|
-
import {
|
|
424
|
+
// filepath: src/routes/api/orders/+server.js
|
|
425
|
+
import { conform } from "specma";
|
|
426
|
+
import { orderSpec } from "$lib/shared/specs/orderSpecs";
|
|
159
427
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
name: async (v = "") => await checkNameIsUnique(v),
|
|
164
|
-
});
|
|
428
|
+
export async function POST({ request }) {
|
|
429
|
+
const data = await request.json();
|
|
430
|
+
const result = conform(data, orderSpec);
|
|
165
431
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
{ name: "Foo", list: [{ id: "1234", x: 20, y: "abc", z: null }] },
|
|
169
|
-
{
|
|
170
|
-
spec: enhancedSpec,
|
|
171
|
-
required: { name: 1, list: spread({ x: 1 }) },
|
|
172
|
-
fields: { name: 1, list: spread({ x: 1, y: 1, z: 1 }) },
|
|
173
|
-
getId: { list: ({ id }) => id },
|
|
174
|
-
id: "myColl",
|
|
432
|
+
if (!result.valid) {
|
|
433
|
+
return json({ success: false, errors: result.problems }, { status: 400 });
|
|
175
434
|
}
|
|
176
|
-
);
|
|
177
435
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
isRequired,
|
|
182
|
-
spec,
|
|
183
|
-
activate,
|
|
184
|
-
add,
|
|
185
|
-
getChild,
|
|
186
|
-
remove,
|
|
187
|
-
reset,
|
|
188
|
-
set,
|
|
189
|
-
update,
|
|
190
|
-
children,
|
|
191
|
-
stores,
|
|
192
|
-
subscribe,
|
|
193
|
-
} = store;
|
|
194
|
-
|
|
195
|
-
/* Store subscription internal state values */
|
|
196
|
-
$: ({
|
|
197
|
-
value,
|
|
198
|
-
active,
|
|
199
|
-
changed,
|
|
200
|
-
valid,
|
|
201
|
-
validating,
|
|
202
|
-
error,
|
|
203
|
-
errors,
|
|
204
|
-
collErrors,
|
|
205
|
-
promise,
|
|
206
|
-
id,
|
|
207
|
-
} = $store);
|
|
436
|
+
// Process order...
|
|
437
|
+
return json({ success: true, orderId: "12345" });
|
|
438
|
+
}
|
|
208
439
|
```
|
|
209
440
|
|
|
210
|
-
|
|
441
|
+
### Benefits of Shared Specs
|
|
211
442
|
|
|
212
|
-
|
|
443
|
+
1. **Single Source of Truth**: Define validation rules once, use everywhere
|
|
444
|
+
2. **Consistency**: Client and server always validate the same way
|
|
445
|
+
3. **Maintainability**: Changes to validation rules only need to be made in one place
|
|
446
|
+
4. **Type Safety**: When using TypeScript, spec definitions can be typed once
|
|
447
|
+
5. **Testing**: Write tests for specs once, applicable to both environments
|
|
448
|
+
6. **Composability**: Build complex specs from smaller, reusable spec functions
|
|
213
449
|
|
|
214
|
-
|
|
215
|
-
- `required`: Collection. A deeply nested collection description of the required fields. Requiredness is defined at the root store to keep specs more reusable.
|
|
216
|
-
- `fields`: Collection. A deeply nested collection description of which fields to expect, that will ensure that those fields are defined as children specable stores. Undeclared field values will be treated as constant and won't be displayed as children stores.
|
|
217
|
-
- `getId`: Collection. A deeply nested collection description of how a subcollection should define the `id` of its children. Can be defined the same way as a spec (with `and`, `spread`, etc.), where the predicate function of a level has the shape `(value, key) => id`. If no function is provided, array items' store will be assigned a unique random id, while objects will use their key as id.
|
|
218
|
-
- `changePred`: Collection. A deeply nested collection description of change predicates (see `predCheckable` inputs). Can be defined the same way as `getId`.
|
|
219
|
-
- `id`: Used to uniquely identify the store. Mainly useful for values part of a list.
|
|
450
|
+
## API summary
|
|
220
451
|
|
|
221
|
-
|
|
452
|
+
- `configure(specma)`: set the Specma implementation (required).
|
|
453
|
+
- `specable(initialValue, options)`: factory — returns `predSpecable` or `collSpecable`.
|
|
454
|
+
- `predSpecable`: single-value store exposing: `{ subscribe, set, reset, activate, submit, id, isRequired, spec }`.
|
|
455
|
+
- status fields in subscribe payload: `value`, `active`, `changed`, `valid`, `validating`, `submitting`, `error`, `promise`, `id`.
|
|
456
|
+
- `collSpecable`: collection-aware store exposing collection helpers:
|
|
457
|
+
- `add`, `remove`, `getChild`, `getChildren`, `update`, `set` (partial/complete), `reset`, `activate`, `submit`.
|
|
458
|
+
- `children`: readable store of child specable stores.
|
|
459
|
+
- subscribe payload contains aggregated status plus `errors` and flattened `errors` list.
|
|
460
|
+
- `register`: Svelte action: `use:register` on input elements (or pass `[store, {toInput,toValue}]`).
|
|
222
461
|
|
|
223
|
-
|
|
462
|
+
## Real-world patterns
|
|
224
463
|
|
|
225
|
-
-
|
|
464
|
+
- **Lazy validation**: create stores inactive by default and call `activate()` on blur or submit.
|
|
465
|
+
- **Centralized form submit**: call `submit()` on a `collSpecable` which will activate children and run configured `onSubmit` handlers.
|
|
466
|
+
- **Reuse specs between server and client**: define Specma specs once and share them in server validation and Svelte forms.
|
|
467
|
+
- **Composable validation**: build complex specs from smaller, reusable spec functions.
|
|
226
468
|
|
|
227
|
-
|
|
469
|
+
## Tips & best practices
|
|
228
470
|
|
|
229
|
-
-
|
|
471
|
+
- Call `configure(specma)` only once (e.g. in your app entry).
|
|
472
|
+
- Use `required` and `fields` options to avoid creating child stores for unneeded fields.
|
|
473
|
+
- For lists, supply `getId` so children retain identity across updates.
|
|
474
|
+
- Treat `predSpecable.submit` as the place to perform side effects (server calls); it can return a Promise.
|
|
475
|
+
- Use `register` for simple inputs — it reduces boilerplate for common cases.
|
|
476
|
+
- **Extract specs into shared modules** for reuse across client and server.
|
|
477
|
+
- **Use Specma helpers** like `spread()` for working with collections.
|
|
230
478
|
|
|
231
|
-
|
|
479
|
+
## Comprehensive Examples
|
|
232
480
|
|
|
233
|
-
|
|
481
|
+
### Example 1: Simple Login Form with Email and Password Validation
|
|
234
482
|
|
|
235
|
-
|
|
483
|
+
This example demonstrates a basic login form with synchronous validation for email and password fields.
|
|
236
484
|
|
|
237
|
-
|
|
485
|
+
```svelte
|
|
486
|
+
<!-- LoginForm.svelte -->
|
|
487
|
+
<script>
|
|
488
|
+
import { specable, register } from "svelte-specma";
|
|
489
|
+
|
|
490
|
+
const email = specable("", {
|
|
491
|
+
id: "email",
|
|
492
|
+
required: true,
|
|
493
|
+
spec: (v) => {
|
|
494
|
+
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
495
|
+
return emailPattern.test(v) || "Invalid email address";
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
const password = specable("", {
|
|
500
|
+
id: "password",
|
|
501
|
+
required: true,
|
|
502
|
+
spec: (v) => (v.length >= 8 ? true : "Password must be at least 8 characters")
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
function handleSubmit() {
|
|
506
|
+
email.activate();
|
|
507
|
+
password.activate();
|
|
508
|
+
|
|
509
|
+
if ($email.valid && $password.valid) {
|
|
510
|
+
console.log("Form submitted:", { email: $email.value, password: $password.value });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
</script>
|
|
514
|
+
|
|
515
|
+
<form on:submit|preventDefault={handleSubmit}>
|
|
516
|
+
<div>
|
|
517
|
+
<label for="email">Email:</label>
|
|
518
|
+
<input id="email" type="email" use:register={email} />
|
|
519
|
+
{#if $email.active && $email.error}
|
|
520
|
+
<p class="error">{$email.error}</p>
|
|
521
|
+
{/if}
|
|
522
|
+
</div>
|
|
523
|
+
|
|
524
|
+
<div>
|
|
525
|
+
<label for="password">Password:</label>
|
|
526
|
+
<input id="password" type="password" use:register={password} />
|
|
527
|
+
{#if $password.active && $password.error}
|
|
528
|
+
<p class="error">{$password.error}</p>
|
|
529
|
+
{/if}
|
|
530
|
+
</div>
|
|
531
|
+
|
|
532
|
+
<button type="submit">Login</button>
|
|
533
|
+
</form>
|
|
534
|
+
|
|
535
|
+
<style>
|
|
536
|
+
.error { color: red; font-size: 0.875rem; }
|
|
537
|
+
</style>
|
|
538
|
+
```
|
|
238
539
|
|
|
239
|
-
|
|
540
|
+
### Example 2: User Profile Form with Nested Validation
|
|
240
541
|
|
|
241
|
-
|
|
542
|
+
This example shows a more complex form with nested object validation including cross-field validation.
|
|
242
543
|
|
|
243
|
-
|
|
544
|
+
```svelte
|
|
545
|
+
<!-- UserProfileForm.svelte -->
|
|
546
|
+
<script>
|
|
547
|
+
import { specable, register } from "svelte-specma";
|
|
548
|
+
|
|
549
|
+
const profile = specable(
|
|
550
|
+
{
|
|
551
|
+
username: "",
|
|
552
|
+
age: null,
|
|
553
|
+
contact: {
|
|
554
|
+
email: "",
|
|
555
|
+
phone: ""
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
spec: {
|
|
560
|
+
username: (v) => (v && v.length >= 3 ? true : "Username must be at least 3 characters"),
|
|
561
|
+
age: (v) => (typeof v === "number" && v >= 18 && v <= 120 ? true : "Age must be between 18 and 120"),
|
|
562
|
+
contact: {
|
|
563
|
+
email: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || "Invalid email",
|
|
564
|
+
phone: (v) => /^\d{10}$/.test(v) || "Phone must be 10 digits"
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
required: {
|
|
568
|
+
username: true,
|
|
569
|
+
age: true,
|
|
570
|
+
contact: { email: true }
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
const ageConverter = {
|
|
576
|
+
toInput: (v) => v == null ? "" : String(v),
|
|
577
|
+
toValue: (s) => s === "" ? null : Number(s)
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
async function handleSubmit() {
|
|
581
|
+
await profile.submit();
|
|
582
|
+
|
|
583
|
+
if ($profile.valid) {
|
|
584
|
+
console.log("Profile submitted:", $profile.value);
|
|
585
|
+
// Perform API call here
|
|
586
|
+
} else {
|
|
587
|
+
console.log("Validation errors:", $profile.errors);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
</script>
|
|
591
|
+
|
|
592
|
+
<form on:submit|preventDefault={handleSubmit}>
|
|
593
|
+
<div>
|
|
594
|
+
<label for="username">Username:</label>
|
|
595
|
+
<input id="username" use:register={profile.getChild(["username"])} />
|
|
596
|
+
{#if $profile.errors.username}
|
|
597
|
+
<p class="error">{$profile.errors.username}</p>
|
|
598
|
+
{/if}
|
|
599
|
+
</div>
|
|
600
|
+
|
|
601
|
+
<div>
|
|
602
|
+
<label for="age">Age:</label>
|
|
603
|
+
<input id="age" type="number" use:register={[profile.getChild(["age"]), ageConverter]} />
|
|
604
|
+
{#if $profile.errors.age}
|
|
605
|
+
<p class="error">{$profile.errors.age}</p>
|
|
606
|
+
{/if}
|
|
607
|
+
</div>
|
|
608
|
+
|
|
609
|
+
<div>
|
|
610
|
+
<label for="email">Email:</label>
|
|
611
|
+
<input id="email" type="email" use:register={profile.getChild(["contact", "email"])} />
|
|
612
|
+
{#if $profile.errors["contact.email"]}
|
|
613
|
+
<p class="error">{$profile.errors["contact.email"]}</p>
|
|
614
|
+
{/if}
|
|
615
|
+
</div>
|
|
616
|
+
|
|
617
|
+
<div>
|
|
618
|
+
<label for="phone">Phone (optional):</label>
|
|
619
|
+
<input id="phone" type="tel" use:register={profile.getChild(["contact", "phone"])} />
|
|
620
|
+
{#if $profile.errors["contact.phone"]}
|
|
621
|
+
<p class="error">{$profile.errors["contact.phone"]}</p>
|
|
622
|
+
{/if}
|
|
623
|
+
</div>
|
|
624
|
+
|
|
625
|
+
<button type="submit" disabled={$profile.submitting}>
|
|
626
|
+
{$profile.submitting ? "Submitting..." : "Save Profile"}
|
|
627
|
+
</button>
|
|
628
|
+
</form>
|
|
629
|
+
```
|
|
244
630
|
|
|
245
|
-
|
|
631
|
+
### Example 3: Dynamic List Management (Shopping Cart)
|
|
246
632
|
|
|
247
|
-
|
|
633
|
+
This example demonstrates managing a dynamic list with add/remove operations and per-item validation.
|
|
248
634
|
|
|
249
|
-
|
|
635
|
+
```svelte
|
|
636
|
+
<!-- ShoppingCart.svelte -->
|
|
637
|
+
<script>
|
|
638
|
+
import { specable } from "svelte-specma";
|
|
639
|
+
import { spread } from "specma";
|
|
640
|
+
|
|
641
|
+
const itemSpec = {
|
|
642
|
+
name: (v) => (v && v.length > 0 ? true : "Product name is required"),
|
|
643
|
+
quantity: (v) => (typeof v === "number" && v > 0 ? true : "Quantity must be positive"),
|
|
644
|
+
price: (v) => (typeof v === "number" && v >= 0 ? true : "Invalid price")
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const cart = specable(
|
|
648
|
+
[],
|
|
649
|
+
{
|
|
650
|
+
spec: spread(itemSpec),
|
|
651
|
+
getId: (item) => item.id,
|
|
652
|
+
required: spread({ name: true, quantity: true, price: true })
|
|
653
|
+
}
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
let nextId = 1;
|
|
657
|
+
|
|
658
|
+
function addItem() {
|
|
659
|
+
cart.add([{
|
|
660
|
+
id: String(nextId++),
|
|
661
|
+
name: "",
|
|
662
|
+
quantity: 1,
|
|
663
|
+
price: 0
|
|
664
|
+
}]);
|
|
665
|
+
}
|
|
250
666
|
|
|
251
|
-
|
|
667
|
+
function removeItem(id) {
|
|
668
|
+
const index = $cart.value.findIndex(item => item.id === id);
|
|
669
|
+
if (index !== -1) {
|
|
670
|
+
cart.remove([index]);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
252
673
|
|
|
253
|
-
|
|
674
|
+
function calculateTotal() {
|
|
675
|
+
return $cart.value.reduce((sum, item) => sum + (item.quantity || 0) * (item.price || 0), 0);
|
|
676
|
+
}
|
|
254
677
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
- `which`: String. Same as `path`, but joined with dots to form a string. Useful to lookup predefined captions in a dictionnary.
|
|
258
|
-
- `isColl`: Boolean. Indicates if the error is on a collection value.
|
|
678
|
+
async function checkout() {
|
|
679
|
+
await cart.submit();
|
|
259
680
|
|
|
260
|
-
|
|
681
|
+
if ($cart.valid) {
|
|
682
|
+
console.log("Checkout:", $cart.value);
|
|
683
|
+
console.log("Total:", calculateTotal());
|
|
684
|
+
// Perform checkout API call
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
</script>
|
|
688
|
+
|
|
689
|
+
<div>
|
|
690
|
+
<h2>Shopping Cart</h2>
|
|
691
|
+
|
|
692
|
+
{#each $cart.children as itemStore, index}
|
|
693
|
+
{@const item = $cart.value[index]}
|
|
694
|
+
<div class="cart-item">
|
|
695
|
+
<input
|
|
696
|
+
placeholder="Product name"
|
|
697
|
+
bind:value={item.name}
|
|
698
|
+
on:blur={() => itemStore.activate()}
|
|
699
|
+
/>
|
|
700
|
+
|
|
701
|
+
<input
|
|
702
|
+
type="number"
|
|
703
|
+
placeholder="Qty"
|
|
704
|
+
bind:value={item.quantity}
|
|
705
|
+
on:blur={() => itemStore.activate()}
|
|
706
|
+
/>
|
|
707
|
+
|
|
708
|
+
<input
|
|
709
|
+
type="number"
|
|
710
|
+
step="0.01"
|
|
711
|
+
placeholder="Price"
|
|
712
|
+
bind:value={item.price}
|
|
713
|
+
on:blur={() => itemStore.activate()}
|
|
714
|
+
/>
|
|
715
|
+
|
|
716
|
+
<button type="button" on:click={() => removeItem(item.id)}>Remove</button>
|
|
717
|
+
|
|
718
|
+
{#if $cart.errors[index]}
|
|
719
|
+
<p class="error">{Object.values($cart.errors[index]).join(", ")}</p>
|
|
720
|
+
{/if}
|
|
721
|
+
</div>
|
|
722
|
+
{/each}
|
|
723
|
+
|
|
724
|
+
<button type="button" on:click={addItem}>Add Item</button>
|
|
725
|
+
|
|
726
|
+
{#if $cart.value.length > 0}
|
|
727
|
+
<div class="total">
|
|
728
|
+
<strong>Total: ${calculateTotal().toFixed(2)}</strong>
|
|
729
|
+
</div>
|
|
730
|
+
<button on:click={checkout} disabled={$cart.submitting}>
|
|
731
|
+
{$cart.submitting ? "Processing..." : "Checkout"}
|
|
732
|
+
</button>
|
|
733
|
+
{/if}
|
|
734
|
+
</div>
|
|
735
|
+
|
|
736
|
+
<style>
|
|
737
|
+
.cart-item { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; }
|
|
738
|
+
.error { color: red; font-size: 0.875rem; }
|
|
739
|
+
.total { margin-top: 1rem; font-size: 1.25rem; }
|
|
740
|
+
</style>
|
|
741
|
+
```
|
|
261
742
|
|
|
262
|
-
|
|
743
|
+
### Example 4: Async Validation (Username Availability Check)
|
|
263
744
|
|
|
264
|
-
|
|
745
|
+
This example shows how to use asynchronous validation to check if a username is available.
|
|
265
746
|
|
|
266
747
|
```svelte
|
|
267
|
-
|
|
748
|
+
<!-- UsernameCheckForm.svelte -->
|
|
749
|
+
<script>
|
|
750
|
+
import { specable, register } from "svelte-specma";
|
|
751
|
+
|
|
752
|
+
const username = specable("", {
|
|
753
|
+
id: "username",
|
|
754
|
+
required: true,
|
|
755
|
+
spec: (v) => (v.length >= 3 ? true : "Username must be at least 3 characters")
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
let checking = false;
|
|
759
|
+
let available = false;
|
|
760
|
+
|
|
761
|
+
async function checkAvailability() {
|
|
762
|
+
checking = true;
|
|
763
|
+
available = false;
|
|
764
|
+
|
|
765
|
+
// Simulate an API call to check username availability
|
|
766
|
+
const isAvailable = await new Promise((resolve) => {
|
|
767
|
+
setTimeout(() => {
|
|
768
|
+
resolve(Math.random() > 0.5);
|
|
769
|
+
}, 1000);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
checking = false;
|
|
773
|
+
available = isAvailable;
|
|
774
|
+
|
|
775
|
+
if (!isAvailable) {
|
|
776
|
+
username.setError("Username is already taken");
|
|
777
|
+
} else {
|
|
778
|
+
username.clearError();
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function handleSubmit() {
|
|
783
|
+
username.activate();
|
|
784
|
+
|
|
785
|
+
if ($username.valid && available) {
|
|
786
|
+
console.log("Form submitted:", { username: $username.value });
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
</script>
|
|
790
|
+
|
|
791
|
+
<form on:submit|preventDefault={handleSubmit}>
|
|
792
|
+
<div>
|
|
793
|
+
<label for="username">Username:</label>
|
|
794
|
+
<input id="username" use:register={username} />
|
|
795
|
+
{#if $username.active && $username.error}
|
|
796
|
+
<p class="error">{$username.error}</p>
|
|
797
|
+
{/if}
|
|
798
|
+
<button type="button" on:click={checkAvailability} disabled={checking}>
|
|
799
|
+
{#if checking}Checking...{#else}Check Availability{/if}
|
|
800
|
+
</button>
|
|
801
|
+
{#if available}
|
|
802
|
+
<p class="available">Username is available!</p>
|
|
803
|
+
{:else if $username.active && !$username.valid}
|
|
804
|
+
<p class="error">Username must be at least 3 characters</p>
|
|
805
|
+
{/if}
|
|
806
|
+
</div>
|
|
807
|
+
|
|
808
|
+
<button type="submit">Submit</button>
|
|
809
|
+
</form>
|
|
810
|
+
|
|
811
|
+
<style>
|
|
812
|
+
.error { color: red; font-size: 0.875rem; }
|
|
813
|
+
.available { color: green; font-size: 0.875rem; }
|
|
814
|
+
</style>
|
|
268
815
|
```
|
|
269
816
|
|
|
270
|
-
|
|
817
|
+
### Example 5: Shared Validation (Full Stack)
|
|
271
818
|
|
|
272
|
-
|
|
273
|
-
- update the input on store value change;
|
|
274
|
-
- activate the store on input blur;
|
|
275
|
-
- remove all listeners and subscriptions when input is unmounted.
|
|
819
|
+
This example demonstrates the full power of reusable specs across client and server.
|
|
276
820
|
|
|
277
|
-
|
|
821
|
+
**Shared specs:**
|
|
278
822
|
|
|
279
|
-
|
|
280
|
-
|
|
823
|
+
```js
|
|
824
|
+
// filepath: lib/shared/specs/productSpecs.js
|
|
825
|
+
import { spread } from "specma";
|
|
826
|
+
|
|
827
|
+
export const productNameSpec = (v) =>
|
|
828
|
+
v && v.length >= 3 && v.length <= 100
|
|
829
|
+
? true
|
|
830
|
+
: "Product name must be 3-100 characters";
|
|
831
|
+
|
|
832
|
+
export const productPriceSpec = (v) =>
|
|
833
|
+
typeof v === "number" && v >= 0 && v <= 1000000
|
|
834
|
+
? true
|
|
835
|
+
: "Price must be between 0 and 1,000,000";
|
|
836
|
+
|
|
837
|
+
export const productDescriptionSpec = (v) =>
|
|
838
|
+
v && v.length >= 10 && v.length <= 500
|
|
839
|
+
? true
|
|
840
|
+
: "Description must be 10-500 characters";
|
|
841
|
+
|
|
842
|
+
export const productCategorySpec = (v) =>
|
|
843
|
+
["electronics", "clothing", "food", "books", "other"].includes(v)
|
|
844
|
+
? true
|
|
845
|
+
: "Invalid category";
|
|
846
|
+
|
|
847
|
+
export const productSpec = {
|
|
848
|
+
name: productNameSpec,
|
|
849
|
+
price: productPriceSpec,
|
|
850
|
+
description: productDescriptionSpec,
|
|
851
|
+
category: productCategorySpec,
|
|
852
|
+
};
|
|
281
853
|
|
|
282
|
-
|
|
854
|
+
export const productRequired = {
|
|
855
|
+
name: true,
|
|
856
|
+
price: true,
|
|
857
|
+
description: true,
|
|
858
|
+
category: true,
|
|
859
|
+
};
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
**Client-side form:**
|
|
283
863
|
|
|
284
864
|
```svelte
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
865
|
+
<!-- filepath: src/routes/products/new/+page.svelte -->
|
|
866
|
+
<script>
|
|
867
|
+
import { specable, register } from "svelte-specma";
|
|
868
|
+
import { productSpec, productRequired } from "$lib/shared/specs/productSpecs";
|
|
869
|
+
|
|
870
|
+
const product = specable(
|
|
871
|
+
{ name: "", price: null, description: "", category: "" },
|
|
872
|
+
{ spec: productSpec, required: productRequired }
|
|
873
|
+
);
|
|
874
|
+
|
|
875
|
+
const priceConverter = {
|
|
876
|
+
toInput: (v) => v == null ? "" : String(v),
|
|
877
|
+
toValue: (s) => s === "" ? null : Number(s)
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
async function handleSubmit() {
|
|
881
|
+
await product.submit();
|
|
882
|
+
|
|
883
|
+
if ($product.valid) {
|
|
884
|
+
const response = await fetch("/api/products", {
|
|
885
|
+
method: "POST",
|
|
886
|
+
headers: { "Content-Type": "application/json" },
|
|
887
|
+
body: JSON.stringify($product.value)
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
if (response.ok) {
|
|
891
|
+
alert("Product created successfully!");
|
|
892
|
+
} else {
|
|
893
|
+
const error = await response.json();
|
|
894
|
+
alert(`Server error: ${JSON.stringify(error.errors)}`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
</script>
|
|
899
|
+
|
|
900
|
+
<form on:submit|preventDefault={handleSubmit}>
|
|
901
|
+
<input use:register={product.getChild(["name"])} placeholder="Product name" />
|
|
902
|
+
{#if $product.errors.name}<p class="error">{$product.errors.name}</p>{/if}
|
|
903
|
+
|
|
904
|
+
<input use:register={[product.getChild(["price"]), priceConverter]}
|
|
905
|
+
type="number" step="0.01" placeholder="Price" />
|
|
906
|
+
{#if $product.errors.price}<p class="error">{$product.errors.price}</p>{/if}
|
|
907
|
+
|
|
908
|
+
<textarea use:register={product.getChild(["description"])}
|
|
909
|
+
placeholder="Description"></textarea>
|
|
910
|
+
{#if $product.errors.description}<p class="error">{$product.errors.description}</p>{/if}
|
|
911
|
+
|
|
912
|
+
<select use:register={product.getChild(["category"])}>
|
|
913
|
+
<option value="">Select category</option>
|
|
914
|
+
<option value="electronics">Electronics</option>
|
|
915
|
+
<option value="clothing">Clothing</option>
|
|
916
|
+
<option value="food">Food</option>
|
|
917
|
+
<option value="books">Books</option>
|
|
918
|
+
<option value="other">Other</option>
|
|
919
|
+
</select>
|
|
920
|
+
{#if $product.errors.category}<p class="error">{$product.errors.category}</p>{/if}
|
|
921
|
+
|
|
922
|
+
<button type="submit" disabled={$product.submitting}>
|
|
923
|
+
{$product.submitting ? "Creating..." : "Create Product"}
|
|
924
|
+
</button>
|
|
925
|
+
</form>
|
|
288
926
|
```
|
|
289
927
|
|
|
290
|
-
|
|
928
|
+
**Server-side validation:**
|
|
291
929
|
|
|
292
|
-
```
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
930
|
+
```js
|
|
931
|
+
// filepath: src/routes/api/products/+server.js
|
|
932
|
+
import { json } from "@sveltejs/kit";
|
|
933
|
+
import { conform } from "specma";
|
|
934
|
+
import { productSpec } from "$lib/shared/specs/productSpecs";
|
|
935
|
+
|
|
936
|
+
export async function POST({ request }) {
|
|
937
|
+
const data = await request.json();
|
|
938
|
+
|
|
939
|
+
// Validate using the exact same specs as the client
|
|
940
|
+
const result = conform(data, productSpec);
|
|
941
|
+
|
|
942
|
+
if (!result.valid) {
|
|
943
|
+
return json(
|
|
944
|
+
{
|
|
945
|
+
success: false,
|
|
946
|
+
errors: result.problems,
|
|
947
|
+
},
|
|
948
|
+
{ status: 400 }
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Save to database
|
|
953
|
+
// const productId = await db.products.create(result.value);
|
|
954
|
+
|
|
955
|
+
return json({
|
|
956
|
+
success: true,
|
|
957
|
+
productId: "mock-id-12345",
|
|
958
|
+
});
|
|
959
|
+
}
|
|
298
960
|
```
|
|
961
|
+
|
|
962
|
+
This example demonstrates:
|
|
963
|
+
|
|
964
|
+
- **Spec extraction** into a shared module
|
|
965
|
+
- **Client-side** reactive validation with Svelte stores
|
|
966
|
+
- **Server-side** validation using the same specs
|
|
967
|
+
- **Consistent validation** across the full stack
|
|
968
|
+
- **Type safety** and maintainability improvements
|