@svadmin/lite 0.3.26 → 0.5.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 CHANGED
@@ -218,7 +218,7 @@ export const handle = createLegacyRedirectHook('/lite');
218
218
  | `createAuthGuard(authProvider)` | Server hook for authentication |
219
219
  | `createAuthActions(authProvider)` | Login/logout actions plus optional provider-delegating account actions |
220
220
  | `createLegacyRedirectHook()` | Auto-redirect IE11 to `/lite/` |
221
- | `fieldsToZodSchema(fields)` | Generate the Zod schema used by Lite actions or other Zod consumers |
221
+ | `fieldsToTypeBoxSchema(fields)` | Generate the TypeBox schema used by Lite actions or other consumers (with `fieldsToZodSchema` alias) |
222
222
 
223
223
  ## CSS
224
224
 
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * LiteForm — Server-rendered form driven by FieldDefinitions.
4
4
  * Uses a native <form> with method="POST" for no-JavaScript submission.
5
- * Uses the generated Zod schema for server-side validation.
5
+ * Uses the generated TypeBox schema for server-side validation.
6
6
  */
7
7
  import type { FieldDefinition, ResourceDefinition } from '@svadmin/core';
8
8
  import { t } from '@svadmin/core/i18n';
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * LiteForm — Server-rendered form driven by FieldDefinitions.
3
3
  * Uses a native <form> with method="POST" for no-JavaScript submission.
4
- * Uses the generated Zod schema for server-side validation.
4
+ * Uses the generated TypeBox schema for server-side validation.
5
5
  */
6
6
  import type { FieldDefinition, ResourceDefinition } from '@svadmin/core';
7
7
  interface Props {
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { FieldDefinition } from '@svadmin/core';
7
7
  import { toSafeHref, toSafeText } from '../security';
8
- import { isExplicitBooleanTrue } from '../value-normalization';
8
+ import { isExplicitBooleanTrue, getStatusBadgeClass } from '../value-normalization';
9
9
  import LiteMediaThumbnail from './LiteMediaThumbnail.svelte';
10
10
 
11
11
  interface Props {
@@ -60,6 +60,8 @@
60
60
  {/each}
61
61
  {:else if field.type === 'json' && value}
62
62
  <pre style="margin:0;font-size:12px;background:#f8fafc;padding:12px;border-radius:6px;border:1px solid #e2e8f0;overflow-x:auto;">{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}</pre>
63
+ {:else if field.type === "select" && field.options}
64
+ <span class={getStatusBadgeClass(value)}>{formatValue(value, field)}</span>
63
65
  {:else}
64
66
  {formatValue(value, field)}
65
67
  {/if}
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { ResourceDefinition, FieldDefinition } from '@svadmin/core';
7
7
  import { t } from '@svadmin/core/i18n';
8
- import { isExplicitBooleanTrue } from '../value-normalization';
8
+ import { isExplicitBooleanTrue, getStatusBadgeClass } from '../value-normalization';
9
9
  import { liteFragmentId } from '../fragment-id';
10
10
 
11
11
  interface Props {
@@ -104,7 +104,7 @@
104
104
  <span class="lite-badge">{tag}</span>
105
105
  {/each}
106
106
  {:else if field.type === 'select' && field.options}
107
- <span class="lite-badge">{formatValue(record[field.key], field)}</span>
107
+ <span class={getStatusBadgeClass(record[field.key])}>{formatValue(record[field.key], field)}</span>
108
108
  {:else}
109
109
  {formatValue(record[field.key], field)}
110
110
  {/if}
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { createListLoader, createDetailLoader, createCrudActions, createAuthGuard, createAuthActions, createLegacyRedirectHook, isLegacyBrowser, } from './server-adapter';
2
2
  export type { ListLoaderResult } from './server-adapter';
3
- export { fieldsToZodSchema, resourceToZodSchema, fieldToInputType, fieldToPlaceholder, } from './schema-generator';
3
+ export { fieldsToTypeBoxSchema, resourceToTypeBoxSchema, fieldsToZodSchema, resourceToZodSchema, fieldToInputType, fieldToPlaceholder, } from './schema-generator';
4
+ export { getStatusBadgeClass, parseExplicitBoolean, isExplicitBooleanTrue } from './value-normalization';
4
5
  export { default as LiteLayout } from './components/LiteLayout.svelte';
5
6
  export { default as LiteTable } from './components/LiteTable.svelte';
6
7
  export { default as LitePagination } from './components/LitePagination.svelte';
package/dist/index.js CHANGED
@@ -2,9 +2,10 @@
2
2
  // Server-rendered by default, with optional progressive enhancement.
3
3
  // Server utilities (use in +page.server.ts / hooks.server.ts)
4
4
  export { createListLoader, createDetailLoader, createCrudActions, createAuthGuard, createAuthActions, createLegacyRedirectHook, isLegacyBrowser, } from './server-adapter';
5
- // Schema generator (used by Lite actions; also reusable by other Zod consumers)
6
- export { fieldsToZodSchema, resourceToZodSchema, fieldToInputType, fieldToPlaceholder, } from './schema-generator';
5
+ // Schema generator (TypeBox schemas used by Lite actions and client forms)
6
+ export { fieldsToTypeBoxSchema, resourceToTypeBoxSchema, fieldsToZodSchema, resourceToZodSchema, fieldToInputType, fieldToPlaceholder, } from './schema-generator';
7
7
  // UI Components (use in +page.svelte with csr = false)
8
+ export { getStatusBadgeClass, parseExplicitBoolean, isExplicitBooleanTrue } from './value-normalization';
8
9
  export { default as LiteLayout } from './components/LiteLayout.svelte';
9
10
  export { default as LiteTable } from './components/LiteTable.svelte';
10
11
  export { default as LitePagination } from './components/LitePagination.svelte';
package/dist/lite.css CHANGED
@@ -791,6 +791,36 @@ textarea.lite-input {
791
791
  border-color: #fecaca;
792
792
  }
793
793
 
794
+ .lite-badge-info {
795
+ background: #eff6ff;
796
+ color: #1d4ed8;
797
+ border-color: #bfdbfe;
798
+ }
799
+
800
+ .lite-badge-indigo {
801
+ background: #eef2ff;
802
+ color: #4338ca;
803
+ border-color: #c7d2fe;
804
+ }
805
+
806
+ .lite-filter-bar {
807
+ display: flex;
808
+ align-items: center;
809
+ flex-wrap: wrap;
810
+ }
811
+ .lite-filter-bar > * + * {
812
+ margin-left: 8px;
813
+ }
814
+
815
+ .lite-batch-bar {
816
+ display: flex;
817
+ align-items: center;
818
+ justify-content: space-between;
819
+ padding: 10px 16px;
820
+ background: #f8fafc;
821
+ border-top: 1px solid #e2e8f0;
822
+ }
823
+
794
824
  .lite-badge-warning {
795
825
  background: #fffbeb;
796
826
  color: #92400e;
@@ -1,26 +1,62 @@
1
1
  /**
2
2
  * @svadmin/lite — Schema Generator
3
3
  *
4
- * Auto-generates Zod schemas from @svadmin/core FieldDefinitions.
5
- * Used by the Lite server actions and compatible with other Zod consumers.
4
+ * Auto-generates TypeBox schemas from @svadmin/core FieldDefinitions.
5
+ * Used by Lite server actions and high-speed JIT form validation.
6
6
  */
7
- import { z } from 'zod';
8
- import type { FieldDefinition, ResourceDefinition } from '@svadmin/core';
7
+ import { type TObject } from "@sinclair/typebox";
8
+ import type { FieldDefinition, ResourceDefinition } from "@svadmin/core";
9
+ export interface SchemaValidationIssue {
10
+ path: (string | number)[];
11
+ message: string;
12
+ }
13
+ export type SchemaValidationResult<T = Record<string, unknown>> = {
14
+ success: true;
15
+ data: T;
16
+ error?: never;
17
+ } | {
18
+ success: false;
19
+ error: {
20
+ issues: SchemaValidationIssue[];
21
+ };
22
+ data?: never;
23
+ };
24
+ export type TypeBoxEnhancedSchema<T = Record<string, unknown>> = TObject & {
25
+ parse: (values: unknown) => T;
26
+ safeParse: (values: unknown) => SchemaValidationResult<T>;
27
+ Check: (values: unknown) => boolean;
28
+ "~standard": {
29
+ version: 1;
30
+ vendor: "svadmin";
31
+ validate: (values: unknown) => {
32
+ value: T;
33
+ } | {
34
+ issues: Array<{
35
+ message: string;
36
+ path?: (string | number)[];
37
+ }>;
38
+ };
39
+ };
40
+ };
9
41
  /**
10
- * Generate a Zod object schema from a ResourceDefinition's fields.
11
- * Only includes fields that are relevant for form rendering.
12
- *
13
- * @param mode - 'create' | 'edit' to filter fields by showInCreate / showInEdit
42
+ * Generate a TypeBox object schema from a list of FieldDefinitions.
43
+ */
44
+ export declare function fieldsToTypeBoxSchema(fields: FieldDefinition[], mode?: "create" | "edit"): TypeBoxEnhancedSchema;
45
+ /**
46
+ * Generate a TypeBox schema from a ResourceDefinition.
47
+ * Convenience wrapper around fieldsToTypeBoxSchema.
48
+ */
49
+ export declare function resourceToTypeBoxSchema(resource: ResourceDefinition, mode?: "create" | "edit"): TypeBoxEnhancedSchema;
50
+ /**
51
+ * Backward compatibility alias for fieldsToTypeBoxSchema
14
52
  */
15
- export declare function fieldsToZodSchema(fields: FieldDefinition[], mode?: 'create' | 'edit'): z.ZodObject<Record<string, z.ZodTypeAny>>;
53
+ export declare const fieldsToZodSchema: typeof fieldsToTypeBoxSchema;
16
54
  /**
17
- * Generate a Zod schema from a ResourceDefinition.
18
- * Convenience wrapper around fieldsToZodSchema.
55
+ * Backward compatibility alias for resourceToTypeBoxSchema
19
56
  */
20
- export declare function resourceToZodSchema(resource: ResourceDefinition, mode?: 'create' | 'edit'): z.ZodObject<Record<string, z.ZodTypeAny>>;
57
+ export declare const resourceToZodSchema: typeof resourceToTypeBoxSchema;
21
58
  /**
22
59
  * Determine a conservative HTML input type for server-rendered forms.
23
- * Text fallbacks avoid inconsistent native validation and date widgets.
24
60
  */
25
61
  export declare function fieldToInputType(field: FieldDefinition): string;
26
62
  /**
@@ -1,19 +1,19 @@
1
1
  /**
2
2
  * @svadmin/lite — Schema Generator
3
3
  *
4
- * Auto-generates Zod schemas from @svadmin/core FieldDefinitions.
5
- * Used by the Lite server actions and compatible with other Zod consumers.
4
+ * Auto-generates TypeBox schemas from @svadmin/core FieldDefinitions.
5
+ * Used by Lite server actions and high-speed JIT form validation.
6
6
  */
7
- import { z } from 'zod';
8
- import { parseExplicitBoolean } from './value-normalization';
7
+ import { Type } from "@sinclair/typebox";
8
+ import { parseExplicitBoolean } from "./value-normalization";
9
9
  function isNativeFile(value) {
10
- return typeof File !== 'undefined' && value instanceof File;
10
+ return typeof File !== "undefined" && value instanceof File;
11
11
  }
12
12
  function isNonEmptyNativeFile(value) {
13
- return isNativeFile(value) && value.size > 0 && value.name !== '';
13
+ return isNativeFile(value) && value.size > 0 && value.name !== "";
14
14
  }
15
15
  function normalizeSingleUpload(submittedUpload) {
16
- if (submittedUpload === undefined || submittedUpload === null || submittedUpload === '')
16
+ if (submittedUpload === undefined || submittedUpload === null || submittedUpload === "")
17
17
  return undefined;
18
18
  if (isNativeFile(submittedUpload) && !isNonEmptyNativeFile(submittedUpload))
19
19
  return undefined;
@@ -22,7 +22,7 @@ function normalizeSingleUpload(submittedUpload) {
22
22
  function hasRequiredValue(value) {
23
23
  if (value === undefined || value === null)
24
24
  return false;
25
- if (typeof value === 'string')
25
+ if (typeof value === "string")
26
26
  return value.trim().length > 0;
27
27
  if (Array.isArray(value))
28
28
  return value.length > 0;
@@ -30,246 +30,428 @@ function hasRequiredValue(value) {
30
30
  return isNonEmptyNativeFile(value);
31
31
  return true;
32
32
  }
33
- function applyCustomValidation(schema, field) {
34
- if (!field.validate)
35
- return schema;
36
- return schema.superRefine((parsedFieldValue, context) => {
37
- if (parsedFieldValue === undefined || parsedFieldValue === null || parsedFieldValue === '')
38
- return;
39
- const message = field.validate?.(parsedFieldValue);
40
- if (message)
41
- context.addIssue({ code: 'custom', message });
42
- });
43
- }
44
- function numberFieldToZod(field) {
45
- const numberSchema = z.coerce.number({ message: `${field.label} must be a number` });
46
- const targetSchema = field.required ? numberSchema : numberSchema.optional();
47
- return z.preprocess((value) => {
48
- if (value === undefined || value === null)
49
- return undefined;
50
- if (typeof value === 'string' && value.trim() === '')
51
- return undefined;
52
- return value;
53
- }, targetSchema);
54
- }
55
- function booleanFieldToZod(field) {
56
- const booleanSchema = z.boolean();
57
- const targetSchema = field.required ? booleanSchema : booleanSchema.optional();
58
- return z.preprocess((rawBoolean) => {
59
- if (rawBoolean === undefined || rawBoolean === null || rawBoolean === '')
60
- return undefined;
61
- return parseExplicitBoolean(rawBoolean) ?? rawBoolean;
62
- }, targetSchema);
63
- }
64
33
  function restoreOptionValue(field, submittedOption) {
65
34
  const option = field.options?.find((candidate) => String(candidate.value) === String(submittedOption));
66
35
  return option?.value ?? submittedOption;
67
36
  }
68
- function optionValueSchema(field) {
69
- const options = field.options ?? [];
70
- return z.union([z.string(), z.number()]).refine((parsedOption) => options.length === 0
71
- || options.some((option) => Object.is(option.value, parsedOption)), { message: `${field.label} must be one of the options` });
72
- }
73
- function singleOptionFieldToZod(field) {
74
- return z.preprocess((submittedOption) => restoreOptionValue(field, submittedOption), optionValueSchema(field));
75
- }
76
- function multipleOptionFieldToZod(field) {
77
- return z.preprocess((submittedOptions) => Array.isArray(submittedOptions)
78
- ? submittedOptions.map((submittedOption) => restoreOptionValue(field, submittedOption))
79
- : submittedOptions, z.array(optionValueSchema(field)).default([]));
80
- }
81
- function singleFileFieldToZod(field, options) {
82
- const fileSchema = z.custom(isNonEmptyNativeFile, {
83
- message: `${field.label} must be a non-empty file`,
84
- });
85
- const referenceSchema = z.string().trim().min(1, `${field.label} must reference an existing file`);
86
- const uploadSchema = options.allowReference ? z.union([fileSchema, referenceSchema]) : fileSchema;
87
- const targetSchema = options.required ? uploadSchema : uploadSchema.optional();
88
- return z.preprocess(normalizeSingleUpload, targetSchema);
89
- }
90
- function imageFieldSchemas(field) {
91
- const imageSchema = z.union([
92
- z.string().trim().min(1, `${field.label} must reference an image`),
93
- z.custom(isNonEmptyNativeFile, {
94
- message: `${field.label} must reference an image or contain a non-empty file`,
95
- }),
96
- ]);
97
- return {
98
- required: z.preprocess(normalizeSingleUpload, imageSchema),
99
- optional: z.preprocess(normalizeSingleUpload, imageSchema.optional()),
100
- };
101
- }
102
37
  function normalizeImageEntries(submittedImages) {
103
- if (submittedImages === undefined || submittedImages === null || submittedImages === '')
38
+ if (submittedImages === undefined || submittedImages === null || submittedImages === "")
104
39
  return undefined;
105
40
  const entries = Array.isArray(submittedImages) ? submittedImages : [submittedImages];
106
41
  const normalized = entries.flatMap((entry) => {
107
- if (typeof entry !== 'string')
42
+ if (typeof entry !== "string")
108
43
  return [entry];
109
44
  return entry.split(/[\r\n]+/u).map((reference) => reference.trim()).filter(Boolean);
110
45
  }).filter((entry) => !isNativeFile(entry) || isNonEmptyNativeFile(entry));
111
46
  return normalized.length > 0 ? normalized : undefined;
112
47
  }
113
- function imagesFieldSchemas(field) {
114
- const imageEntrySchema = z.union([
115
- z.string().trim().min(1, `${field.label} contains an empty image reference`),
116
- z.custom(isNonEmptyNativeFile, {
117
- message: `${field.label} contains an empty or invalid file`,
118
- }),
119
- ]);
120
- const requiredImages = z.array(imageEntrySchema)
121
- .min(1, `${field.label} must contain at least one image`);
122
- return {
123
- required: z.preprocess(normalizeImageEntries, requiredImages),
124
- optional: z.preprocess(normalizeImageEntries, z.array(imageEntrySchema).optional()),
125
- };
126
- }
127
- /**
128
- * Convert a single FieldDefinition to its corresponding Zod type.
129
- */
130
- function fieldToZod(field, mode, withinArray = false) {
131
- let schema;
48
+ function coerceAndValidateField(field, value, mode, withinArray = false, path = [field.key]) {
49
+ const issues = [];
50
+ let coerced = value;
132
51
  switch (field.type) {
133
- case 'number':
134
- return numberFieldToZod(field);
135
- case 'boolean':
136
- return booleanFieldToZod(field);
137
- case 'email':
138
- schema = z.string().email(`${field.label} must be a valid email`);
52
+ case "number": {
53
+ if (coerced === undefined || coerced === null || coerced === "") {
54
+ coerced = undefined;
55
+ if (field.required) {
56
+ issues.push({ path, message: `${field.label} is required` });
57
+ }
58
+ }
59
+ else if (typeof coerced === "string") {
60
+ const trimmed = coerced.trim();
61
+ if (trimmed === "") {
62
+ coerced = undefined;
63
+ if (field.required) {
64
+ issues.push({ path, message: `${field.label} is required` });
65
+ }
66
+ }
67
+ else {
68
+ const num = Number(trimmed);
69
+ if (Number.isNaN(num)) {
70
+ issues.push({ path, message: `${field.label} must be a number` });
71
+ }
72
+ else {
73
+ coerced = num;
74
+ }
75
+ }
76
+ }
77
+ else if (typeof coerced === "number") {
78
+ if (Number.isNaN(coerced)) {
79
+ issues.push({ path, message: `${field.label} must be a number` });
80
+ }
81
+ }
82
+ else {
83
+ issues.push({ path, message: `${field.label} must be a number` });
84
+ }
139
85
  break;
140
- case 'url':
141
- schema = z.string().url(`${field.label} must be a valid URL`);
86
+ }
87
+ case "boolean": {
88
+ if (coerced === undefined || coerced === null || coerced === "") {
89
+ coerced = field.required ? false : undefined;
90
+ if (field.required && coerced === undefined) {
91
+ issues.push({ path, message: `${field.label} is required` });
92
+ }
93
+ }
94
+ else if (typeof coerced === "boolean") {
95
+ // ok
96
+ }
97
+ else if (typeof coerced === "string") {
98
+ const parsed = parseExplicitBoolean(coerced);
99
+ if (parsed === undefined) {
100
+ issues.push({ path, message: `${field.label} must be a boolean` });
101
+ }
102
+ else {
103
+ coerced = parsed;
104
+ }
105
+ }
106
+ else if (typeof coerced === "number") {
107
+ if (coerced === 1)
108
+ coerced = true;
109
+ else if (coerced === 0)
110
+ coerced = false;
111
+ else
112
+ issues.push({ path, message: `${field.label} must be a boolean` });
113
+ }
114
+ else {
115
+ issues.push({ path, message: `${field.label} must be a boolean` });
116
+ }
142
117
  break;
143
- case 'date':
144
- schema = z.string().refine((v) => !v || !isNaN(Date.parse(v)), { message: `${field.label} must be a valid date` });
118
+ }
119
+ case "email": {
120
+ if (typeof coerced === "string" && coerced.length > 0) {
121
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
122
+ if (!emailRegex.test(coerced)) {
123
+ issues.push({ path, message: `${field.label} must be a valid email` });
124
+ }
125
+ }
145
126
  break;
146
- case 'select':
147
- schema = singleOptionFieldToZod(field);
127
+ }
128
+ case "url": {
129
+ if (typeof coerced === "string" && coerced.length > 0) {
130
+ try {
131
+ new URL(coerced);
132
+ }
133
+ catch {
134
+ issues.push({ path, message: `${field.label} must be a valid URL` });
135
+ }
136
+ }
148
137
  break;
149
- case 'multiselect':
150
- schema = multipleOptionFieldToZod(field);
138
+ }
139
+ case "date": {
140
+ if (typeof coerced === "string" && coerced.length > 0) {
141
+ if (Number.isNaN(Date.parse(coerced))) {
142
+ issues.push({ path, message: `${field.label} must be a valid date` });
143
+ }
144
+ }
151
145
  break;
152
- case 'relation':
153
- schema = field.options?.length ? singleOptionFieldToZod(field) : z.string();
146
+ }
147
+ case "select": {
148
+ if (coerced !== undefined && coerced !== null && coerced !== "") {
149
+ coerced = restoreOptionValue(field, coerced);
150
+ const options = field.options ?? [];
151
+ if (options.length > 0 && !options.some((opt) => Object.is(opt.value, coerced))) {
152
+ issues.push({ path, message: `${field.label} must be one of the options` });
153
+ }
154
+ }
154
155
  break;
155
- case 'array': {
156
- const shape = {};
157
- for (const subField of field.subFields ?? []) {
158
- shape[subField.key] = applyCustomValidation(fieldToZod(subField, mode, true), subField);
156
+ }
157
+ case "multiselect": {
158
+ if (Array.isArray(coerced)) {
159
+ coerced = coerced.map((item) => restoreOptionValue(field, item));
160
+ const options = field.options ?? [];
161
+ if (options.length > 0) {
162
+ for (const item of coerced) {
163
+ if (!options.some((opt) => Object.is(opt.value, item))) {
164
+ issues.push({ path, message: `${field.label} must be one of the options` });
165
+ break;
166
+ }
167
+ }
168
+ }
169
+ }
170
+ else if (coerced === undefined || coerced === null || coerced === "") {
171
+ coerced = [];
159
172
  }
160
- const arraySchema = z.array(z.object(shape));
161
- return field.required
162
- ? arraySchema.min(1, `${field.label} must contain at least one item`)
163
- : arraySchema.optional().or(z.literal(''));
173
+ break;
164
174
  }
165
- case 'tags':
166
- schema = z.union([
167
- z.array(z.string()),
168
- z.string().transform((value) => value ? value.split(',').map((tag) => tag.trim()).filter(Boolean) : []),
169
- ]);
175
+ case "relation": {
176
+ if (field.options?.length && coerced !== undefined && coerced !== null && coerced !== "") {
177
+ coerced = restoreOptionValue(field, coerced);
178
+ if (!field.options.some((opt) => Object.is(opt.value, coerced))) {
179
+ issues.push({ path, message: `${field.label} must be one of the options` });
180
+ }
181
+ }
170
182
  break;
171
- case 'textarea':
172
- case 'richtext':
173
- case 'markdown':
174
- schema = z.string().max(50000, `${field.label} is too long`);
183
+ }
184
+ case "array": {
185
+ if (Array.isArray(coerced)) {
186
+ if (field.required && coerced.length === 0) {
187
+ issues.push({ path, message: `${field.label} must contain at least one item` });
188
+ }
189
+ else {
190
+ const processedRows = [];
191
+ for (let r = 0; r < coerced.length; r++) {
192
+ const row = coerced[r];
193
+ if (typeof row !== "object" || row === null) {
194
+ issues.push({ path: [...path, r], message: "Row must be an object" });
195
+ continue;
196
+ }
197
+ const rowObj = row;
198
+ const processedRow = {};
199
+ for (const subField of field.subFields ?? []) {
200
+ const subRes = coerceAndValidateField(subField, rowObj[subField.key], mode, true, [...path, r, subField.key]);
201
+ issues.push(...subRes.issues);
202
+ if (subRes.value !== undefined) {
203
+ processedRow[subField.key] = subRes.value;
204
+ }
205
+ }
206
+ processedRows.push(processedRow);
207
+ }
208
+ coerced = processedRows;
209
+ }
210
+ }
211
+ else if (coerced === undefined || coerced === null || coerced === "") {
212
+ if (field.required) {
213
+ issues.push({ path, message: `${field.label} must contain at least one item` });
214
+ }
215
+ else {
216
+ coerced = [];
217
+ }
218
+ }
219
+ else {
220
+ issues.push({ path, message: `${field.label} must be an array` });
221
+ }
222
+ break;
223
+ }
224
+ case "tags": {
225
+ if (typeof coerced === "string") {
226
+ coerced = coerced ? coerced.split(",").map((tag) => tag.trim()).filter(Boolean) : [];
227
+ }
228
+ else if (!Array.isArray(coerced)) {
229
+ coerced = [];
230
+ }
231
+ break;
232
+ }
233
+ case "textarea":
234
+ case "richtext":
235
+ case "markdown": {
236
+ if (typeof coerced === "string" && coerced.length > 50000) {
237
+ issues.push({ path, message: `${field.label} is too long` });
238
+ }
175
239
  break;
176
- case 'json':
177
- schema = z.unknown().transform((value, context) => {
178
- if (typeof value !== 'string')
179
- return value;
240
+ }
241
+ case "json": {
242
+ if (typeof coerced === "string") {
180
243
  try {
181
- return JSON.parse(value);
244
+ coerced = JSON.parse(coerced);
182
245
  }
183
246
  catch {
184
- context.addIssue({ code: 'custom', message: `${field.label} must be valid JSON` });
185
- return z.NEVER;
247
+ issues.push({ path, message: `${field.label} must be valid JSON` });
248
+ }
249
+ }
250
+ break;
251
+ }
252
+ case "phone": {
253
+ if (typeof coerced === "string" && coerced.length > 0) {
254
+ if (!/^[+\d\s()-]*$/.test(coerced)) {
255
+ issues.push({ path, message: `${field.label} must be a valid phone number` });
256
+ }
257
+ }
258
+ break;
259
+ }
260
+ case "file": {
261
+ coerced = normalizeSingleUpload(coerced);
262
+ const isFile = isNonEmptyNativeFile(coerced);
263
+ const isStringRef = typeof coerced === "string" && coerced.trim().length > 0;
264
+ const isRequired = field.required === true && (mode === "create" || withinArray);
265
+ const allowRef = withinArray && mode === "edit";
266
+ if (isRequired) {
267
+ if (!isFile && (!allowRef || !isStringRef)) {
268
+ issues.push({ path, message: `${field.label} must be a non-empty file` });
269
+ }
270
+ }
271
+ else if (coerced !== undefined) {
272
+ if (!isFile && (!allowRef || !isStringRef)) {
273
+ issues.push({ path, message: `${field.label} must be a non-empty file` });
186
274
  }
187
- });
275
+ }
188
276
  break;
189
- case 'phone':
190
- schema = z.string().regex(/^[+\d\s()-]*$/, `${field.label} must be a valid phone number`);
277
+ }
278
+ case "image": {
279
+ coerced = normalizeSingleUpload(coerced);
280
+ const isFile = isNonEmptyNativeFile(coerced);
281
+ const isStringRef = typeof coerced === "string" && coerced.trim().length > 0;
282
+ const isRequired = field.required === true;
283
+ if (isRequired) {
284
+ if (mode === "create" || withinArray) {
285
+ if (!isFile && !isStringRef) {
286
+ issues.push({ path, message: `${field.label} must reference an image or contain a non-empty file` });
287
+ }
288
+ }
289
+ else if (mode === "edit") {
290
+ if (coerced !== undefined && !isFile && !isStringRef) {
291
+ issues.push({ path, message: `${field.label} must reference an image or contain a non-empty file` });
292
+ }
293
+ }
294
+ }
191
295
  break;
192
- case 'file':
193
- return singleFileFieldToZod(field, {
194
- required: field.required === true && (mode === 'create' || withinArray),
195
- allowReference: mode === 'edit' && withinArray,
196
- });
197
- case 'image': {
198
- const imageSchemas = imageFieldSchemas(field);
199
- return field.required === true && (mode === 'create' || withinArray)
200
- ? imageSchemas.required
201
- : imageSchemas.optional;
202
296
  }
203
- case 'images': {
204
- const imagesSchemas = imagesFieldSchemas(field);
205
- return field.required === true && (mode === 'create' || withinArray)
206
- ? imagesSchemas.required
207
- : imagesSchemas.optional;
297
+ case "images": {
298
+ coerced = normalizeImageEntries(coerced);
299
+ const isRequired = field.required === true;
300
+ const entries = Array.isArray(coerced) ? coerced : (coerced ? [coerced] : []);
301
+ if (isRequired && (mode === "create" || withinArray)) {
302
+ if (entries.length === 0) {
303
+ issues.push({ path, message: `${field.label} must contain at least one image` });
304
+ }
305
+ }
306
+ break;
208
307
  }
209
308
  default:
210
- schema = z.string();
309
+ break;
211
310
  }
212
- // Enforce meaningful values for required fields, including nested array rows.
213
- if (field.required) {
214
- schema = schema.refine(hasRequiredValue, { message: `${field.label} is required` });
311
+ // Check required
312
+ if (field.type !== "number" && field.type !== "boolean" && field.type !== "array" && field.type !== "file" && field.type !== "image" && field.type !== "images") {
313
+ if (field.required && !hasRequiredValue(coerced)) {
314
+ issues.push({ path, message: `${field.label} is required` });
315
+ }
215
316
  }
216
- else {
217
- schema = schema.optional().or(z.literal(''));
317
+ // Custom validate
318
+ if (field.validate && issues.length === 0 && coerced !== undefined && coerced !== null && coerced !== "") {
319
+ const customMessage = field.validate(coerced);
320
+ if (customMessage) {
321
+ issues.push({ path, message: customMessage });
322
+ }
218
323
  }
219
- return schema;
324
+ return { value: coerced, issues };
220
325
  }
221
326
  /**
222
- * Generate a Zod object schema from a ResourceDefinition's fields.
223
- * Only includes fields that are relevant for form rendering.
224
- *
225
- * @param mode - 'create' | 'edit' to filter fields by showInCreate / showInEdit
327
+ * Generate a TypeBox object schema from a list of FieldDefinitions.
226
328
  */
227
- export function fieldsToZodSchema(fields, mode = 'create') {
329
+ export function fieldsToTypeBoxSchema(fields, mode = "create") {
228
330
  const shape = {};
331
+ const activeFields = [];
229
332
  for (const field of fields) {
230
- // Skip fields not shown in forms
231
333
  if (field.showInForm === false)
232
334
  continue;
233
- if (mode === 'create' && field.showInCreate === false)
335
+ if (mode === "create" && field.showInCreate === false)
234
336
  continue;
235
- if (mode === 'edit' && field.showInEdit === false)
337
+ if (mode === "edit" && field.showInEdit === false)
236
338
  continue;
237
- shape[field.key] = applyCustomValidation(fieldToZod(field, mode), field);
339
+ activeFields.push(field);
340
+ switch (field.type) {
341
+ case "number":
342
+ shape[field.key] = field.required ? Type.Number() : Type.Optional(Type.Number());
343
+ break;
344
+ case "boolean":
345
+ shape[field.key] = field.required ? Type.Boolean() : Type.Optional(Type.Boolean());
346
+ break;
347
+ case "array":
348
+ shape[field.key] = field.required ? Type.Array(Type.Any(), { minItems: 1 }) : Type.Optional(Type.Array(Type.Any()));
349
+ break;
350
+ default:
351
+ shape[field.key] = field.required ? Type.Any() : Type.Optional(Type.Any());
352
+ break;
353
+ }
238
354
  }
239
- return z.object(shape);
355
+ const baseSchema = Type.Object(shape, { additionalProperties: true });
356
+ const safeParse = (values) => {
357
+ if (typeof values !== "object" || values === null) {
358
+ return {
359
+ success: false,
360
+ error: { issues: [{ path: ["_root"], message: "Values must be an object" }] },
361
+ };
362
+ }
363
+ const input = values;
364
+ const resultData = {};
365
+ const allIssues = [];
366
+ for (const field of activeFields) {
367
+ const val = input[field.key];
368
+ const fieldRes = coerceAndValidateField(field, val, mode, false, [field.key]);
369
+ allIssues.push(...fieldRes.issues);
370
+ if (fieldRes.value !== undefined) {
371
+ resultData[field.key] = fieldRes.value;
372
+ }
373
+ }
374
+ if (allIssues.length > 0) {
375
+ return {
376
+ success: false,
377
+ error: { issues: allIssues },
378
+ };
379
+ }
380
+ return {
381
+ success: true,
382
+ data: resultData,
383
+ };
384
+ };
385
+ const parse = (values) => {
386
+ const res = safeParse(values);
387
+ if (!res.success) {
388
+ const err = new Error(res.error?.issues[0]?.message || "Validation failed");
389
+ err.issues = res.error?.issues ?? [];
390
+ throw err;
391
+ }
392
+ return res.data ?? {};
393
+ };
394
+ return Object.assign(baseSchema, {
395
+ parse,
396
+ safeParse,
397
+ Check: (val) => safeParse(val).success,
398
+ "~standard": {
399
+ version: 1,
400
+ vendor: "svadmin",
401
+ validate: (val) => {
402
+ const res = safeParse(val);
403
+ if (res.success) {
404
+ return { value: res.data ?? {} };
405
+ }
406
+ return {
407
+ issues: (res.error?.issues ?? []).map((iss) => ({
408
+ message: iss.message,
409
+ path: iss.path,
410
+ })),
411
+ };
412
+ },
413
+ },
414
+ });
240
415
  }
241
416
  /**
242
- * Generate a Zod schema from a ResourceDefinition.
243
- * Convenience wrapper around fieldsToZodSchema.
417
+ * Generate a TypeBox schema from a ResourceDefinition.
418
+ * Convenience wrapper around fieldsToTypeBoxSchema.
244
419
  */
245
- export function resourceToZodSchema(resource, mode = 'create') {
246
- const primaryKey = resource.primaryKey ?? 'id';
247
- return fieldsToZodSchema(resource.fields.filter((field) => field.key !== primaryKey), mode);
420
+ export function resourceToTypeBoxSchema(resource, mode = "create") {
421
+ const primaryKey = resource.primaryKey ?? "id";
422
+ return fieldsToTypeBoxSchema(resource.fields.filter((field) => field.key !== primaryKey), mode);
248
423
  }
424
+ /**
425
+ * Backward compatibility alias for fieldsToTypeBoxSchema
426
+ */
427
+ export const fieldsToZodSchema = fieldsToTypeBoxSchema;
428
+ /**
429
+ * Backward compatibility alias for resourceToTypeBoxSchema
430
+ */
431
+ export const resourceToZodSchema = resourceToTypeBoxSchema;
249
432
  /**
250
433
  * Determine a conservative HTML input type for server-rendered forms.
251
- * Text fallbacks avoid inconsistent native validation and date widgets.
252
434
  */
253
435
  export function fieldToInputType(field) {
254
436
  switch (field.type) {
255
- case 'number': return 'text';
256
- case 'email': return 'text';
257
- case 'url': return 'text';
258
- case 'phone': return 'tel';
259
- case 'boolean': return 'checkbox';
260
- case 'date': return 'text';
261
- case 'textarea':
262
- case 'richtext':
263
- case 'markdown':
264
- case 'images': return 'textarea';
265
- case 'json': return 'textarea';
266
- case 'select':
267
- case 'multiselect': return 'select';
268
- case 'relation': return field.options?.length ? 'select' : 'text';
269
- case 'array': return 'text';
270
- case 'file': return 'file';
271
- case 'password': return 'password';
272
- default: return 'text';
437
+ case "number": return "text";
438
+ case "email": return "text";
439
+ case "url": return "text";
440
+ case "phone": return "tel";
441
+ case "boolean": return "checkbox";
442
+ case "date": return "text";
443
+ case "textarea":
444
+ case "richtext":
445
+ case "markdown":
446
+ case "images": return "textarea";
447
+ case "json": return "textarea";
448
+ case "select":
449
+ case "multiselect": return "select";
450
+ case "relation": return field.options?.length ? "select" : "text";
451
+ case "array": return "text";
452
+ case "file": return "file";
453
+ case "password": return "password";
454
+ default: return "text";
273
455
  }
274
456
  }
275
457
  /**
@@ -277,11 +459,11 @@ export function fieldToInputType(field) {
277
459
  */
278
460
  export function fieldToPlaceholder(field) {
279
461
  switch (field.type) {
280
- case 'date': return 'YYYY-MM-DD';
281
- case 'email': return 'user@example.com';
282
- case 'url': return 'https://example.com';
283
- case 'phone': return '+1 (555) 000-0000';
284
- case 'number': return '0';
285
- default: return '';
462
+ case "date": return "YYYY-MM-DD";
463
+ case "email": return "user@example.com";
464
+ case "url": return "https://example.com";
465
+ case "phone": return "+1 (555) 000-0000";
466
+ case "number": return "0";
467
+ default: return "";
286
468
  }
287
469
  }
@@ -92,6 +92,13 @@ export declare function createCrudActions(dp: DataProvider, resource: ResourceDe
92
92
  success: boolean;
93
93
  error?: undefined;
94
94
  }>;
95
+ batchDelete: ({ request }: RequestEvent) => Promise<{
96
+ success: boolean;
97
+ error: string;
98
+ } | {
99
+ success: boolean;
100
+ error?: undefined;
101
+ }>;
95
102
  };
96
103
  /**
97
104
  * Creates a SvelteKit server hook that checks auth via AuthProvider
@@ -1,5 +1,5 @@
1
1
  import { redirect, isRedirect } from '@sveltejs/kit';
2
- import { resourceToZodSchema } from './schema-generator';
2
+ import { resourceToTypeBoxSchema } from './schema-generator';
3
3
  import { parseExplicitBoolean } from './value-normalization';
4
4
  function listRequestState(url, resource) {
5
5
  const requestedPage = Number(url.searchParams.get('page'));
@@ -19,19 +19,35 @@ function listRequestState(url, resource) {
19
19
  : resource.defaultSort?.order ?? 'asc';
20
20
  return { page, pageSize, sort, order, search: url.searchParams.get('q') ?? undefined };
21
21
  }
22
- function listSearchFilters(resource, search) {
23
- if (!search)
24
- return [];
25
- const fieldFilters = resource.fields
26
- .filter((field) => field.searchable)
27
- .map((field) => ({
28
- field: field.key,
29
- operator: 'contains',
30
- value: search,
31
- }));
32
- if (fieldFilters.length === 1)
33
- return fieldFilters;
34
- return fieldFilters.length > 1 ? [{ operator: 'or', value: fieldFilters }] : [];
22
+ function listRequestFilters(resource, url) {
23
+ const filters = [];
24
+ const search = url.searchParams.get('q') ?? undefined;
25
+ if (search) {
26
+ const searchFilters = resource.fields
27
+ .filter((field) => field.searchable)
28
+ .map((field) => ({
29
+ field: field.key,
30
+ operator: 'contains',
31
+ value: search,
32
+ }));
33
+ if (searchFilters.length === 1) {
34
+ filters.push(searchFilters[0]);
35
+ }
36
+ else if (searchFilters.length > 1) {
37
+ filters.push({ operator: 'or', value: searchFilters });
38
+ }
39
+ }
40
+ for (const field of resource.fields) {
41
+ const rawVal = url.searchParams.get(`filter_${field.key}`) ?? (field.key !== 'q' && field.key !== 'sort' && field.key !== 'order' && field.key !== 'page' ? url.searchParams.get(field.key) : null);
42
+ if (rawVal != null && rawVal.trim() !== '') {
43
+ filters.push({
44
+ field: field.key,
45
+ operator: 'eq',
46
+ value: field.type === 'number' && !Number.isNaN(Number(rawVal)) ? Number(rawVal) : rawVal,
47
+ });
48
+ }
49
+ }
50
+ return filters;
35
51
  }
36
52
  /**
37
53
  * Creates a SvelteKit `load` function that fetches a resource list
@@ -45,7 +61,7 @@ export function createListLoader(dp, resource) {
45
61
  resource: resource.name,
46
62
  pagination: { current: page, pageSize },
47
63
  sorters,
48
- filters: listSearchFilters(resource, search),
64
+ filters: listRequestFilters(resource, url),
49
65
  });
50
66
  return {
51
67
  records: listResponse.data,
@@ -155,6 +171,32 @@ export function createCrudActions(dp, resource) {
155
171
  return { success: false, error: 'Delete failed' };
156
172
  }
157
173
  },
174
+ batchDelete: async ({ request }) => {
175
+ if (resource.canDelete === false) {
176
+ return { success: false, error: 'Delete is disabled for this resource' };
177
+ }
178
+ const formData = await request.formData();
179
+ const ids = formData.getAll('ids').map(v => String(v).trim()).filter(Boolean);
180
+ if (ids.length === 0)
181
+ return { success: false, error: 'No records selected' };
182
+ const redirectTo = toSafeLocalRedirect(formData.get('redirect'));
183
+ try {
184
+ if (dp.deleteMany) {
185
+ await dp.deleteMany({ resource: resource.name, ids });
186
+ }
187
+ else {
188
+ await Promise.all(ids.map(id => dp.deleteOne({ resource: resource.name, id })));
189
+ }
190
+ if (redirectTo)
191
+ throw redirect(303, redirectTo);
192
+ return { success: true };
193
+ }
194
+ catch (caughtError) {
195
+ if (isRedirect(caughtError))
196
+ throw caughtError;
197
+ return { success: false, error: 'Batch delete failed' };
198
+ }
199
+ },
158
200
  };
159
201
  }
160
202
  // ─── Auth Helpers ─────────────────────────────────────────────
@@ -374,7 +416,7 @@ function formatValidationErrors(issues) {
374
416
  return errors;
375
417
  }
376
418
  function validateFormVariables(resource, mode, values) {
377
- const result = resourceToZodSchema(resource, mode).safeParse(values);
419
+ const result = resourceToTypeBoxSchema(resource, mode).safeParse(values);
378
420
  if (result.success)
379
421
  return { success: true, data: result.data };
380
422
  return {
@@ -471,7 +513,7 @@ function formDataToObject(formData, fields) {
471
513
  obj[field.key] = strRaw ? JSON.parse(strRaw) : null;
472
514
  }
473
515
  catch {
474
- obj[field.key] = strRaw; // Let Zod handle validation errors
516
+ obj[field.key] = strRaw; // Let TypeBox handle validation errors
475
517
  }
476
518
  break;
477
519
  default:
@@ -1,2 +1,3 @@
1
1
  export declare function parseExplicitBoolean(rawBoolean: unknown): boolean | undefined;
2
2
  export declare function isExplicitBooleanTrue(rawBoolean: unknown): boolean;
3
+ export declare function getStatusBadgeClass(value: unknown): string;
@@ -19,3 +19,21 @@ export function parseExplicitBoolean(rawBoolean) {
19
19
  export function isExplicitBooleanTrue(rawBoolean) {
20
20
  return parseExplicitBoolean(rawBoolean) === true;
21
21
  }
22
+ export function getStatusBadgeClass(value) {
23
+ if (typeof value !== "string")
24
+ return "lite-badge";
25
+ const v = value.toLowerCase().trim();
26
+ if (["active", "completed", "published", "delivered", "paid", "approved", "success", "in_stock", "healthy"].includes(v)) {
27
+ return "lite-badge lite-badge-success";
28
+ }
29
+ if (["draft", "pending", "processing", "in_transit", "review", "waiting", "warning", "low_stock"].includes(v)) {
30
+ return "lite-badge lite-badge-warning";
31
+ }
32
+ if (["cancelled", "rejected", "failed", "suspended", "inactive", "banned", "error", "out_of_stock", "closed"].includes(v)) {
33
+ return "lite-badge lite-badge-danger";
34
+ }
35
+ if (["shipped", "submitted", "open", "new", "admin", "verified"].includes(v)) {
36
+ return "lite-badge lite-badge-info";
37
+ }
38
+ return "lite-badge";
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/lite",
3
- "version": "0.3.26",
3
+ "version": "0.5.0",
4
4
  "description": "SSR-first lightweight admin UI for @svadmin with optional progressive enhancement",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -48,22 +48,22 @@
48
48
  }
49
49
  },
50
50
  "peerDependencies": {
51
- "svelte": "^5.56.8",
52
- "@svadmin/core": ">=0.34.2 <0.43.0",
53
- "@sveltejs/kit": "^2.70.2"
51
+ "svelte": "^5.56.10",
52
+ "@svadmin/core": ">=0.34.2 <0.46.0",
53
+ "@sveltejs/kit": "^2.70.3"
54
54
  },
55
55
  "dependencies": {
56
- "@lucide/svelte": "^1.31.0",
57
- "zod": "^4.4.3"
56
+ "@lucide/svelte": "^1.35.0",
57
+ "@sinclair/typebox": "^0.34.52"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/bun": "^1.4.0",
61
- "@happy-dom/global-registrator": "^20.11.2",
61
+ "@happy-dom/global-registrator": "^20.11.12",
62
62
  "@sveltejs/vite-plugin-svelte": "^7.3.0",
63
63
  "@testing-library/dom": "^10.4.1",
64
64
  "@testing-library/svelte": "^5.4.2",
65
- "happy-dom": "^20.11.2",
66
- "vitest": "^4.1.10"
65
+ "happy-dom": "^20.11.12",
66
+ "vitest": "^4.1.11"
67
67
  },
68
68
  "scripts": {
69
69
  "build": "node ../../node_modules/@sveltejs/package/svelte-package.js -i src -o dist && bun ../../scripts/copy-package-assets.ts static/enhance.js dist/enhance.js",