@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,70 @@
1
+ import type { CmsLink } from './collections/header-footer';
2
+ import type { Locale } from './locales';
3
+ import type { Author, Category, Page, Post, Tag } from './payload-types';
4
+ import type { CreateRoutes, HomePageRef } from './routes';
5
+
6
+ export type CmsToHref = ReturnType<typeof defineCmsToHref>;
7
+
8
+ /** `cmsToHref`, bound to the site's route builder. Pure; islands use it too. */
9
+ export function defineCmsToHref(createRoutes: CreateRoutes) {
10
+ return (
11
+ link: Partial<CmsLink>,
12
+ locale: Locale,
13
+ homePage: HomePageRef,
14
+ ): string | undefined => cmsToHref(createRoutes, link, locale, homePage);
15
+ }
16
+
17
+ function cmsToHref(
18
+ createRoutes: CreateRoutes,
19
+ { type, url, reference }: Partial<CmsLink>,
20
+ locale: Locale,
21
+ // The page configured as the site homepage — read it with `getHomePageRef`
22
+ // (server) or take it as a prop (islands). `createRoutes` owns the collapse
23
+ // to `/`, so every `routes.page()` caller gets the same behaviour; passing it
24
+ // here just hands it through. Required so a new call site can't silently fall
25
+ // back to the wrong URL.
26
+ homePage: HomePageRef,
27
+ ): string | undefined {
28
+ if (type === 'custom') {
29
+ return url ?? undefined;
30
+ }
31
+
32
+ if (!reference?.relationTo || !reference.value) {
33
+ return undefined;
34
+ }
35
+
36
+ const routes = createRoutes(locale, homePage);
37
+
38
+ if (reference.relationTo === 'pages') {
39
+ const page = reference.value as Page | string;
40
+
41
+ // An unpopulated reference is just an id, so there is no path to route by.
42
+ // The homepage is still recoverable — it routes by id.
43
+ if (typeof page === 'string') {
44
+ return homePage && page === homePage.id ? routes.root() : undefined;
45
+ }
46
+
47
+ const rawPath = page.breadcrumbs?.at(-1)?.url ?? page.slug;
48
+ if (!rawPath) {
49
+ return undefined;
50
+ }
51
+ const path = rawPath.replace(/^\//, '');
52
+ return routes.find('page', path);
53
+ }
54
+
55
+ if (reference.relationTo === 'categories') {
56
+ return routes.find('category', (reference.value as Category).slug);
57
+ }
58
+
59
+ if (reference.relationTo === 'posts') {
60
+ return routes.find('post', (reference.value as Post).slug);
61
+ }
62
+
63
+ if (reference.relationTo === 'authors') {
64
+ return routes.find('author', (reference.value as Author).slug);
65
+ }
66
+
67
+ if (reference.relationTo === 'tags') {
68
+ return routes.find('tag', (reference.value as Tag).slug);
69
+ }
70
+ }
package/src/cms.ts ADDED
@@ -0,0 +1,86 @@
1
+ import appearance from './collections/appearance';
2
+ import areas from './collections/areas';
3
+ import authors from './collections/authors';
4
+ import banners from './collections/banners';
5
+ import categories, { type CountedCategory } from './collections/categories';
6
+ import forms from './collections/forms';
7
+ import headerFooter from './collections/header-footer';
8
+ import imageLinks from './collections/image-links';
9
+ import media from './collections/media';
10
+ import options from './collections/options';
11
+ import pages from './collections/pages';
12
+ import posts from './collections/posts';
13
+ import project from './collections/project';
14
+ import questions from './collections/questions';
15
+ import seo from './collections/seo';
16
+ import tags from './collections/tags';
17
+ import teamMembers from './collections/team-members';
18
+ import type { Context } from './context';
19
+ import type { Locale } from './locales';
20
+
21
+ export type Cms = ReturnType<typeof buildCms>;
22
+
23
+ export function buildCms(ctx: Context, locale: Locale) {
24
+ const categoriesService = categories(ctx, locale);
25
+ const postsService = posts(ctx, locale);
26
+
27
+ return {
28
+ locale,
29
+ areas: areas(ctx, locale),
30
+ authors: authors(ctx, locale),
31
+ banners: banners(ctx, locale),
32
+ appearance: appearance(ctx),
33
+ imageLinks: imageLinks(ctx, locale),
34
+ categories: {
35
+ ...categoriesService,
36
+ populated: async () => {
37
+ const cats = await categoriesService.all();
38
+ const allPosts = await postsService.all();
39
+ const ids = new Set([
40
+ ...allPosts.map((p) => p.category?.id).filter(Boolean),
41
+ ...allPosts.flatMap((p) =>
42
+ (p.secondaryCategories ?? []).map((c) => c.id),
43
+ ),
44
+ ]);
45
+ return cats.filter((c) => ids.has(c.id));
46
+ },
47
+ // Categories that hold posts, each with how many. `populated()`
48
+ // answers only whether that number is non-zero and has five callers
49
+ // depending on its shape, so this is a second reading of the same
50
+ // fold rather than a change to it. Both collections are already
51
+ // cached, so it costs no extra fetch.
52
+ withCounts: async (): Promise<CountedCategory[]> => {
53
+ const cats = await categoriesService.all();
54
+ const allPosts = await postsService.all();
55
+
56
+ const counts = new Map<string, number>();
57
+ for (const post of allPosts) {
58
+ const ids = new Set(
59
+ [
60
+ post.category?.id,
61
+ ...(post.secondaryCategories ?? []).map((c) => c.id),
62
+ ].filter((id): id is string => !!id),
63
+ );
64
+ for (const id of ids) {
65
+ counts.set(id, (counts.get(id) ?? 0) + 1);
66
+ }
67
+ }
68
+
69
+ return cats
70
+ .map((c) => ({ ...c, postCount: counts.get(c.id) ?? 0 }))
71
+ .filter((c) => c.postCount > 0);
72
+ },
73
+ },
74
+ forms: forms(ctx, locale),
75
+ headerFooter: headerFooter(ctx, locale),
76
+ media: media(ctx, locale),
77
+ options: options(ctx, locale),
78
+ pages: pages(ctx, locale),
79
+ posts: postsService,
80
+ project: project(ctx),
81
+ questions: questions(ctx, locale),
82
+ seo: seo(ctx, locale),
83
+ tags: tags(ctx, locale),
84
+ teamMembers: teamMembers(ctx, locale),
85
+ };
86
+ }
@@ -0,0 +1,94 @@
1
+ import type { Context } from '../context';
2
+ import { createGlobalLoader } from '../loader';
3
+ import type { Appearance } from '../payload-types';
4
+
5
+ export type WiredAppearance = Appearance & {
6
+ branding: NonNullable<Appearance['branding']> & {
7
+ brandColor: NonNullable<NonNullable<Appearance['branding']>['brandColor']>;
8
+ logo: NonNullable<NonNullable<Appearance['branding']>['logo']>;
9
+ };
10
+ backgroundColor: {
11
+ lightTheme: string;
12
+ darkTheme: string;
13
+ };
14
+ };
15
+
16
+ /**
17
+ * What a blank Appearance field falls back to. Both are CSS values, and both
18
+ * are a site's own: the template states them in its theme file, so its
19
+ * defaults are variables that file defines.
20
+ */
21
+ export interface AppearanceFallbacks {
22
+ brandColor: string;
23
+ backgroundColor: { lightTheme: string; darkTheme: string };
24
+ }
25
+
26
+ export const DEFAULT_APPEARANCE_FALLBACKS: AppearanceFallbacks = {
27
+ // The theme ramp's 500.
28
+ brandColor: 'var(--color-theme-500)',
29
+ backgroundColor: {
30
+ lightTheme: 'var(--site-background-light)',
31
+ darkTheme: 'var(--site-background-dark)',
32
+ },
33
+ };
34
+
35
+ function removeInvalid<T extends Record<string, any>>(
36
+ obj: T | null | undefined,
37
+ ): Partial<T> {
38
+ if (obj === null || obj === undefined) {
39
+ return {};
40
+ }
41
+
42
+ const cleaned: Partial<T> = {};
43
+ for (const [key, value] of Object.entries(obj)) {
44
+ if (value !== null && value !== undefined && value !== '') {
45
+ cleaned[key as keyof T] = value;
46
+ }
47
+ }
48
+
49
+ return cleaned;
50
+ }
51
+
52
+ // Appearance (theme/colors/fonts) is fetched once, at config time, and handed
53
+ // to `createClient`. It has no text content that needs translation, so every
54
+ // locale reads the same document.
55
+ //
56
+ // Header theming is deliberately absent: it is not an Appearance field, so it
57
+ // lives in the site.
58
+ export default function (ctx: Context) {
59
+ return createGlobalLoader<Appearance, WiredAppearance>(
60
+ async () => {
61
+ if (!ctx.options.appearance) {
62
+ throw new Error(
63
+ 'cms.appearance() needs the `appearance` option of createClient: the Appearance document fetched at config time.',
64
+ );
65
+ }
66
+ return ctx.options.appearance;
67
+ },
68
+ {
69
+ transform: (appearance) => {
70
+ const fallbacks =
71
+ ctx.options.appearanceFallbacks ?? DEFAULT_APPEARANCE_FALLBACKS;
72
+ return {
73
+ ...appearance,
74
+ branding: {
75
+ ...appearance.branding,
76
+ brandColor: appearance.branding?.brandColor || fallbacks.brandColor,
77
+ logo: {
78
+ type: appearance.branding?.logo?.type,
79
+ dark: appearance.branding?.logo?.dark,
80
+ darkSvg: appearance.branding?.logo?.darkSvg,
81
+ light: appearance.branding?.logo?.light,
82
+ lightSvg: appearance.branding?.logo?.lightSvg,
83
+ },
84
+ icon: appearance.branding?.icon,
85
+ },
86
+ backgroundColor: {
87
+ ...fallbacks.backgroundColor,
88
+ ...removeInvalid(appearance.backgroundColor),
89
+ },
90
+ } as WiredAppearance;
91
+ },
92
+ },
93
+ );
94
+ }
@@ -0,0 +1,29 @@
1
+ import type { Context } from '../context';
2
+ import type { Area, Page } from '../payload-types';
3
+ import type { Locale } from '../locales';
4
+ import { createCollectionLoader } from '../loader';
5
+
6
+ export interface WiredArea extends Omit<Area, 'page'> {
7
+ page?: Page | null;
8
+ }
9
+
10
+ export default function (ctx: Context, locale: Locale) {
11
+ const areas = createCollectionLoader(
12
+ () =>
13
+ ctx.api.fetchCmsCollection<Area>('areas', {
14
+ sort: '_order',
15
+ locale,
16
+ }),
17
+ {
18
+ transform: (areas): WiredArea[] =>
19
+ areas.map((area) => ({
20
+ ...area,
21
+ page: typeof area.page === 'object' ? area.page : null,
22
+ })),
23
+ },
24
+ );
25
+
26
+ return {
27
+ all: areas,
28
+ };
29
+ }
@@ -0,0 +1,27 @@
1
+ import type { Context } from '../context';
2
+ import type { Author } from '../payload-types';
3
+ import type { Locale } from '../locales';
4
+ import { createCollectionLoader } from '../loader';
5
+
6
+ export interface WiredAuthor extends Omit<Author, 'slug'> {
7
+ slug: string;
8
+ }
9
+
10
+ export default function (ctx: Context, locale: Locale) {
11
+ const authors = createCollectionLoader(
12
+ async () => ctx.api.fetchCmsCollection<Author>('authors', { locale }),
13
+ {
14
+ transform: (authors) => {
15
+ const validAuthors = authors.filter((author) => author.slug);
16
+ return validAuthors as WiredAuthor[];
17
+ },
18
+ },
19
+ );
20
+
21
+ return {
22
+ all: authors,
23
+ findBySlug: async (slug: string) => {
24
+ return authors.find((author) => author.slug === slug);
25
+ },
26
+ };
27
+ }
@@ -0,0 +1,14 @@
1
+ import type { Context } from '../context';
2
+ import type { Banner } from '../payload-types';
3
+ import type { Locale } from '../locales';
4
+ import { createCollectionLoader } from '../loader';
5
+
6
+ export default function (ctx: Context, locale: Locale) {
7
+ const banners = createCollectionLoader(async () =>
8
+ ctx.api.fetchCmsCollection<Banner>('banners', { locale }),
9
+ );
10
+
11
+ return {
12
+ all: banners,
13
+ };
14
+ }
@@ -0,0 +1,111 @@
1
+ import type { Context } from '../context';
2
+ import type { Category } from '../payload-types';
3
+ import type { Locale } from '../locales';
4
+ import { decodeHtml } from '../decode-html';
5
+ import type { Locales } from '../locales';
6
+ import { createCollectionLoader } from '../loader';
7
+
8
+ export type CategoryBreadcrumb = { label: string; url: string };
9
+
10
+ export interface WiredCategory extends Omit<Category, 'parent' | 'slug'> {
11
+ slug: string;
12
+ parent?: WiredCategory;
13
+ children: WiredCategory[];
14
+ path: string;
15
+ breadcrumbs: CategoryBreadcrumb[];
16
+ }
17
+
18
+ /**
19
+ * A category plus how many posts are filed under it. The count follows the same
20
+ * rule as `cms.posts.findByCategory`, counting a post under its primary and its
21
+ * secondary categories alike, so the number a listing prints is the number of
22
+ * entries the category page then shows.
23
+ */
24
+ export interface CountedCategory extends WiredCategory {
25
+ postCount: number;
26
+ }
27
+
28
+ function getCategoryPath(category: WiredCategory): string {
29
+ if (!category.parent) {
30
+ return category.slug;
31
+ }
32
+ return `${getCategoryPath(category.parent)}/${category.slug}`;
33
+ }
34
+
35
+ function getCategoryBreadcrumbs(
36
+ category: WiredCategory,
37
+ locale: Locale,
38
+ prefixPath: Locales['prefixPath'],
39
+ ): CategoryBreadcrumb[] {
40
+ const ancestors = category.parent
41
+ ? getCategoryBreadcrumbs(category.parent, locale, prefixPath)
42
+ : [];
43
+ const path = getCategoryPath(category);
44
+ return [
45
+ ...ancestors,
46
+ {
47
+ label: category.title,
48
+ url: prefixPath(`/blog/category/${path}`, locale),
49
+ },
50
+ ];
51
+ }
52
+
53
+ export default function (ctx: Context, locale: Locale) {
54
+ const categories = createCollectionLoader(
55
+ async () => ctx.api.fetchCmsCollection<Category>('categories', { locale }),
56
+ {
57
+ transform: (categories) => {
58
+ const catMap = new Map<string, WiredCategory>(
59
+ categories
60
+ .filter((category) => category.slug)
61
+ .map((category) => [
62
+ category.id,
63
+ {
64
+ ...category,
65
+ title: decodeHtml(category.title),
66
+ children: [],
67
+ path: category.slug as string,
68
+ breadcrumbs: [],
69
+ } as WiredCategory,
70
+ ]),
71
+ );
72
+
73
+ const wireRelationships = (category: WiredCategory) => {
74
+ if (typeof category.parent === 'string') {
75
+ const parent = catMap.get(category.parent);
76
+ if (parent) {
77
+ category.parent = parent;
78
+ parent.children.push(category);
79
+ }
80
+ }
81
+ };
82
+
83
+ catMap.forEach((category) => wireRelationships(category));
84
+
85
+ catMap.forEach((category) => {
86
+ category.path = getCategoryPath(category);
87
+ category.breadcrumbs = getCategoryBreadcrumbs(
88
+ category,
89
+ locale,
90
+ ctx.locales.prefixPath,
91
+ );
92
+ });
93
+
94
+ return Array.from(catMap.values());
95
+ },
96
+ },
97
+ );
98
+
99
+ return {
100
+ all: categories,
101
+ wire: async (category: Category | string | null | undefined) => {
102
+ if (!category) {
103
+ return null;
104
+ }
105
+
106
+ const id = typeof category === 'string' ? category : category.id;
107
+
108
+ return (await categories()).find((cat) => cat.id === id);
109
+ },
110
+ };
111
+ }
@@ -0,0 +1,29 @@
1
+ import type { Context } from '../context';
2
+ import type { Form } from '../payload-types';
3
+ import type { Locale } from '../locales';
4
+ import { createCollectionLoader } from '../loader';
5
+
6
+ export default function (ctx: Context, locale: Locale) {
7
+ // Forms are fetched on their own instead of relying on the copy populated
8
+ // inside page/global blocks: those requests disable locale fallback, so an
9
+ // untranslated label would come back null and render an unusable field.
10
+ // A default-locale label beats a blank one.
11
+ const forms = createCollectionLoader(() =>
12
+ ctx.api.fetchCmsCollection<Form>('forms', { locale, fallback: 'default' }),
13
+ );
14
+
15
+ const findById = async (id: string): Promise<Form | null> =>
16
+ (await forms.find((form: Form) => form.id === id)) ?? null;
17
+
18
+ return {
19
+ all: forms,
20
+ findById,
21
+ // Swaps a form reference coming from a block for its localized document
22
+ resolve: async (form: Form | string): Promise<Form | null> => {
23
+ if (typeof form === 'string') {
24
+ return await findById(form);
25
+ }
26
+ return (await findById(form.id)) ?? form;
27
+ },
28
+ };
29
+ }
@@ -0,0 +1,19 @@
1
+ import type { Context } from '../context';
2
+ import type { HeaderFooter } from '../payload-types';
3
+ import type { Locale } from '../locales';
4
+ import { createGlobalLoader } from '../loader';
5
+
6
+ export type CmsHeader = NonNullable<NonNullable<HeaderFooter>['header']>;
7
+ export type CmsFooter = NonNullable<NonNullable<HeaderFooter>['footer']>;
8
+
9
+ export type CmsMainMenu = NonNullable<CmsHeader['mainMenu']>;
10
+ export type CmsMenuItem = NonNullable<CmsMainMenu[0]['menuItem']>;
11
+ export type CmsLink = NonNullable<CmsMenuItem['mainLink']>;
12
+ export type CmsSocialLinks = NonNullable<CmsHeader['socialLinks']>;
13
+ export type CmsCallToActionLinks = NonNullable<CmsHeader['callToActionLinks']>;
14
+
15
+ export default function (ctx: Context, locale: Locale) {
16
+ return createGlobalLoader(async () =>
17
+ ctx.api.fetchCmsGlobalCollection<HeaderFooter>('header-footer', { locale }),
18
+ );
19
+ }
@@ -0,0 +1,14 @@
1
+ import type { Context } from '../context';
2
+ import type { ImageLink } from '../payload-types';
3
+ import type { Locale } from '../locales';
4
+ import { createCollectionLoader } from '../loader';
5
+
6
+ export default function (ctx: Context, locale: Locale) {
7
+ const imageLinks = createCollectionLoader(async () =>
8
+ ctx.api.fetchCmsCollection<ImageLink>('image-links', { locale }),
9
+ );
10
+
11
+ return {
12
+ all: imageLinks,
13
+ };
14
+ }
@@ -0,0 +1,18 @@
1
+ import type { Context } from '../context';
2
+ import type { Media } from '../payload-types';
3
+ import type { Locale } from '../locales';
4
+ import { createCollectionLoader } from '../loader';
5
+
6
+ export default function (ctx: Context, locale: Locale) {
7
+ // depth: 0 — an upload's only relationship is `project`, which the CMS's
8
+ // multi-tenant plugin pins at maxDepth 0, so the response is identical to
9
+ // the default depth=2 one. Populating it costs roughly 3x per page, and
10
+ // this is the biggest collection a build reads.
11
+ const media = createCollectionLoader(async () =>
12
+ ctx.api.fetchCmsCollection<Media>('media', { locale, depth: 0 }),
13
+ );
14
+
15
+ return {
16
+ all: media,
17
+ };
18
+ }
@@ -0,0 +1,10 @@
1
+ import type { Context } from '../context';
2
+ import type { Options } 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<Options>('options', { locale }),
9
+ );
10
+ }
@@ -0,0 +1,83 @@
1
+ import type { Context } from '../context';
2
+ import type { Page } from '../payload-types';
3
+ import type { Locale } from '../locales';
4
+ import { createCollectionLoader } from '../loader';
5
+
6
+ export interface WiredPage extends Omit<Page, 'parent'> {
7
+ parent?: WiredPage;
8
+ children: WiredPage[];
9
+ path: string;
10
+ }
11
+
12
+ export default function (ctx: Context, locale: Locale) {
13
+ const pages = createCollectionLoader(
14
+ async () =>
15
+ ctx.api.fetchCmsCollection<Page>('pages', {
16
+ status: 'published',
17
+ locale,
18
+ }),
19
+ {
20
+ transform: (rawPages): WiredPage[] => {
21
+ // Pass 1: build map with path derived from Payload's breadcrumbs plugin
22
+ const pageMap = new Map<string, WiredPage>(
23
+ rawPages
24
+ .filter((page) => page.slug)
25
+ .map((page) => {
26
+ const path = (page.breadcrumbs?.at(-1)?.url ?? page.slug).replace(
27
+ /^\//,
28
+ '',
29
+ );
30
+ return [page.id, { ...page, children: [], path } as WiredPage];
31
+ }),
32
+ );
33
+
34
+ // Pass 2: wire parent references and populate children[]
35
+ // parent may be a string ID (depth=0) or a populated object (depth>0)
36
+ pageMap.forEach((page) => {
37
+ const rawParent = page.parent as
38
+ | WiredPage
39
+ | string
40
+ | null
41
+ | undefined;
42
+ const parentId =
43
+ typeof rawParent === 'string' ? rawParent : rawParent?.id;
44
+ if (parentId) {
45
+ const parent = pageMap.get(parentId);
46
+ if (parent) {
47
+ page.parent = parent;
48
+ parent.children.push(page);
49
+ } else {
50
+ page.parent = undefined;
51
+ }
52
+ }
53
+ });
54
+
55
+ // Pass 3: sort each parent's children by order (nulls sort last)
56
+ pageMap.forEach((page) => {
57
+ page.children.sort((a, b) => {
58
+ if (a.order == null && b.order == null) {
59
+ return 0;
60
+ }
61
+ if (a.order == null) {
62
+ return 1;
63
+ }
64
+ if (b.order == null) {
65
+ return -1;
66
+ }
67
+ return a.order - b.order;
68
+ });
69
+ });
70
+
71
+ return Array.from(pageMap.values());
72
+ },
73
+ },
74
+ );
75
+
76
+ return {
77
+ all: pages,
78
+ findByPath: async (path: string) =>
79
+ (await pages()).find((p) => p.path === path),
80
+ findById: async (id: string) =>
81
+ (await pages()).find((p) => p.id === id) ?? null,
82
+ };
83
+ }