@astronox/core 0.2.4 → 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.
Files changed (112) 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.css +5 -10
  20. package/src/components/actions/IdentityProviderAction/IdentityProviderAction.astro +6 -8
  21. package/src/components/actions/MediaTrigger/MediaTrigger.astro +1 -1
  22. package/src/components/actions/ScrollToTop/ScrollToTop.astro +2 -1
  23. package/src/components/actions/ScrollToTop/ScrollToTop.css +2 -6
  24. package/src/components/actions/ThemeToggle/ThemeToggle.astro +3 -3
  25. package/src/components/actions/ThemeToggle/themeToggleController.ts +2 -3
  26. package/src/components/content/CardFooter/CardFooter.css +2 -4
  27. package/src/components/content/CardHeader/CardHeader.astro +1 -4
  28. package/src/components/content/CardLink/CardLink.astro +3 -15
  29. package/src/components/content/CardLink/CardLink.css +4 -15
  30. package/src/components/content/CodeBlock/CodeBlock.astro +7 -6
  31. package/src/components/content/CodeBlock/CodeBlock.css +2 -5
  32. package/src/components/content/CodeBlock/codeBlockController.ts +1 -2
  33. package/src/components/content/MarkdownContent/MarkdownContent.astro +1 -3
  34. package/src/components/content/MarkdownContent/MarkdownContent.css +7 -2
  35. package/src/components/content/MarkdownContent/MarkdownInline.astro +11 -3
  36. package/src/components/content/MarkdownContent/MarkdownNode.astro +28 -33
  37. package/src/components/content/MarkdownContent/MarkdownSymbol.astro +30 -19
  38. package/src/components/content/MarkdownContent/contracts.ts +15 -15
  39. package/src/components/content/MarkdownContent/markdownContentRenderer.ts +27 -83
  40. package/src/components/feedback/PreferenceBootstrap/PreferenceBootstrap.astro +19 -22
  41. package/src/components/feedback/Spinner/Spinner.astro +1 -2
  42. package/src/components/feedback/Tooltip/tooltipController.ts +1 -4
  43. package/src/components/forms/ChoiceGroup/ChoiceGroup.css +1 -4
  44. package/src/components/forms/DateTimeInput/DateTimeInput.astro +2 -1
  45. package/src/components/forms/PreparedFormBootstrap/PreparedFormBootstrap.astro +10 -8
  46. package/src/components/forms/PreparedFormBootstrap/parsePreparedFormState.ts +48 -0
  47. package/src/components/forms/Textarea/Textarea.astro +2 -2
  48. package/src/components/icons/BurnIcon.astro +2 -1
  49. package/src/components/icons/FieldIcon.astro +10 -4
  50. package/src/components/icons/GrassIcon.astro +3 -2
  51. package/src/components/icons/SlowIcon.astro +1 -2
  52. package/src/components/icons/StunIcon.astro +2 -1
  53. package/src/components/icons/WaterIcon.astro +3 -2
  54. package/src/components/index.ts +2 -0
  55. package/src/components/media/CanvasFrame/CanvasFrame.astro +1 -2
  56. package/src/components/media/CanvasFrame/canvasFrameController.ts +1 -4
  57. package/src/components/media/EmbedFrame/EmbedFrame.astro +5 -12
  58. package/src/components/media/EmbedFrame/embedFrameController.ts +4 -4
  59. package/src/components/monetization/AdSlot/AdSlot.astro +33 -0
  60. package/src/components/monetization/AdSlot/AdSlot.css +53 -0
  61. package/src/components/navigation/DesktopNavigation.css +1 -2
  62. package/src/components/navigation/Footer/Footer.css +3 -9
  63. package/src/components/navigation/MobileNavigation.css +3 -12
  64. package/src/components/navigation/navigationTypes.ts +1 -4
  65. package/src/components/seo/SeoHead.astro +1 -6
  66. package/src/composition/SurfaceGroup/SurfaceGroup.css +4 -14
  67. package/src/config/environment.ts +52 -0
  68. package/src/config/index.ts +3 -0
  69. package/src/config/integrations.ts +9 -0
  70. package/src/config/siteConfig.ts +5 -0
  71. package/src/config/storage.ts +16 -0
  72. package/src/content/admin.ts +354 -0
  73. package/src/content/cache.ts +177 -0
  74. package/src/content/catalog.ts +72 -0
  75. package/src/content/contracts.ts +77 -0
  76. package/src/content/drafts.ts +49 -0
  77. package/src/content/index.ts +7 -0
  78. package/src/content/markdown.ts +146 -0
  79. package/src/content/snapshot.ts +139 -0
  80. package/src/data/firestore/client/contracts.ts +10 -2
  81. package/src/data/index.ts +1 -0
  82. package/src/data/memoizedLoader.ts +13 -0
  83. package/src/display/contracts.ts +1 -5
  84. package/src/i18n/config.ts +2 -8
  85. package/src/i18n/coreMessages.ts +2 -9
  86. package/src/i18n/preference.ts +2 -6
  87. package/src/patterns/Combobox/Combobox.astro +9 -12
  88. package/src/patterns/Combobox/comboboxController.ts +14 -8
  89. package/src/patterns/Fullscreen/FullscreenControl.astro +5 -1
  90. package/src/patterns/Fullscreen/fullscreenController.ts +1 -3
  91. package/src/patterns/MapViewport/geometry.ts +1 -4
  92. package/src/patterns/Modal/Modal.css +1 -4
  93. package/src/patterns/Modal/ModalOpen.astro +5 -1
  94. package/src/patterns/Table/Table.astro +10 -10
  95. package/src/patterns/Table/TableBody.astro +1 -4
  96. package/src/patterns/Table/TableBody.css +2 -4
  97. package/src/patterns/Table/TablePagination.astro +23 -30
  98. package/src/patterns/Table/TableSkeleton.astro +1 -5
  99. package/src/patterns/Table/tableClientController.ts +2 -2
  100. package/src/patterns/Table/tableFilterController.ts +1 -3
  101. package/src/patterns/Table/tableHeaderOverflowController.ts +1 -4
  102. package/src/patterns/Table/tableProgressiveController.ts +62 -60
  103. package/src/patterns/Table/tableScrollController.ts +3 -2
  104. package/src/primitives/text/TextBlock/TextBlock.astro +1 -3
  105. package/src/publication/contracts.ts +11 -0
  106. package/src/publication/http.ts +31 -0
  107. package/src/publication/index.ts +2 -0
  108. package/src/routing/localized.ts +9 -12
  109. package/src/seo/localizedMeta.ts +2 -6
  110. package/src/styles/interaction.css +7 -7
  111. package/src/styles/style.css +11 -20
  112. package/src/theme/contracts.ts +1 -6
@@ -0,0 +1,354 @@
1
+ import type { AdminResource } from '../admin';
2
+ import type { AuthActor } from '../auth';
3
+ import type {
4
+ ContentDocument,
5
+ ContentDocumentAdminRepository,
6
+ ContentDocumentDraft,
7
+ ContentOrder,
8
+ ContentPlacement,
9
+ ContentPlacementAdminRepository,
10
+ ContentPlacementDraft,
11
+ } from './contracts';
12
+
13
+ type IndexInput = { cursor?: string; limit?: number } | undefined;
14
+
15
+ export type ContentAdminSnapshotReader<
16
+ TPlacement extends ContentPlacement,
17
+ TDocument extends ContentDocument,
18
+ > = {
19
+ getDocuments(actor: AuthActor): readonly TDocument[] | null;
20
+ getPlacements(actor: AuthActor): readonly TPlacement[] | null;
21
+ };
22
+
23
+ export type ContentAdminErrors = {
24
+ documentNotFound: string;
25
+ documentSlugExists: string;
26
+ placementInUse: string;
27
+ placementKeyExists: string;
28
+ placementNotFound: string;
29
+ };
30
+
31
+ export function createContentAdminResources<
32
+ TPlacement extends ContentPlacement,
33
+ TDocument extends ContentDocument,
34
+ TPlacementDraft extends ContentPlacementDraft,
35
+ TDocumentDraft extends ContentDocumentDraft,
36
+ >({
37
+ assertRootSlug,
38
+ documents,
39
+ errors,
40
+ parseDocumentDraft,
41
+ parsePlacementDraft,
42
+ placements,
43
+ requireWriteAccess,
44
+ rootPlacementKey,
45
+ snapshots,
46
+ }: {
47
+ assertRootSlug?(slug: string): void;
48
+ documents: ContentDocumentAdminRepository<TDocument, TDocumentDraft>;
49
+ errors: ContentAdminErrors;
50
+ parseDocumentDraft(input: unknown): TDocumentDraft;
51
+ parsePlacementDraft(input: unknown): TPlacementDraft;
52
+ placements: ContentPlacementAdminRepository<TPlacement, TPlacementDraft>;
53
+ requireWriteAccess(actor: AuthActor): void;
54
+ rootPlacementKey?: string;
55
+ snapshots: ContentAdminSnapshotReader<TPlacement, TDocument>;
56
+ }) {
57
+ const placementResource: AdminResource<
58
+ TPlacement,
59
+ TPlacementDraft,
60
+ TPlacementDraft,
61
+ IndexInput
62
+ > = {
63
+ getIndex: (input) => placements.getIndex(input),
64
+ getDetail: (input) => placements.getDetail(input),
65
+ async create(input, actor) {
66
+ requireWriteAccess(actor);
67
+ const draft = parsePlacementDraft(input);
68
+ const allPlacements = await loadAll(
69
+ placements,
70
+ snapshots.getPlacements(actor),
71
+ );
72
+ assertUniquePlacementKey(allPlacements, draft.key, errors);
73
+ return placements.create(draft);
74
+ },
75
+ async update({ id, value }, actor) {
76
+ requireWriteAccess(actor);
77
+ const draft = parsePlacementDraft(value);
78
+ const allPlacements = await loadAll(
79
+ placements,
80
+ snapshots.getPlacements(actor),
81
+ );
82
+ const current = allPlacements.find((placement) => placement.id === id);
83
+ if (!current) throw new Error(errors.placementNotFound);
84
+ if (samePlacement(current, draft)) return current;
85
+ assertUniquePlacementKey(allPlacements, draft.key, errors, id);
86
+ if (
87
+ rootPlacementKey &&
88
+ draft.key === rootPlacementKey &&
89
+ assertRootSlug
90
+ ) {
91
+ const allDocuments = await loadAll(
92
+ documents,
93
+ snapshots.getDocuments(actor),
94
+ );
95
+ allDocuments
96
+ .filter((document) => document.placementId === id)
97
+ .forEach((document) => assertRootSlug(document.slug));
98
+ }
99
+ return placements.update({ id, value: draft });
100
+ },
101
+ async delete({ id }, actor) {
102
+ requireWriteAccess(actor);
103
+ const allDocuments = await loadAll(
104
+ documents,
105
+ snapshots.getDocuments(actor),
106
+ );
107
+ if (allDocuments.some((document) => document.placementId === id)) {
108
+ throw new Error(errors.placementInUse);
109
+ }
110
+ return placements.delete({ id });
111
+ },
112
+ };
113
+
114
+ const documentResource: AdminResource<
115
+ TDocument,
116
+ TDocumentDraft,
117
+ TDocumentDraft,
118
+ IndexInput
119
+ > = {
120
+ getIndex: (input) => documents.getIndex(input),
121
+ getDetail: (input) => documents.getDetail(input),
122
+ async create(input, actor) {
123
+ requireWriteAccess(actor);
124
+ const draft = parseDocumentDraft(input);
125
+ const [allDocuments, placement] = await Promise.all([
126
+ loadAll(documents, snapshots.getDocuments(actor)),
127
+ findPlacement(
128
+ placements,
129
+ draft.placementId,
130
+ snapshots.getPlacements(actor),
131
+ ),
132
+ ]);
133
+ assertDocumentReferences(
134
+ placement,
135
+ allDocuments,
136
+ draft,
137
+ errors,
138
+ rootPlacementKey,
139
+ assertRootSlug,
140
+ );
141
+ const siblings = allDocuments.filter(
142
+ (document) => document.placementId === draft.placementId,
143
+ );
144
+ const position = Math.min(draft.order, siblings.length + 1);
145
+ return documents.createOrdered({
146
+ value: { ...draft, order: position },
147
+ orders: changedOrders(allDocuments, insertOrders(siblings, position)),
148
+ });
149
+ },
150
+ async update({ id, value }, actor) {
151
+ requireWriteAccess(actor);
152
+ const draft = parseDocumentDraft(value);
153
+ const allDocuments = await loadAll(
154
+ documents,
155
+ snapshots.getDocuments(actor),
156
+ );
157
+ const current = allDocuments.find((document) => document.id === id);
158
+ if (!current) throw new Error(errors.documentNotFound);
159
+ const placement = await findPlacement(
160
+ placements,
161
+ draft.placementId,
162
+ snapshots.getPlacements(actor),
163
+ );
164
+ assertDocumentReferences(
165
+ placement,
166
+ allDocuments,
167
+ draft,
168
+ errors,
169
+ rootPlacementKey,
170
+ assertRootSlug,
171
+ id,
172
+ );
173
+ const previousSiblings = allDocuments.filter(
174
+ (document) =>
175
+ document.id !== id && document.placementId === current.placementId,
176
+ );
177
+ const nextSiblings = allDocuments.filter(
178
+ (document) =>
179
+ document.id !== id && document.placementId === draft.placementId,
180
+ );
181
+ const position = Math.min(draft.order, nextSiblings.length + 1);
182
+ const valueWithOrder = { ...draft, order: position };
183
+ const orders =
184
+ current.placementId === draft.placementId
185
+ ? insertOrders(nextSiblings, position)
186
+ : [
187
+ ...normalizeOrders(previousSiblings),
188
+ ...insertOrders(nextSiblings, position),
189
+ ];
190
+ const updates = changedOrders(allDocuments, orders).filter(
191
+ (order) => order.id !== id,
192
+ );
193
+ if (sameDocument(current, valueWithOrder) && updates.length === 0) {
194
+ return current;
195
+ }
196
+ return documents.updateOrdered({
197
+ id,
198
+ value: valueWithOrder,
199
+ orders: updates,
200
+ });
201
+ },
202
+ async delete({ id }, actor) {
203
+ requireWriteAccess(actor);
204
+ const allDocuments = await loadAll(
205
+ documents,
206
+ snapshots.getDocuments(actor),
207
+ );
208
+ const current = allDocuments.find((document) => document.id === id);
209
+ if (!current) return;
210
+ const siblings = allDocuments.filter(
211
+ (document) =>
212
+ document.id !== id && document.placementId === current.placementId,
213
+ );
214
+ await documents.deleteOrdered({
215
+ id,
216
+ orders: changedOrders(allDocuments, normalizeOrders(siblings)),
217
+ });
218
+ },
219
+ };
220
+
221
+ return {
222
+ documents: documentResource,
223
+ placements: placementResource,
224
+ } as const;
225
+ }
226
+
227
+ export async function loadAdminIndex<T>(
228
+ repository: {
229
+ getIndex(input?: IndexInput): Promise<{
230
+ items: readonly T[];
231
+ nextCursor?: string;
232
+ }>;
233
+ },
234
+ cached?: readonly T[] | null,
235
+ ): Promise<T[]> {
236
+ return loadAll(repository, cached);
237
+ }
238
+
239
+ function assertUniquePlacementKey(
240
+ placements: readonly ContentPlacement[],
241
+ key: string,
242
+ errors: ContentAdminErrors,
243
+ exceptId?: string,
244
+ ): void {
245
+ if (placements.some((item) => item.id !== exceptId && item.key === key)) {
246
+ throw new Error(errors.placementKeyExists);
247
+ }
248
+ }
249
+
250
+ function assertDocumentReferences<
251
+ TPlacement extends ContentPlacement,
252
+ TDocument extends ContentDocument,
253
+ >(
254
+ placement: TPlacement | null,
255
+ documents: readonly TDocument[],
256
+ draft: ContentDocumentDraft,
257
+ errors: ContentAdminErrors,
258
+ rootPlacementKey?: string,
259
+ assertRootSlug?: (slug: string) => void,
260
+ exceptId?: string,
261
+ ): void {
262
+ if (!placement) throw new Error(errors.placementNotFound);
263
+ if (rootPlacementKey && placement.key === rootPlacementKey) {
264
+ assertRootSlug?.(draft.slug);
265
+ }
266
+ if (
267
+ documents.some(
268
+ (document) => document.id !== exceptId && document.slug === draft.slug,
269
+ )
270
+ ) {
271
+ throw new Error(errors.documentSlugExists);
272
+ }
273
+ }
274
+
275
+ function findPlacement<TPlacement extends ContentPlacement>(
276
+ repository: { getDetail(input: { id: string }): Promise<TPlacement | null> },
277
+ id: string,
278
+ cached: readonly TPlacement[] | null,
279
+ ): Promise<TPlacement | null> {
280
+ const placement = cached?.find((candidate) => candidate.id === id);
281
+ return placement ? Promise.resolve(placement) : repository.getDetail({ id });
282
+ }
283
+
284
+ function insertOrders(
285
+ siblings: readonly ContentDocument[],
286
+ position: number,
287
+ ): ContentOrder[] {
288
+ return [...siblings]
289
+ .sort((left, right) => left.order - right.order)
290
+ .map((document, index) => ({
291
+ id: document.id,
292
+ order: index + 1 >= position ? index + 2 : index + 1,
293
+ }));
294
+ }
295
+
296
+ function normalizeOrders(
297
+ documents: readonly ContentDocument[],
298
+ ): ContentOrder[] {
299
+ return [...documents]
300
+ .sort((left, right) => left.order - right.order)
301
+ .map((document, index) => ({ id: document.id, order: index + 1 }));
302
+ }
303
+
304
+ function changedOrders(
305
+ documents: readonly ContentDocument[],
306
+ orders: readonly ContentOrder[],
307
+ ): ContentOrder[] {
308
+ const current = new Map(
309
+ documents.map((document) => [document.id, document.order]),
310
+ );
311
+ return orders.filter((order) => current.get(order.id) !== order.order);
312
+ }
313
+
314
+ function samePlacement(
315
+ placement: ContentPlacement,
316
+ draft: ContentPlacementDraft,
317
+ ): boolean {
318
+ return placement.key === draft.key && placement.title === draft.title;
319
+ }
320
+
321
+ function sameDocument(
322
+ document: ContentDocument,
323
+ draft: ContentDocumentDraft,
324
+ ): boolean {
325
+ return (
326
+ document.content === draft.content &&
327
+ document.order === draft.order &&
328
+ document.placementId === draft.placementId &&
329
+ document.slug === draft.slug &&
330
+ document.summary === draft.summary &&
331
+ document.title === draft.title &&
332
+ document.visibility === draft.visibility
333
+ );
334
+ }
335
+
336
+ async function loadAll<T>(
337
+ repository: {
338
+ getIndex(input?: IndexInput): Promise<{
339
+ items: readonly T[];
340
+ nextCursor?: string;
341
+ }>;
342
+ },
343
+ cached?: readonly T[] | null,
344
+ ): Promise<T[]> {
345
+ if (cached) return [...cached];
346
+ const items: T[] = [];
347
+ let cursor: string | undefined;
348
+ do {
349
+ const page = await repository.getIndex({ cursor, limit: 100 });
350
+ items.push(...page.items);
351
+ cursor = page.nextCursor;
352
+ } while (cursor);
353
+ return items;
354
+ }
@@ -0,0 +1,177 @@
1
+ import { createAdminResourceCache } from '../admin';
2
+ import type { ContentDocument, ContentPlacement } from './contracts';
3
+
4
+ type StoredDocument<TDocument extends ContentDocument> = Omit<
5
+ TDocument,
6
+ 'updatedAt'
7
+ > & { updatedAt: number };
8
+
9
+ export function createContentAdminCaches<
10
+ TPlacement extends ContentPlacement,
11
+ TDocument extends ContentDocument,
12
+ >({ namespace }: { namespace: string }) {
13
+ const placementCache = createAdminResourceCache<TPlacement, TPlacement>({
14
+ collectionKey: `${namespace}:placements`,
15
+ editorKey: `${namespace}:placement-editor`,
16
+ getKey: (placement) => placement.id,
17
+ serializer: {
18
+ parse(value) {
19
+ if (!value || typeof value !== 'object') return null;
20
+ const placement = value as Partial<TPlacement>;
21
+ return typeof placement.id === 'string' &&
22
+ typeof placement.key === 'string' &&
23
+ typeof placement.title === 'string'
24
+ ? (placement as TPlacement)
25
+ : null;
26
+ },
27
+ serialize: (placement) => placement,
28
+ },
29
+ sort: (items) =>
30
+ [...items].sort((left, right) => left.key.localeCompare(right.key)),
31
+ });
32
+ const documentCache = createAdminResourceCache<
33
+ TDocument,
34
+ StoredDocument<TDocument>
35
+ >({
36
+ collectionKey: `${namespace}:documents`,
37
+ editorKey: `${namespace}:document-editor`,
38
+ getKey: (document) => document.id,
39
+ serializer: {
40
+ parse(value) {
41
+ if (!value || typeof value !== 'object') return null;
42
+ const document = value as Partial<StoredDocument<TDocument>>;
43
+ if (
44
+ typeof document.id !== 'string' ||
45
+ typeof document.placementId !== 'string' ||
46
+ typeof document.slug !== 'string' ||
47
+ typeof document.order !== 'number' ||
48
+ typeof document.updatedAt !== 'number' ||
49
+ typeof document.title !== 'string' ||
50
+ typeof document.content !== 'string' ||
51
+ (document.summary !== undefined &&
52
+ typeof document.summary !== 'string') ||
53
+ (document.visibility !== 'private' &&
54
+ document.visibility !== 'public')
55
+ ) {
56
+ return null;
57
+ }
58
+ const updatedAt = new Date(document.updatedAt);
59
+ return Number.isNaN(updatedAt.getTime())
60
+ ? null
61
+ : ({ ...document, updatedAt } as unknown as TDocument);
62
+ },
63
+ serialize: (document) => ({
64
+ ...document,
65
+ updatedAt: document.updatedAt.getTime(),
66
+ }),
67
+ },
68
+ sort: (items) =>
69
+ [...items].sort(
70
+ (left, right) =>
71
+ left.placementId.localeCompare(right.placementId) ||
72
+ left.order - right.order ||
73
+ left.title.localeCompare(right.title),
74
+ ),
75
+ });
76
+
77
+ const readCompletePlacements = (
78
+ actorId: string,
79
+ ): readonly TPlacement[] | null => {
80
+ const page = placementCache.read(actorId, {
81
+ limit: Number.MAX_SAFE_INTEGER,
82
+ });
83
+ return page && !page.nextCursor ? page.items : null;
84
+ };
85
+ const readCompleteDocuments = (
86
+ actorId: string,
87
+ ): readonly TDocument[] | null => {
88
+ const page = documentCache.read(actorId, {
89
+ limit: Number.MAX_SAFE_INTEGER,
90
+ });
91
+ return page && !page.nextCursor ? page.items : null;
92
+ };
93
+
94
+ const syncDocumentAfterWrite = (
95
+ actorId: string,
96
+ document: TDocument,
97
+ ): void => {
98
+ const cached = readCompleteDocuments(actorId);
99
+ if (!cached) {
100
+ documentCache.clear(actorId);
101
+ return;
102
+ }
103
+ const previous = cached.find((candidate) => candidate.id === document.id);
104
+ const placementIds = new Set([
105
+ document.placementId,
106
+ ...(previous ? [previous.placementId] : []),
107
+ ]);
108
+ const untouched = cached.filter(
109
+ (candidate) =>
110
+ candidate.id !== document.id &&
111
+ !placementIds.has(candidate.placementId),
112
+ );
113
+ const updated: TDocument[] = [];
114
+ for (const placementId of placementIds) {
115
+ const siblings = cached
116
+ .filter(
117
+ (candidate) =>
118
+ candidate.id !== document.id &&
119
+ candidate.placementId === placementId,
120
+ )
121
+ .toSorted(compareDocumentOrder);
122
+ if (placementId === document.placementId) {
123
+ const position = Math.min(
124
+ Math.max(document.order, 1),
125
+ siblings.length + 1,
126
+ );
127
+ siblings.splice(position - 1, 0, { ...document, order: position });
128
+ }
129
+ updated.push(
130
+ ...siblings.map((candidate, index) => ({
131
+ ...candidate,
132
+ order: index + 1,
133
+ })),
134
+ );
135
+ }
136
+ documentCache.write(actorId, { items: [...untouched, ...updated] });
137
+ };
138
+
139
+ const syncDocumentAfterDelete = (actorId: string, id: string): void => {
140
+ const cached = readCompleteDocuments(actorId);
141
+ if (!cached) {
142
+ documentCache.clear(actorId);
143
+ return;
144
+ }
145
+ const deleted = cached.find((candidate) => candidate.id === id);
146
+ if (!deleted) return;
147
+ const items = cached.filter((candidate) => candidate.id !== id);
148
+ const siblings = items
149
+ .filter((candidate) => candidate.placementId === deleted.placementId)
150
+ .toSorted(compareDocumentOrder)
151
+ .map((candidate, index) => ({ ...candidate, order: index + 1 }));
152
+ documentCache.write(actorId, {
153
+ items: [
154
+ ...items.filter(
155
+ (candidate) => candidate.placementId !== deleted.placementId,
156
+ ),
157
+ ...siblings,
158
+ ],
159
+ });
160
+ };
161
+
162
+ return {
163
+ documentCache,
164
+ placementCache,
165
+ readCompleteDocuments,
166
+ readCompletePlacements,
167
+ syncDocumentAfterDelete,
168
+ syncDocumentAfterWrite,
169
+ } as const;
170
+ }
171
+
172
+ function compareDocumentOrder(
173
+ left: ContentDocument,
174
+ right: ContentDocument,
175
+ ): number {
176
+ return left.order - right.order || left.title.localeCompare(right.title);
177
+ }
@@ -0,0 +1,72 @@
1
+ import type {
2
+ ContentDocument,
3
+ ContentPlacement,
4
+ ContentSnapshot,
5
+ } from './contracts';
6
+
7
+ export type ContentCatalog<
8
+ TPlacement extends ContentPlacement,
9
+ TDocument extends ContentDocument,
10
+ > = {
11
+ placements: readonly TPlacement[];
12
+ placementsById: ReadonlyMap<string, TPlacement>;
13
+ documents: readonly TDocument[];
14
+ documentsBySlug: ReadonlyMap<string, TDocument>;
15
+ };
16
+
17
+ export function createContentCatalog<
18
+ TPlacement extends ContentPlacement,
19
+ TDocument extends ContentDocument,
20
+ >(
21
+ snapshot: ContentSnapshot<TPlacement, TDocument>,
22
+ ): ContentCatalog<TPlacement, TDocument> {
23
+ const placements = [...snapshot.placements].sort((left, right) =>
24
+ left.title.localeCompare(right.title, 'en'),
25
+ );
26
+ const placementsById = new Map(
27
+ placements.map((placement) => [placement.id, placement]),
28
+ );
29
+ const documents = snapshot.documents.toSorted(
30
+ (left, right) =>
31
+ left.order - right.order || left.title.localeCompare(right.title, 'en'),
32
+ );
33
+
34
+ for (const document of documents) {
35
+ if (!placementsById.has(document.placementId)) {
36
+ throw new Error(`Unknown content placement: ${document.placementId}`);
37
+ }
38
+ }
39
+ assertUnique(
40
+ placements.map((placement) => placement.key),
41
+ 'content placement key',
42
+ );
43
+ assertUnique(
44
+ documents.map((document) => document.slug),
45
+ 'content document slug',
46
+ );
47
+ for (const placement of placements) {
48
+ assertUnique(
49
+ documents
50
+ .filter((document) => document.placementId === placement.id)
51
+ .map((document) => String(document.order)),
52
+ `content document order in placement ${placement.key}`,
53
+ );
54
+ }
55
+
56
+ return {
57
+ placements,
58
+ placementsById,
59
+ documents,
60
+ documentsBySlug: new Map(
61
+ documents.map((document) => [document.slug, document]),
62
+ ),
63
+ };
64
+ }
65
+
66
+ function assertUnique(values: readonly string[], label: string): void {
67
+ const seen = new Set<string>();
68
+ for (const value of values) {
69
+ if (seen.has(value)) throw new Error(`Duplicate ${label}: ${value}`);
70
+ seen.add(value);
71
+ }
72
+ }
@@ -0,0 +1,77 @@
1
+ import type { AdminPage } from '../admin';
2
+
3
+ export type ContentVisibility = 'public' | 'private';
4
+
5
+ export type ContentPlacement = {
6
+ id: string;
7
+ key: string;
8
+ title: string;
9
+ };
10
+
11
+ export type ContentDocument = {
12
+ content: string;
13
+ id: string;
14
+ order: number;
15
+ placementId: string;
16
+ slug: string;
17
+ summary?: string;
18
+ title: string;
19
+ updatedAt: Date;
20
+ visibility: ContentVisibility;
21
+ };
22
+
23
+ export type ContentSnapshot<
24
+ TPlacement extends ContentPlacement = ContentPlacement,
25
+ TDocument extends ContentDocument = ContentDocument,
26
+ > = {
27
+ placements: readonly TPlacement[];
28
+ documents: readonly TDocument[];
29
+ updatedAt: Date | null;
30
+ };
31
+
32
+ export type ContentPlacementDraft = Pick<ContentPlacement, 'key' | 'title'>;
33
+
34
+ export type ContentDocumentDraft = Omit<ContentDocument, 'id' | 'updatedAt'>;
35
+
36
+ export interface ContentPlacementAdminRepository<
37
+ TPlacement extends ContentPlacement,
38
+ TPlacementDraft extends ContentPlacementDraft,
39
+ > {
40
+ getIndex(input?: {
41
+ cursor?: string;
42
+ limit?: number;
43
+ }): Promise<AdminPage<TPlacement>>;
44
+ getDetail(input: { id: string }): Promise<TPlacement | null>;
45
+ create(input: TPlacementDraft): Promise<TPlacement>;
46
+ update(input: { id: string; value: TPlacementDraft }): Promise<TPlacement>;
47
+ delete(input: { id: string }): Promise<void>;
48
+ }
49
+
50
+ export interface ContentDocumentAdminRepository<
51
+ TDocument extends ContentDocument,
52
+ TDocumentDraft extends ContentDocumentDraft,
53
+ > {
54
+ getIndex(input?: {
55
+ cursor?: string;
56
+ limit?: number;
57
+ }): Promise<AdminPage<TDocument>>;
58
+ getDetail(input: { id: string }): Promise<TDocument | null>;
59
+ create(input: TDocumentDraft): Promise<TDocument>;
60
+ update(input: { id: string; value: TDocumentDraft }): Promise<TDocument>;
61
+ delete(input: { id: string }): Promise<void>;
62
+ createOrdered(input: {
63
+ value: TDocumentDraft;
64
+ orders: readonly ContentOrder[];
65
+ }): Promise<TDocument>;
66
+ updateOrdered(input: {
67
+ id: string;
68
+ value: TDocumentDraft;
69
+ orders: readonly ContentOrder[];
70
+ }): Promise<TDocument>;
71
+ deleteOrdered(input: {
72
+ id: string;
73
+ orders: readonly ContentOrder[];
74
+ }): Promise<void>;
75
+ }
76
+
77
+ export type ContentOrder = { id: string; order: number };