@svadmin/core 0.0.3 → 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,189 @@
1
+ // Unit tests for Inferencer
2
+ import { describe, test, expect } from 'bun:test';
3
+ import { inferFieldType, inferResource } from './inferencer';
4
+
5
+ describe('inferFieldType', () => {
6
+ test('detects boolean', () => {
7
+ expect(inferFieldType('active', true)).toBe('boolean');
8
+ });
9
+
10
+ test('detects number', () => {
11
+ expect(inferFieldType('age', 25)).toBe('number');
12
+ });
13
+
14
+ test('detects email', () => {
15
+ expect(inferFieldType('email', 'user@example.com')).toBe('email');
16
+ });
17
+
18
+ test('detects email from key name', () => {
19
+ expect(inferFieldType('user_email', 'test')).toBe('email');
20
+ });
21
+
22
+ test('detects URL', () => {
23
+ expect(inferFieldType('website', 'https://example.com')).toBe('url');
24
+ });
25
+
26
+ test('detects image URL', () => {
27
+ expect(inferFieldType('avatar', 'https://example.com/photo.jpg')).toBe('image');
28
+ });
29
+
30
+ test('detects image from key name', () => {
31
+ expect(inferFieldType('avatar', '/uploads/user.png')).toBe('image');
32
+ });
33
+
34
+ test('detects date ISO string', () => {
35
+ expect(inferFieldType('created', '2024-01-15T10:30:00Z')).toBe('date');
36
+ });
37
+
38
+ test('detects date from key name', () => {
39
+ expect(inferFieldType('created_at', 'some value')).toBe('date');
40
+ });
41
+
42
+ test('detects phone', () => {
43
+ expect(inferFieldType('phone', '+1 (555) 123-4567')).toBe('phone');
44
+ });
45
+
46
+ test('detects color hex', () => {
47
+ expect(inferFieldType('color', '#ff5533')).toBe('color');
48
+ });
49
+
50
+ test('detects textarea for long strings', () => {
51
+ expect(inferFieldType('content', 'a'.repeat(250))).toBe('textarea');
52
+ });
53
+
54
+ test('detects textarea from key name', () => {
55
+ expect(inferFieldType('description', 'short')).toBe('textarea');
56
+ });
57
+
58
+ test('detects tags array', () => {
59
+ expect(inferFieldType('tags', ['svelte', 'admin'])).toBe('tags');
60
+ });
61
+
62
+ test('detects images array', () => {
63
+ expect(inferFieldType('photos', ['https://x.com/a.png', 'https://x.com/b.jpg'])).toBe('images');
64
+ });
65
+
66
+ test('detects json object', () => {
67
+ expect(inferFieldType('meta', { foo: 'bar' })).toBe('json');
68
+ });
69
+
70
+ test('null defaults to text', () => {
71
+ expect(inferFieldType('unknown', null)).toBe('text');
72
+ });
73
+
74
+ test('plain string defaults to text', () => {
75
+ expect(inferFieldType('title', 'Hello World')).toBe('text');
76
+ });
77
+ });
78
+
79
+ describe('inferResource', () => {
80
+ const sampleData = [
81
+ {
82
+ id: 1,
83
+ title: 'First Post',
84
+ content: 'A very long description that is more than two hundred characters. '.repeat(4),
85
+ author_id: 5,
86
+ status: 'published',
87
+ views: 100,
88
+ is_featured: true,
89
+ created_at: '2024-01-15T10:00:00Z',
90
+ email: 'author@blog.com',
91
+ tags: ['svelte', 'admin'],
92
+ },
93
+ {
94
+ id: 2,
95
+ title: 'Second Post',
96
+ content: 'Another long body text that exceeds the 200 char limit easily. '.repeat(5),
97
+ author_id: 3,
98
+ status: 'draft',
99
+ views: 42,
100
+ is_featured: false,
101
+ created_at: '2024-02-20T14:30:00Z',
102
+ email: 'editor@blog.com',
103
+ tags: ['react'],
104
+ },
105
+ ];
106
+
107
+ test('returns correct resource name and label', () => {
108
+ const result = inferResource('posts', sampleData);
109
+ expect(result.resource.name).toBe('posts');
110
+ expect(result.resource.label).toBe('Posts');
111
+ });
112
+
113
+ test('infers correct number of fields', () => {
114
+ const result = inferResource('posts', sampleData);
115
+ expect(result.fields.length).toBe(10); // id, title, content, author_id, status, views, is_featured, created_at, email, tags
116
+ });
117
+
118
+ test('detects id as primary key', () => {
119
+ const result = inferResource('posts', sampleData);
120
+ const idField = result.fields.find(f => f.key === 'id');
121
+ expect(idField).toBeDefined();
122
+ expect(idField!.showInForm).toBe(false);
123
+ });
124
+
125
+ test('detects number type for views', () => {
126
+ const result = inferResource('posts', sampleData);
127
+ const viewsField = result.fields.find(f => f.key === 'views');
128
+ expect(viewsField!.type).toBe('number');
129
+ });
130
+
131
+ test('detects boolean type for is_featured', () => {
132
+ const result = inferResource('posts', sampleData);
133
+ const field = result.fields.find(f => f.key === 'is_featured');
134
+ expect(field!.type).toBe('boolean');
135
+ });
136
+
137
+ test('detects date for created_at', () => {
138
+ const result = inferResource('posts', sampleData);
139
+ const field = result.fields.find(f => f.key === 'created_at');
140
+ expect(field!.type).toBe('date');
141
+ });
142
+
143
+ test('detects relation for author_id', () => {
144
+ const result = inferResource('posts', sampleData);
145
+ const field = result.fields.find(f => f.key === 'author_id');
146
+ expect(field!.type).toBe('relation');
147
+ expect(field!.resource).toBe('authors');
148
+ });
149
+
150
+ test('detects email type', () => {
151
+ const result = inferResource('posts', sampleData);
152
+ const field = result.fields.find(f => f.key === 'email');
153
+ expect(field!.type).toBe('email');
154
+ });
155
+
156
+ test('detects tags array', () => {
157
+ const result = inferResource('posts', sampleData);
158
+ const field = result.fields.find(f => f.key === 'tags');
159
+ expect(field!.type).toBe('tags');
160
+ });
161
+
162
+ test('detects textarea for content', () => {
163
+ const result = inferResource('posts', sampleData);
164
+ const field = result.fields.find(f => f.key === 'content');
165
+ expect(field!.type).toBe('textarea');
166
+ expect(field!.showInList).toBe(false);
167
+ });
168
+
169
+ test('generates valid TypeScript code', () => {
170
+ const result = inferResource('posts', sampleData);
171
+ expect(result.code).toContain("name: 'posts'");
172
+ expect(result.code).toContain("import type { ResourceDefinition }");
173
+ expect(result.code).toContain("export const postsResource");
174
+ });
175
+
176
+ test('handles empty data gracefully', () => {
177
+ const result = inferResource('empty', []);
178
+ expect(result.fields.length).toBe(0);
179
+ expect(result.code).toContain('No data available');
180
+ });
181
+
182
+ test('custom primaryKey', () => {
183
+ const data = [{ _id: 'abc', name: 'Test' }];
184
+ const result = inferResource('items', data, { primaryKey: '_id' });
185
+ expect(result.resource.primaryKey).toBe('_id');
186
+ const pkField = result.fields.find(f => f.key === '_id');
187
+ expect(pkField!.showInForm).toBe(false);
188
+ });
189
+ });
@@ -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,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
+ }