@svadmin/core 0.31.0 → 0.32.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/src/url-sync.ts CHANGED
@@ -1,7 +1,44 @@
1
1
  import type { Filter } from './types';
2
- import { getRouterProvider } from './context.svelte';
2
+ import { captureAdminContext } from './context.svelte';
3
+ import type { AdminContextAccessor } from './context.svelte';
3
4
  import { getAdminOptions } from './options.svelte';
4
5
 
6
+ const CRUD_OPERATORS = new Set([
7
+ 'eq', 'ne', 'lt', 'gt', 'lte', 'gte',
8
+ 'contains', 'ncontains', 'startswith', 'endswith',
9
+ 'in', 'nin', 'null', 'nnull', 'between', 'nbetween',
10
+ ]);
11
+
12
+ function isRecord(value: unknown): value is Record<string, unknown> {
13
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
14
+ }
15
+
16
+ function isFilter(value: unknown, depth: number, budget: { remaining: number }): value is Filter {
17
+ if (depth > 20 || budget.remaining-- <= 0 || !isRecord(value)) return false;
18
+
19
+ if (typeof value.field === 'string') {
20
+ return value.field.trim().length > 0
21
+ && typeof value.operator === 'string'
22
+ && CRUD_OPERATORS.has(value.operator)
23
+ && 'value' in value;
24
+ }
25
+
26
+ return (value.operator === 'and' || value.operator === 'or')
27
+ && Array.isArray(value.value)
28
+ && value.value.every((entry) => isFilter(entry, depth + 1, budget));
29
+ }
30
+
31
+ function parseFilters(value: string): Filter[] | undefined {
32
+ try {
33
+ const parsed: unknown = JSON.parse(value);
34
+ if (!Array.isArray(parsed)) return undefined;
35
+ const budget = { remaining: 200 };
36
+ return parsed.every((entry) => isFilter(entry, 0, budget)) ? parsed : undefined;
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ }
41
+
5
42
  export interface URLState {
6
43
  page?: number;
7
44
  pageSize?: number;
@@ -11,9 +48,9 @@ export interface URLState {
11
48
  filters?: Filter[];
12
49
  }
13
50
 
14
- export function readURLState(): URLState {
51
+ export function readURLState(adminContext: AdminContextAccessor = captureAdminContext()): URLState {
15
52
  if (typeof window === 'undefined') return {};
16
- const rp = getRouterProvider();
53
+ const rp = adminContext.routerProvider;
17
54
  if (!rp) return {};
18
55
 
19
56
  const { params } = rp.parse();
@@ -36,17 +73,19 @@ export function readURLState(): URLState {
36
73
 
37
74
  const filtersRaw = params['filters'];
38
75
  if (filtersRaw) {
39
- try {
40
- state.filters = JSON.parse(filtersRaw);
41
- } catch { /* ignore invalid json */ }
76
+ const filters = parseFilters(filtersRaw);
77
+ if (filters) state.filters = filters;
42
78
  }
43
79
 
44
80
  return state;
45
81
  }
46
82
 
47
- export function writeURLState(state: URLState): void {
83
+ export function writeURLState(
84
+ state: URLState,
85
+ adminContext: AdminContextAccessor = captureAdminContext(),
86
+ ): void {
48
87
  if (typeof window === 'undefined') return;
49
- const rp = getRouterProvider();
88
+ const rp = adminContext.routerProvider;
50
89
  if (!rp) return;
51
90
 
52
91
  const current = rp.parse();
@@ -1,9 +1,8 @@
1
- /* eslint-disable svelte/prefer-svelte-reactivity */
2
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
3
2
  // useParsed — parse current URL hash into structured route info
4
3
 
5
- import { currentPath, registerRouterSync } from './router';
6
- import { getResources } from './context.svelte';
4
+ import { registerRouterSync } from './router';
5
+ import { captureAdminContext } from './context.svelte';
7
6
 
8
7
  interface ParsedRoute {
9
8
  resource?: string;
@@ -16,13 +15,13 @@ interface ParsedRoute {
16
15
  parentParams: Record<string, string>;
17
16
  }
18
17
 
19
- let globalPath = $state('/');
18
+ let routeVersion = $state(0);
20
19
 
21
20
  export function resetGlobalPath(): void {
22
- globalPath = typeof window !== 'undefined' ? currentPath() : '/';
21
+ routeVersion++;
23
22
  }
24
23
  export function syncGlobalPath(): void {
25
- if (typeof window !== 'undefined') globalPath = currentPath();
24
+ routeVersion++;
26
25
  }
27
26
 
28
27
  /** 简单的英文名词单数化,处理常见复数模式(-ies, -ses, -es, -s) */
@@ -37,11 +36,10 @@ function singularize(word: string): string {
37
36
  registerRouterSync(syncGlobalPath);
38
37
 
39
38
  if (typeof window !== 'undefined') {
40
- globalPath = currentPath();
41
39
  if (!(window as any).__svadminParsedInit) {
42
40
  (window as any).__svadminParsedInit = true;
43
- window.addEventListener('hashchange', () => { globalPath = currentPath(); });
44
- window.addEventListener('popstate', () => { globalPath = currentPath(); });
41
+ window.addEventListener('hashchange', syncGlobalPath);
42
+ window.addEventListener('popstate', syncGlobalPath);
45
43
  }
46
44
  }
47
45
 
@@ -58,10 +56,17 @@ if (typeof window !== 'undefined') {
58
56
  * // parsed.parentParams contains nested route params such as teamId
59
57
  */
60
58
  export function useParsed(): ParsedRoute {
61
- const resources = $derived((() => { try { return getResources(); } catch { return []; } })());
59
+ const adminContext = captureAdminContext();
60
+ const resources = $derived(adminContext.resources);
61
+ const activePath = $derived.by(() => {
62
+ // Any router navigation increments routeVersion. Each tree then asks its
63
+ // captured provider for its own path instead of consuming a global router.
64
+ void routeVersion;
65
+ return adminContext.currentPath();
66
+ });
62
67
 
63
68
  const parsed = $derived.by(() => {
64
- const p = globalPath;
69
+ const p = activePath;
65
70
  const result: ParsedRoute = { params: {}, parentParams: {} };
66
71
 
67
72
  // Parse query params from hash
@@ -1,12 +1,11 @@
1
- /* eslint-disable svelte/prefer-svelte-reactivity */
2
- import { useForm, type UseFormOptions, type UseFormReturn } from './form-hooks.svelte';
1
+ import { useForm, type UseFormOptions } from './form-hooks.svelte';
3
2
  import { useQueryClient } from '@tanstack/svelte-query';
4
3
  import { useParsed } from './useParsed.svelte';
5
- import { getResource, getResources } from './context.svelte';
4
+ import { captureAdminContext } from './context.svelte';
6
5
  import { getAdminOptions } from './options.svelte';
7
6
  import { t } from './i18n.svelte';
8
7
  import type { BaseRecord, HttpError, Filter, KnownResources, ResourceDefinition } from './types';
9
- import { useList, type UseListOptions } from './query-hooks.svelte';
8
+ import { useList } from './query-hooks.svelte';
10
9
  import { toast } from './toast.svelte';
11
10
  import type { UseSelectOptions } from './hooks.svelte';
12
11
  import { useSelect as useSelectImpl } from './hooks.svelte';
@@ -89,11 +88,12 @@ export interface MenuConfig {
89
88
  }
90
89
 
91
90
  export function useMenu() {
91
+ const adminContext = captureAdminContext();
92
92
  const parsed = useParsed();
93
93
 
94
94
  const menuItems = $derived.by<MenuConfig[]>(() => {
95
95
  const adminOptions = getAdminOptions();
96
- const resources = getResources();
96
+ const resources = adminContext.resources;
97
97
  let items: MenuConfig[] = resources
98
98
  .filter((r: ResourceDefinition) => r.showInMenu !== false)
99
99
  .map((r: ResourceDefinition): MenuConfig => ({
@@ -125,6 +125,7 @@ export interface BreadcrumbItem {
125
125
  }
126
126
 
127
127
  export function useBreadcrumb() {
128
+ const adminContext = captureAdminContext();
128
129
  const parsed = useParsed();
129
130
 
130
131
  const items = $derived.by<BreadcrumbItem[]>(() => {
@@ -132,7 +133,7 @@ export function useBreadcrumb() {
132
133
  if (!parsed.resource) return list;
133
134
 
134
135
  try {
135
- const res = getResource(parsed.resource);
136
+ const res = adminContext.getResource(parsed.resource);
136
137
  const chain: { label: string; link: string; icon?: string }[] = [];
137
138
  let current: typeof res | undefined = res;
138
139
  const visited = new Set<string>();
@@ -140,7 +141,7 @@ export function useBreadcrumb() {
140
141
  if (visited.has(current.name)) break;
141
142
  visited.add(current.name);
142
143
  chain.unshift({ label: current.label || current.name, link: `/${current.name}`, icon: current.icon });
143
- current = current.parentName ? getResource(current.parentName) : undefined;
144
+ current = current.parentName ? adminContext.getResource(current.parentName) : undefined;
144
145
  }
145
146
  list.push(...chain);
146
147