@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 @@
1
+ //# sourceMappingURL=payload-types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@postedin/cms-client",
3
+ "version": "0.1.0",
4
+ "description": "Payload CMS data access, wiring, routes and form proxy for postedin sites.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=22"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/postedin/cms-sites.git",
13
+ "directory": "packages/cms-client"
14
+ },
15
+ "sideEffects": false,
16
+ "files": [
17
+ "dist",
18
+ "src",
19
+ "bin",
20
+ "!**/__tests__",
21
+ "!**/__fixtures__"
22
+ ],
23
+ "bin": {
24
+ "cms-profile-build": "./bin/profile/build.mjs",
25
+ "cms-profile-report": "./bin/profile/report.mjs",
26
+ "cms-profile-interference": "./bin/profile/interference.mjs",
27
+ "cms-dissect-build-log": "./bin/dissect/cli.mjs"
28
+ },
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ },
34
+ "./browser": {
35
+ "types": "./dist/browser.d.ts",
36
+ "import": "./dist/browser.js"
37
+ },
38
+ "./upload-policy": {
39
+ "types": "./dist/form-proxy/upload-policy.d.ts",
40
+ "import": "./dist/form-proxy/upload-policy.js"
41
+ },
42
+ "./payload-types": {
43
+ "types": "./dist/payload-types.d.ts",
44
+ "import": "./dist/payload-types.js"
45
+ }
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "dependencies": {
51
+ "natural": "^8.1.1",
52
+ "qs-esm": "^7.0.3"
53
+ },
54
+ "peerDependencies": {
55
+ "vite": ">=6"
56
+ },
57
+ "peerDependenciesMeta": {
58
+ "vite": {
59
+ "optional": true
60
+ }
61
+ },
62
+ "devDependencies": {
63
+ "@types/node": "^24.12.0",
64
+ "tsup": "^8.5.0",
65
+ "typescript": "^5.9.3",
66
+ "vitest": "^4.1.2"
67
+ },
68
+ "scripts": {
69
+ "build": "tsup",
70
+ "typecheck": "tsc --noEmit",
71
+ "test": "vitest run",
72
+ "test:watch": "vitest"
73
+ }
74
+ }
package/src/api.ts ADDED
@@ -0,0 +1,387 @@
1
+ import { stringify } from 'qs-esm';
2
+ import type { Locale } from './locales';
3
+ import type { Media, Project } from './payload-types';
4
+ import { getRandomPlaceholderUrl } from './placeholder';
5
+
6
+ // The scheme names the CMS collection the key belongs to. Build credentials
7
+ // are project-scoped `api-keys` documents — a principal that can read one
8
+ // project and write nothing — rather than a person's key in `users`, which
9
+ // authenticates as that person with all of their permissions.
10
+ const CMS_API_KEY_COLLECTION = 'api-keys';
11
+
12
+ const DEPTH = 2;
13
+ const LIMIT = 1000;
14
+
15
+ // How many pages of one collection are fetched at a time. A CMS on a
16
+ // shared-tier database tears down connections under a build's request fan-out:
17
+ // `media` on a large site is 20k+ documents, so firing its remaining 20 pages
18
+ // in a single `Promise.all` made the CMS answer 500 and failed the build.
19
+ // Pages are still fetched in parallel, just a few at a time.
20
+ const PAGE_CONCURRENCY = 4;
21
+
22
+ /** Which of the two URL helpers is asking: a download link or an `<img>`. */
23
+ export type UploadUse = 'file' | 'image';
24
+
25
+ /**
26
+ * A site's own rule for where an upload is served from. Return a URL to use
27
+ * it, or `undefined` to let the default rule decide. Consulted for every
28
+ * production build, bucket or not, since a site's rewrites do not depend on
29
+ * one; in dev the URL the CMS returned is always used.
30
+ */
31
+ export type ResolveUploadUrl = (
32
+ media: Media,
33
+ use: UploadUse,
34
+ ) => string | null | undefined;
35
+
36
+ export interface ApiOptions {
37
+ apiUrl: string;
38
+ apiKey: string;
39
+ projectSlug: string;
40
+ uploadsBaseUrl?: string;
41
+ protectionBypassSecret?: string;
42
+ isDev: boolean;
43
+ pageConcurrency?: number;
44
+ resolveUploadUrl?: ResolveUploadUrl;
45
+ }
46
+
47
+ export type Collection<T> = {
48
+ docs: T[];
49
+ totalDocs: number;
50
+ limit: number;
51
+ totalPages: number;
52
+ page: number;
53
+ pagingCounter: number;
54
+ hasPrevPage: boolean;
55
+ hasNextPage: boolean;
56
+ prevPage: number | null;
57
+ nextPage: number | null;
58
+ };
59
+
60
+ // Runs `worker` over `items` with at most `limit` in flight. Results keep the
61
+ // order of `items`, and the first rejection rejects the whole call — the same
62
+ // contract as the `Promise.all` this replaces.
63
+ export async function mapWithConcurrency<TItem, TResult>(
64
+ items: TItem[],
65
+ limit: number,
66
+ worker: (item: TItem, index: number) => Promise<TResult>,
67
+ ): Promise<TResult[]> {
68
+ const results = new Array<TResult>(items.length);
69
+ let next = 0;
70
+
71
+ const runner = async () => {
72
+ while (next < items.length) {
73
+ const index = next++;
74
+ results[index] = await worker(items[index], index);
75
+ }
76
+ };
77
+
78
+ await Promise.all(
79
+ Array.from({ length: Math.min(limit, items.length) }, runner),
80
+ );
81
+
82
+ return results;
83
+ }
84
+
85
+ export type FallbackMode = 'null' | 'default';
86
+
87
+ function applyLocale(
88
+ query: any,
89
+ locale: Locale | undefined,
90
+ fallback: FallbackMode,
91
+ ) {
92
+ if (!locale) {
93
+ return query;
94
+ }
95
+ query.locale = locale;
96
+ // 'null' → untranslated localized fields come back as null so callers can
97
+ // use null slug/title as the "not translated" signal (used by collections
98
+ // that drive getStaticPaths to skip untranslated entries).
99
+ // 'default' → let Payload fall back to the default-locale value. Right for
100
+ // globals/chrome (header, footer, seo, appearance) where we'd rather show
101
+ // the default-locale content than an empty UI.
102
+ if (fallback === 'null') {
103
+ query['fallback-locale'] = 'null';
104
+ }
105
+ return query;
106
+ }
107
+
108
+ export type FetchCmsGlobalOptions = {
109
+ query?: any;
110
+ depth?: number;
111
+ locale?: Locale;
112
+ fallback?: FallbackMode;
113
+ };
114
+
115
+ export type FetchCmsCollectionOptions = FetchCmsGlobalOptions & {
116
+ status?: string;
117
+ limit?: number;
118
+ sort?: string;
119
+ };
120
+
121
+ export type Api = ReturnType<typeof createApi>;
122
+
123
+ export function createApi(options: ApiOptions) {
124
+ const pageConcurrency = options.pageConcurrency ?? PAGE_CONCURRENCY;
125
+
126
+ function buildHeaders(): Record<string, string> {
127
+ const headers: Record<string, string> = {
128
+ Authorization: `${CMS_API_KEY_COLLECTION} API-Key ${options.apiKey}`,
129
+ };
130
+
131
+ if (options.protectionBypassSecret) {
132
+ headers['x-vercel-protection-bypass'] = options.protectionBypassSecret;
133
+ }
134
+
135
+ return headers;
136
+ }
137
+
138
+ // `false` asks for no project scope at all — the one query that needs it is
139
+ // the project lookup itself.
140
+ function apiUrl(url: string, query: any = {}, scoped = true) {
141
+ if (scoped) {
142
+ query.where = {
143
+ and: [
144
+ { 'project.slug': { equals: options.projectSlug } },
145
+ ...(query.where ? [query.where] : []),
146
+ ],
147
+ };
148
+ }
149
+
150
+ return options.apiUrl + url + stringify(query, { addQueryPrefix: true });
151
+ }
152
+
153
+ function getUploadsPublicUrl(endpoint: string) {
154
+ return `${options.uploadsBaseUrl}/${endpoint}`;
155
+ }
156
+
157
+ // Uploads are served straight from the bucket at `uploadsBaseUrl`, unless
158
+ // the site's `resolveUploadUrl` says otherwise. The site is asked first,
159
+ // with or without a bucket: its rule points at its own rewrites.
160
+ //
161
+ // Without a bucket the default rule has nothing to point at, so fall back
162
+ // to the absolute URL Payload itself returns and let the CMS serve the
163
+ // file. Testing `isDev` alone is not enough: `pnpm build` is a production
164
+ // build, so a local build took the bucket branch with no bucket configured
165
+ // and emitted `/<filename>` — a root-relative path to a file that exists
166
+ // nowhere in the output. That is why images render in `pnpm dev` but not
167
+ // in `pnpm preview`.
168
+ const hasUploadsBucket = !!options.uploadsBaseUrl;
169
+
170
+ function siteUploadUrl(media: Media, use: UploadUse) {
171
+ return options.resolveUploadUrl?.(media, use) ?? undefined;
172
+ }
173
+
174
+ function fileUrl(media?: Media | null) {
175
+ if (!media) {
176
+ return null;
177
+ }
178
+
179
+ if (options.isDev) {
180
+ return media.url ?? null;
181
+ }
182
+
183
+ const own = siteUploadUrl(media, 'file');
184
+ if (own !== undefined) {
185
+ return own;
186
+ }
187
+
188
+ if (hasUploadsBucket && media.filename) {
189
+ return getUploadsPublicUrl(media.filename);
190
+ }
191
+
192
+ return media.url ?? null;
193
+ }
194
+
195
+ function imageUrl(media?: Media | null) {
196
+ if (!media) {
197
+ return getRandomPlaceholderUrl();
198
+ }
199
+
200
+ if (options.isDev) {
201
+ return media.url ?? getRandomPlaceholderUrl();
202
+ }
203
+
204
+ const own = siteUploadUrl(media, 'image');
205
+ if (own !== undefined) {
206
+ return own ?? getRandomPlaceholderUrl();
207
+ }
208
+
209
+ if (hasUploadsBucket && media.filename) {
210
+ return getUploadsPublicUrl(media.filename);
211
+ }
212
+
213
+ return media.url || getRandomPlaceholderUrl();
214
+ }
215
+
216
+ // Fetch a raw uploaded file through the CMS API. Upload docs' `url` is
217
+ // relative to the CMS host (e.g. /api/vcards/file/{filename}).
218
+ async function fetchCmsFile(url: string): Promise<Response> {
219
+ return await fetch(new URL(url, options.apiUrl), {
220
+ headers: buildHeaders(),
221
+ });
222
+ }
223
+
224
+ async function fetchCmsGlobal<T>(
225
+ global: string,
226
+ fetchOptions: FetchCmsGlobalOptions = {},
227
+ ): Promise<T> {
228
+ const query = fetchOptions.query || {};
229
+ const depth = fetchOptions.depth ?? DEPTH;
230
+
231
+ applyLocale(query, fetchOptions.locale, fetchOptions.fallback ?? 'null');
232
+
233
+ const res = await fetch(
234
+ apiUrl(`/api/globals/${global}`, { depth, ...query }),
235
+ { headers: buildHeaders() },
236
+ );
237
+
238
+ return (await res.json()) as T;
239
+ }
240
+
241
+ async function fetchCmsGlobalCollection<T>(
242
+ collection: string,
243
+ fetchOptions: FetchCmsGlobalOptions = {},
244
+ ): Promise<T> {
245
+ const query = fetchOptions.query || {};
246
+ const depth = fetchOptions.depth ?? DEPTH;
247
+
248
+ applyLocale(query, fetchOptions.locale, fetchOptions.fallback ?? 'null');
249
+
250
+ const res = await fetch(apiUrl(`/api/${collection}`, { depth, ...query }), {
251
+ headers: buildHeaders(),
252
+ });
253
+
254
+ if (!res.ok) {
255
+ const url = apiUrl(`/api/${collection}`, { depth, ...query });
256
+ const text = await res.text();
257
+ let error: string;
258
+
259
+ try {
260
+ const json = JSON.parse(text);
261
+ error = `${res.status} (${url})j: ${json.message || JSON.stringify(json)}`;
262
+ } catch {
263
+ error = `${res.status} (${url})t: ${text}`;
264
+ }
265
+
266
+ throw new Error(error);
267
+ }
268
+
269
+ const docs = (await res.json()).docs;
270
+
271
+ if (!docs.length) {
272
+ throw new Error(
273
+ `Failed to load global collection "${collection}", no docs found.`,
274
+ );
275
+ }
276
+
277
+ return docs[0];
278
+ }
279
+
280
+ async function fetchCmsCollection<T>(
281
+ collection: string,
282
+ fetchOptions: FetchCmsCollectionOptions = {},
283
+ ): Promise<T[]> {
284
+ const query = fetchOptions.query || {};
285
+ const depth = fetchOptions.depth ?? DEPTH;
286
+ const limit = fetchOptions.limit || LIMIT;
287
+ const sort = fetchOptions.sort || '-createdAt';
288
+ const fields = query.fields || {};
289
+
290
+ if (fetchOptions.status) {
291
+ query.where = query?.where || {};
292
+ query.where._status = { equals: fetchOptions.status };
293
+ }
294
+
295
+ applyLocale(query, fetchOptions.locale, fetchOptions.fallback ?? 'null');
296
+
297
+ const fetchPage = async (page: number = 1): Promise<Collection<T>> => {
298
+ const res = await fetch(
299
+ apiUrl(`/api/${collection}`, {
300
+ depth,
301
+ limit,
302
+ sort,
303
+ fields,
304
+ ...query,
305
+ page,
306
+ }),
307
+ { headers: buildHeaders() },
308
+ );
309
+
310
+ if (!res.ok) {
311
+ const errorText = await res.text();
312
+
313
+ throw new Error(
314
+ `CMS API request failed for ${collection} (page ${page})\n` +
315
+ `Status: ${res.status} ${res.statusText}\n` +
316
+ `Details: ${errorText.substring(0, 500)}${errorText.length > 500 ? '...' : ''}`,
317
+ );
318
+ }
319
+
320
+ return await res.json();
321
+ };
322
+
323
+ const firstPage = await fetchPage();
324
+
325
+ let otherPagesData: T[][] = [];
326
+ if (firstPage.totalPages > 1) {
327
+ const otherPages = Array.from(
328
+ { length: firstPage.totalPages - 1 },
329
+ (_, i) => i + 2,
330
+ );
331
+ otherPagesData = await mapWithConcurrency(
332
+ otherPages,
333
+ pageConcurrency,
334
+ async (p) => (await fetchPage(p)).docs,
335
+ );
336
+ }
337
+
338
+ return firstPage.docs.concat(otherPagesData.flat());
339
+ }
340
+
341
+ async function fetchCmsProject(): Promise<Project> {
342
+ const slug = options.projectSlug;
343
+ const res = await fetch(
344
+ apiUrl(
345
+ '/api/projects',
346
+ { limit: 1, where: { slug: { equals: slug } } },
347
+ false,
348
+ ),
349
+ { headers: buildHeaders() },
350
+ );
351
+
352
+ if (!res.ok) {
353
+ const errorText = await res.text();
354
+
355
+ throw new Error(
356
+ `CMS API request failed for ProjectBySlug\n` +
357
+ `Status: ${res.status} ${res.statusText}\n` +
358
+ `Details: ${errorText.substring(0, 500)}${errorText.length > 500 ? '...' : ''}`,
359
+ );
360
+ }
361
+
362
+ const docs = (await res.json()).docs;
363
+
364
+ if (!docs) {
365
+ throw new Error(
366
+ `CMS API request failed for ProjectBySlug\n` +
367
+ `Status: ${res.status} ${res.statusText}\n` +
368
+ `Details: failed to find project by slug "${slug}"`,
369
+ );
370
+ }
371
+
372
+ return docs[0];
373
+ }
374
+
375
+ return {
376
+ apiUrl,
377
+ buildHeaders,
378
+ getUploadsPublicUrl,
379
+ fileUrl,
380
+ imageUrl,
381
+ fetchCmsFile,
382
+ fetchCmsGlobal,
383
+ fetchCmsGlobalCollection,
384
+ fetchCmsCollection,
385
+ fetchCmsProject,
386
+ };
387
+ }
@@ -0,0 +1,75 @@
1
+ import type { Context } from './context';
2
+ import type { Locale } from './locales';
3
+ import type { Page } from './payload-types';
4
+
5
+ export interface BlogListing {
6
+ /**
7
+ * What the project calls its blog. `null` when no blog page is configured or
8
+ * the editor left the field blank, in which case the caller falls back to the
9
+ * `blog.title` translation.
10
+ */
11
+ name: string | null;
12
+ /** Intro copy for the listing, in the dual Lexical/HTML shape. */
13
+ description: Page['listingDescription'] | null;
14
+ }
15
+
16
+ const EMPTY: BlogListing = { name: null, description: null };
17
+
18
+ async function fetchBlogListing(
19
+ ctx: Context,
20
+ locale: Locale,
21
+ ): Promise<BlogListing> {
22
+ const options = await ctx.cms(locale).options();
23
+ const configured = options.blog?.page;
24
+ const id =
25
+ typeof configured === 'string' ? configured : (configured?.id ?? null);
26
+
27
+ if (!id) {
28
+ return EMPTY;
29
+ }
30
+
31
+ // Read the standalone document rather than the copy embedded in the options
32
+ // global, for the same reason `home-page.ts` does: globals are fetched
33
+ // without locale fallback, so an untranslated blog page comes back with a
34
+ // null `listingName` and would head the English listing with nothing at all.
35
+ const [doc] = await ctx.api.fetchCmsCollection<Page>('pages', {
36
+ locale,
37
+ fallback: 'default',
38
+ status: 'published',
39
+ limit: 1,
40
+ query: { where: { id: { equals: id } } },
41
+ });
42
+
43
+ if (!doc) {
44
+ return EMPTY;
45
+ }
46
+
47
+ return {
48
+ name: doc.listingName || null,
49
+ description: doc.listingDescription ?? null,
50
+ };
51
+ }
52
+
53
+ export function createBlogListing(ctx: Context) {
54
+ const cache = new Map<Locale, Promise<BlogListing>>();
55
+
56
+ /**
57
+ * The name and intro copy the CMS holds for the blog listing. Every surface that
58
+ * heads a blog listing — the paginated index, the category and tag pages —
59
+ * reads it from here, so a project that calls its blog something else says so
60
+ * everywhere rather than only in its menu.
61
+ *
62
+ * Server-only. Cached per locale, so calling it from several components costs
63
+ * one fetch per locale per build.
64
+ */
65
+ return function getBlogListing(locale: Locale): Promise<BlogListing> {
66
+ let pending = cache.get(locale);
67
+
68
+ if (!pending) {
69
+ pending = fetchBlogListing(ctx, locale);
70
+ cache.set(locale, pending);
71
+ }
72
+
73
+ return pending;
74
+ };
75
+ }
package/src/browser.ts ADDED
@@ -0,0 +1,24 @@
1
+ // Everything a hydrated island may import. Nothing here reaches the CMS or
2
+ // holds a credential, so it is safe in the client bundle.
3
+ export type {
4
+ CmsCallToActionLinks,
5
+ CmsFooter,
6
+ CmsHeader,
7
+ CmsLink,
8
+ CmsMainMenu,
9
+ CmsMenuItem,
10
+ CmsSocialLinks,
11
+ } from './collections/header-footer';
12
+ export { type CmsToHref, defineCmsToHref } from './cms-to-href';
13
+ export {
14
+ defineLocales,
15
+ type Locale,
16
+ type LocaleOptions,
17
+ type Locales,
18
+ } from './locales';
19
+ export {
20
+ type CreateRoutes,
21
+ defineRoutes,
22
+ type HomePageRef,
23
+ type Routes,
24
+ } from './routes';
package/src/client.ts ADDED
@@ -0,0 +1,144 @@
1
+ import { createApi, type ResolveUploadUrl } from './api';
2
+ import { createBlogListing } from './blog-listing';
3
+ import { buildCms, type Cms } from './cms';
4
+ import { defineCmsToHref } from './cms-to-href';
5
+ import type { AppearanceFallbacks } from './collections/appearance';
6
+ import { wirePost } from './collections/posts';
7
+ import { createConfigTime } from './config-time';
8
+ import type { Context } from './context';
9
+ import { createFormProxy, createFormProxyCmsClient } from './form-proxy';
10
+ import { createHomePage } from './home-page';
11
+ import { createCollectionLoader, createGlobalLoader } from './loader';
12
+ import { defineLocales, type Locale, type LocaleOptions } from './locales';
13
+ import type { Appearance, Post, Project } from './payload-types';
14
+ import { defineRoutes } from './routes';
15
+
16
+ /** What an extension collection is built from. */
17
+ export interface ExtensionContext {
18
+ api: Context['api'];
19
+ locale: Locale;
20
+ /** The client's other services, for a collection that wires against them. */
21
+ cms: (locale: Locale) => Cms;
22
+ createCollectionLoader: typeof createCollectionLoader;
23
+ createGlobalLoader: typeof createGlobalLoader;
24
+ }
25
+
26
+ /**
27
+ * A collection only one site reads. It is built per locale and appears on
28
+ * `client.cms(locale)` under the key it was registered with.
29
+ */
30
+ export type Extension<T = unknown> = (context: ExtensionContext) => T;
31
+
32
+ /** What each extension builds, by the key it appears under. */
33
+ export type Extensions = Record<string, unknown>;
34
+
35
+ export type CmsWith<E extends Extensions> = Cms & E;
36
+
37
+ export interface ClientOptions<E extends Extensions = Record<never, never>>
38
+ extends LocaleOptions {
39
+ /** The CMS origin, e.g. `https://cms.example.com`. */
40
+ apiUrl: string;
41
+ /** A project-scoped `api-keys` document's key. */
42
+ apiKey: string;
43
+ /** Every query is scoped to this project. */
44
+ projectSlug: string;
45
+ /** Public origin of the uploads bucket. Without it the CMS serves files. */
46
+ uploadsBaseUrl?: string;
47
+ /** Sent as `x-vercel-protection-bypass` when the CMS sits behind it. */
48
+ protectionBypassSecret?: string;
49
+ /** Dev serves every upload from the CMS's own URL. */
50
+ isDev: boolean;
51
+ /**
52
+ * The Appearance document, fetched at config time. Required for
53
+ * `cms.appearance()`; a config-time client leaves it out.
54
+ */
55
+ appearance?: Appearance;
56
+ /** What a blank Appearance field falls back to. */
57
+ appearanceFallbacks?: AppearanceFallbacks;
58
+ /**
59
+ * The Project document, fetched at config time. Required for
60
+ * `cms.project()` and the form proxy; a config-time client leaves it out.
61
+ */
62
+ project?: Project;
63
+ /** Collection pages fetched at a time. Default 4. */
64
+ pageConcurrency?: number;
65
+ /** A site's own rule for where an upload is served from. */
66
+ resolveUploadUrl?: ResolveUploadUrl;
67
+ /** Collections only this site reads, by the key they appear under. */
68
+ extensions?: { [K in keyof E]: Extension<E[K]> };
69
+ }
70
+
71
+ export type Client<E extends Extensions = Record<never, never>> = ReturnType<
72
+ typeof createClient<E>
73
+ >;
74
+
75
+ /**
76
+ * The CMS client a site builds once from its environment. Nothing in here
77
+ * reads the environment itself: an npm-installed package is externalised by
78
+ * Astro, so `import.meta.env` would be empty by the time it ran.
79
+ */
80
+ export function createClient<E extends Extensions = Record<never, never>>(
81
+ options: ClientOptions<E>,
82
+ ) {
83
+ const locales = defineLocales(options);
84
+ const api = createApi(options);
85
+ const createRoutes = defineRoutes(locales);
86
+ const cmsToHref = defineCmsToHref(createRoutes);
87
+
88
+ const instances = new Map<Locale, CmsWith<E>>();
89
+
90
+ const ctx: Context = {
91
+ api,
92
+ locales,
93
+ options,
94
+ cms: (locale) => cms(locale),
95
+ };
96
+
97
+ function cms(locale: Locale = locales.defaultLocale): CmsWith<E> {
98
+ let instance = instances.get(locale);
99
+ if (!instance) {
100
+ const base = buildCms(ctx, locale);
101
+ const extended: Record<string, unknown> = {};
102
+ for (const [key, extension] of Object.entries<Extension>(
103
+ options.extensions ?? {},
104
+ )) {
105
+ extended[key] = extension({
106
+ api,
107
+ locale,
108
+ cms: ctx.cms,
109
+ createCollectionLoader,
110
+ createGlobalLoader,
111
+ });
112
+ }
113
+ instance = { ...base, ...extended } as CmsWith<E>;
114
+ instances.set(locale, instance);
115
+ }
116
+ return instance;
117
+ }
118
+
119
+ const { getHomePage, getHomePageRef } = createHomePage(ctx);
120
+
121
+ const formProxy = createFormProxy(
122
+ createFormProxyCmsClient(api, options.apiUrl),
123
+ () => cms().project(),
124
+ locales.locales,
125
+ );
126
+
127
+ return {
128
+ options,
129
+ locales,
130
+ api,
131
+ cms,
132
+ routes: createRoutes,
133
+ cmsToHref,
134
+ getHomePage,
135
+ getHomePageRef,
136
+ getBlogListing: createBlogListing(ctx),
137
+ /** Wires a single post document, e.g. a draft for live preview. */
138
+ wirePost: (post: Post | undefined, locale: Locale) =>
139
+ wirePost(ctx, post, locale),
140
+ formProxy,
141
+ /** The fetches `astro.config.mjs` makes before the build starts. */
142
+ config: createConfigTime(api, createRoutes(locales.defaultLocale)),
143
+ };
144
+ }