@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.
- package/package.json +1 -1
- package/src/auth-hooks.svelte.ts +293 -0
- package/src/context.ts +12 -0
- package/src/data-transfer.test.ts +118 -0
- package/src/data-transfer.ts +139 -0
- package/src/hooks.svelte.ts +110 -5
- package/src/i18n.svelte.ts +70 -4
- package/src/i18n.test.ts +90 -0
- package/src/index.ts +15 -0
- package/src/inferencer.test.ts +189 -0
- package/src/inferencer.ts +247 -0
- package/src/notification.svelte.ts +45 -0
- package/src/permissions.test.ts +55 -0
- package/src/router-provider.test.ts +71 -0
- package/src/router-provider.ts +100 -0
- package/src/types.ts +2 -0
- package/src/useCan.ts +37 -0
- package/src/useParsed.ts +74 -0
|
@@ -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
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
|
+
}
|
package/src/useParsed.ts
ADDED
|
@@ -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
|
+
}
|