@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
|
@@ -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 };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ContentDocumentDraft, ContentPlacementDraft } from './contracts';
|
|
3
|
+
|
|
4
|
+
export const contentKeySchema = z
|
|
5
|
+
.string()
|
|
6
|
+
.trim()
|
|
7
|
+
.min(1)
|
|
8
|
+
.max(80)
|
|
9
|
+
.transform((value) =>
|
|
10
|
+
value
|
|
11
|
+
.normalize('NFKD')
|
|
12
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
15
|
+
.replace(/^-+|-+$/g, ''),
|
|
16
|
+
)
|
|
17
|
+
.pipe(z.string().min(1));
|
|
18
|
+
|
|
19
|
+
const placementDraftSchema = z.object({
|
|
20
|
+
key: contentKeySchema,
|
|
21
|
+
title: z.string().trim().min(1).max(120),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const documentDraftSchema = z.object({
|
|
25
|
+
placementId: z.string().trim().min(1),
|
|
26
|
+
content: z.string().trim().min(1).max(200_000),
|
|
27
|
+
order: z.coerce.number().int().positive(),
|
|
28
|
+
slug: contentKeySchema,
|
|
29
|
+
summary: z
|
|
30
|
+
.string()
|
|
31
|
+
.trim()
|
|
32
|
+
.max(500)
|
|
33
|
+
.optional()
|
|
34
|
+
.transform((value) => value || undefined),
|
|
35
|
+
title: z.string().trim().min(1).max(160),
|
|
36
|
+
visibility: z.enum(['public', 'private']),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export function parseContentPlacementDraft(
|
|
40
|
+
input: unknown,
|
|
41
|
+
): ContentPlacementDraft {
|
|
42
|
+
return placementDraftSchema.parse(input);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseContentDocumentDraft(
|
|
46
|
+
input: unknown,
|
|
47
|
+
): ContentDocumentDraft {
|
|
48
|
+
return documentDraftSchema.parse(input);
|
|
49
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { fromMarkdown } from 'mdast-util-from-markdown';
|
|
2
|
+
import type {
|
|
3
|
+
MarkdownBlock,
|
|
4
|
+
MarkdownDocument,
|
|
5
|
+
MarkdownHeadingLevel,
|
|
6
|
+
MarkdownInline,
|
|
7
|
+
} from '../components/content/MarkdownContent/contracts';
|
|
8
|
+
|
|
9
|
+
type AstNode = {
|
|
10
|
+
children?: AstNode[];
|
|
11
|
+
depth?: number;
|
|
12
|
+
lang?: string;
|
|
13
|
+
type?: string;
|
|
14
|
+
url?: string;
|
|
15
|
+
value?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function parseMarkdownDocument(source: string): MarkdownDocument {
|
|
19
|
+
const root = fromMarkdown(source) as AstNode;
|
|
20
|
+
const headingIds = new Map<string, number>();
|
|
21
|
+
return {
|
|
22
|
+
blocks: (root.children ?? []).flatMap((node) => toBlocks(node, headingIds)),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toBlocks(
|
|
27
|
+
node: AstNode,
|
|
28
|
+
headingIds: Map<string, number>,
|
|
29
|
+
): MarkdownBlock[] {
|
|
30
|
+
switch (node.type) {
|
|
31
|
+
case 'paragraph':
|
|
32
|
+
return [{ type: 'paragraph', children: toInlineChildren(node) }];
|
|
33
|
+
case 'heading': {
|
|
34
|
+
const children = toInlineChildren(node);
|
|
35
|
+
const title = inlineText(children);
|
|
36
|
+
const level = Math.min(
|
|
37
|
+
6,
|
|
38
|
+
Math.max(2, node.depth ?? 2),
|
|
39
|
+
) as MarkdownHeadingLevel;
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
type: 'heading',
|
|
43
|
+
children,
|
|
44
|
+
id: uniqueSlug(title, headingIds),
|
|
45
|
+
level,
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
case 'list':
|
|
50
|
+
return [
|
|
51
|
+
{
|
|
52
|
+
type: 'list',
|
|
53
|
+
items: (node.children ?? [])
|
|
54
|
+
.filter((item) => item.type === 'listItem')
|
|
55
|
+
.map((item) => ({
|
|
56
|
+
children: (item.children ?? []).flatMap((child) =>
|
|
57
|
+
toBlocks(child, headingIds),
|
|
58
|
+
),
|
|
59
|
+
})),
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
case 'blockquote':
|
|
63
|
+
return [
|
|
64
|
+
{
|
|
65
|
+
type: 'quote',
|
|
66
|
+
children: (node.children ?? []).flatMap((child) =>
|
|
67
|
+
toBlocks(child, headingIds),
|
|
68
|
+
),
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
case 'code':
|
|
72
|
+
return [
|
|
73
|
+
{
|
|
74
|
+
type: 'code',
|
|
75
|
+
language: node.lang,
|
|
76
|
+
value: node.value ?? '',
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
case 'thematicBreak':
|
|
80
|
+
return [{ type: 'separator' }];
|
|
81
|
+
default:
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toInlineChildren(node: AstNode): MarkdownInline[] {
|
|
87
|
+
return (node.children ?? []).flatMap(toInline);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function toInline(node: AstNode): MarkdownInline[] {
|
|
91
|
+
switch (node.type) {
|
|
92
|
+
case 'text':
|
|
93
|
+
return [{ type: 'text', value: node.value ?? '' }];
|
|
94
|
+
case 'strong':
|
|
95
|
+
return [{ type: 'strong', children: toInlineChildren(node) }];
|
|
96
|
+
case 'emphasis':
|
|
97
|
+
return [{ type: 'emphasis', children: toInlineChildren(node) }];
|
|
98
|
+
case 'inlineCode':
|
|
99
|
+
return [{ type: 'code', value: node.value ?? '' }];
|
|
100
|
+
case 'break':
|
|
101
|
+
return [{ type: 'break' }];
|
|
102
|
+
case 'link': {
|
|
103
|
+
const href = node.url ?? '';
|
|
104
|
+
return [
|
|
105
|
+
{
|
|
106
|
+
type: 'link',
|
|
107
|
+
children: toInlineChildren(node),
|
|
108
|
+
external: /^(?:https?:)?\/\//i.test(href),
|
|
109
|
+
href,
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
default:
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function inlineText(nodes: readonly MarkdownInline[]): string {
|
|
119
|
+
return nodes
|
|
120
|
+
.map((node) => {
|
|
121
|
+
if (node.type === 'text' || node.type === 'code') return node.value;
|
|
122
|
+
if (
|
|
123
|
+
node.type === 'strong' ||
|
|
124
|
+
node.type === 'emphasis' ||
|
|
125
|
+
node.type === 'link'
|
|
126
|
+
) {
|
|
127
|
+
return inlineText(node.children);
|
|
128
|
+
}
|
|
129
|
+
return '';
|
|
130
|
+
})
|
|
131
|
+
.join('')
|
|
132
|
+
.trim();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function uniqueSlug(value: string, counts: Map<string, number>): string {
|
|
136
|
+
const base =
|
|
137
|
+
value
|
|
138
|
+
.normalize('NFKD')
|
|
139
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
140
|
+
.toLowerCase()
|
|
141
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
142
|
+
.replace(/^-+|-+$/g, '') || 'section';
|
|
143
|
+
const count = counts.get(base) ?? 0;
|
|
144
|
+
counts.set(base, count + 1);
|
|
145
|
+
return count === 0 ? base : `${base}-${count + 1}`;
|
|
146
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ContentDocument,
|
|
3
|
+
ContentPlacement,
|
|
4
|
+
ContentSnapshot,
|
|
5
|
+
} from './contracts';
|
|
6
|
+
|
|
7
|
+
type RecordValue = Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
export type ContentSnapshotFields = {
|
|
10
|
+
documents: string;
|
|
11
|
+
documentPlacementId: string;
|
|
12
|
+
label: string;
|
|
13
|
+
placements: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function parseContentSnapshot(
|
|
17
|
+
value: unknown,
|
|
18
|
+
fields: ContentSnapshotFields,
|
|
19
|
+
): ContentSnapshot {
|
|
20
|
+
const source = requireRecord(value, fields.label);
|
|
21
|
+
const rawPlacements = requireArray(source[fields.placements], fields.label);
|
|
22
|
+
const rawDocuments = requireArray(source[fields.documents], fields.label);
|
|
23
|
+
const placements = rawPlacements.map((entry) =>
|
|
24
|
+
parsePlacement(entry, fields.label),
|
|
25
|
+
);
|
|
26
|
+
const documents = rawDocuments.map((entry) =>
|
|
27
|
+
parseDocument(entry, fields.documentPlacementId, fields.label),
|
|
28
|
+
);
|
|
29
|
+
const placementIds = new Set(placements.map((placement) => placement.id));
|
|
30
|
+
|
|
31
|
+
for (const document of documents) {
|
|
32
|
+
if (!placementIds.has(document.placementId)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Invalid ${fields.label} document placement: ${document.id}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
placements,
|
|
41
|
+
documents,
|
|
42
|
+
updatedAt:
|
|
43
|
+
source.updatedAt === undefined || source.updatedAt === null
|
|
44
|
+
? null
|
|
45
|
+
: parseSnapshotDate(source.updatedAt, fields.label),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function projectPublicContentSnapshot<
|
|
50
|
+
TPlacement extends ContentPlacement,
|
|
51
|
+
TDocument extends ContentDocument,
|
|
52
|
+
>(
|
|
53
|
+
snapshot: ContentSnapshot<TPlacement, TDocument>,
|
|
54
|
+
): ContentSnapshot<TPlacement, TDocument> {
|
|
55
|
+
return {
|
|
56
|
+
...snapshot,
|
|
57
|
+
documents: snapshot.documents.filter(
|
|
58
|
+
(document) => document.visibility === 'public',
|
|
59
|
+
),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseSnapshotDate(value: unknown, label = 'snapshot'): Date {
|
|
64
|
+
const timestamp = isRecord(value)
|
|
65
|
+
? typeof value._seconds === 'number'
|
|
66
|
+
? value._seconds * 1_000
|
|
67
|
+
: typeof value.seconds === 'number'
|
|
68
|
+
? value.seconds * 1_000
|
|
69
|
+
: Number.NaN
|
|
70
|
+
: value;
|
|
71
|
+
const date = new Date(timestamp as string | number | Date);
|
|
72
|
+
if (Number.isNaN(date.getTime())) throw new Error(`Invalid ${label} date`);
|
|
73
|
+
return date;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parsePlacement(value: unknown, label: string): ContentPlacement {
|
|
77
|
+
const source = requireRecord(value, label);
|
|
78
|
+
return {
|
|
79
|
+
id: requireString(source.id, label),
|
|
80
|
+
key: requireString(source.key, label),
|
|
81
|
+
title: requireString(source.title, label),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseDocument(
|
|
86
|
+
value: unknown,
|
|
87
|
+
placementIdField: string,
|
|
88
|
+
label: string,
|
|
89
|
+
): ContentDocument {
|
|
90
|
+
const source = requireRecord(value, label);
|
|
91
|
+
const order =
|
|
92
|
+
typeof source.order === 'number' ? source.order : Number(source.order);
|
|
93
|
+
if (!Number.isInteger(order) || order < 1) {
|
|
94
|
+
throw new Error(`Invalid ${label} document order`);
|
|
95
|
+
}
|
|
96
|
+
if (source.private !== undefined && typeof source.private !== 'boolean') {
|
|
97
|
+
throw new Error(`Invalid ${label} document visibility`);
|
|
98
|
+
}
|
|
99
|
+
const summary = readOptionalString(source.summary, label);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
content: requireString(source.content, label),
|
|
103
|
+
id: requireString(source.id, label),
|
|
104
|
+
order,
|
|
105
|
+
placementId: requireString(source[placementIdField], label),
|
|
106
|
+
slug: requireString(source.slug, label),
|
|
107
|
+
...(summary ? { summary } : {}),
|
|
108
|
+
title: requireString(source.title, label),
|
|
109
|
+
updatedAt: parseSnapshotDate(source.updatedAt, label),
|
|
110
|
+
visibility: source.private === true ? 'private' : 'public',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function requireArray(value: unknown, label: string): unknown[] {
|
|
115
|
+
if (!Array.isArray(value)) throw new Error(`Invalid ${label} snapshot`);
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function requireRecord(value: unknown, label: string): RecordValue {
|
|
120
|
+
if (!isRecord(value)) throw new Error(`Invalid ${label} snapshot`);
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function requireString(value: unknown, label: string): string {
|
|
125
|
+
const resolved = typeof value === 'string' ? value.trim() : '';
|
|
126
|
+
if (!resolved) throw new Error(`Invalid ${label} snapshot string`);
|
|
127
|
+
return resolved;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readOptionalString(value: unknown, label: string): string | undefined {
|
|
131
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
132
|
+
if (typeof value !== 'string')
|
|
133
|
+
throw new Error(`Invalid ${label} optional string`);
|
|
134
|
+
return value.trim() || undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isRecord(value: unknown): value is RecordValue {
|
|
138
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
139
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Firestore } from 'firebase/firestore';
|
|
2
2
|
|
|
3
|
+
export type ClientFirestore = Firestore;
|
|
4
|
+
|
|
3
5
|
export type ClientFirestorePage<TItem> = {
|
|
4
6
|
items: readonly TItem[];
|
|
5
7
|
nextCursor?: string;
|
|
@@ -16,11 +18,12 @@ export type ClientFirestoreCursor<TDocument> = {
|
|
|
16
18
|
};
|
|
17
19
|
|
|
18
20
|
export type ClientFirestoreDocumentMapper<
|
|
21
|
+
TDocument,
|
|
19
22
|
TItem,
|
|
20
23
|
TCreateInput,
|
|
21
24
|
TUpdateInput,
|
|
22
25
|
> = {
|
|
23
|
-
fromDocument(id: string, value:
|
|
26
|
+
fromDocument(id: string, value: TDocument): TItem;
|
|
24
27
|
toCreate(input: TCreateInput): Record<string, unknown>;
|
|
25
28
|
toUpdate(input: TUpdateInput): Record<string, unknown>;
|
|
26
29
|
};
|
|
@@ -49,7 +52,12 @@ export type ClientFirestoreRepositoryConfig<
|
|
|
49
52
|
collection: string;
|
|
50
53
|
cursor?: ClientFirestoreCursor<TDocument>;
|
|
51
54
|
database: Firestore;
|
|
52
|
-
mapper: ClientFirestoreDocumentMapper<
|
|
55
|
+
mapper: ClientFirestoreDocumentMapper<
|
|
56
|
+
TDocument,
|
|
57
|
+
TItem,
|
|
58
|
+
TCreateInput,
|
|
59
|
+
TUpdateInput
|
|
60
|
+
>;
|
|
53
61
|
orderBy?: readonly ClientFirestoreOrder[];
|
|
54
62
|
schema: ClientFirestoreSchema<TDocument>;
|
|
55
63
|
};
|