@supersoniks/concorde 4.2.1 → 4.3.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.
Files changed (55) hide show
  1. package/README.md +163 -0
  2. package/build-infos.json +1 -1
  3. package/concorde-core.bundle.js +175 -171
  4. package/concorde-core.es.js +2490 -2246
  5. package/dist/concorde-core.bundle.js +175 -171
  6. package/dist/concorde-core.es.js +2490 -2246
  7. package/package.json +22 -1
  8. package/php/get-challenge.php +34 -0
  9. package/php/some-service.php +42 -0
  10. package/scripts/pre-build.mjs +4 -0
  11. package/src/core/_types/endpoint.ts +4 -0
  12. package/src/core/_types/key.ts +1 -0
  13. package/src/core/components/functional/example/example.ts +38 -6
  14. package/src/core/decorators/Subscriber.ts +2 -0
  15. package/src/core/decorators/api.spec.ts +150 -0
  16. package/src/core/decorators/api.ts +244 -0
  17. package/src/core/decorators/subscriber/bind.ts +57 -145
  18. package/src/core/decorators/subscriber/dynamicPath.ts +77 -0
  19. package/src/core/decorators/subscriber/dynamicPropertyWatch.ts +105 -0
  20. package/src/core/decorators/subscriber/onAssign.ts +11 -147
  21. package/src/core/decorators/subscriber/publish.spec.ts +21 -0
  22. package/src/core/decorators/subscriber/publish.ts +148 -0
  23. package/src/core/decorators/subscriber/publisherPath.ts +13 -0
  24. package/src/core/decorators/subscriber/subscribe.spec.ts +21 -0
  25. package/src/core/decorators/subscriber/subscribe.ts +32 -0
  26. package/src/core/decorators/subscriber/subscribe.type-test.ts +32 -0
  27. package/src/core/utils/api.ts +83 -15
  28. package/src/core/utils/dataProviderKey.spec.ts +34 -0
  29. package/src/core/utils/dataProviderKey.ts +86 -0
  30. package/src/core/utils/endpoint.spec.ts +41 -0
  31. package/src/core/utils/endpoint.ts +87 -0
  32. package/src/decorators.ts +14 -0
  33. package/src/docs/{_misc → _decorators}/ancestor-attribute.md +15 -31
  34. package/src/docs/_decorators/bind.md +164 -0
  35. package/src/docs/_decorators/get.md +65 -0
  36. package/src/docs/_decorators/publish.md +54 -0
  37. package/src/docs/_decorators/subscribe.md +36 -0
  38. package/src/docs/_misc/dataProviderKey.md +135 -0
  39. package/src/docs/_misc/endpoint.md +42 -0
  40. package/src/docs/example/decorators-demo-bind-demos.ts +210 -0
  41. package/src/docs/example/decorators-demo-geo.ts +45 -0
  42. package/src/docs/example/decorators-demo-init.ts +228 -0
  43. package/src/docs/example/decorators-demo-subscribe-publish-get-demos.ts +324 -0
  44. package/src/docs/example/decorators-demo.ts +12 -459
  45. package/src/docs/navigation/navigation.ts +27 -10
  46. package/src/docs/search/docs-search.json +1059 -609
  47. package/src/tsconfig-model.json +1 -1
  48. package/src/tsconfig.json +65 -1
  49. package/src/tsconfig.tsbuildinfo +1 -1
  50. package/src/utils.ts +8 -1
  51. package/vite.config.mts +11 -0
  52. package/src/docs/_misc/bind.md +0 -362
  53. /package/src/docs/{_misc → _decorators}/auto-subscribe.md +0 -0
  54. /package/src/docs/{_misc → _decorators}/on-assign.md +0 -0
  55. /package/src/docs/{_misc → _decorators}/wait-for-ancestors.md +0 -0
@@ -0,0 +1,148 @@
1
+ import type {
2
+ DataProviderKey,
3
+ DataProviderKeyHost,
4
+ } from "../../utils/dataProviderKey";
5
+ import DataProvider from "../../utils/PublisherProxy";
6
+ import { ConnectedComponent, setSubscribable } from "./common";
7
+ import { extractDynamicDependencies, resolveDynamicPath } from "./dynamicPath";
8
+ import {
9
+ publishDynamicWatchKeys,
10
+ registerDynamicPropertyWatcher,
11
+ } from "./dynamicPropertyWatch";
12
+ import { getPublisherFromPath } from "./publisherPath";
13
+
14
+ /**
15
+ * Publishes property writes to a publisher path. Inverse of @subscribe.
16
+ * When the property is set, the value is written to the publisher (reflect-only, no subscription).
17
+ * The decorated property is typed as `T` (or optional / `| null` / `| undefined` for Lit / TS 5).
18
+ * Supports dynamic paths: use placeholders like "users.${userIndex}.email" in the DataProviderKey.
19
+ *
20
+ * @example
21
+ * const formKey = new DataProviderKey<FormData>("formData");
22
+ * @publish(formKey.email)
23
+ * @state()
24
+ * email = "";
25
+ *
26
+ * // Dynamic path + hôte typé via le 2ᵉ générique de la clé :
27
+ * @publish(new DataProviderKey<string, { userIndex: number }>("users.${userIndex}.email"))
28
+ * email = "";
29
+ */
30
+ export function publish<T, U = any>(
31
+ key: DataProviderKey<T, U>,
32
+ ): <K extends string>(
33
+ target: DataProviderKeyHost<U> & { [P in K]?: T | null | undefined },
34
+ propertyKey: K,
35
+ ) => void {
36
+ const path = key.path;
37
+ const dynamicDependencies = extractDynamicDependencies(path);
38
+
39
+ return function (target: object, propertyKey: string) {
40
+ setSubscribable(target);
41
+ const publisherKey = `__publish_${propertyKey}_publisher__`;
42
+ const internalValueKey = `__publish_${propertyKey}_value__`;
43
+
44
+ const existingDescriptor = Object.getOwnPropertyDescriptor(
45
+ target as object,
46
+ propertyKey,
47
+ );
48
+ const initialValue =
49
+ existingDescriptor && !existingDescriptor.get && !existingDescriptor.set
50
+ ? existingDescriptor.value
51
+ : undefined;
52
+
53
+ Object.defineProperty(target as object, propertyKey, {
54
+ get() {
55
+ if (existingDescriptor?.get) {
56
+ return existingDescriptor.get.call(this);
57
+ }
58
+ if (
59
+ !Object.prototype.hasOwnProperty.call(this, internalValueKey) &&
60
+ initialValue !== undefined
61
+ ) {
62
+ (this as Record<string, unknown>)[internalValueKey] = initialValue;
63
+ }
64
+ return (this as Record<string, unknown>)[internalValueKey];
65
+ },
66
+ set(newValue: unknown) {
67
+ if (existingDescriptor?.set) {
68
+ existingDescriptor.set.call(this, newValue);
69
+ } else {
70
+ (this as Record<string, unknown>)[internalValueKey] = newValue;
71
+ }
72
+ const publisher = (this as Record<string, unknown>)[publisherKey] as
73
+ | DataProvider
74
+ | undefined;
75
+ if (publisher) {
76
+ publisher.set(newValue);
77
+ }
78
+ },
79
+ enumerable: existingDescriptor?.enumerable ?? true,
80
+ configurable: existingDescriptor?.configurable ?? true,
81
+ });
82
+
83
+ (target as ConnectedComponent).__onConnected__((component) => {
84
+ const comp = component as Record<string, unknown>;
85
+ const stateKey = `__publish_state_${propertyKey}`;
86
+ type PublishState = { cleanupWatchers: Array<() => void> };
87
+ const state: PublishState =
88
+ (comp[stateKey] as PublishState) ||
89
+ ((comp[stateKey] as PublishState) = {
90
+ cleanupWatchers: [],
91
+ });
92
+
93
+ const updatePublisher = () => {
94
+ let resolvedPath: string | null;
95
+ if (dynamicDependencies.length) {
96
+ const resolution = resolveDynamicPath(component, path);
97
+ resolvedPath = resolution.ready ? resolution.path : null;
98
+ } else {
99
+ resolvedPath = path;
100
+ }
101
+ const publisher = resolvedPath
102
+ ? getPublisherFromPath(resolvedPath)
103
+ : undefined;
104
+ comp[publisherKey] = publisher ?? null;
105
+ if (publisher && propertyKey in component) {
106
+ const currentValue = comp[propertyKey];
107
+ if (currentValue !== undefined) {
108
+ publisher.set(currentValue);
109
+ }
110
+ }
111
+ };
112
+
113
+ state.cleanupWatchers.forEach((cleanup: () => void) => cleanup());
114
+ state.cleanupWatchers = [];
115
+
116
+ if (dynamicDependencies.length) {
117
+ for (const dependency of dynamicDependencies) {
118
+ state.cleanupWatchers.push(
119
+ registerDynamicPropertyWatcher(
120
+ publishDynamicWatchKeys.watcherStore,
121
+ publishDynamicWatchKeys.hooked,
122
+ comp,
123
+ dependency,
124
+ updatePublisher,
125
+ ),
126
+ );
127
+ }
128
+ }
129
+
130
+ updatePublisher();
131
+ });
132
+
133
+ (target as ConnectedComponent).__onDisconnected__((component) => {
134
+ const comp = component as Record<string, unknown>;
135
+ const stateKey = `__publish_state_${propertyKey}`;
136
+ const state = comp[stateKey] as
137
+ | { cleanupWatchers: Array<() => void> }
138
+ | undefined;
139
+ if (state?.cleanupWatchers) {
140
+ state.cleanupWatchers.forEach((cleanup: () => void) => cleanup());
141
+ }
142
+ comp[publisherKey] = undefined;
143
+ });
144
+ } as <K extends string>(
145
+ target: DataProviderKeyHost<U> & { [P in K]?: T | null | undefined },
146
+ propertyKey: K,
147
+ ) => void;
148
+ }
@@ -0,0 +1,13 @@
1
+ import { Objects } from "@supersoniks/concorde/utils";
2
+ import DataProvider, { PublisherManager } from "../../utils/PublisherProxy";
3
+
4
+ export function getPublisherFromPath(path: string): DataProvider | null {
5
+ const segments = path.split(".").filter((segment) => segment.length > 0);
6
+ if (segments.length === 0) return null;
7
+ const dataProvider = segments.shift() || "";
8
+ if (!dataProvider) return null;
9
+ let publisher = PublisherManager.get(dataProvider);
10
+ if (!publisher) return null;
11
+ publisher = Objects.traverse(publisher, segments);
12
+ return publisher as DataProvider | null;
13
+ }
@@ -0,0 +1,21 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { DataProviderKey } from "../../utils/dataProviderKey";
3
+ import { subscribe } from "./subscribe";
4
+
5
+ type ControlStats = {
6
+ gauge: number;
7
+ checkedTickets: { id: string }[];
8
+ };
9
+
10
+ const statsKey = new DataProviderKey<ControlStats>("stats");
11
+
12
+ describe("subscribe", () => {
13
+ it("accepts DataProviderKey and binds to path", () => {
14
+ class TestClass {
15
+ @subscribe(statsKey.gauge)
16
+ gauge = 0;
17
+ }
18
+ const instance = new TestClass();
19
+ expect(instance.gauge).toBe(0);
20
+ });
21
+ });
@@ -0,0 +1,32 @@
1
+ import type {
2
+ DataProviderKey,
3
+ DataProviderKeyHost,
4
+ } from "../../utils/dataProviderKey";
5
+ import { bind } from "./bind";
6
+
7
+ /**
8
+ * Read-only subscription to a publisher path via DataProviderKey&lt;T&gt;. No reflect.
9
+ * The decorated property is typed as `T` (or optional / `| null` / `| undefined` for Lit / TS 5).
10
+ * Supports dynamic paths: use placeholders like "users.${userIndex}" in the DataProviderKey.
11
+ *
12
+ * @example
13
+ * const dataKey = new DataProviderKey<Data>("data");
14
+ * @subscribe(dataKey.count)
15
+ * @state()
16
+ * count: number;
17
+ *
18
+ * // Dynamic path:
19
+ * @subscribe(new DataProviderKey<User>("users.${userIndex}"))
20
+ * user: User | null;
21
+ */
22
+ export function subscribe<T, U = any>(
23
+ key: DataProviderKey<T, U>,
24
+ ): <K extends string>(
25
+ target: DataProviderKeyHost<U> & { [P in K]?: T | null | undefined },
26
+ propertyKey: K,
27
+ ) => void {
28
+ return bind(key) as <K extends string>(
29
+ target: DataProviderKeyHost<U> & { [P in K]?: T | null | undefined },
30
+ propertyKey: K,
31
+ ) => void;
32
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Type tests for subscribe — ensures property type must match DataProviderKey<T>.
3
+ * Excluded from build. To verify: temporarily remove from exclude and run build.
4
+ * Expected: InvalidUsage and InvalidUsage2 should produce type errors.
5
+ */
6
+ import { DataProviderKey } from "../../utils/dataProviderKey";
7
+ import { subscribe } from "./subscribe";
8
+
9
+ type ControlStats = {
10
+ gauge: number;
11
+ label: string;
12
+ };
13
+
14
+ const statsKey = new DataProviderKey<ControlStats>("stats");
15
+
16
+ // OK: gauge is number, matches DataProviderKey<number>
17
+ class ValidUsage {
18
+ @subscribe(statsKey.gauge)
19
+ gauge = 0;
20
+ }
21
+
22
+ // Should error: gauge should be number, not string
23
+ class InvalidUsage {
24
+ @subscribe(statsKey.gauge)
25
+ gauge: string = "";
26
+ }
27
+
28
+ // Should error: label should be string, not number
29
+ class InvalidUsage2 {
30
+ @subscribe(statsKey.label)
31
+ label: number = 0;
32
+ }
@@ -36,6 +36,30 @@ export type APIResponse = {
36
36
  http: Response;
37
37
  processed: ResultTypeInterface;
38
38
  };
39
+
40
+ /** Valeur assignée par `@get` : requête native, réponse `fetch` si HTTP, résultat métier typé `T`. */
41
+ export type ApiGetResult<T> = {
42
+ request: Request;
43
+ /** Absent / `undefined` pour les chemins `dataProvider(...)` (pas d’appel réseau). */
44
+ response?: Response;
45
+ /** Corps utile sans `_sonic_http_response_` sur l’objet racine. */
46
+ result: T;
47
+ };
48
+
49
+ /** Extrait le corps typé depuis le résultat traité par `handleResult`. */
50
+ export function extractTypedApiResult<T>(
51
+ processed: ResultTypeInterface | null | undefined,
52
+ ): T {
53
+ if (processed == null || typeof processed !== "object") {
54
+ return processed as T;
55
+ }
56
+ if (Array.isArray(processed)) {
57
+ return processed as T;
58
+ }
59
+ const copy = { ...(processed as Record<string, unknown>) };
60
+ delete copy._sonic_http_response_;
61
+ return copy as T;
62
+ }
39
63
  class API {
40
64
  /**
41
65
  * Ce tableau static permet de ne pas appeler plusieurs fois le même service lors d'appel concurrents en GET.
@@ -149,7 +173,7 @@ class API {
149
173
  }
150
174
  async handleResult(
151
175
  fetchResult: Response,
152
- lastCall: APICall
176
+ lastCall: APICall,
153
177
  ): Promise<ResultTypeInterface> {
154
178
  API.firstCallDoneFlags.set(this.serviceURL, "done");
155
179
  this.lastResult = fetchResult;
@@ -177,14 +201,14 @@ class API {
177
201
  if (lastCall.apiMethod === "get") {
178
202
  result = await this[lastCall.apiMethod](
179
203
  lastCall.path,
180
- lastCall.additionalHeaders
204
+ lastCall.additionalHeaders,
181
205
  );
182
206
  } else {
183
207
  result = await this[lastCall.apiMethod](
184
208
  lastCall.path,
185
209
  lastCall.data,
186
210
  lastCall.method,
187
- lastCall.additionalHeaders
211
+ lastCall.additionalHeaders,
188
212
  );
189
213
  }
190
214
  }
@@ -192,7 +216,7 @@ class API {
192
216
  /**
193
217
  * Publication en global de la réponse de l'api
194
218
  */
195
- const apiPublisher = dp<{lastResponse: APIResponse}>("sonic-api");
219
+ const apiPublisher = dp<{ lastResponse: APIResponse }>("sonic-api");
196
220
  apiPublisher.lastResponse.set({
197
221
  http: fetchResult,
198
222
  processed: result,
@@ -218,7 +242,7 @@ class API {
218
242
  Authorization:
219
243
  "Basic " +
220
244
  window.btoa(
221
- unescape(encodeURIComponent(this.userName + ":" + this.password))
245
+ unescape(encodeURIComponent(this.userName + ":" + this.password)),
222
246
  ),
223
247
  };
224
248
  } else if (this.authToken) {
@@ -237,7 +261,7 @@ class API {
237
261
  headers: headers,
238
262
  credentials: this.credentials,
239
263
  keepalive: this.keepAlive,
240
- }
264
+ },
241
265
  );
242
266
 
243
267
  try {
@@ -344,7 +368,7 @@ class API {
344
368
  const loop = () => {
345
369
  if (
346
370
  ![undefined, "loading"].includes(
347
- API.firstCallDoneFlags.get(this.serviceURL)
371
+ API.firstCallDoneFlags.get(this.serviceURL),
348
372
  )
349
373
  ) {
350
374
  resolve(true);
@@ -400,6 +424,50 @@ class API {
400
424
  API.loadingGetPromises.delete(mapKey);
401
425
  return result as T & ResultTypeInterface;
402
426
  }
427
+
428
+ /**
429
+ * S’appuie sur `get()` puis reformate la réponse : `Request` équivalent à l’appel,
430
+ * `Response` renseignée par `lastResult` après un GET HTTP (via `handleResult`),
431
+ * `result` = corps métier typé `T` (sans `_sonic_http_response_` à la racine).
432
+ * Pour `dataProvider(...)`, pas de `response` (pas de fetch).
433
+ */
434
+ async getDetailed<T>(
435
+ path: string,
436
+ additionalHeaders?: HeadersInit,
437
+ ): Promise<ApiGetResult<T> | undefined> {
438
+ const isDataProvider = /dataProvider\((.*?)\)(.*?)$/.test(path);
439
+
440
+ const processed = await this.get<T>(path, additionalHeaders);
441
+ if (processed == null) {
442
+ return undefined;
443
+ }
444
+
445
+ const result = extractTypedApiResult<T>(processed as ResultTypeInterface);
446
+ const url = this.computeURL(path);
447
+
448
+ if (isDataProvider) {
449
+ return {
450
+ request: new Request(url, { method: "GET" }),
451
+ result,
452
+ };
453
+ }
454
+
455
+ const headers = await this.createHeaders(additionalHeaders);
456
+ const request = new Request(url, {
457
+ method: "GET",
458
+ headers: new Headers(headers as HeadersInit),
459
+ credentials: this.credentials,
460
+ cache: this.cache,
461
+ keepalive: this.keepAlive,
462
+ });
463
+
464
+ return {
465
+ request,
466
+ response: this.lastResult,
467
+ result,
468
+ };
469
+ }
470
+
403
471
  /**
404
472
  * Création du header, avec authentification si besoin
405
473
  * ajout du language via le header accept-language qui contient le langue du navigateur
@@ -440,7 +508,7 @@ class API {
440
508
  path: string,
441
509
  data: SendType,
442
510
  method = "POST",
443
- additionalHeaders?: HeadersInit
511
+ additionalHeaders?: HeadersInit,
444
512
  ) {
445
513
  const lastCall: APICall = {
446
514
  apiMethod: "send",
@@ -471,7 +539,7 @@ class API {
471
539
  path: string,
472
540
  data: SendType,
473
541
  method = "POST",
474
- additionalHeaders?: HeadersInit
542
+ additionalHeaders?: HeadersInit,
475
543
  ) {
476
544
  const lastCall: APICall = {
477
545
  apiMethod: "submitFormData",
@@ -502,7 +570,7 @@ class API {
502
570
  async put<T, SendType = CoreJSType>(
503
571
  path: string,
504
572
  data: SendType,
505
- additionalHeaders?: HeadersInit
573
+ additionalHeaders?: HeadersInit,
506
574
  ) {
507
575
  return this.send<T, SendType>(path, data, "PUT", additionalHeaders);
508
576
  }
@@ -513,7 +581,7 @@ class API {
513
581
  async post<T, SendType = CoreJSType>(
514
582
  path: string,
515
583
  data: SendType,
516
- additionalHeaders?: HeadersInit
584
+ additionalHeaders?: HeadersInit,
517
585
  ) {
518
586
  return this.send<T, SendType>(path, data, "POST", additionalHeaders);
519
587
  }
@@ -523,7 +591,7 @@ class API {
523
591
  async patch<T, SendType = CoreJSType>(
524
592
  path: string,
525
593
  data: SendType,
526
- additionalHeaders?: HeadersInit
594
+ additionalHeaders?: HeadersInit,
527
595
  ) {
528
596
  return this.send<T, SendType>(path, data, "PATCH", additionalHeaders);
529
597
  }
@@ -534,7 +602,7 @@ class API {
534
602
  async delete<T, SendType = CoreJSType>(
535
603
  path: string,
536
604
  data: SendType,
537
- additionalHeaders?: HeadersInit
605
+ additionalHeaders?: HeadersInit,
538
606
  ) {
539
607
  return this.send<T, SendType>(path, data, "delete", additionalHeaders);
540
608
  }
@@ -548,7 +616,7 @@ export default API;
548
616
  let logIsConf = false;
549
617
  let logEndPoint = "log";
550
618
  let logConfiguration: APIConfiguration = HTML.getApiConfiguration(
551
- document.body || document.documentElement
619
+ document.body || document.documentElement,
552
620
  );
553
621
 
554
622
  let logsToSend: any = [];
@@ -623,6 +691,6 @@ export const configLog = (newConfig: APIConfiguration, endPoint = "log") => {
623
691
  timestamp: new Date().toISOString(),
624
692
  };
625
693
  log(event.reason?.message || "Unknown rejection reason.", errorDetails);
626
- }
694
+ },
627
695
  );
628
696
  };
@@ -0,0 +1,34 @@
1
+ import { DataProviderKey } from "./dataProviderKey";
2
+
3
+ type LegacyCheckedTicket = { id: string };
4
+ type ControlRateType = "A" | "B";
5
+ type ControlStats = {
6
+ checkedTickets: LegacyCheckedTicket[];
7
+ gauge: number;
8
+ insidePeople: number;
9
+ soldTickets: number;
10
+ controlRateType: ControlRateType;
11
+ totalCheckedTickets: number;
12
+ };
13
+
14
+ describe("DataProviderKey", () => {
15
+ it("construit le chemin cumulatif via les accès", () => {
16
+ const myKey = new DataProviderKey<ControlStats>(
17
+ "idDonneesDeStats",
18
+ ).checkedTickets[0];
19
+ expect(myKey.toString()).toBe("idDonneesDeStats.checkedTickets.0");
20
+ expect(myKey.path).toBe("idDonneesDeStats.checkedTickets.0");
21
+ });
22
+
23
+ it("permet la navigation type-safe sur les propriétés", () => {
24
+ const key = new DataProviderKey<ControlStats>("stats");
25
+ const gaugeKey = key.gauge;
26
+ expect(gaugeKey.path).toBe("stats.gauge");
27
+ expect(gaugeKey.toString()).toBe("stats.gauge");
28
+ });
29
+
30
+ it("fonctionne avec new", () => {
31
+ const key = new DataProviderKey<ControlStats>("root");
32
+ expect(key.path).toBe("root");
33
+ });
34
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Type-safe navigation through composite data structures (publisher paths).
3
+ * Each property or index access extends the path. Retrieve the final path via toString() or path.
4
+ * Supports dynamic paths: use placeholders like "users.${userIndex}" in the constructor.
5
+ *
6
+ * @example
7
+ * const myKey = new DataProviderKey<Data>("data").items[0];
8
+ * myKey.toString(); // "data.items.0"
9
+ * myKey.path; // same
10
+ *
11
+ * @example
12
+ * // U = dépendances dynamiques sur l’hôte (voir DataProviderKeyHost) — propagé sur .foo.bar
13
+ * new DataProviderKey<User, { userIndex: number }>("demoUsers.${userIndex}");
14
+ */
15
+
16
+ type IsAny<T> = 0 extends 1 & T ? true : false;
17
+
18
+ /**
19
+ * Prototype de classe décorée : propriétés minimales attendues sur l’hôte quand la clé est
20
+ * `DataProviderKey<…, U>` (U renseigné à la construction). Avec `U` par défaut (`any`), pas de contrainte.
21
+ */
22
+ export type DataProviderKeyHost<U> = IsAny<U> extends true
23
+ ? object
24
+ : keyof U extends never
25
+ ? object
26
+ : object & U;
27
+
28
+ /**
29
+ * U : forme minimale du composant pour résoudre les placeholders `${…}` du path ; inchangée lors de la navigation.
30
+ */
31
+ type DataProviderKeyProxy<T, U = any> = T extends object
32
+ ? {
33
+ [K in keyof T as T[K] extends (...args: unknown[]) => unknown
34
+ ? never
35
+ : K]: DataProviderKey<T[K], U>;
36
+ }
37
+ : object;
38
+
39
+ export type DataProviderKey<T, U = any> = DataProviderKeyImpl<T, U> &
40
+ DataProviderKeyProxy<T, U>;
41
+
42
+ class DataProviderKeyImpl<T, U = any> {
43
+ declare readonly _phantom?: T;
44
+ declare readonly _phantomDeps?: U;
45
+
46
+ constructor(public readonly path: string) {}
47
+
48
+ toString(): string {
49
+ return this.path;
50
+ }
51
+ }
52
+
53
+ function createDataProviderKeyProxy<T, U = any>(
54
+ key: DataProviderKeyImpl<T, U>,
55
+ ): DataProviderKey<T, U> {
56
+ return new Proxy(key, {
57
+ get(target, prop: string | symbol) {
58
+ if (prop === "path") return target.path;
59
+ if (prop === "toString") return target.toString.bind(target);
60
+ if (prop === Symbol.toStringTag) return "DataProviderKey";
61
+ if (typeof prop === "symbol")
62
+ return (target as unknown as Record<symbol, unknown>)[prop];
63
+ const newPath = target.path
64
+ ? `${target.path}.${String(prop)}`
65
+ : String(prop);
66
+ return createDataProviderKeyProxy(
67
+ new DataProviderKeyImpl<unknown, U>(newPath),
68
+ );
69
+ },
70
+ }) as DataProviderKey<T, U>;
71
+ }
72
+
73
+ export interface DataProviderKeyConstructor {
74
+ new <T, U = any>(path: string): DataProviderKey<T, U>;
75
+ }
76
+
77
+ /* eslint-disable @typescript-eslint/no-explicit-any */
78
+ export const DataProviderKey: DataProviderKeyConstructor = function (
79
+ this: any,
80
+ path: string,
81
+ ): DataProviderKey<unknown, any> {
82
+ if (!(this instanceof DataProviderKey)) {
83
+ return new (DataProviderKey as DataProviderKeyConstructor)(path);
84
+ }
85
+ return createDataProviderKeyProxy(new DataProviderKeyImpl(path));
86
+ } as any;
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { Endpoint } from "./endpoint";
3
+
4
+ describe("Endpoint", () => {
5
+ it("expose le path normalisé (trim)", () => {
6
+ const e = new Endpoint<{ x: number }>(" communes?limit=1 ");
7
+ expect(e.path).toBe("communes?limit=1");
8
+ expect(e.toString()).toBe("communes?limit=1");
9
+ });
10
+
11
+ it("refuse le path vide", () => {
12
+ expect(() => new Endpoint("")).toThrow(RangeError);
13
+ expect(() => new Endpoint(" ")).toThrow(RangeError);
14
+ });
15
+
16
+ it("normalizePath et isNonEmpty", () => {
17
+ expect(Endpoint.normalizePath("a")).toBe("a");
18
+ expect(Endpoint.isNonEmpty(" x ")).toBe(true);
19
+ expect(Endpoint.isNonEmpty("")).toBe(false);
20
+ });
21
+
22
+ it("normalizePath enlève le slash initial et les doubles slash (relatif)", () => {
23
+ expect(Endpoint.normalizePath("/users/1")).toBe("users/1");
24
+ expect(Endpoint.normalizePath("//users//1")).toBe("users/1");
25
+ expect(Endpoint.normalizePath("v1//users//x?limit=1")).toBe(
26
+ "v1/users/x?limit=1",
27
+ );
28
+ });
29
+
30
+ it("normalizePath pour URL absolue : pathname sans doubles slash", () => {
31
+ expect(
32
+ Endpoint.normalizePath("https://example.com/api//v1//x"),
33
+ ).toBe("https://example.com/api/v1/x");
34
+ });
35
+
36
+ it("looksLikeDataProviderPath", () => {
37
+ expect(Endpoint.looksLikeDataProviderPath("dataProvider(foo)")).toBe(true);
38
+ expect(Endpoint.looksLikeDataProviderPath(" DataProvider(x)")).toBe(true);
39
+ expect(Endpoint.looksLikeDataProviderPath("users/1")).toBe(false);
40
+ });
41
+ });