@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.
Files changed (73) hide show
  1. package/README.md +66 -0
  2. package/bin/dissect/cli.mjs +138 -0
  3. package/bin/dissect/dissect.mjs +290 -0
  4. package/bin/profile/build.mjs +106 -0
  5. package/bin/profile/fetch-log.mjs +298 -0
  6. package/bin/profile/format.mjs +90 -0
  7. package/bin/profile/interference-summary.mjs +558 -0
  8. package/bin/profile/interference.mjs +604 -0
  9. package/bin/profile/measure.mjs +137 -0
  10. package/bin/profile/report.mjs +90 -0
  11. package/bin/profile/site-env.mjs +16 -0
  12. package/bin/profile/summarize.mjs +429 -0
  13. package/dist/browser.d.ts +145 -0
  14. package/dist/browser.js +11 -0
  15. package/dist/browser.js.map +1 -0
  16. package/dist/chunk-6V54ITTK.js +197 -0
  17. package/dist/chunk-6V54ITTK.js.map +1 -0
  18. package/dist/chunk-MNZ7DIGC.js +51 -0
  19. package/dist/chunk-MNZ7DIGC.js.map +1 -0
  20. package/dist/form-proxy/upload-policy.d.ts +40 -0
  21. package/dist/form-proxy/upload-policy.js +17 -0
  22. package/dist/form-proxy/upload-policy.js.map +1 -0
  23. package/dist/index.d.ts +570 -0
  24. package/dist/index.js +1636 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/payload-types.d.ts +8985 -0
  27. package/dist/payload-types.js +1 -0
  28. package/dist/payload-types.js.map +1 -0
  29. package/package.json +74 -0
  30. package/src/api.ts +387 -0
  31. package/src/blog-listing.ts +75 -0
  32. package/src/browser.ts +24 -0
  33. package/src/client.ts +144 -0
  34. package/src/cms-to-href.ts +70 -0
  35. package/src/cms.ts +86 -0
  36. package/src/collections/appearance.ts +94 -0
  37. package/src/collections/areas.ts +29 -0
  38. package/src/collections/authors.ts +27 -0
  39. package/src/collections/banners.ts +14 -0
  40. package/src/collections/categories.ts +111 -0
  41. package/src/collections/forms.ts +29 -0
  42. package/src/collections/header-footer.ts +19 -0
  43. package/src/collections/image-links.ts +14 -0
  44. package/src/collections/media.ts +18 -0
  45. package/src/collections/options.ts +10 -0
  46. package/src/collections/pages.ts +83 -0
  47. package/src/collections/posts.ts +249 -0
  48. package/src/collections/project.ts +16 -0
  49. package/src/collections/questions.ts +35 -0
  50. package/src/collections/seo.ts +10 -0
  51. package/src/collections/tags.ts +25 -0
  52. package/src/collections/team-members.ts +79 -0
  53. package/src/config-time.ts +98 -0
  54. package/src/context.ts +12 -0
  55. package/src/decode-html.ts +8 -0
  56. package/src/form-proxy/cms-client.ts +95 -0
  57. package/src/form-proxy/cms-errors.ts +73 -0
  58. package/src/form-proxy/cms-write.ts +44 -0
  59. package/src/form-proxy/http.ts +96 -0
  60. package/src/form-proxy/index.ts +73 -0
  61. package/src/form-proxy/rate-limit.ts +46 -0
  62. package/src/form-proxy/submissions.ts +88 -0
  63. package/src/form-proxy/types.ts +23 -0
  64. package/src/form-proxy/upload-policy.ts +92 -0
  65. package/src/form-proxy/uploads.ts +81 -0
  66. package/src/home-page.ts +83 -0
  67. package/src/index.ts +68 -0
  68. package/src/loader.ts +83 -0
  69. package/src/locales.ts +80 -0
  70. package/src/payload-types.ts +10854 -0
  71. package/src/placeholder.ts +9 -0
  72. package/src/resolve-menu-items.ts +184 -0
  73. 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,8 @@
1
+ export function decodeHtml(str: string): string {
2
+ return str
3
+ .replace(/&amp;/g, '&')
4
+ .replace(/&lt;/g, '<')
5
+ .replace(/&gt;/g, '>')
6
+ .replace(/&quot;/g, '"')
7
+ .replace(/&#39;/g, "'");
8
+ }
@@ -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
+ }