@postedin/cms-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/bin/dissect/cli.mjs +138 -0
- package/bin/dissect/dissect.mjs +290 -0
- package/bin/profile/build.mjs +106 -0
- package/bin/profile/fetch-log.mjs +298 -0
- package/bin/profile/format.mjs +90 -0
- package/bin/profile/interference-summary.mjs +558 -0
- package/bin/profile/interference.mjs +604 -0
- package/bin/profile/measure.mjs +137 -0
- package/bin/profile/report.mjs +90 -0
- package/bin/profile/site-env.mjs +16 -0
- package/bin/profile/summarize.mjs +429 -0
- package/dist/browser.d.ts +145 -0
- package/dist/browser.js +11 -0
- package/dist/browser.js.map +1 -0
- package/dist/chunk-6V54ITTK.js +197 -0
- package/dist/chunk-6V54ITTK.js.map +1 -0
- package/dist/chunk-MNZ7DIGC.js +51 -0
- package/dist/chunk-MNZ7DIGC.js.map +1 -0
- package/dist/form-proxy/upload-policy.d.ts +40 -0
- package/dist/form-proxy/upload-policy.js +17 -0
- package/dist/form-proxy/upload-policy.js.map +1 -0
- package/dist/index.d.ts +570 -0
- package/dist/index.js +1636 -0
- package/dist/index.js.map +1 -0
- package/dist/payload-types.d.ts +8985 -0
- package/dist/payload-types.js +1 -0
- package/dist/payload-types.js.map +1 -0
- package/package.json +74 -0
- package/src/api.ts +387 -0
- package/src/blog-listing.ts +75 -0
- package/src/browser.ts +24 -0
- package/src/client.ts +144 -0
- package/src/cms-to-href.ts +70 -0
- package/src/cms.ts +86 -0
- package/src/collections/appearance.ts +94 -0
- package/src/collections/areas.ts +29 -0
- package/src/collections/authors.ts +27 -0
- package/src/collections/banners.ts +14 -0
- package/src/collections/categories.ts +111 -0
- package/src/collections/forms.ts +29 -0
- package/src/collections/header-footer.ts +19 -0
- package/src/collections/image-links.ts +14 -0
- package/src/collections/media.ts +18 -0
- package/src/collections/options.ts +10 -0
- package/src/collections/pages.ts +83 -0
- package/src/collections/posts.ts +249 -0
- package/src/collections/project.ts +16 -0
- package/src/collections/questions.ts +35 -0
- package/src/collections/seo.ts +10 -0
- package/src/collections/tags.ts +25 -0
- package/src/collections/team-members.ts +79 -0
- package/src/config-time.ts +98 -0
- package/src/context.ts +12 -0
- package/src/decode-html.ts +8 -0
- package/src/form-proxy/cms-client.ts +95 -0
- package/src/form-proxy/cms-errors.ts +73 -0
- package/src/form-proxy/cms-write.ts +44 -0
- package/src/form-proxy/http.ts +96 -0
- package/src/form-proxy/index.ts +73 -0
- package/src/form-proxy/rate-limit.ts +46 -0
- package/src/form-proxy/submissions.ts +88 -0
- package/src/form-proxy/types.ts +23 -0
- package/src/form-proxy/upload-policy.ts +92 -0
- package/src/form-proxy/uploads.ts +81 -0
- package/src/home-page.ts +83 -0
- package/src/index.ts +68 -0
- package/src/loader.ts +83 -0
- package/src/locales.ts +80 -0
- package/src/payload-types.ts +10854 -0
- package/src/placeholder.ts +9 -0
- package/src/resolve-menu-items.ts +184 -0
- package/src/routes.ts +184 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { Context } from '../context';
|
|
2
|
+
import { TfIdf } from 'natural/lib/natural/tfidf/index.js';
|
|
3
|
+
import type { Author, Category, Media, Post, Tag } from '../payload-types';
|
|
4
|
+
import type { Locale } from '../locales';
|
|
5
|
+
import type { WiredCategory } from './categories';
|
|
6
|
+
import { createCollectionLoader } from '../loader';
|
|
7
|
+
import type { WiredTag } from './tags';
|
|
8
|
+
|
|
9
|
+
export interface WiredPost extends Post {
|
|
10
|
+
coverImage?: Media | null;
|
|
11
|
+
category?: WiredCategory | null;
|
|
12
|
+
secondaryCategories?: WiredCategory[];
|
|
13
|
+
tags?: WiredTag[];
|
|
14
|
+
searchTokens: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function wirePost(
|
|
18
|
+
ctx: Context,
|
|
19
|
+
post: Post | undefined,
|
|
20
|
+
locale: Locale,
|
|
21
|
+
tags: Tag[] = [],
|
|
22
|
+
): Promise<WiredPost | undefined> {
|
|
23
|
+
if (!post) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let coverImage = post.coverImage;
|
|
28
|
+
if (typeof coverImage === 'string') {
|
|
29
|
+
// TODO: I think we need to be getting the actual image here instead
|
|
30
|
+
coverImage = undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const postTags = post.tags
|
|
34
|
+
?.map((tag) =>
|
|
35
|
+
typeof tag === 'string' ? tags.find((t) => t.id === tag) : tag,
|
|
36
|
+
)
|
|
37
|
+
.filter((tag): tag is WiredTag => tag !== undefined);
|
|
38
|
+
|
|
39
|
+
const cms = ctx.cms(locale);
|
|
40
|
+
|
|
41
|
+
const secondaryCategories = (
|
|
42
|
+
await Promise.all(
|
|
43
|
+
(post.secondaryCategories ?? []).map((c) =>
|
|
44
|
+
cms.categories.wire(c as Category | string),
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
).filter((c): c is WiredCategory => c != null);
|
|
48
|
+
|
|
49
|
+
const category = await cms.categories.wire(post.category);
|
|
50
|
+
const categoryTitles = [category, ...secondaryCategories]
|
|
51
|
+
.filter((c): c is WiredCategory => c != null)
|
|
52
|
+
.map((c) => c.title);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
...post,
|
|
56
|
+
category,
|
|
57
|
+
secondaryCategories,
|
|
58
|
+
coverImage,
|
|
59
|
+
tags: postTags,
|
|
60
|
+
searchTokens: `${post.title} ${categoryTitles.join(' ')}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Only top-level team-member blocks with filter='custom' are indexed.
|
|
65
|
+
// Nested blocks (inside tabs/columns) are intentionally out of scope.
|
|
66
|
+
function collectCustomTeamMemberIds(post: WiredPost): string[] {
|
|
67
|
+
const ids: string[] = [];
|
|
68
|
+
for (const block of post.contentBlocks ?? []) {
|
|
69
|
+
if (block.blockType !== 'team-member') {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (block.filter !== 'custom') {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
for (const m of block.members ?? []) {
|
|
76
|
+
const id = typeof m === 'string' ? m : m?.id;
|
|
77
|
+
if (id) {
|
|
78
|
+
ids.push(id);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return ids;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default function (ctx: Context, locale: Locale) {
|
|
86
|
+
const tfIdf = new TfIdf();
|
|
87
|
+
const keyedPosts: Record<string, WiredPost> = {};
|
|
88
|
+
let teamMemberIndex: Promise<Map<string, WiredPost[]>> | null = null;
|
|
89
|
+
|
|
90
|
+
const posts = createCollectionLoader(
|
|
91
|
+
async () =>
|
|
92
|
+
ctx.api.fetchCmsCollection<Post>('posts', {
|
|
93
|
+
status: 'published',
|
|
94
|
+
sort: '-publishDate',
|
|
95
|
+
locale,
|
|
96
|
+
}) || [],
|
|
97
|
+
{
|
|
98
|
+
transform: async (posts): Promise<WiredPost[]> => {
|
|
99
|
+
// Lazy-resolve cms here (not at factory call time) to avoid the
|
|
100
|
+
// circular init: createCms → buildCms → posts() → createCms → ...
|
|
101
|
+
const cms = ctx.cms(locale);
|
|
102
|
+
const tags = await cms.tags.all();
|
|
103
|
+
|
|
104
|
+
const wiredPosts = await Promise.all(
|
|
105
|
+
posts
|
|
106
|
+
?.filter((post) => post.slug)
|
|
107
|
+
.map(async (post) => {
|
|
108
|
+
return wirePost(ctx, post, locale, tags) as Promise<WiredPost>;
|
|
109
|
+
}) || [],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const currentYear = new Date().getFullYear();
|
|
113
|
+
|
|
114
|
+
// we don't want every post used for performance reasons so instead use the last 4 years of
|
|
115
|
+
// posts and if there are less than 200 then keep going until we have 100
|
|
116
|
+
wiredPosts.forEach((post) => {
|
|
117
|
+
if (
|
|
118
|
+
post.publishDate &&
|
|
119
|
+
(new Date(post.publishDate).getFullYear() > currentYear - 4 ||
|
|
120
|
+
tfIdf.documents.length < 200)
|
|
121
|
+
) {
|
|
122
|
+
tfIdf.addDocument(post.searchTokens, post.id);
|
|
123
|
+
keyedPosts[post.id] = post;
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return wiredPosts;
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
all: posts,
|
|
134
|
+
exists: async () => !!(await posts()).length,
|
|
135
|
+
page: async (...args: Parameters<typeof posts.page>) => {
|
|
136
|
+
return posts.page(...args);
|
|
137
|
+
},
|
|
138
|
+
latest: async () => {
|
|
139
|
+
return posts.page(1, 6);
|
|
140
|
+
},
|
|
141
|
+
featured: async () => {
|
|
142
|
+
return (await posts()).filter((post) => post.featured).slice(0, 10);
|
|
143
|
+
},
|
|
144
|
+
// TF-IDF similarity over title + category titles. When similarity
|
|
145
|
+
// yields fewer than `limit` posts, pad with the latest posts from the
|
|
146
|
+
// same category (newest first, secondary categories count too).
|
|
147
|
+
related: async (post: WiredPost, limit: number = 6) => {
|
|
148
|
+
const scores: { score: number; postId: string }[] = [];
|
|
149
|
+
tfIdf.tfidfs(post.searchTokens, (_, score, postId) => {
|
|
150
|
+
scores.push({
|
|
151
|
+
postId: postId as string,
|
|
152
|
+
score,
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const similar = scores
|
|
157
|
+
.filter((entry) => entry.postId !== post.id && entry.score > 0)
|
|
158
|
+
.sort((a, b) => b.score - a.score)
|
|
159
|
+
.slice(0, limit)
|
|
160
|
+
.map((entry) => keyedPosts[entry.postId]);
|
|
161
|
+
|
|
162
|
+
if (similar.length >= limit) {
|
|
163
|
+
return similar;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const categoryId = post.category?.id;
|
|
167
|
+
const seen = new Set(similar.map((p) => p.id));
|
|
168
|
+
const fromCategory = await posts.filter(
|
|
169
|
+
(p) =>
|
|
170
|
+
p.id !== post.id &&
|
|
171
|
+
!seen.has(p.id) &&
|
|
172
|
+
(!categoryId ||
|
|
173
|
+
p.category?.id === categoryId ||
|
|
174
|
+
(p.secondaryCategories ?? []).some(
|
|
175
|
+
(c: WiredCategory) => c.id === categoryId,
|
|
176
|
+
)),
|
|
177
|
+
);
|
|
178
|
+
return [...similar, ...fromCategory.slice(0, limit - similar.length)];
|
|
179
|
+
},
|
|
180
|
+
findBySlug: async (slug: string) => {
|
|
181
|
+
return posts.find((post) => post.slug === slug);
|
|
182
|
+
},
|
|
183
|
+
findByAuthor: async (author: Author) => {
|
|
184
|
+
return posts.filter((post) => post.author?.id === author.id);
|
|
185
|
+
},
|
|
186
|
+
findByTeamMember: async (memberId: string) => {
|
|
187
|
+
if (!teamMemberIndex) {
|
|
188
|
+
teamMemberIndex = (async () => {
|
|
189
|
+
const all = await posts();
|
|
190
|
+
const index = new Map<string, WiredPost[]>();
|
|
191
|
+
for (const post of all) {
|
|
192
|
+
for (const id of collectCustomTeamMemberIds(post)) {
|
|
193
|
+
const list = index.get(id);
|
|
194
|
+
if (list) {
|
|
195
|
+
list.push(post);
|
|
196
|
+
} else {
|
|
197
|
+
index.set(id, [post]);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const list of index.values()) {
|
|
202
|
+
list.sort(
|
|
203
|
+
(a, b) =>
|
|
204
|
+
new Date(b.publishDate ?? 0).getTime() -
|
|
205
|
+
new Date(a.publishDate ?? 0).getTime(),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return index;
|
|
209
|
+
})();
|
|
210
|
+
}
|
|
211
|
+
return (await teamMemberIndex).get(memberId) ?? [];
|
|
212
|
+
},
|
|
213
|
+
findByCategory: async (category: Category) => {
|
|
214
|
+
return posts.filter(
|
|
215
|
+
(post) =>
|
|
216
|
+
post.category === category.id ||
|
|
217
|
+
(post.category as Category)?.id === category.id ||
|
|
218
|
+
(post.secondaryCategories ?? []).some(
|
|
219
|
+
(c: WiredCategory) => c.id === category.id,
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
},
|
|
223
|
+
findByTag: async (tag: Tag) => {
|
|
224
|
+
return posts.filter((post) => {
|
|
225
|
+
return post.tags?.some((postTag: WiredTag) => postTag.id === tag.id);
|
|
226
|
+
});
|
|
227
|
+
},
|
|
228
|
+
nextInCategory: async (post: WiredPost): Promise<WiredPost | undefined> => {
|
|
229
|
+
if (!post.category) {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
const allCategoryIds = new Set([
|
|
233
|
+
(post.category as Category).id,
|
|
234
|
+
...(post.secondaryCategories ?? []).map((c) => c.id),
|
|
235
|
+
]);
|
|
236
|
+
const categoryPosts = await posts.filter(
|
|
237
|
+
(p) =>
|
|
238
|
+
allCategoryIds.has((p.category as Category)?.id ?? '') ||
|
|
239
|
+
(p.secondaryCategories ?? []).some((c: WiredCategory) =>
|
|
240
|
+
allCategoryIds.has(c.id),
|
|
241
|
+
),
|
|
242
|
+
);
|
|
243
|
+
const idx = categoryPosts.findIndex((p) => p.id === post.id);
|
|
244
|
+
return idx >= 0 && idx + 1 < categoryPosts.length
|
|
245
|
+
? categoryPosts[idx + 1]
|
|
246
|
+
: undefined;
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Context } from '../context';
|
|
2
|
+
import { createGlobalLoader } from '../loader';
|
|
3
|
+
import type { Project } from '../payload-types';
|
|
4
|
+
|
|
5
|
+
// The project document is fetched once, at config time, and handed to
|
|
6
|
+
// `createClient`. It is the same for every locale.
|
|
7
|
+
export default function (ctx: Context) {
|
|
8
|
+
return createGlobalLoader(async () => {
|
|
9
|
+
if (!ctx.options.project) {
|
|
10
|
+
throw new Error(
|
|
11
|
+
'cms.project() needs the `project` option of createClient: the Project document fetched at config time.',
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
return ctx.options.project as Project;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Context } from '../context';
|
|
2
|
+
import type { Question, QuestionTag } from '../payload-types';
|
|
3
|
+
import type { Locale } from '../locales';
|
|
4
|
+
import { createCollectionLoader } from '../loader';
|
|
5
|
+
|
|
6
|
+
export interface WiredQuestion extends Omit<Question, 'tags'> {
|
|
7
|
+
tags: QuestionTag[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function (ctx: Context, locale: Locale) {
|
|
11
|
+
const questions = createCollectionLoader(
|
|
12
|
+
async () =>
|
|
13
|
+
ctx.api.fetchCmsCollection<Question>('questions', {
|
|
14
|
+
locale,
|
|
15
|
+
sort: '_questions_questions_order',
|
|
16
|
+
}),
|
|
17
|
+
{
|
|
18
|
+
transform: (questions) => {
|
|
19
|
+
const validQuestions = questions.filter((question) => question.tags);
|
|
20
|
+
return validQuestions as WiredQuestion[];
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
all: questions,
|
|
27
|
+
filterByTag: async (searchTag: QuestionTag) => {
|
|
28
|
+
return questions.filter((question: WiredQuestion) =>
|
|
29
|
+
question.tags.some((tag) => {
|
|
30
|
+
return tag.id === searchTag.id;
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Context } from '../context';
|
|
2
|
+
import type { Seo } from '../payload-types';
|
|
3
|
+
import type { Locale } from '../locales';
|
|
4
|
+
import { createGlobalLoader } from '../loader';
|
|
5
|
+
|
|
6
|
+
export default function (ctx: Context, locale: Locale) {
|
|
7
|
+
return createGlobalLoader(async () =>
|
|
8
|
+
ctx.api.fetchCmsGlobalCollection<Seo>('seo', { locale }),
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Context } from '../context';
|
|
2
|
+
import type { Tag } from '../payload-types';
|
|
3
|
+
import type { Locale } from '../locales';
|
|
4
|
+
import { createCollectionLoader } from '../loader';
|
|
5
|
+
|
|
6
|
+
export interface WiredTag extends Tag {}
|
|
7
|
+
|
|
8
|
+
export default function (ctx: Context, locale: Locale) {
|
|
9
|
+
const tags = createCollectionLoader(
|
|
10
|
+
async () => ctx.api.fetchCmsCollection<Tag>('tags', { locale }),
|
|
11
|
+
{
|
|
12
|
+
transform: (tags) => {
|
|
13
|
+
const validTags = tags.filter((tag) => tag.slug);
|
|
14
|
+
return validTags as WiredTag[];
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
all: tags,
|
|
21
|
+
findBySlug: async (slug: string) => {
|
|
22
|
+
return tags.find((tag) => tag.slug === slug);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Context } from '../context';
|
|
2
|
+
import type { MemberType, TeamMember } from '../payload-types';
|
|
3
|
+
import type { Locale } from '../locales';
|
|
4
|
+
import type { WiredArea } from './areas';
|
|
5
|
+
import { createCollectionLoader } from '../loader';
|
|
6
|
+
|
|
7
|
+
export interface WiredTeamMember extends Omit<TeamMember, 'slug' | 'areas'> {
|
|
8
|
+
slug: string;
|
|
9
|
+
// Built from `memberAreas` at wire time. Kept under `areas` for backward
|
|
10
|
+
// compatibility with consumers that read `member.areas.docs` (the shape
|
|
11
|
+
// Payload's `join` field used to produce before the schema flipped to a
|
|
12
|
+
// member-side `memberAreas` array).
|
|
13
|
+
areas?: {
|
|
14
|
+
docs?: (string | WiredArea)[];
|
|
15
|
+
hasNextPage?: boolean;
|
|
16
|
+
totalDocs?: number;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function (ctx: Context, locale: Locale) {
|
|
21
|
+
const members = createCollectionLoader(
|
|
22
|
+
async () =>
|
|
23
|
+
ctx.api.fetchCmsCollection<TeamMember>('team-members', {
|
|
24
|
+
sort: '_order',
|
|
25
|
+
locale,
|
|
26
|
+
}),
|
|
27
|
+
{
|
|
28
|
+
transform: async (members): Promise<WiredTeamMember[]> => {
|
|
29
|
+
const allAreas = await ctx.cms(locale).areas.all();
|
|
30
|
+
const areaById = new Map(allAreas.map((a) => [a.id, a]));
|
|
31
|
+
|
|
32
|
+
return members
|
|
33
|
+
.filter((m) => m.slug)
|
|
34
|
+
.map((m): WiredTeamMember => {
|
|
35
|
+
const docs: (string | WiredArea)[] = [];
|
|
36
|
+
for (const row of m.memberAreas ?? []) {
|
|
37
|
+
const ref = row.area;
|
|
38
|
+
const id = typeof ref === 'string' ? ref : ref.id;
|
|
39
|
+
const hit = areaById.get(id);
|
|
40
|
+
if (hit) {
|
|
41
|
+
docs.push(hit);
|
|
42
|
+
} else if (typeof ref === 'string') {
|
|
43
|
+
docs.push(ref);
|
|
44
|
+
} else {
|
|
45
|
+
docs.push({
|
|
46
|
+
...ref,
|
|
47
|
+
page: typeof ref.page === 'object' ? ref.page : null,
|
|
48
|
+
} as WiredArea);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
...m,
|
|
54
|
+
areas: {
|
|
55
|
+
docs,
|
|
56
|
+
hasNextPage: false,
|
|
57
|
+
totalDocs: docs.length,
|
|
58
|
+
},
|
|
59
|
+
} as WiredTeamMember;
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const memberTypes = createCollectionLoader(() =>
|
|
66
|
+
ctx.api.fetchCmsCollection<MemberType>('member-types', {
|
|
67
|
+
sort: '_order',
|
|
68
|
+
locale,
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
all: members,
|
|
74
|
+
types: memberTypes,
|
|
75
|
+
findBySlug: async (slug: string) => {
|
|
76
|
+
return members.find((m) => m.slug === slug);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Api } from './api';
|
|
2
|
+
import type {
|
|
3
|
+
Appearance,
|
|
4
|
+
Media,
|
|
5
|
+
Options,
|
|
6
|
+
Project,
|
|
7
|
+
Redirect,
|
|
8
|
+
} from './payload-types';
|
|
9
|
+
import type { Routes } from './routes';
|
|
10
|
+
|
|
11
|
+
function createRedirect(
|
|
12
|
+
routes: Routes,
|
|
13
|
+
from: string,
|
|
14
|
+
to: Redirect['to'],
|
|
15
|
+
): Record<string, string> | undefined {
|
|
16
|
+
if (!to) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// These things should never be null but checking anyway for ts
|
|
21
|
+
if (to.type === 'custom') {
|
|
22
|
+
if (!to.url) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return { [from]: to.url };
|
|
27
|
+
} else if (!to.reference) {
|
|
28
|
+
return;
|
|
29
|
+
} else if (typeof to.reference.value === 'string') {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
switch (to.reference.relationTo) {
|
|
34
|
+
// NOTE: astro doesn't support external redirects so if this is that then it won't work
|
|
35
|
+
case 'pages':
|
|
36
|
+
return to.reference.value.slug
|
|
37
|
+
? { [from]: routes.page(to.reference.value.slug) }
|
|
38
|
+
: undefined;
|
|
39
|
+
case 'categories':
|
|
40
|
+
return to.reference.value.slug
|
|
41
|
+
? { [from]: routes.blog('category', to.reference.value.slug as string) }
|
|
42
|
+
: undefined;
|
|
43
|
+
default:
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createConfigTime(api: Api, defaultRoutes: Routes) {
|
|
49
|
+
return {
|
|
50
|
+
/** The three documents the Astro config and `createClient` need. */
|
|
51
|
+
async fetchProjectSettings(): Promise<{
|
|
52
|
+
project: Project;
|
|
53
|
+
appearance: Appearance;
|
|
54
|
+
options: Options;
|
|
55
|
+
}> {
|
|
56
|
+
const [project, appearance, options] = await Promise.all([
|
|
57
|
+
api.fetchCmsProject(),
|
|
58
|
+
api.fetchCmsGlobalCollection<Appearance>('appearance', { depth: 2 }),
|
|
59
|
+
api.fetchCmsGlobalCollection<Options>('options', { depth: 2 }),
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
return { project, appearance, options };
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
// Redirects are emitted once at build time for the whole site. Use the
|
|
66
|
+
// default locale — prefixed paths are handled by page routing, not the
|
|
67
|
+
// redirect table.
|
|
68
|
+
async redirects(): Promise<Record<string, string>> {
|
|
69
|
+
const docs = await api.fetchCmsCollection<Redirect>('redirects', {
|
|
70
|
+
depth: 2,
|
|
71
|
+
limit: 1000,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return Object.assign(
|
|
75
|
+
{},
|
|
76
|
+
...(docs
|
|
77
|
+
?.map(({ from, to }) => createRedirect(defaultRoutes, from, to))
|
|
78
|
+
.filter(Boolean) ?? []),
|
|
79
|
+
);
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
/** The favicon source file, or `false` when there is none to read. */
|
|
83
|
+
async fetchIconBuffer(icon: Media) {
|
|
84
|
+
if (icon.url) {
|
|
85
|
+
const response = await fetch(
|
|
86
|
+
icon.url.startsWith('/') ? api.apiUrl(icon.url, {}, false) : icon.url,
|
|
87
|
+
{ headers: api.buildHeaders() },
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (response.ok) {
|
|
91
|
+
return Buffer.from(await response.arrayBuffer());
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return false as const;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Api } from './api';
|
|
2
|
+
import type { Cms } from './cms';
|
|
3
|
+
import type { ClientOptions } from './client';
|
|
4
|
+
import type { Locale, Locales } from './locales';
|
|
5
|
+
|
|
6
|
+
/** What every collection service is built from. Internal to the package. */
|
|
7
|
+
export interface Context {
|
|
8
|
+
api: Api;
|
|
9
|
+
locales: Locales;
|
|
10
|
+
options: ClientOptions<any>;
|
|
11
|
+
cms: (locale: Locale) => Cms;
|
|
12
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Api } from '../api';
|
|
2
|
+
import type { Locale } from '../locales';
|
|
3
|
+
import type { Form } from '../payload-types';
|
|
4
|
+
import { refusedFieldNames } from './cms-errors';
|
|
5
|
+
import type { SubmissionsCmsClient } from './submissions';
|
|
6
|
+
import type { CmsResult } from './types';
|
|
7
|
+
import type { UploadsCmsClient } from './uploads';
|
|
8
|
+
|
|
9
|
+
// Writes made on behalf of anonymous visitors. Every request pins depth=0 so
|
|
10
|
+
// the CMS never populates the project relationship (which carries deploy
|
|
11
|
+
// hooks and tokens) into a response we hold in memory, and callers only ever
|
|
12
|
+
// get back the status, the new document id, and the names of any fields a
|
|
13
|
+
// refusal named.
|
|
14
|
+
//
|
|
15
|
+
// depth=0 is belt and braces on a create since postedin/cms#279: the CMS
|
|
16
|
+
// depopulates a submission's create response itself, so `form` and `project`
|
|
17
|
+
// come back as bare ids whatever depth is asked for. Nothing here ever read
|
|
18
|
+
// them — only `doc.id` — and nothing should start.
|
|
19
|
+
async function stripToStatusAndId(res: Response): Promise<CmsResult> {
|
|
20
|
+
let body: unknown;
|
|
21
|
+
try {
|
|
22
|
+
body = await res.json();
|
|
23
|
+
} catch {
|
|
24
|
+
return { status: res.status };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
return { status: res.status, fields: refusedFieldNames(body) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const doc = body as { doc?: { id?: unknown }; id?: unknown } | null;
|
|
32
|
+
const id = doc?.doc?.id ?? doc?.id;
|
|
33
|
+
return { status: res.status, id: typeof id === 'string' ? id : undefined };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type FormProxyCmsClient = SubmissionsCmsClient & UploadsCmsClient;
|
|
37
|
+
|
|
38
|
+
export function createFormProxyCmsClient(
|
|
39
|
+
api: Pick<Api, 'buildHeaders' | 'fetchCmsCollection'>,
|
|
40
|
+
apiUrl: string,
|
|
41
|
+
): FormProxyCmsClient {
|
|
42
|
+
function writeUrl(path: string, locale?: Locale) {
|
|
43
|
+
const url = new URL(path, apiUrl);
|
|
44
|
+
url.searchParams.set('depth', '0');
|
|
45
|
+
if (locale) {
|
|
46
|
+
url.searchParams.set('locale', locale);
|
|
47
|
+
}
|
|
48
|
+
return url;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Only forms of this project are visible: fetchCmsCollection scopes every
|
|
52
|
+
// query to the client's project, so a foreign form id simply comes back empty.
|
|
53
|
+
async function formBelongsToProject(formId: string): Promise<boolean> {
|
|
54
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(formId)) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
const docs = await api.fetchCmsCollection<Pick<Form, 'id'>>('forms', {
|
|
58
|
+
depth: 0,
|
|
59
|
+
limit: 1,
|
|
60
|
+
query: { where: { id: { equals: formId } } },
|
|
61
|
+
});
|
|
62
|
+
return docs.some((doc) => doc.id === formId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
formBelongsToProject,
|
|
67
|
+
|
|
68
|
+
async createSubmission(payload, locale?: Locale) {
|
|
69
|
+
// `project` is still sent: the CMS derives the tenant from the form and
|
|
70
|
+
// ignores what the body says, but an older deployment of it does not.
|
|
71
|
+
const res = await fetch(writeUrl('/api/form-submissions', locale), {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { ...api.buildHeaders(), 'Content-Type': 'application/json' },
|
|
74
|
+
body: JSON.stringify(payload),
|
|
75
|
+
});
|
|
76
|
+
return stripToStatusAndId(res);
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
// `form` in the payload is dropped by the CMS — the field is server-written
|
|
80
|
+
// there, and an upload carries no form until the submission claiming it
|
|
81
|
+
// arrives. It is sent anyway so an older CMS still records it, and nothing
|
|
82
|
+
// on this side reads it back.
|
|
83
|
+
async createUpload(file, payload) {
|
|
84
|
+
const body = new FormData();
|
|
85
|
+
body.append('file', file, file.name);
|
|
86
|
+
body.append('_payload', JSON.stringify(payload));
|
|
87
|
+
const res = await fetch(writeUrl('/api/form-uploads'), {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: api.buildHeaders(),
|
|
90
|
+
body,
|
|
91
|
+
});
|
|
92
|
+
return stripToStatusAndId(res);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|