@astronox/core 0.2.4 → 0.3.1

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 (114) hide show
  1. package/README.md +16 -1
  2. package/package.json +18 -4
  3. package/src/admin/authController.ts +166 -0
  4. package/src/admin/cache.ts +44 -17
  5. package/src/admin/index.ts +3 -0
  6. package/src/admin/preparedDestination.ts +114 -0
  7. package/src/admin/resourceClient.ts +107 -0
  8. package/src/ads/adsense/index.ts +1 -0
  9. package/src/ads/adsense.ts +112 -0
  10. package/src/ads/contracts.ts +11 -0
  11. package/src/ads/index.ts +2 -0
  12. package/src/ads/slot.ts +16 -0
  13. package/src/auth/contracts.ts +1 -4
  14. package/src/auth/firebase/client.ts +19 -6
  15. package/src/auth/firebase/index.ts +1 -0
  16. package/src/client/formSubmission.ts +6 -1
  17. package/src/client/preferences.ts +8 -4
  18. package/src/components/actions/Button/Button.astro +30 -42
  19. package/src/components/actions/CardAction/CardAction.astro +7 -0
  20. package/src/components/actions/CardAction/CardAction.css +31 -10
  21. package/src/components/actions/IdentityProviderAction/IdentityProviderAction.astro +6 -8
  22. package/src/components/actions/MediaTrigger/MediaTrigger.astro +1 -1
  23. package/src/components/actions/ScrollToTop/ScrollToTop.astro +2 -1
  24. package/src/components/actions/ScrollToTop/ScrollToTop.css +2 -6
  25. package/src/components/actions/ThemeToggle/ThemeToggle.astro +3 -3
  26. package/src/components/actions/ThemeToggle/themeToggleController.ts +7 -4
  27. package/src/components/content/CardFooter/CardFooter.css +2 -4
  28. package/src/components/content/CardHeader/CardHeader.astro +1 -4
  29. package/src/components/content/CardLink/CardLink.astro +3 -15
  30. package/src/components/content/CardLink/CardLink.css +4 -15
  31. package/src/components/content/CodeBlock/CodeBlock.astro +7 -6
  32. package/src/components/content/CodeBlock/CodeBlock.css +2 -5
  33. package/src/components/content/CodeBlock/codeBlockController.ts +1 -2
  34. package/src/components/content/MarkdownContent/MarkdownContent.astro +1 -3
  35. package/src/components/content/MarkdownContent/MarkdownContent.css +7 -2
  36. package/src/components/content/MarkdownContent/MarkdownInline.astro +11 -3
  37. package/src/components/content/MarkdownContent/MarkdownNode.astro +28 -33
  38. package/src/components/content/MarkdownContent/MarkdownSymbol.astro +30 -19
  39. package/src/components/content/MarkdownContent/contracts.ts +15 -15
  40. package/src/components/content/MarkdownContent/markdownContentRenderer.ts +27 -83
  41. package/src/components/feedback/PreferenceBootstrap/PreferenceBootstrap.astro +19 -22
  42. package/src/components/feedback/Spinner/Spinner.astro +1 -2
  43. package/src/components/feedback/Tooltip/tooltipController.ts +1 -4
  44. package/src/components/forms/ChoiceGroup/ChoiceGroup.css +1 -4
  45. package/src/components/forms/DateTimeInput/DateTimeInput.astro +2 -1
  46. package/src/components/forms/PreparedFormBootstrap/PreparedFormBootstrap.astro +10 -8
  47. package/src/components/forms/PreparedFormBootstrap/parsePreparedFormState.ts +48 -0
  48. package/src/components/forms/Textarea/Textarea.astro +2 -2
  49. package/src/components/icons/BurnIcon.astro +2 -1
  50. package/src/components/icons/FieldIcon.astro +10 -4
  51. package/src/components/icons/GrassIcon.astro +3 -2
  52. package/src/components/icons/SlowIcon.astro +1 -2
  53. package/src/components/icons/StunIcon.astro +2 -1
  54. package/src/components/icons/WaterIcon.astro +3 -2
  55. package/src/components/index.ts +2 -0
  56. package/src/components/media/CanvasFrame/CanvasFrame.astro +1 -2
  57. package/src/components/media/CanvasFrame/canvasFrameController.ts +1 -4
  58. package/src/components/media/EmbedFrame/EmbedFrame.astro +5 -12
  59. package/src/components/media/EmbedFrame/embedFrameController.ts +4 -4
  60. package/src/components/monetization/AdSlot/AdSlot.astro +33 -0
  61. package/src/components/monetization/AdSlot/AdSlot.css +53 -0
  62. package/src/components/navigation/DesktopNavigation.css +1 -2
  63. package/src/components/navigation/Footer/Footer.css +3 -9
  64. package/src/components/navigation/MobileNavigation.astro +10 -2
  65. package/src/components/navigation/MobileNavigation.css +8 -22
  66. package/src/components/navigation/navigationTypes.ts +1 -4
  67. package/src/components/seo/SeoHead.astro +21 -6
  68. package/src/composition/SurfaceGroup/SurfaceGroup.css +4 -14
  69. package/src/config/environment.ts +52 -0
  70. package/src/config/index.ts +3 -0
  71. package/src/config/integrations.ts +9 -0
  72. package/src/config/siteConfig.ts +7 -0
  73. package/src/config/storage.ts +16 -0
  74. package/src/content/admin.ts +354 -0
  75. package/src/content/cache.ts +177 -0
  76. package/src/content/catalog.ts +72 -0
  77. package/src/content/contracts.ts +77 -0
  78. package/src/content/drafts.ts +49 -0
  79. package/src/content/index.ts +7 -0
  80. package/src/content/markdown.ts +146 -0
  81. package/src/content/snapshot.ts +139 -0
  82. package/src/data/firestore/client/contracts.ts +10 -2
  83. package/src/data/index.ts +1 -0
  84. package/src/data/memoizedLoader.ts +13 -0
  85. package/src/display/contracts.ts +1 -5
  86. package/src/i18n/config.ts +2 -8
  87. package/src/i18n/coreMessages.ts +2 -9
  88. package/src/i18n/preference.ts +2 -6
  89. package/src/patterns/Combobox/Combobox.astro +9 -12
  90. package/src/patterns/Combobox/comboboxController.ts +14 -8
  91. package/src/patterns/Fullscreen/FullscreenControl.astro +5 -1
  92. package/src/patterns/Fullscreen/fullscreenController.ts +1 -3
  93. package/src/patterns/MapViewport/geometry.ts +1 -4
  94. package/src/patterns/Modal/Modal.css +1 -4
  95. package/src/patterns/Modal/ModalOpen.astro +5 -1
  96. package/src/patterns/Table/Table.astro +10 -10
  97. package/src/patterns/Table/TableBody.astro +1 -4
  98. package/src/patterns/Table/TableBody.css +2 -4
  99. package/src/patterns/Table/TablePagination.astro +23 -30
  100. package/src/patterns/Table/TableSkeleton.astro +1 -5
  101. package/src/patterns/Table/tableClientController.ts +2 -2
  102. package/src/patterns/Table/tableFilterController.ts +1 -3
  103. package/src/patterns/Table/tableHeaderOverflowController.ts +1 -4
  104. package/src/patterns/Table/tableProgressiveController.ts +62 -60
  105. package/src/patterns/Table/tableScrollController.ts +3 -2
  106. package/src/primitives/text/TextBlock/TextBlock.astro +1 -3
  107. package/src/publication/contracts.ts +11 -0
  108. package/src/publication/http.ts +31 -0
  109. package/src/publication/index.ts +2 -0
  110. package/src/routing/localized.ts +9 -12
  111. package/src/seo/localizedMeta.ts +2 -6
  112. package/src/styles/interaction.css +14 -7
  113. package/src/styles/style.css +11 -20
  114. package/src/theme/contracts.ts +1 -6
package/README.md CHANGED
@@ -13,7 +13,9 @@ in the consuming site.
13
13
  npm install @astronox/core
14
14
  ```
15
15
 
16
- Astro is a peer dependency. Consumers must use a compatible Astro 7 release.
16
+ Astro is a peer dependency. Firebase is an optional peer dependency used only
17
+ by the dedicated Firebase entry points. Consumers must install the providers
18
+ they activate.
17
19
 
18
20
  ## Usage
19
21
 
@@ -52,3 +54,16 @@ and responsive media presentation are available through the `i18n`, `routing`,
52
54
  definitions, translations, route names, product metadata, and assets. Generic
53
55
  component labels can extend `defaultCoreMessages` through
54
56
  `resolveCoreMessages()` while retaining the English fallback.
57
+
58
+ ## Infrastructure
59
+
60
+ - [`content`](docs/content-markdown.md): reusable ordered content and Markdown
61
+ contracts.
62
+ - [`Firebase admin`](docs/integrations/firebase-admin.md): authentication,
63
+ claims, Firestore, and app-owned mappings.
64
+ - [`R2 publication`](docs/integrations/r2-publication.md): full provider
65
+ snapshots and build-time public projection.
66
+ - [`AdSense`](docs/integrations/adsense.md): provider setup, consent, slots, and
67
+ fallback canvas.
68
+ - [`release workflow`](docs/release-workflow.md): local links, verification, and
69
+ consumer pinning order.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astronox/core",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
4
4
  "description": "Reusable Astro components, patterns, styles, and infrastructure for AstroNox sites.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -29,6 +29,8 @@
29
29
  ],
30
30
  "exports": {
31
31
  ".": "./src/index.ts",
32
+ "./ads": "./src/ads/index.ts",
33
+ "./ads/adsense": "./src/ads/adsense/index.ts",
32
34
  "./client": "./src/client/index.ts",
33
35
  "./admin": "./src/admin/index.ts",
34
36
  "./auth": "./src/auth/index.ts",
@@ -37,12 +39,14 @@
37
39
  "./components": "./src/components/index.ts",
38
40
  "./composition": "./src/composition/index.ts",
39
41
  "./config": "./src/config/index.ts",
42
+ "./content": "./src/content/index.ts",
40
43
  "./data": "./src/data/index.ts",
41
44
  "./data/firestore/client": "./src/data/firestore/client/index.ts",
42
45
  "./display": "./src/display/index.ts",
43
46
  "./i18n": "./src/i18n/index.ts",
44
47
  "./layouts": "./src/layouts/index.ts",
45
48
  "./patterns": "./src/patterns/index.ts",
49
+ "./publication": "./src/publication/index.ts",
46
50
  "./primitives": "./src/primitives/index.ts",
47
51
  "./routing": "./src/routing/index.ts",
48
52
  "./seo": "./src/seo/index.ts",
@@ -54,7 +58,9 @@
54
58
  "check": "astro check",
55
59
  "format": "prettier --write .",
56
60
  "format:check": "prettier --check .",
57
- "lint": "eslint ."
61
+ "lint": "eslint .",
62
+ "test": "node --import tsx --test test/*.test.ts",
63
+ "verify": "npm run format:check && npm run lint && npm run check && npm test && npm pack --dry-run"
58
64
  },
59
65
  "publishConfig": {
60
66
  "access": "public"
@@ -64,10 +70,16 @@
64
70
  "url": "git+https://github.com/gaudesp/astronox-core.git"
65
71
  },
66
72
  "peerDependencies": {
67
- "astro": "^7.0.0"
73
+ "astro": "^7.0.0",
74
+ "firebase": "^12.0.0"
75
+ },
76
+ "peerDependenciesMeta": {
77
+ "firebase": {
78
+ "optional": true
79
+ }
68
80
  },
69
81
  "dependencies": {
70
- "firebase": "^12.0.0",
82
+ "mdast-util-from-markdown": "2.0.2",
71
83
  "zod": "4.4.3"
72
84
  },
73
85
  "devDependencies": {
@@ -77,8 +89,10 @@
77
89
  "astro-eslint-parser": "1.4.0",
78
90
  "eslint": "9.39.5",
79
91
  "eslint-plugin-astro": "1.7.0",
92
+ "firebase": "^12.0.0",
80
93
  "prettier": "3.9.5",
81
94
  "prettier-plugin-astro": "0.14.1",
95
+ "tsx": "4.21.0",
82
96
  "typescript": "6.0.3",
83
97
  "typescript-eslint": "8.64.0"
84
98
  }
@@ -0,0 +1,166 @@
1
+ import { AuthorizationError, type AuthActor } from '../auth';
2
+ import type { AdminStorage } from './cache';
3
+
4
+ export type AdminAuthState =
5
+ | { status: 'pending' }
6
+ | { status: 'signed-out' }
7
+ | { status: 'denied' }
8
+ | { status: 'authenticated'; actor: AuthActor };
9
+
10
+ export type AdminAuthSnapshot =
11
+ { status: 'authenticated'; actor: AuthActor } | { status: 'signed-out' };
12
+
13
+ export type AdminAuthSnapshotStore = {
14
+ read(): AdminAuthSnapshot | null;
15
+ write(snapshot: AdminAuthSnapshot): void;
16
+ };
17
+
18
+ export type ClientAuthProvider = {
19
+ observe(
20
+ listener: (actor: AuthActor | null) => void,
21
+ onError?: (reason: unknown) => void,
22
+ ): () => void;
23
+ signIn(): Promise<AuthActor>;
24
+ signOut(): Promise<void>;
25
+ };
26
+
27
+ export function createAdminAuthController({
28
+ auth,
29
+ clearActorState,
30
+ isAuthorized,
31
+ snapshots,
32
+ }: {
33
+ auth: ClientAuthProvider;
34
+ clearActorState(actorId: string): void;
35
+ isAuthorized(actor: AuthActor): boolean;
36
+ snapshots: AdminAuthSnapshotStore;
37
+ }) {
38
+ type Listener = (state: AdminAuthState) => void;
39
+
40
+ const listeners = new Set<Listener>();
41
+ let state: AdminAuthState = snapshots.read() ?? { status: 'pending' };
42
+ let observing = false;
43
+
44
+ const setState = (next: AdminAuthState): void => {
45
+ state = next;
46
+ snapshots.write(
47
+ next.status === 'authenticated' ? next : { status: 'signed-out' },
48
+ );
49
+ listeners.forEach((listener) => listener(state));
50
+ };
51
+
52
+ const observeProvider = (): void => {
53
+ if (observing) return;
54
+ observing = true;
55
+ auth.observe(
56
+ (actor) => {
57
+ if (!actor) {
58
+ if (state.status !== 'denied') setState({ status: 'signed-out' });
59
+ return;
60
+ }
61
+ if (!isAuthorized(actor)) {
62
+ setState({ status: 'denied' });
63
+ void auth.signOut().catch((reason) => {
64
+ console.error(
65
+ '[admin] Failed to sign out a denied account',
66
+ reason,
67
+ );
68
+ });
69
+ return;
70
+ }
71
+ setState({ status: 'authenticated', actor });
72
+ },
73
+ (reason) => {
74
+ console.error('[admin] Failed to observe authentication', reason);
75
+ if (state.status === 'pending') setState({ status: 'signed-out' });
76
+ },
77
+ );
78
+ };
79
+
80
+ return {
81
+ getActor(): AuthActor | null {
82
+ return state.status === 'authenticated' ? state.actor : null;
83
+ },
84
+ observe(listener: Listener): () => void {
85
+ listeners.add(listener);
86
+ listener(state);
87
+ observeProvider();
88
+ return () => listeners.delete(listener);
89
+ },
90
+ requireActor(): AuthActor {
91
+ if (state.status === 'authenticated') return state.actor;
92
+ throw new AuthorizationError();
93
+ },
94
+ async signIn(): Promise<AuthActor> {
95
+ const actor = await auth.signIn();
96
+ if (!isAuthorized(actor)) {
97
+ setState({ status: 'denied' });
98
+ await auth.signOut().catch(() => undefined);
99
+ throw new AuthorizationError();
100
+ }
101
+ setState({ status: 'authenticated', actor });
102
+ return actor;
103
+ },
104
+ async signOut(): Promise<void> {
105
+ if (state.status === 'authenticated') clearActorState(state.actor.id);
106
+ await auth.signOut();
107
+ setState({ status: 'signed-out' });
108
+ },
109
+ } as const;
110
+ }
111
+
112
+ export function createBrowserAdminAuthSnapshotStore({
113
+ key,
114
+ storage,
115
+ }: {
116
+ key: string;
117
+ storage?: AdminStorage;
118
+ }): AdminAuthSnapshotStore {
119
+ const getStorage = (): AdminStorage => storage ?? localStorage;
120
+
121
+ return {
122
+ read() {
123
+ try {
124
+ const raw = getStorage().getItem(key);
125
+ if (!raw) return null;
126
+ const value: unknown = JSON.parse(raw);
127
+ if (!value || typeof value !== 'object') return null;
128
+ const snapshot = value as Partial<AdminAuthSnapshot>;
129
+ if (snapshot.status === 'signed-out') return { status: 'signed-out' };
130
+ if (snapshot.status !== 'authenticated') return null;
131
+ const actor = parseAuthActor(snapshot.actor);
132
+ return actor ? { status: 'authenticated', actor } : null;
133
+ } catch {
134
+ return null;
135
+ }
136
+ },
137
+ write(snapshot) {
138
+ try {
139
+ getStorage().setItem(key, JSON.stringify(snapshot));
140
+ } catch {
141
+ // Persistence is optional; Firebase remains authoritative.
142
+ }
143
+ },
144
+ };
145
+ }
146
+
147
+ function parseAuthActor(value: unknown): AuthActor | null {
148
+ if (!value || typeof value !== 'object') return null;
149
+ const actor = value as Partial<AuthActor>;
150
+ if (typeof actor.id !== 'string') return null;
151
+ const claims =
152
+ actor.claims &&
153
+ typeof actor.claims === 'object' &&
154
+ !Array.isArray(actor.claims)
155
+ ? actor.claims
156
+ : {};
157
+
158
+ return {
159
+ claims,
160
+ displayName:
161
+ typeof actor.displayName === 'string' ? actor.displayName : undefined,
162
+ email: typeof actor.email === 'string' ? actor.email : undefined,
163
+ id: actor.id,
164
+ imageUrl: typeof actor.imageUrl === 'string' ? actor.imageUrl : undefined,
165
+ };
166
+ }
@@ -11,9 +11,32 @@ export type AdminResourceCacheConfig<TItem, TStored> = {
11
11
  getKey(item: TItem): string;
12
12
  serializer: AdminResourceCacheSerializer<TItem, TStored>;
13
13
  sort(items: readonly TItem[]): TItem[];
14
+ storage?: AdminStorage;
14
15
  ttlMs?: number;
15
16
  };
16
17
 
18
+ export type AdminStorage = Pick<
19
+ Storage,
20
+ 'getItem' | 'key' | 'length' | 'removeItem' | 'setItem'
21
+ >;
22
+
23
+ export type AdminResourceCache<TItem> = {
24
+ append(actorId: string, page: AdminPage<TItem>): void;
25
+ clear(actorId: string): void;
26
+ editorKey: string;
27
+ find(actorId: string, id: string): TItem | null;
28
+ isLocalCursor(cursor?: string): boolean;
29
+ read(
30
+ actorId: string,
31
+ input?: { cursor?: string; limit?: number },
32
+ ): AdminPage<TItem> | null;
33
+ readEditor(actorId: string, id: string): TItem | null;
34
+ remove(actorId: string, id: string): void;
35
+ upsert(actorId: string, item: TItem): void;
36
+ write(actorId: string, page: AdminPage<TItem>): void;
37
+ writeEditor(actorId: string, item: TItem): boolean;
38
+ };
39
+
17
40
  type StoredPage<TStored> = {
18
41
  expiresAt: number;
19
42
  items: TStored[];
@@ -34,8 +57,10 @@ export function createAdminResourceCache<TItem, TStored>({
34
57
  getKey,
35
58
  serializer,
36
59
  sort,
60
+ storage,
37
61
  ttlMs = 10 * 60 * 1000,
38
- }: AdminResourceCacheConfig<TItem, TStored>) {
62
+ }: AdminResourceCacheConfig<TItem, TStored>): AdminResourceCache<TItem> {
63
+ const getStorage = (): AdminStorage => storage ?? sessionStorage;
39
64
  const collectionCacheKey = (actorId: string) =>
40
65
  `${collectionKey}:${encodeURIComponent(actorId)}`;
41
66
  const editorCacheKey = (id: string) =>
@@ -44,7 +69,8 @@ export function createAdminResourceCache<TItem, TStored>({
44
69
  const readSnapshot = (actorId: string): AdminPage<TItem> | null => {
45
70
  try {
46
71
  const key = collectionCacheKey(actorId);
47
- const raw = sessionStorage.getItem(key);
72
+ const target = getStorage();
73
+ const raw = target.getItem(key);
48
74
  if (!raw) return null;
49
75
  const stored = JSON.parse(raw) as Partial<StoredPage<TStored>>;
50
76
  if (
@@ -52,7 +78,7 @@ export function createAdminResourceCache<TItem, TStored>({
52
78
  stored.expiresAt <= Date.now() ||
53
79
  !Array.isArray(stored.items)
54
80
  ) {
55
- sessionStorage.removeItem(key);
81
+ target.removeItem(key);
56
82
  return null;
57
83
  }
58
84
 
@@ -60,7 +86,7 @@ export function createAdminResourceCache<TItem, TStored>({
60
86
  for (const value of stored.items) {
61
87
  const item = serializer.parse(value);
62
88
  if (!item) {
63
- sessionStorage.removeItem(key);
89
+ target.removeItem(key);
64
90
  return null;
65
91
  }
66
92
  items.push(item);
@@ -83,7 +109,7 @@ export function createAdminResourceCache<TItem, TStored>({
83
109
  items: sort(page.items).map(serializer.serialize),
84
110
  nextCursor: page.nextCursor,
85
111
  };
86
- sessionStorage.setItem(
112
+ getStorage().setItem(
87
113
  collectionCacheKey(actorId),
88
114
  JSON.stringify(payload),
89
115
  );
@@ -122,14 +148,14 @@ export function createAdminResourceCache<TItem, TStored>({
122
148
  },
123
149
  find(actorId: string, id: string): TItem | null {
124
150
  return (
125
- readSnapshot(actorId)?.items.find((item) => getKey(item) === id) ??
126
- null
151
+ readSnapshot(actorId)?.items.find((item) => getKey(item) === id) ?? null
127
152
  );
128
153
  },
129
154
  readEditor(actorId: string, id: string): TItem | null {
130
155
  try {
131
156
  const key = editorCacheKey(id);
132
- const raw = sessionStorage.getItem(key);
157
+ const target = getStorage();
158
+ const raw = target.getItem(key);
133
159
  if (!raw) return null;
134
160
  const stored = JSON.parse(raw) as Partial<StoredEditor<TStored>>;
135
161
  if (
@@ -137,7 +163,7 @@ export function createAdminResourceCache<TItem, TStored>({
137
163
  typeof stored.expiresAt !== 'number' ||
138
164
  stored.expiresAt <= Date.now()
139
165
  ) {
140
- sessionStorage.removeItem(key);
166
+ target.removeItem(key);
141
167
  return null;
142
168
  }
143
169
  return serializer.parse(stored.item);
@@ -177,7 +203,7 @@ export function createAdminResourceCache<TItem, TStored>({
177
203
  });
178
204
  }
179
205
  try {
180
- sessionStorage.removeItem(editorCacheKey(id));
206
+ getStorage().removeItem(editorCacheKey(id));
181
207
  } catch {
182
208
  // Cleanup is best effort.
183
209
  }
@@ -189,7 +215,7 @@ export function createAdminResourceCache<TItem, TStored>({
189
215
  expiresAt: Date.now() + ttlMs,
190
216
  item: serializer.serialize(item),
191
217
  };
192
- sessionStorage.setItem(
218
+ getStorage().setItem(
193
219
  editorCacheKey(getKey(item)),
194
220
  JSON.stringify(payload),
195
221
  );
@@ -200,18 +226,19 @@ export function createAdminResourceCache<TItem, TStored>({
200
226
  },
201
227
  clear(actorId: string): void {
202
228
  try {
203
- sessionStorage.removeItem(collectionCacheKey(actorId));
204
- for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
205
- const key = sessionStorage.key(index);
229
+ const target = getStorage();
230
+ target.removeItem(collectionCacheKey(actorId));
231
+ for (let index = target.length - 1; index >= 0; index -= 1) {
232
+ const key = target.key(index);
206
233
  if (!key?.startsWith(`${editorKey}:`)) continue;
207
- const raw = sessionStorage.getItem(key);
234
+ const raw = target.getItem(key);
208
235
  if (!raw) continue;
209
236
  const stored = JSON.parse(raw) as Partial<StoredEditor<TStored>>;
210
- if (stored.actorId === actorId) sessionStorage.removeItem(key);
237
+ if (stored.actorId === actorId) target.removeItem(key);
211
238
  }
212
239
  } catch {
213
240
  // Cleanup is best effort.
214
241
  }
215
242
  },
216
- } as const;
243
+ };
217
244
  }
@@ -1,3 +1,6 @@
1
+ export * from './authController';
1
2
  export * from './contracts';
2
3
  export * from './cache';
3
4
  export * from './module';
5
+ export * from './preparedDestination';
6
+ export * from './resourceClient';
@@ -0,0 +1,114 @@
1
+ import type { AdminStorage } from './cache';
2
+
3
+ type PreparedDestinationEnvelope<TStored> = {
4
+ actorId: string;
5
+ destination: string;
6
+ expiresAt: number;
7
+ payload: TStored;
8
+ };
9
+
10
+ export type PreparedDestinationCodec<TPayload, TStored> = {
11
+ parse(value: unknown): TPayload | null;
12
+ serialize(payload: TPayload): TStored;
13
+ };
14
+
15
+ export function normalizePreparedDestinationPath(pathname: string): string {
16
+ return pathname === '/' ? pathname : pathname.replace(/\/+$/, '');
17
+ }
18
+
19
+ export function createPreparedDestinationStore<TPayload, TStored>({
20
+ codec,
21
+ getActorId,
22
+ getBaseUrl,
23
+ keyPrefix,
24
+ storage,
25
+ ttlMs = 10 * 60 * 1000,
26
+ }: {
27
+ codec: PreparedDestinationCodec<TPayload, TStored>;
28
+ getActorId(): string | null;
29
+ getBaseUrl?: () => string;
30
+ keyPrefix: string;
31
+ storage?: AdminStorage;
32
+ ttlMs?: number;
33
+ }) {
34
+ const getStorage = (): AdminStorage => storage ?? sessionStorage;
35
+ const resolveBaseUrl = (): string => getBaseUrl?.() ?? window.location.href;
36
+ const getDestination = (href: string): string => {
37
+ const url = new URL(href, resolveBaseUrl());
38
+ return `${normalizePreparedDestinationPath(url.pathname)}${url.search}`;
39
+ };
40
+ const getKey = (href: string): string =>
41
+ `${keyPrefix}:${encodeURIComponent(getDestination(href))}`;
42
+
43
+ return {
44
+ clear(actorId?: string): void {
45
+ try {
46
+ const target = getStorage();
47
+ for (let index = target.length - 1; index >= 0; index -= 1) {
48
+ const key = target.key(index);
49
+ if (!key?.startsWith(`${keyPrefix}:`)) continue;
50
+ if (!actorId) {
51
+ target.removeItem(key);
52
+ continue;
53
+ }
54
+ const raw = target.getItem(key);
55
+ const envelope = raw
56
+ ? (JSON.parse(raw) as Partial<PreparedDestinationEnvelope<TStored>>)
57
+ : null;
58
+ if (envelope?.actorId === actorId) target.removeItem(key);
59
+ }
60
+ } catch {
61
+ // Prepared state is an optimization and must never block administration.
62
+ }
63
+ },
64
+ getKey,
65
+ read(href: string): TPayload | null {
66
+ try {
67
+ const actorId = getActorId();
68
+ if (!actorId) return null;
69
+ const key = getKey(href);
70
+ const target = getStorage();
71
+ const raw = target.getItem(key);
72
+ if (!raw) return null;
73
+ const envelope = JSON.parse(raw) as Partial<
74
+ PreparedDestinationEnvelope<TStored>
75
+ >;
76
+ if (
77
+ envelope.actorId !== actorId ||
78
+ envelope.destination !== getDestination(href) ||
79
+ typeof envelope.expiresAt !== 'number' ||
80
+ envelope.expiresAt <= Date.now()
81
+ ) {
82
+ target.removeItem(key);
83
+ return null;
84
+ }
85
+ const payload = codec.parse(envelope.payload);
86
+ if (!payload) target.removeItem(key);
87
+ return payload;
88
+ } catch {
89
+ return null;
90
+ }
91
+ },
92
+ remove(href: string): void {
93
+ try {
94
+ getStorage().removeItem(getKey(href));
95
+ } catch {
96
+ // Prepared state is an optimization and must never block administration.
97
+ }
98
+ },
99
+ write(actorId: string, href: string, payload: TPayload): void {
100
+ try {
101
+ const destination = getDestination(href);
102
+ const envelope: PreparedDestinationEnvelope<TStored> = {
103
+ actorId,
104
+ destination,
105
+ expiresAt: Date.now() + ttlMs,
106
+ payload: codec.serialize(payload),
107
+ };
108
+ getStorage().setItem(getKey(href), JSON.stringify(envelope));
109
+ } catch {
110
+ // Prepared state is an optimization and must never block administration.
111
+ }
112
+ },
113
+ } as const;
114
+ }
@@ -0,0 +1,107 @@
1
+ import type { AuthActor } from '../auth';
2
+ import type { AdminResourceCache } from './cache';
3
+ import type { AdminResource } from './contracts';
4
+
5
+ type IndexInput = { cursor?: string; limit?: number } | undefined;
6
+
7
+ export function createCachedAdminResourceClient<
8
+ TItem,
9
+ TCreateInput,
10
+ TUpdateInput = TCreateInput,
11
+ >({
12
+ afterDelete,
13
+ afterWrite,
14
+ cache,
15
+ getActor,
16
+ resource,
17
+ }: {
18
+ afterDelete?(actorId: string, id: string): void;
19
+ afterWrite?(actorId: string, item: TItem): void;
20
+ cache: AdminResourceCache<TItem>;
21
+ getActor(): AuthActor;
22
+ resource: AdminResource<TItem, TCreateInput, TUpdateInput, IndexInput>;
23
+ }) {
24
+ const indexRequests = new Map<string, ReturnType<typeof resource.getIndex>>();
25
+ const detailRequests = new Map<
26
+ string,
27
+ ReturnType<typeof resource.getDetail>
28
+ >();
29
+
30
+ const requestKey = (actorId: string, input?: IndexInput): string =>
31
+ `${actorId}:${input?.cursor ?? 'root'}:${input?.limit ?? 25}`;
32
+
33
+ const syncWrite =
34
+ afterWrite ?? ((actorId, item) => cache.upsert(actorId, item));
35
+ const syncDelete =
36
+ afterDelete ?? ((actorId, id) => cache.remove(actorId, id));
37
+ const isActiveActor = (actorId: string): boolean => {
38
+ try {
39
+ return getActor().id === actorId;
40
+ } catch {
41
+ return false;
42
+ }
43
+ };
44
+
45
+ return {
46
+ getIndex(input?: IndexInput) {
47
+ const actor = getActor();
48
+ const cached = cache.read(actor.id, input);
49
+ if (cached) return Promise.resolve(cached);
50
+ const requestInput = cache.isLocalCursor(input?.cursor)
51
+ ? { ...input, cursor: undefined }
52
+ : input;
53
+ const key = requestKey(actor.id, requestInput);
54
+ const pending = indexRequests.get(key);
55
+ if (pending) return pending;
56
+
57
+ const request = resource.getIndex(requestInput).then((page) => {
58
+ if (isActiveActor(actor.id)) {
59
+ if (requestInput?.cursor) {
60
+ cache.append(actor.id, page);
61
+ } else {
62
+ cache.write(actor.id, page);
63
+ }
64
+ }
65
+ return page;
66
+ });
67
+ indexRequests.set(key, request);
68
+ void request.then(
69
+ () => indexRequests.delete(key),
70
+ () => indexRequests.delete(key),
71
+ );
72
+ return request;
73
+ },
74
+ getDetail(id: string) {
75
+ const actor = getActor();
76
+ const cached = cache.find(actor.id, id);
77
+ if (cached) return Promise.resolve(cached);
78
+ const key = `${actor.id}:${id}`;
79
+ const pending = detailRequests.get(key);
80
+ if (pending) return pending;
81
+ const request = resource.getDetail({ id });
82
+ detailRequests.set(key, request);
83
+ void request.then(
84
+ () => detailRequests.delete(key),
85
+ () => detailRequests.delete(key),
86
+ );
87
+ return request;
88
+ },
89
+ async create(input: TCreateInput) {
90
+ const actor = getActor();
91
+ const item = await resource.create(input, actor);
92
+ if (isActiveActor(actor.id)) syncWrite(actor.id, item);
93
+ return item;
94
+ },
95
+ async update(id: string, input: TUpdateInput) {
96
+ const actor = getActor();
97
+ const item = await resource.update({ id, value: input }, actor);
98
+ if (isActiveActor(actor.id)) syncWrite(actor.id, item);
99
+ return item;
100
+ },
101
+ async delete(id: string) {
102
+ const actor = getActor();
103
+ await resource.delete({ id }, actor);
104
+ if (isActiveActor(actor.id)) syncDelete(actor.id, id);
105
+ },
106
+ } as const;
107
+ }
@@ -0,0 +1 @@
1
+ export * from '../adsense';