@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
@@ -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,7 @@
1
+ export * from './admin';
2
+ export * from './cache';
3
+ export * from './catalog';
4
+ export * from './contracts';
5
+ export * from './drafts';
6
+ export * from './markdown';
7
+ export * from './snapshot';
@@ -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: unknown): TItem;
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<TItem, TCreateInput, TUpdateInput>;
55
+ mapper: ClientFirestoreDocumentMapper<
56
+ TDocument,
57
+ TItem,
58
+ TCreateInput,
59
+ TUpdateInput
60
+ >;
53
61
  orderBy?: readonly ClientFirestoreOrder[];
54
62
  schema: ClientFirestoreSchema<TDocument>;
55
63
  };