@svadmin/core 0.0.2 → 0.0.6

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.
@@ -0,0 +1,247 @@
1
+ /**
2
+ * @svadmin/inferencer
3
+ *
4
+ * Analyzes API response data and infers ResourceDefinition + FieldDefinition[].
5
+ * Can also generate copy-paste-ready Svelte component code.
6
+ */
7
+ import type { FieldDefinition, ResourceDefinition } from '@svadmin/core';
8
+
9
+ // ─── Field Type Inference ────────────────────────────────────
10
+
11
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
12
+ const URL_RE = /^https?:\/\//;
13
+ const IMAGE_RE = /\.(png|jpe?g|gif|svg|webp|avif|ico)(\?.*)?$/i;
14
+ const PHONE_RE = /^\+?[\d\s\-()]{7,}$/;
15
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})/;
16
+ const COLOR_RE = /^#([0-9a-fA-F]{3,8})$/;
17
+
18
+ type InferredType = FieldDefinition['type'];
19
+
20
+ /**
21
+ * Infer the svadmin field type from a single sample value.
22
+ */
23
+ export function inferFieldType(key: string, value: unknown): InferredType {
24
+ if (value === null || value === undefined) return 'text';
25
+
26
+ if (typeof value === 'boolean') return 'boolean';
27
+ if (typeof value === 'number') return 'number';
28
+
29
+ if (Array.isArray(value)) {
30
+ if (value.length > 0 && typeof value[0] === 'string') {
31
+ if (value.every(v => typeof v === 'string' && IMAGE_RE.test(v))) return 'images';
32
+ return 'tags';
33
+ }
34
+ return 'tags';
35
+ }
36
+
37
+ if (typeof value === 'object') return 'json';
38
+
39
+ if (typeof value === 'string') {
40
+ if (COLOR_RE.test(value)) return 'color';
41
+ if (EMAIL_RE.test(value)) return 'email';
42
+ if (IMAGE_RE.test(value)) return 'image';
43
+ if (URL_RE.test(value)) return 'url';
44
+ if (PHONE_RE.test(value)) return 'phone';
45
+ if (ISO_DATE_RE.test(value)) return 'date';
46
+ if (value.length > 200) return 'textarea';
47
+
48
+ // Heuristic: key name hints
49
+ const lk = key.toLowerCase();
50
+ if (lk.includes('email')) return 'email';
51
+ if (lk.includes('phone') || lk.includes('tel') || lk.includes('mobile')) return 'phone';
52
+ if (lk.includes('url') || lk.includes('link') || lk.includes('website')) return 'url';
53
+ if (lk.includes('avatar') || lk.includes('image') || lk.includes('photo') || lk.includes('thumbnail') || lk.includes('logo')) return 'image';
54
+ if (lk.includes('color') || lk.includes('colour')) return 'color';
55
+ if (lk.includes('description') || lk.includes('content') || lk.includes('body') || lk.includes('bio') || lk.includes('summary')) return 'textarea';
56
+ if (lk === 'created_at' || lk === 'updated_at' || lk.endsWith('_at') || lk.endsWith('_date') || lk.includes('date')) return 'date';
57
+
58
+ return 'text';
59
+ }
60
+
61
+ return 'text';
62
+ }
63
+
64
+ // ─── Relation Detection ──────────────────────────────────────
65
+
66
+ const RELATION_SUFFIXES = ['_id', 'Id', '_ID'];
67
+
68
+ function isLikelyRelation(key: string): string | null {
69
+ for (const suffix of RELATION_SUFFIXES) {
70
+ if (key.endsWith(suffix)) {
71
+ const resource = key.slice(0, -suffix.length);
72
+ // Pluralize naively
73
+ return resource.endsWith('s') ? resource : resource + 's';
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+
79
+ // ─── Core: Infer from Sample Data ────────────────────────────
80
+
81
+ export interface InferResult {
82
+ fields: FieldDefinition[];
83
+ resource: ResourceDefinition;
84
+ code: string;
85
+ }
86
+
87
+ /**
88
+ * Analyze a sample data array and produce a ResourceDefinition.
89
+ * @param resourceName - Name of the resource (e.g. "posts")
90
+ * @param sampleData - An array of records from the API
91
+ * @param options - Additional configuration
92
+ */
93
+ export function inferResource(
94
+ resourceName: string,
95
+ sampleData: Record<string, unknown>[],
96
+ options: {
97
+ primaryKey?: string;
98
+ label?: string;
99
+ } = {}
100
+ ): InferResult {
101
+ const primaryKey = options.primaryKey ?? 'id';
102
+ const label = options.label ?? capitalize(resourceName);
103
+
104
+ if (!sampleData.length) {
105
+ return {
106
+ fields: [],
107
+ resource: { name: resourceName, label, fields: [], primaryKey },
108
+ code: `// No data available to infer fields for "${resourceName}".`,
109
+ };
110
+ }
111
+
112
+ // Gather all unique keys across all records
113
+ const keySet = new Set<string>();
114
+ for (const row of sampleData) {
115
+ for (const k of Object.keys(row)) keySet.add(k);
116
+ }
117
+
118
+ // For each key, infer from all non-null values
119
+ const fields: FieldDefinition[] = [];
120
+ for (const key of keySet) {
121
+ // Collect non-null values
122
+ const values = sampleData
123
+ .map(r => r[key])
124
+ .filter(v => v !== null && v !== undefined);
125
+
126
+ const sampleValue = values[0];
127
+ let inferredType = inferFieldType(key, sampleValue);
128
+
129
+ // Cross-validate: if most values for this key are of a different type, use majority
130
+ const typeCounts = new Map<InferredType, number>();
131
+ for (const v of values) {
132
+ const t = inferFieldType(key, v);
133
+ typeCounts.set(t, (typeCounts.get(t) ?? 0) + 1);
134
+ }
135
+ let maxCount = 0;
136
+ for (const [t, count] of typeCounts) {
137
+ if (count > maxCount) {
138
+ maxCount = count;
139
+ inferredType = t;
140
+ }
141
+ }
142
+
143
+ // Check for relation
144
+ const relatedResource = isLikelyRelation(key);
145
+
146
+ // Check if it looks like a select (few unique string values)
147
+ const uniqueStrings = new Set(values.filter(v => typeof v === 'string') as string[]);
148
+ const isSelect = inferredType === 'text' && uniqueStrings.size > 1 && uniqueStrings.size <= 10 && values.length >= 5;
149
+
150
+ const field: FieldDefinition = {
151
+ key,
152
+ label: humanize(key),
153
+ type: relatedResource ? 'relation' : isSelect ? 'select' : inferredType,
154
+ sortable: inferredType === 'text' || inferredType === 'number' || inferredType === 'date',
155
+ searchable: inferredType === 'text' || inferredType === 'email',
156
+ showInList: key !== primaryKey && inferredType !== 'textarea' && inferredType !== 'json' && inferredType !== 'richtext',
157
+ showInForm: key !== primaryKey,
158
+ };
159
+
160
+ if (relatedResource) {
161
+ field.resource = relatedResource;
162
+ field.optionLabel = 'name';
163
+ field.optionValue = 'id';
164
+ }
165
+
166
+ if (isSelect) {
167
+ field.options = [...uniqueStrings].map(v => ({ label: capitalize(v), value: v }));
168
+ }
169
+
170
+ fields.push(field);
171
+ }
172
+
173
+ // Sort: primaryKey first, then alphabetically
174
+ fields.sort((a, b) => {
175
+ if (a.key === primaryKey) return -1;
176
+ if (b.key === primaryKey) return 1;
177
+ return a.key.localeCompare(b.key);
178
+ });
179
+
180
+ const resource: ResourceDefinition = {
181
+ name: resourceName,
182
+ label,
183
+ primaryKey,
184
+ fields,
185
+ canCreate: true,
186
+ canEdit: true,
187
+ canDelete: true,
188
+ canShow: true,
189
+ };
190
+
191
+ const code = generateCode(resource);
192
+
193
+ return { fields, resource, code };
194
+ }
195
+
196
+ // ─── Code Generation ─────────────────────────────────────────
197
+
198
+ function generateCode(resource: ResourceDefinition): string {
199
+ const fieldsCode = resource.fields.map(f => {
200
+ const lines = [
201
+ ` { key: '${f.key}', label: '${f.label}', type: '${f.type}'`,
202
+ ];
203
+ if (f.sortable) lines.push(` sortable: true`);
204
+ if (f.searchable) lines.push(` searchable: true`);
205
+ if (f.showInList === false) lines.push(` showInList: false`);
206
+ if (f.showInForm === false) lines.push(` showInForm: false`);
207
+ if (f.resource) {
208
+ lines.push(` resource: '${f.resource}'`);
209
+ lines.push(` optionLabel: '${f.optionLabel}'`);
210
+ lines.push(` optionValue: '${f.optionValue}'`);
211
+ }
212
+ if (f.options) {
213
+ lines.push(` options: ${JSON.stringify(f.options)}`);
214
+ }
215
+
216
+ return lines.join(',\n') + ' }';
217
+ }).join(',\n');
218
+
219
+ return `import type { ResourceDefinition } from '@svadmin/core';
220
+
221
+ export const ${resource.name}Resource: ResourceDefinition = {
222
+ name: '${resource.name}',
223
+ label: '${resource.label}',
224
+ primaryKey: '${resource.primaryKey ?? 'id'}',
225
+ canCreate: true,
226
+ canEdit: true,
227
+ canDelete: true,
228
+ canShow: true,
229
+ fields: [
230
+ ${fieldsCode}
231
+ ],
232
+ };
233
+ `;
234
+ }
235
+
236
+ // ─── Utilities ───────────────────────────────────────────────
237
+
238
+ function capitalize(s: string): string {
239
+ return s.charAt(0).toUpperCase() + s.slice(1);
240
+ }
241
+
242
+ function humanize(key: string): string {
243
+ return key
244
+ .replace(/_/g, ' ')
245
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
246
+ .replace(/\b\w/g, c => c.toUpperCase());
247
+ }
@@ -0,0 +1,45 @@
1
+ // Notification Provider — pluggable notification system
2
+ // Falls back to built-in toast when no provider is set
3
+
4
+ import { toast } from './toast.svelte';
5
+ import type { NotificationProvider } from './types';
6
+
7
+ let notificationProvider: NotificationProvider | null = null;
8
+
9
+ export function setNotificationProvider(provider: NotificationProvider): void {
10
+ notificationProvider = provider;
11
+ }
12
+
13
+ export function getNotificationProvider(): NotificationProvider | null {
14
+ return notificationProvider;
15
+ }
16
+
17
+ /**
18
+ * Send a notification through the configured provider, or fall back to toast.
19
+ */
20
+ export function notify(params: {
21
+ type: 'success' | 'error' | 'warning' | 'info';
22
+ message: string;
23
+ description?: string;
24
+ key?: string;
25
+ }): void {
26
+ if (notificationProvider) {
27
+ notificationProvider.open(params);
28
+ } else {
29
+ // Fallback to built-in toast
30
+ const { type, message } = params;
31
+ switch (type) {
32
+ case 'success': toast.success(message); break;
33
+ case 'error': toast.error(message); break;
34
+ case 'warning': toast.warning(message); break;
35
+ case 'info': toast.info(message); break;
36
+ }
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Close a notification by key (only works with custom provider).
42
+ */
43
+ export function closeNotification(key: string): void {
44
+ notificationProvider?.close(key);
45
+ }
@@ -0,0 +1,55 @@
1
+ // Unit tests for permissions module
2
+ import { describe, test, expect, beforeEach } from 'bun:test';
3
+ import { setAccessControl, canAccess, canAccessAsync } from './permissions';
4
+ import type { Action } from './permissions';
5
+
6
+ describe('permissions', () => {
7
+ beforeEach(() => {
8
+ // Reset to no access control (allow all)
9
+ setAccessControl((() => ({ can: true })) as Parameters<typeof setAccessControl>[0]);
10
+ });
11
+
12
+ test('canAccess returns { can: true } when no deny rules', () => {
13
+ const result = canAccess('posts', 'list' as Action);
14
+ expect(result.can).toBe(true);
15
+ });
16
+
17
+ test('canAccess respects deny rule', () => {
18
+ setAccessControl((_resource: string, action: Action) => {
19
+ if (action === 'delete') return { can: false, reason: 'Denied' };
20
+ return { can: true };
21
+ });
22
+ expect(canAccess('posts', 'delete')).toEqual({ can: false, reason: 'Denied' });
23
+ expect(canAccess('posts', 'list')).toEqual({ can: true });
24
+ });
25
+
26
+ test('canAccess returns { can: true } when async fn used sync', () => {
27
+ setAccessControl(async () => ({ can: false }));
28
+ // Should warn and default to { can: true }
29
+ const result = canAccess('posts', 'list');
30
+ expect(result.can).toBe(true);
31
+ });
32
+
33
+ test('canAccessAsync resolves with allowed status', async () => {
34
+ setAccessControl(async () => ({ can: true }));
35
+ const result = await canAccessAsync('posts', 'edit');
36
+ expect(result.can).toBe(true);
37
+ });
38
+
39
+ test('canAccessAsync resolves with denied status', async () => {
40
+ setAccessControl(async (_r: string, _a: Action) => ({ can: false, reason: 'No permission' }));
41
+ const result = await canAccessAsync('users', 'delete');
42
+ expect(result.can).toBe(false);
43
+ expect(result.reason).toBe('No permission');
44
+ });
45
+
46
+ test('resource-specific rules', () => {
47
+ setAccessControl((resource: string, action: Action) => {
48
+ if (resource === 'users' && action === 'delete') return { can: false, reason: 'Cannot delete users' };
49
+ return { can: true };
50
+ });
51
+ expect(canAccess('users', 'delete').can).toBe(false);
52
+ expect(canAccess('users', 'list').can).toBe(true);
53
+ expect(canAccess('posts', 'delete').can).toBe(true);
54
+ });
55
+ });
@@ -0,0 +1,71 @@
1
+ // Unit tests for RouterProvider implementations
2
+ // Router provider tests need a DOM environment.
3
+ // Skip if window is not available (CI without happy-dom).
4
+ import { describe, test, expect } from 'bun:test';
5
+ import { createHashRouterProvider, createHistoryRouterProvider } from './router-provider';
6
+
7
+ const hasWindow = typeof globalThis.window !== 'undefined';
8
+
9
+ describe('createHashRouterProvider (unit — no DOM)', () => {
10
+ test('returns provider with go, back, parse methods', () => {
11
+ const provider = createHashRouterProvider();
12
+ expect(typeof provider.go).toBe('function');
13
+ expect(typeof provider.back).toBe('function');
14
+ expect(typeof provider.parse).toBe('function');
15
+ });
16
+ });
17
+
18
+ describe('createHistoryRouterProvider (unit — no DOM)', () => {
19
+ test('returns provider with go, back, parse methods', () => {
20
+ const provider = createHistoryRouterProvider();
21
+ expect(typeof provider.go).toBe('function');
22
+ expect(typeof provider.back).toBe('function');
23
+ expect(typeof provider.parse).toBe('function');
24
+ });
25
+
26
+ test('accepts basePath argument', () => {
27
+ const provider = createHistoryRouterProvider('/admin');
28
+ expect(typeof provider.parse).toBe('function');
29
+ });
30
+ });
31
+
32
+ // DOM-dependent tests — only run if window is available
33
+ describe.skipIf(!hasWindow)('createHashRouterProvider (DOM)', () => {
34
+ test('parse returns empty segments for root', () => {
35
+ window.location.hash = '';
36
+ const provider = createHashRouterProvider();
37
+ const parsed = provider.parse();
38
+ expect(parsed.pathname).toBe('/');
39
+ });
40
+
41
+ test('parse extracts resource from hash', () => {
42
+ window.location.hash = '#/posts';
43
+ const provider = createHashRouterProvider();
44
+ const parsed = provider.parse();
45
+ expect(parsed.pathname).toBe('/posts');
46
+ expect(parsed.resource).toBe('posts');
47
+ });
48
+
49
+ test('parse extracts resource, action, and id', () => {
50
+ window.location.hash = '#/posts/edit/42';
51
+ const provider = createHashRouterProvider();
52
+ const parsed = provider.parse();
53
+ expect(parsed.resource).toBe('posts');
54
+ expect(parsed.action).toBe('edit');
55
+ expect(parsed.id).toBe('42');
56
+ });
57
+
58
+ test('parse extracts query params from hash', () => {
59
+ window.location.hash = '#/posts?page=2&sort=name';
60
+ const provider = createHashRouterProvider();
61
+ const parsed = provider.parse();
62
+ expect(parsed.params.page).toBe('2');
63
+ expect(parsed.params.sort).toBe('name');
64
+ });
65
+
66
+ test('go sets window.location.hash', () => {
67
+ const provider = createHashRouterProvider();
68
+ provider.go({ to: '/users' });
69
+ expect(window.location.hash).toBe('#/users');
70
+ });
71
+ });
@@ -0,0 +1,100 @@
1
+ // Router Provider — abstracts routing strategy (hash, history, SvelteKit, etc.)
2
+
3
+ export interface RouterProvider {
4
+ /** Navigate to a path */
5
+ go: (options: {
6
+ to: string;
7
+ query?: Record<string, string>;
8
+ hash?: string;
9
+ type?: 'push' | 'replace';
10
+ }) => void;
11
+ /** Navigate back */
12
+ back: () => void;
13
+ /** Parse current URL into structured route info */
14
+ parse: () => {
15
+ resource?: string;
16
+ action?: string;
17
+ id?: string;
18
+ params: Record<string, string>;
19
+ pathname: string;
20
+ };
21
+ }
22
+
23
+ // ─── Hash Router (default) ──────────────────────────────────
24
+
25
+ export function createHashRouterProvider(): RouterProvider {
26
+ return {
27
+ go({ to, query, type = 'push' }) {
28
+ let url = `#${to}`;
29
+ if (query) {
30
+ const params = new URLSearchParams(query).toString();
31
+ if (params) url += `?${params}`;
32
+ }
33
+ if (type === 'replace') {
34
+ window.location.replace(url);
35
+ } else {
36
+ window.location.hash = to;
37
+ }
38
+ },
39
+ back() {
40
+ history.back();
41
+ },
42
+ parse() {
43
+ const hash = window.location.hash.slice(1) || '/';
44
+ const [pathname, queryString] = hash.split('?');
45
+ const params: Record<string, string> = {};
46
+ if (queryString) {
47
+ for (const [k, v] of new URLSearchParams(queryString).entries()) {
48
+ params[k] = v;
49
+ }
50
+ }
51
+ const segments = pathname.split('/').filter(Boolean);
52
+ return {
53
+ resource: segments[0],
54
+ action: segments[1],
55
+ id: segments[2],
56
+ params,
57
+ pathname,
58
+ };
59
+ },
60
+ };
61
+ }
62
+
63
+ // ─── History Router (HTML5 pushState) ───────────────────────
64
+
65
+ export function createHistoryRouterProvider(basePath = ''): RouterProvider {
66
+ return {
67
+ go({ to, query, type = 'push' }) {
68
+ let url = `${basePath}${to}`;
69
+ if (query) {
70
+ const params = new URLSearchParams(query).toString();
71
+ if (params) url += `?${params}`;
72
+ }
73
+ if (type === 'replace') {
74
+ history.replaceState(null, '', url);
75
+ } else {
76
+ history.pushState(null, '', url);
77
+ }
78
+ // Dispatch event so other parts of the app can react
79
+ window.dispatchEvent(new PopStateEvent('popstate'));
80
+ },
81
+ back() {
82
+ history.back();
83
+ },
84
+ parse() {
85
+ const pathname = window.location.pathname.replace(basePath, '') || '/';
86
+ const params: Record<string, string> = {};
87
+ for (const [k, v] of new URLSearchParams(window.location.search).entries()) {
88
+ params[k] = v;
89
+ }
90
+ const segments = pathname.split('/').filter(Boolean);
91
+ return {
92
+ resource: segments[0],
93
+ action: segments[1],
94
+ id: segments[2],
95
+ params,
96
+ pathname,
97
+ };
98
+ },
99
+ };
100
+ }
package/src/types.ts CHANGED
@@ -233,4 +233,6 @@ export interface FieldDefinition {
233
233
  resource?: string; // related resource name
234
234
  optionLabel?: string; // field to use as label
235
235
  optionValue?: string; // field to use as value
236
+ // Validation
237
+ validate?: (value: unknown) => string | null;
236
238
  }
package/src/useCan.ts ADDED
@@ -0,0 +1,37 @@
1
+ // useCan — reactive permission check hook
2
+
3
+ import { canAccessAsync } from './permissions';
4
+ import type { Action, AccessControlResult } from './permissions';
5
+
6
+ interface UseCanResult {
7
+ allowed: boolean;
8
+ reason?: string;
9
+ isLoading: boolean;
10
+ }
11
+
12
+ /**
13
+ * Reactive permission check. Calls canAccessAsync() and returns reactive state.
14
+ * Usage: const can = useCan('posts', 'delete');
15
+ * if (can.allowed) { ... }
16
+ */
17
+ export function useCan(resource: string, action: Action, params?: Record<string, unknown>): UseCanResult {
18
+ let allowed = $state(true);
19
+ let reason = $state<string | undefined>(undefined);
20
+ let isLoading = $state(true);
21
+
22
+ // Run the async check
23
+ canAccessAsync(resource, action, params).then((result: AccessControlResult) => {
24
+ allowed = result.can;
25
+ reason = result.reason;
26
+ isLoading = false;
27
+ }).catch(() => {
28
+ allowed = true; // default to allowed on error
29
+ isLoading = false;
30
+ });
31
+
32
+ return {
33
+ get allowed() { return allowed; },
34
+ get reason() { return reason; },
35
+ get isLoading() { return isLoading; },
36
+ };
37
+ }
@@ -0,0 +1,74 @@
1
+ // useParsed — parse current URL hash into structured route info
2
+
3
+ import { currentPath } from './router';
4
+ import { getResources } from './context';
5
+
6
+ interface ParsedRoute {
7
+ resource?: string;
8
+ action?: 'list' | 'create' | 'edit' | 'show';
9
+ id?: string;
10
+ params: Record<string, string>;
11
+ }
12
+
13
+ /**
14
+ * Parse the current hash URL into a structured route object.
15
+ * Returns reactive properties that update when the URL changes.
16
+ *
17
+ * Usage:
18
+ * const parsed = useParsed();
19
+ * // parsed.resource === 'posts'
20
+ * // parsed.action === 'edit'
21
+ * // parsed.id === '123'
22
+ */
23
+ export function useParsed(): ParsedRoute {
24
+ const path = $derived(currentPath());
25
+ const resources = $derived((() => { try { return getResources(); } catch { return []; } })());
26
+
27
+ const parsed = $derived.by(() => {
28
+ const p = path;
29
+ const result: ParsedRoute = { params: {} };
30
+
31
+ // Parse query params from hash
32
+ const [pathname, queryString] = p.split('?');
33
+ if (queryString) {
34
+ const sp = new URLSearchParams(queryString);
35
+ for (const [k, v] of sp.entries()) {
36
+ result.params[k] = v;
37
+ }
38
+ }
39
+
40
+ const segments = pathname.split('/').filter(Boolean);
41
+ if (segments.length === 0) return result;
42
+
43
+ // First segment is typically the resource name
44
+ const resourceNames = resources.map(r => r.name);
45
+ if (resourceNames.includes(segments[0])) {
46
+ result.resource = segments[0];
47
+
48
+ if (segments.length === 1) {
49
+ result.action = 'list';
50
+ } else if (segments[1] === 'create') {
51
+ result.action = 'create';
52
+ } else if (segments[1] === 'edit' && segments[2]) {
53
+ result.action = 'edit';
54
+ result.id = segments[2];
55
+ } else if (segments[1] === 'show' && segments[2]) {
56
+ result.action = 'show';
57
+ result.id = segments[2];
58
+ } else if (segments[1]) {
59
+ // Legacy: /:resource/:id
60
+ result.action = 'show';
61
+ result.id = segments[1];
62
+ }
63
+ }
64
+
65
+ return result;
66
+ });
67
+
68
+ return {
69
+ get resource() { return parsed.resource; },
70
+ get action() { return parsed.action; },
71
+ get id() { return parsed.id; },
72
+ get params() { return parsed.params; },
73
+ };
74
+ }