@astronox/core 0.2.3 → 0.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.
- package/README.md +16 -1
- package/package.json +18 -4
- package/src/admin/authController.ts +166 -0
- package/src/admin/cache.ts +44 -17
- package/src/admin/index.ts +3 -0
- package/src/admin/preparedDestination.ts +114 -0
- package/src/admin/resourceClient.ts +107 -0
- package/src/ads/adsense/index.ts +1 -0
- package/src/ads/adsense.ts +112 -0
- package/src/ads/contracts.ts +11 -0
- package/src/ads/index.ts +2 -0
- package/src/ads/slot.ts +16 -0
- package/src/auth/contracts.ts +1 -4
- package/src/auth/firebase/client.ts +19 -6
- package/src/auth/firebase/index.ts +1 -0
- package/src/client/formSubmission.ts +6 -1
- package/src/client/preferences.ts +8 -4
- package/src/components/actions/Button/Button.astro +30 -42
- package/src/components/actions/Button/Button.css +1 -1
- package/src/components/actions/CardAction/CardAction.css +8 -13
- package/src/components/actions/IdentityProviderAction/IdentityProviderAction.astro +6 -8
- package/src/components/actions/MediaTrigger/MediaTrigger.astro +1 -1
- package/src/components/actions/ScrollToTop/ScrollToTop.astro +2 -1
- package/src/components/actions/ScrollToTop/ScrollToTop.css +2 -6
- package/src/components/actions/ThemeToggle/ThemeToggle.astro +59 -0
- package/src/components/actions/ThemeToggle/ThemeToggle.css +83 -0
- package/src/components/actions/ThemeToggle/themeToggleController.ts +110 -0
- package/src/components/content/CardFooter/CardFooter.css +2 -4
- package/src/components/content/CardHeader/CardHeader.astro +1 -4
- package/src/components/content/CardLink/CardLink.astro +3 -15
- package/src/components/content/CardLink/CardLink.css +4 -15
- package/src/components/content/CodeBlock/CodeBlock.astro +7 -6
- package/src/components/content/CodeBlock/CodeBlock.css +2 -5
- package/src/components/content/CodeBlock/codeBlockController.ts +1 -2
- package/src/components/content/MarkdownContent/MarkdownContent.astro +1 -3
- package/src/components/content/MarkdownContent/MarkdownContent.css +7 -2
- package/src/components/content/MarkdownContent/MarkdownInline.astro +11 -3
- package/src/components/content/MarkdownContent/MarkdownNode.astro +28 -33
- package/src/components/content/MarkdownContent/MarkdownSymbol.astro +30 -19
- package/src/components/content/MarkdownContent/contracts.ts +15 -15
- package/src/components/content/MarkdownContent/markdownContentRenderer.ts +27 -83
- package/src/components/feedback/PreferenceBootstrap/PreferenceBootstrap.astro +19 -22
- package/src/components/feedback/Spinner/Spinner.astro +1 -2
- package/src/components/feedback/ThemeBootstrap/ThemeBootstrap.astro +31 -0
- package/src/components/feedback/Tooltip/tooltipController.ts +1 -4
- package/src/components/forms/ChoiceGroup/ChoiceGroup.css +2 -5
- package/src/components/forms/DateTimeInput/DateTimeInput.astro +2 -1
- package/src/components/forms/PreparedFormBootstrap/PreparedFormBootstrap.astro +10 -8
- package/src/components/forms/PreparedFormBootstrap/parsePreparedFormState.ts +48 -0
- package/src/components/forms/Textarea/Textarea.astro +2 -2
- package/src/components/icons/BurnIcon.astro +2 -1
- package/src/components/icons/CircleSymbolIcon.css +3 -3
- package/src/components/icons/FieldIcon.astro +10 -4
- package/src/components/icons/GrassIcon.astro +3 -2
- package/src/components/icons/SlowIcon.astro +1 -2
- package/src/components/icons/StunIcon.astro +2 -1
- package/src/components/icons/WaterIcon.astro +3 -2
- package/src/components/index.ts +4 -0
- package/src/components/media/CanvasFrame/CanvasFrame.astro +1 -2
- package/src/components/media/CanvasFrame/canvasFrameController.ts +1 -4
- package/src/components/media/EmbedFrame/EmbedFrame.astro +5 -12
- package/src/components/media/EmbedFrame/embedFrameController.ts +4 -4
- package/src/components/monetization/AdSlot/AdSlot.astro +33 -0
- package/src/components/monetization/AdSlot/AdSlot.css +53 -0
- package/src/components/navigation/DesktopNavigation.css +5 -3
- package/src/components/navigation/Footer/Footer.css +3 -9
- package/src/components/navigation/Header.astro +20 -2
- package/src/components/navigation/MobileNavigation.astro +107 -43
- package/src/components/navigation/MobileNavigation.css +102 -26
- package/src/components/navigation/NavigationBrand.css +1 -1
- package/src/components/navigation/NavigationControl.css +16 -2
- package/src/components/navigation/NavigationItemContent.astro +6 -1
- package/src/components/navigation/mobileNavigationController.ts +0 -16
- package/src/components/navigation/navigationTypes.ts +6 -1
- package/src/components/seo/SeoHead.astro +1 -6
- package/src/composition/SurfaceGroup/SurfaceGroup.css +4 -14
- package/src/config/environment.ts +52 -0
- package/src/config/index.ts +3 -0
- package/src/config/integrations.ts +9 -0
- package/src/config/siteConfig.ts +5 -0
- package/src/config/storage.ts +16 -0
- package/src/content/admin.ts +354 -0
- package/src/content/cache.ts +177 -0
- package/src/content/catalog.ts +72 -0
- package/src/content/contracts.ts +77 -0
- package/src/content/drafts.ts +49 -0
- package/src/content/index.ts +7 -0
- package/src/content/markdown.ts +146 -0
- package/src/content/snapshot.ts +139 -0
- package/src/data/firestore/client/contracts.ts +10 -2
- package/src/data/index.ts +1 -0
- package/src/data/memoizedLoader.ts +13 -0
- package/src/display/contracts.ts +2 -5
- package/src/i18n/config.ts +2 -8
- package/src/i18n/coreMessages.ts +2 -9
- package/src/i18n/preference.ts +2 -6
- package/src/layouts/BaseLayout.astro +5 -0
- package/src/patterns/Combobox/Combobox.astro +9 -12
- package/src/patterns/Combobox/comboboxController.ts +14 -8
- package/src/patterns/Fullscreen/FullscreenControl.astro +5 -1
- package/src/patterns/Fullscreen/fullscreenController.ts +1 -3
- package/src/patterns/MapViewport/MapViewport.css +3 -3
- package/src/patterns/MapViewport/geometry.ts +1 -4
- package/src/patterns/Modal/Modal.css +1 -4
- package/src/patterns/Modal/ModalOpen.astro +5 -1
- package/src/patterns/Table/Table.astro +10 -10
- package/src/patterns/Table/TableBody.astro +1 -4
- package/src/patterns/Table/TableBody.css +2 -4
- package/src/patterns/Table/TablePagination.astro +23 -30
- package/src/patterns/Table/TableSkeleton.astro +1 -5
- package/src/patterns/Table/tableClientController.ts +2 -2
- package/src/patterns/Table/tableFilterController.ts +1 -3
- package/src/patterns/Table/tableHeaderOverflowController.ts +1 -4
- package/src/patterns/Table/tableProgressiveController.ts +62 -60
- package/src/patterns/Table/tableScrollController.ts +3 -2
- package/src/primitives/text/TextBlock/TextBlock.astro +1 -3
- package/src/publication/contracts.ts +11 -0
- package/src/publication/http.ts +31 -0
- package/src/publication/index.ts +2 -0
- package/src/routing/localized.ts +9 -12
- package/src/seo/localizedMeta.ts +2 -6
- package/src/styles/interaction.css +7 -7
- package/src/styles/style.css +12 -20
- package/src/styles/tokens.css +55 -15
- package/src/theme/contracts.ts +3 -6
- package/src/theme/index.ts +1 -0
- package/src/theme/storage.ts +1 -0
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.
|
|
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.
|
|
3
|
+
"version": "0.3.0",
|
|
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
|
-
"
|
|
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
|
+
}
|
package/src/admin/cache.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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 =
|
|
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)
|
|
237
|
+
if (stored.actorId === actorId) target.removeItem(key);
|
|
211
238
|
}
|
|
212
239
|
} catch {
|
|
213
240
|
// Cleanup is best effort.
|
|
214
241
|
}
|
|
215
242
|
},
|
|
216
|
-
}
|
|
243
|
+
};
|
|
217
244
|
}
|
package/src/admin/index.ts
CHANGED
|
@@ -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';
|