@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.
- 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/CardAction/CardAction.astro +7 -0
- package/src/components/actions/CardAction/CardAction.css +31 -10
- 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 +3 -3
- package/src/components/actions/ThemeToggle/themeToggleController.ts +7 -4
- 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/Tooltip/tooltipController.ts +1 -4
- package/src/components/forms/ChoiceGroup/ChoiceGroup.css +1 -4
- 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/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 +2 -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 +1 -2
- package/src/components/navigation/Footer/Footer.css +3 -9
- package/src/components/navigation/MobileNavigation.astro +10 -2
- package/src/components/navigation/MobileNavigation.css +8 -22
- package/src/components/navigation/navigationTypes.ts +1 -4
- package/src/components/seo/SeoHead.astro +21 -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 +7 -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 +1 -5
- package/src/i18n/config.ts +2 -8
- package/src/i18n/coreMessages.ts +2 -9
- package/src/i18n/preference.ts +2 -6
- 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/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 +14 -7
- package/src/styles/style.css +11 -20
- 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
|
+
}
|
package/src/config/index.ts
CHANGED
package/src/config/siteConfig.ts
CHANGED
|
@@ -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
|
+
}
|