@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
@@ -72,12 +72,7 @@ const jsonLdNodes = [
72
72
  <title>{seo.documentTitle}</title>
73
73
  {
74
74
  site.seo.icons?.map((icon) => (
75
- <link
76
- rel="icon"
77
- href={icon.href}
78
- type={icon.type}
79
- sizes={icon.sizes}
80
- />
75
+ <link rel="icon" href={icon.href} type={icon.type} sizes={icon.sizes} />
81
76
  ))
82
77
  }
83
78
  {
@@ -99,10 +94,30 @@ const jsonLdNodes = [
99
94
  <meta
100
95
  name="theme-color"
101
96
  content={themeColor.color}
97
+ data-theme-color={themeColor.theme}
102
98
  media={themeColor.media}
103
99
  />
104
100
  ))
105
101
  }
102
+ <script is:inline>
103
+ (() => {
104
+ const applyThemeColor = (theme) => {
105
+ if (theme !== 'light' && theme !== 'dark') return;
106
+
107
+ for (const meta of document.querySelectorAll(
108
+ 'meta[name="theme-color"][data-theme-color]',
109
+ )) {
110
+ if (!(meta instanceof HTMLMetaElement)) continue;
111
+ meta.media = meta.dataset.themeColor === theme ? 'all' : 'not all';
112
+ }
113
+ };
114
+
115
+ applyThemeColor(document.documentElement.dataset.theme);
116
+ document.addEventListener('astronox:theme-change', (event) => {
117
+ applyThemeColor(event.detail?.theme);
118
+ });
119
+ })();
120
+ </script>
106
121
  <meta name="application-name" content={site.identity.name} />
107
122
  <meta name="apple-mobile-web-app-title" content={site.identity.name} />
108
123
  {seo.description ? <meta name="description" content={seo.description} /> : null}
@@ -34,9 +34,7 @@
34
34
  content: '';
35
35
  }
36
36
 
37
- .surface-group:has(> [data-sticky-surface]):not(
38
- [data-sticky-measured]
39
- )::before,
37
+ .surface-group:has(> [data-sticky-surface]):not([data-sticky-measured])::before,
40
38
  .surface-group[data-sticky-active]::before {
41
39
  visibility: visible;
42
40
  }
@@ -52,16 +50,10 @@
52
50
  [data-surface-sizing='content']
53
51
  ),
54
52
  .surface-group
55
- > [data-surface]:not([hidden]):not(
56
- [data-surface-sizing='content']
57
- ):has(
53
+ > [data-surface]:not([hidden]):not([data-surface-sizing='content']):has(
58
54
  ~ [data-surface]:not([hidden])[data-surface-sizing='content']
59
55
  ):not(
60
- :has(
61
- ~ [data-surface]:not([hidden]):not(
62
- [data-surface-sizing='content']
63
- )
64
- )
56
+ :has(~ [data-surface]:not([hidden]):not([data-surface-sizing='content']))
65
57
  ) {
66
58
  flex: 1 1 auto;
67
59
  min-height: 0;
@@ -73,9 +65,7 @@
73
65
  border-end-end-radius: 0;
74
66
  }
75
67
 
76
- .surface-group
77
- > [data-surface]:not([hidden])
78
- ~ [data-surface]:not([hidden]) {
68
+ .surface-group > [data-surface]:not([hidden]) ~ [data-surface]:not([hidden]) {
79
69
  border-block-start-width: 0;
80
70
  border-start-start-radius: 0;
81
71
  border-start-end-radius: 0;
@@ -0,0 +1,52 @@
1
+ export function readRequiredEnvironmentValue(
2
+ name: string,
3
+ value: string | undefined,
4
+ ): string {
5
+ const resolved = value?.trim();
6
+ if (!resolved) throw new Error(`Missing environment variable: ${name}`);
7
+ return resolved;
8
+ }
9
+
10
+ export function readOptionalEnvironmentValue(
11
+ value: string | undefined,
12
+ ): string | undefined {
13
+ const resolved = value?.trim();
14
+ return resolved || undefined;
15
+ }
16
+
17
+ export function readRequiredEnvironmentUrl(
18
+ name: string,
19
+ value: string | undefined,
20
+ ): string {
21
+ const resolved = readRequiredEnvironmentValue(name, value);
22
+
23
+ try {
24
+ const url = new URL(resolved);
25
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
26
+ throw new Error();
27
+ return url.toString();
28
+ } catch {
29
+ throw new Error(`Invalid environment URL: ${name}`);
30
+ }
31
+ }
32
+
33
+ export function readEnvironmentRecord(
34
+ name: string,
35
+ value: string | undefined,
36
+ ): Readonly<Record<string, string>> {
37
+ const resolved = readRequiredEnvironmentValue(name, value);
38
+
39
+ try {
40
+ const parsed: unknown = JSON.parse(resolved);
41
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
42
+ throw new Error();
43
+ }
44
+
45
+ const entries = Object.entries(parsed);
46
+ if (entries.some(([, entry]) => typeof entry !== 'string'))
47
+ throw new Error();
48
+ return Object.fromEntries(entries) as Readonly<Record<string, string>>;
49
+ } catch {
50
+ throw new Error(`Invalid environment record: ${name}`);
51
+ }
52
+ }
@@ -1,2 +1,5 @@
1
+ export * from './environment';
2
+ export * from './integrations';
1
3
  export * from './pageMeta';
2
4
  export * from './siteConfig';
5
+ export * from './storage';
@@ -0,0 +1,9 @@
1
+ export type IntegrationConfig = Readonly<
2
+ Record<string, Readonly<Record<string, boolean>>>
3
+ >;
4
+
5
+ export function defineIntegrationConfig<
6
+ const TConfig extends IntegrationConfig,
7
+ >(config: TConfig): TConfig {
8
+ return config;
9
+ }
@@ -1,4 +1,5 @@
1
1
  import type { PageMeta } from './pageMeta';
2
+ import type { ColorTheme } from '../theme/contracts';
2
3
 
3
4
  export type SiteTextDirection = 'ltr' | 'rtl';
4
5
 
@@ -13,6 +14,7 @@ export type SiteLocaleConfig = {
13
14
  };
14
15
 
15
16
  export type SiteIdentity = {
17
+ id: string;
16
18
  name: string;
17
19
  shortName?: string;
18
20
  logo?: {
@@ -34,6 +36,7 @@ export type SiteIconConfig = {
34
36
  export type SiteThemeColor = {
35
37
  color: string;
36
38
  media?: string;
39
+ theme?: ColorTheme;
37
40
  };
38
41
 
39
42
  export type SiteSeoConfig = {
@@ -68,6 +71,10 @@ export type SiteConfig = {
68
71
  export function defineSiteConfig<const TConfig extends SiteConfig>(
69
72
  config: TConfig,
70
73
  ): TConfig {
74
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(config.identity.id)) {
75
+ throw new Error(`Invalid site identity: ${config.identity.id}`);
76
+ }
77
+
71
78
  const localeIds = new Set<string>();
72
79
  const urlCodes = new Set<string>();
73
80
 
@@ -0,0 +1,16 @@
1
+ const storageSegmentPattern = /^[a-z0-9][a-z0-9-]*$/;
2
+
3
+ export function createSiteStorageKey(
4
+ siteId: string,
5
+ ...segments: readonly string[]
6
+ ): string {
7
+ const values = [siteId, ...segments];
8
+ if (
9
+ segments.length === 0 ||
10
+ values.some((segment) => !storageSegmentPattern.test(segment))
11
+ ) {
12
+ throw new Error('Invalid site storage key');
13
+ }
14
+
15
+ return values.join(':');
16
+ }
@@ -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
+ }