@akinon/pz-theme 2.0.113-rc.0 → 2.0.114-beta.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/CHANGELOG.md CHANGED
@@ -1,40 +1,21 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.113-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c2]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea41430]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
18
- - Updated dependencies [bfafa3f49]
19
- - Updated dependencies [57d7eb305]
20
- - Updated dependencies [d99a6a7d5]
21
- - Updated dependencies [9db81a714]
22
- - Updated dependencies [591e345e1]
23
- - Updated dependencies [4de5303c5]
24
- - Updated dependencies [95b139dc1]
25
- - Updated dependencies [1d00f2d06]
26
- - Updated dependencies [4ac7b2a1e]
27
- - Updated dependencies [e9598c71d]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd6c]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a631]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b1]
34
- - Updated dependencies [1595d45]
35
- - Updated dependencies [dca1281]
36
- - @akinon/next@2.0.113-rc.0
37
- - @akinon/theme-contract@2.0.113-rc.0
3
+ ## 2.0.114-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.114-beta.0
9
+ - @akinon/theme-contract@2.0.114-beta.0
10
+
11
+ ## 2.0.113
12
+
13
+ ### Patch Changes
14
+
15
+ - ca9e69e: Add `registerCustomPages([{ path, label }])` so a brand storefront announces its own composable pages (CMS flat pages behind pretty URLs, campaign landings) to the theme editor's page dropdown as the `customPages` field of `PLACEHOLDERS_AVAILABLE`, each with the `page-<slug>` placeholder it composes on. The list belongs to the brand build rather than the editor, which serves every tenant.
16
+ - ca9e69e: ZERO-5088: implement custom page catalog management
17
+ - @akinon/next@2.0.113
18
+ - @akinon/theme-contract@2.0.113
38
19
 
39
20
  ## 2.0.112
40
21
 
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.113-rc.0",
4
+ "version": "2.0.114-beta.0",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.113-rc.0",
11
+ "@akinon/next": "2.0.114-beta.0",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.113-rc.0",
16
+ "@akinon/theme-contract": "2.0.114-beta.0",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "react-multi-carousel": "2.8.4",
@@ -21,7 +21,7 @@
21
21
  "zod": "4.4.1"
22
22
  },
23
23
  "devDependencies": {
24
- "@akinon/next": "2.0.113-rc.0",
24
+ "@akinon/next": "2.0.114-beta.0",
25
25
  "@testing-library/jest-dom": "5.16.4",
26
26
  "@testing-library/react": "16.3.2",
27
27
  "@testing-library/user-event": "14.1.1",
@@ -127,6 +127,46 @@ describe('PLACEHOLDERS_AVAILABLE announce', () => {
127
127
  ]);
128
128
  });
129
129
 
130
+ it('carries the brand page list ungated, as a third sibling field', () => {
131
+ // No designer flag: the page list is route paths and page names, the same
132
+ // class of fact as the placeholder slugs, so it rides every frame — a brand
133
+ // that registers pages and nothing else still gets them listed on a
134
+ // document whose only frame went out before the designer flag arrived.
135
+ const { registry, register } = load();
136
+
137
+ registry.registerPlaceholder('header');
138
+ register.registerCustomPages([
139
+ { path: '/hakkimizda', label: 'Hakkımızda' }
140
+ ]);
141
+ jest.advanceTimersByTime(100);
142
+
143
+ expect(postMessage).toHaveBeenCalledTimes(1);
144
+ const [payload] = frames();
145
+ expect(payload.placeholders).toEqual(['header']);
146
+ expect(payload.customPages).toEqual({
147
+ pages: [
148
+ {
149
+ path: '/hakkimizda',
150
+ label: 'Hakkımızda',
151
+ placeholder: 'page-hakkimizda'
152
+ }
153
+ ]
154
+ });
155
+ // The two gated catalogs stay withheld from an unproven frame.
156
+ expect('customBlocks' in payload).toBe(false);
157
+ expect('customSections' in payload).toBe(false);
158
+ });
159
+
160
+ it('posts no page field at all for a build that registers no pages', () => {
161
+ const { registry } = load();
162
+
163
+ registry.registerPlaceholder('header');
164
+ jest.advanceTimersByTime(100);
165
+
166
+ const [payload] = frames();
167
+ expect('customPages' in payload).toBe(false);
168
+ });
169
+
130
170
  it('carries the section catalog as a SIBLING field, not inside the block one', () => {
131
171
  (window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
132
172
  true;
@@ -0,0 +1,77 @@
1
+ import { getCustomPageCatalog, resetCustomPageCatalog } from '../page-catalog';
2
+ import { CUSTOM_PAGE_LIMITS } from '../page-types';
3
+ import { registerCustomPages } from '../register';
4
+
5
+ /**
6
+ * The brand page list a storefront announces to the editor's page dropdown.
7
+ * The list is the brand's to declare — the editor serves every tenant, so a
8
+ * page written into its static registry showed up in every brand's dropdown
9
+ * (GANT's flat pages in a ZsaZsaZsu editor). Registration validates the whole
10
+ * list before it mutates anything, and materializes the placeholder each page
11
+ * composes on so the two repos cannot derive it differently.
12
+ */
13
+ describe('registerCustomPages', () => {
14
+ afterEach(() => {
15
+ resetCustomPageCatalog();
16
+ });
17
+
18
+ it('announces nothing until a page is registered', () => {
19
+ expect(getCustomPageCatalog()).toBeNull();
20
+ });
21
+
22
+ it('announces each page with the page-<slug> placeholder it composes on', () => {
23
+ registerCustomPages([
24
+ { path: '/hakkimizda', label: ' Hakkımızda ' },
25
+ { path: '/q/class-of-gant', label: 'Class Of GANT' }
26
+ ]);
27
+
28
+ expect(getCustomPageCatalog()).toEqual({
29
+ pages: [
30
+ { path: '/hakkimizda', label: 'Hakkımızda', placeholder: 'page-hakkimizda' },
31
+ {
32
+ path: '/q/class-of-gant',
33
+ label: 'Class Of GANT',
34
+ placeholder: 'page-q-class-of-gant'
35
+ }
36
+ ]
37
+ });
38
+ });
39
+
40
+ it('refuses a path with a trailing slash, query, uppercase or locale prefix', () => {
41
+ for (const path of ['/hakkimizda/', '/hakkimizda?x=1', '/Hakkimizda', 'hakkimizda', '/tr/hakkimizda ']) {
42
+ expect(() => registerCustomPages([{ path, label: 'X' }])).toThrow(
43
+ /not a lowercase \/segment path/
44
+ );
45
+ }
46
+ // Nothing registered from a call that failed validation.
47
+ expect(getCustomPageCatalog()).toBeNull();
48
+ });
49
+
50
+ it('refuses an empty label and a duplicate path, in one call or across calls', () => {
51
+ expect(() =>
52
+ registerCustomPages([{ path: '/hakkimizda', label: ' ' }])
53
+ ).toThrow(/label is empty/);
54
+
55
+ expect(() =>
56
+ registerCustomPages([
57
+ { path: '/hakkimizda', label: 'A' },
58
+ { path: '/hakkimizda', label: 'B' }
59
+ ])
60
+ ).toThrow(/appears twice/);
61
+
62
+ registerCustomPages([{ path: '/hakkimizda', label: 'A' }]);
63
+ expect(() =>
64
+ registerCustomPages([{ path: '/hakkimizda', label: 'B' }])
65
+ ).toThrow(/registered twice/);
66
+ });
67
+
68
+ it('refuses a list over the cap before registering any of it', () => {
69
+ const pages = Array.from(
70
+ { length: CUSTOM_PAGE_LIMITS.pagesPerBuild + 1 },
71
+ (_, index) => ({ path: `/page-${index}`, label: `Page ${index}` })
72
+ );
73
+
74
+ expect(() => registerCustomPages(pages)).toThrow(/over the 32 cap/);
75
+ expect(getCustomPageCatalog()).toBeNull();
76
+ });
77
+ });
@@ -53,7 +53,11 @@ export type {
53
53
 
54
54
  export type { CustomPartDeclaration, CustomPartsDeclaration } from './parts';
55
55
 
56
- export { registerCustomBlocks, registerCustomSections } from './register';
56
+ export {
57
+ registerCustomBlocks,
58
+ registerCustomPages,
59
+ registerCustomSections
60
+ } from './register';
57
61
 
58
62
  /**
59
63
  * The SECTION authoring API.
@@ -182,6 +186,20 @@ export type {
182
186
  CustomSectionDescriptor
183
187
  } from './section-types';
184
188
 
189
+ /**
190
+ * The brand PAGE list: `registerCustomPages([{ path, label }])` from the client
191
+ * root, announced beside the two catalogs so the editor's page dropdown lists a
192
+ * brand's own routes for that brand only. The caps and the path grammar are
193
+ * exported for the editor's mirror, like `CUSTOM_SECTION_LIMITS`.
194
+ */
195
+ export { getCustomPageCatalog } from './page-catalog';
196
+ export { CUSTOM_PAGE_LIMITS, CUSTOM_PAGE_PATH_PATTERN } from './page-types';
197
+ export type {
198
+ CustomPageCatalog,
199
+ CustomPageDescriptor,
200
+ CustomPageInput
201
+ } from './page-types';
202
+
185
203
  // The predicate, not the pattern. Every consumer that has to treat a
186
204
  // developer-authored type differently tests this one function, so the grammar
187
205
  // stays in types.ts and cannot be re-derived slightly wrong somewhere else.
@@ -0,0 +1,64 @@
1
+ import { devWarn } from '../utils/dev-warn';
2
+ import { CUSTOM_PAGE_LIMITS } from './page-types';
3
+ import type { CustomPageCatalog, CustomPageDescriptor } from './page-types';
4
+
5
+ /**
6
+ * The brand pages this build announces to the editor, keyed by path.
7
+ *
8
+ * Module state on the same terms as `catalog.ts` and `section-catalog.ts`: one
9
+ * Map per bundle, filled from the client root only, read by the announce in
10
+ * `placeholder-registry.ts`. Directive-free for the same reason those two are —
11
+ * the announcer calls `getCustomPageCatalog()` without a directive of its own.
12
+ */
13
+ const pageCatalog = new Map<string, CustomPageDescriptor>();
14
+
15
+ /** Adds a page, refusing rather than overwriting a path already announced. */
16
+ export const addPageToCatalog = (descriptor: CustomPageDescriptor) => {
17
+ if (pageCatalog.has(descriptor.path)) {
18
+ throw new Error(
19
+ `[pz-theme] Brand page "${descriptor.path}" is registered twice in this build. ` +
20
+ `Register each path once — the second entry would replace the first in the editor's page list.`
21
+ );
22
+ }
23
+
24
+ if (pageCatalog.size >= CUSTOM_PAGE_LIMITS.pagesPerBuild) {
25
+ throw new Error(
26
+ `[pz-theme] This build registers more than ${CUSTOM_PAGE_LIMITS.pagesPerBuild} brand pages. ` +
27
+ `The cap exists because the whole list is posted to the editor on every placeholder announce.`
28
+ );
29
+ }
30
+
31
+ pageCatalog.set(descriptor.path, descriptor);
32
+ };
33
+
34
+ export const getCustomPagePaths = (): string[] => Array.from(pageCatalog.keys());
35
+
36
+ /**
37
+ * The payload the storefront announces. Null when nothing is registered, so
38
+ * the announcer omits the field rather than posting an empty list — a build
39
+ * with no brand pages must be indistinguishable from one built before the
40
+ * field existed.
41
+ */
42
+ export const getCustomPageCatalog = (): CustomPageCatalog | null => {
43
+ if (pageCatalog.size === 0) return null;
44
+
45
+ const payload: CustomPageCatalog = {
46
+ pages: Array.from(pageCatalog.values())
47
+ };
48
+
49
+ const bytes = JSON.stringify(payload).length;
50
+ if (bytes > CUSTOM_PAGE_LIMITS.catalogBytes) {
51
+ devWarn(
52
+ 'customPageCatalog',
53
+ `Brand page catalog is ${bytes} bytes, over the ${CUSTOM_PAGE_LIMITS.catalogBytes} byte cap. ` +
54
+ `The editor will reject it and this brand's pages will be missing from the page list; shorten the labels.`
55
+ );
56
+ }
57
+
58
+ return payload;
59
+ };
60
+
61
+ /** Test-only. Module state outlives a test file otherwise. */
62
+ export const resetCustomPageCatalog = () => {
63
+ pageCatalog.clear();
64
+ };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Brand pages announced to the theme editor's page list.
3
+ *
4
+ * A brand storefront routes pages the reference app never had — CMS flat pages
5
+ * behind pretty URLs (`/gizlilik-politikasi`), campaign landings under a legacy
6
+ * prefix (`/q/class-of-gant`). Each composes on its own `page-<slug>`
7
+ * placeholder derived from the path (`customPageSlugFromPath`), and the editor
8
+ * lists it in the page dropdown so a designer can open and compose it.
9
+ *
10
+ * The LIST lives in the brand's build and rides the placeholder announce, not
11
+ * in the editor. The editor is one SPA serving every tenant: a page path
12
+ * written into its static registry appears in EVERY brand's dropdown, pointing
13
+ * at a route only one storefront can serve — which is exactly what happened
14
+ * with GANT's flat pages before this existed.
15
+ *
16
+ * The page catalog is the third sibling field on `PLACEHOLDERS_AVAILABLE`,
17
+ * beside `customBlocks` and `customSections`, and it is a sibling for the same
18
+ * reason those two are: the editor validates each field whole and drops it on
19
+ * an unexpected shape, so a malformed page list costs the page list only.
20
+ */
21
+
22
+ export interface CustomPageInput {
23
+ /**
24
+ * The storefront path the editor opens, without locale or query:
25
+ * `/hakkimizda`, `/q/class-of-gant`. Lowercase ASCII segments, because the
26
+ * placeholder slug is derived from it and the slug grammar is exactly that.
27
+ */
28
+ path: string;
29
+ /** What the page dropdown shows for it, after the "Flat Page /" prefix. */
30
+ label: string;
31
+ }
32
+
33
+ /** What is announced: the input plus the placeholder the page composes on. */
34
+ export interface CustomPageDescriptor {
35
+ path: string;
36
+ label: string;
37
+ /**
38
+ * `customPageSlugFromPath(path)`, materialized by the SENDER so the editor
39
+ * and the storefront cannot disagree on which slot a composition saves to.
40
+ * The editor still checks it is a `page-` slug: the family is what keeps a
41
+ * page from claiming a chrome placeholder.
42
+ */
43
+ placeholder: string;
44
+ }
45
+
46
+ export interface CustomPageCatalog {
47
+ pages: CustomPageDescriptor[];
48
+ }
49
+
50
+ /**
51
+ * Caps, mirrored LITERALLY in the editor (`config/pages/custom-page-catalog.ts`)
52
+ * for the reason `CUSTOM_SECTION_LIMITS` gives: the receiver drops the whole
53
+ * catalog when one is exceeded, so a number that moves on one side alone
54
+ * empties a brand's page list.
55
+ */
56
+ export const CUSTOM_PAGE_LIMITS = {
57
+ pagesPerBuild: 32,
58
+ pathLength: 128,
59
+ labelLength: 64,
60
+ catalogBytes: 16 * 1024
61
+ } as const;
62
+
63
+ /**
64
+ * `/segment` or `/segment/segment`, each segment lowercase letters, digits and
65
+ * hyphens. No trailing slash, no query, no locale prefix — the editor prefixes
66
+ * the channel's base URL itself.
67
+ */
68
+ export const CUSTOM_PAGE_PATH_PATTERN =
69
+ /^\/[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)*$/;
70
+
71
+ /** Why a path is not a valid brand page path, or null when it is. */
72
+ export const customPagePathError = (path: unknown): string | null => {
73
+ if (typeof path !== 'string') return 'the path is not a string';
74
+ if (path.length > CUSTOM_PAGE_LIMITS.pathLength) {
75
+ return `the path is longer than ${CUSTOM_PAGE_LIMITS.pathLength} characters`;
76
+ }
77
+ if (!CUSTOM_PAGE_PATH_PATTERN.test(path)) {
78
+ return `the path ${JSON.stringify(path)} is not a lowercase /segment path without a trailing slash`;
79
+ }
80
+ return null;
81
+ };
82
+
83
+ /** Why a label is not a valid brand page label, or null when it is. */
84
+ export const customPageLabelError = (label: unknown): string | null => {
85
+ if (typeof label !== 'string') return 'the label is not a string';
86
+ if (label.trim().length === 0) return 'the label is empty';
87
+ if (label.length > CUSTOM_PAGE_LIMITS.labelLength) {
88
+ return `the label is longer than ${CUSTOM_PAGE_LIMITS.labelLength} characters`;
89
+ }
90
+ return null;
91
+ };
@@ -70,6 +70,17 @@ import {
70
70
  partStateError
71
71
  } from './parts';
72
72
  import { slotNameError } from './slot-nodes';
73
+ import { customPageSlugFromPath } from '../chrome/placeholder-slugs';
74
+ import {
75
+ addPageToCatalog,
76
+ getCustomPagePaths
77
+ } from './page-catalog';
78
+ import {
79
+ CUSTOM_PAGE_LIMITS,
80
+ customPageLabelError,
81
+ customPagePathError
82
+ } from './page-types';
83
+ import type { CustomPageDescriptor, CustomPageInput } from './page-types';
73
84
  import type {
74
85
  CustomBlockDefinition,
75
86
  CustomBlockInput,
@@ -916,3 +927,86 @@ export const registerCustomSections = (
916
927
  // catalogs in one frame.
917
928
  announceCustomBlocks();
918
929
  };
930
+
931
+ /**
932
+ * Registers the brand pages the editor lists in its page dropdown.
933
+ *
934
+ * A brand routes pages the reference app never had — CMS flat pages behind
935
+ * pretty URLs, campaign landings under a legacy prefix. Each composes on its own
936
+ * `page-<slug>` placeholder (`customPageSlugFromPath`), and the editor can only
937
+ * offer what it knows about: the list rides the placeholder announce as the
938
+ * `customPages` field, beside the block and section catalogs. The list belongs
939
+ * HERE and not in the editor because the editor serves every tenant — a path
940
+ * in its static registry shows up in every brand's dropdown.
941
+ *
942
+ * Import the barrel that calls this from the CLIENT ROOT ONLY, like the other
943
+ * two registrations. The whole list is validated before anything is mutated,
944
+ * so a bad entry fails the build with its path in the message rather than
945
+ * announcing half a list.
946
+ */
947
+ export const registerCustomPages = (pages: CustomPageInput[]): void => {
948
+ if (!Array.isArray(pages)) {
949
+ throw new Error(
950
+ '[pz-theme] registerCustomPages() expects an array of { path, label } entries.'
951
+ );
952
+ }
953
+
954
+ const pending: CustomPageDescriptor[] = [];
955
+ const claimed = new Set<string>();
956
+
957
+ pages.forEach((entry, index) => {
958
+ if (!entry || typeof entry !== 'object') {
959
+ throw new Error(
960
+ `[pz-theme] registerCustomPages() entry #${index} is not a { path, label } object.`
961
+ );
962
+ }
963
+
964
+ const pathError = customPagePathError(entry.path);
965
+ if (pathError) {
966
+ throw new Error(
967
+ `[pz-theme] registerCustomPages() entry #${index}: ${pathError}. ` +
968
+ `Use the storefront path without locale, query or trailing slash, e.g. "/hakkimizda" or "/q/class-of-gant".`
969
+ );
970
+ }
971
+
972
+ const labelError = customPageLabelError(entry.label);
973
+ if (labelError) {
974
+ throw new Error(
975
+ `[pz-theme] registerCustomPages() entry ${JSON.stringify(entry.path)}: ${labelError}. ` +
976
+ `The label is what the editor's page dropdown shows.`
977
+ );
978
+ }
979
+
980
+ if (claimed.has(entry.path)) {
981
+ throw new Error(
982
+ `[pz-theme] Brand page "${entry.path}" appears twice in one registerCustomPages() call.`
983
+ );
984
+ }
985
+
986
+ claimed.add(entry.path);
987
+ pending.push({
988
+ path: entry.path,
989
+ label: entry.label.trim(),
990
+ // Materialized by the sender: the storefront mounts this slot for the
991
+ // page, and the editor saves compositions against what is announced, so
992
+ // the two cannot disagree on which slot a page composes on.
993
+ placeholder: customPageSlugFromPath(entry.path)
994
+ });
995
+ });
996
+
997
+ const total = getCustomPagePaths().length + pending.length;
998
+ if (total > CUSTOM_PAGE_LIMITS.pagesPerBuild) {
999
+ throw new Error(
1000
+ `[pz-theme] This build would register ${total} brand pages, over the ` +
1001
+ `${CUSTOM_PAGE_LIMITS.pagesPerBuild} cap. The cap exists because the whole list is posted to ` +
1002
+ `the editor on every placeholder announce.`
1003
+ );
1004
+ }
1005
+
1006
+ pending.forEach(addPageToCatalog);
1007
+
1008
+ // The same re-arm the two catalogs use: this runs at module evaluation, long
1009
+ // before any placeholder has mounted, so it only schedules the announce that
1010
+ // will eventually carry the slugs and the page list in one frame.
1011
+ announceCustomBlocks();
1012
+ };
package/src/index.ts CHANGED
@@ -216,6 +216,7 @@ export {
216
216
  defineCustomBlock,
217
217
  defineCustomSection,
218
218
  registerCustomBlocks,
219
+ registerCustomPages,
219
220
  registerCustomSections,
220
221
  ThemeEditable,
221
222
  ThemeRegion,
@@ -223,6 +224,9 @@ export {
223
224
  useThemeSetting,
224
225
  getCustomCatalog,
225
226
  getCustomSectionCatalog,
227
+ getCustomPageCatalog,
228
+ CUSTOM_PAGE_LIMITS,
229
+ CUSTOM_PAGE_PATH_PATTERN,
226
230
  isCustomThemeType,
227
231
  CUSTOM_TYPE_PREFIX,
228
232
  CUSTOM_SLOT_BLOCK_TYPE,
@@ -242,6 +246,9 @@ export type {
242
246
  CustomBlockInput,
243
247
  CustomBlockOptions,
244
248
  CustomCatalog,
249
+ CustomPageCatalog,
250
+ CustomPageDescriptor,
251
+ CustomPageInput,
245
252
  CustomPartDeclaration,
246
253
  CustomPartsDeclaration,
247
254
  CustomRegionDeclaration,
@@ -1,5 +1,6 @@
1
1
  import { getCatalogVendor, getCustomCatalog } from './custom/catalog';
2
2
  import { getCustomSectionCatalog } from './custom/section-catalog';
3
+ import { getCustomPageCatalog } from './custom/page-catalog';
3
4
  import {
4
5
  ACTION_CAPABILITIES,
5
6
  type ActionCapability
@@ -9,6 +10,7 @@ import {
9
10
  type StatePinCapability
10
11
  } from './state-pin-capabilities';
11
12
  import type { CustomSectionCatalog } from './custom/section-types';
13
+ import type { CustomPageCatalog } from './custom/page-types';
12
14
  import type { CustomCatalog } from './custom/types';
13
15
  import { isTrustedEditorEvent, postToEditor } from './editor-bridge';
14
16
 
@@ -60,6 +62,19 @@ interface PlaceholdersAvailablePayload {
60
62
  * with it.
61
63
  */
62
64
  customSections?: CustomSectionCatalog;
65
+ /**
66
+ * The brand pages the editor lists in its page dropdown, each with the
67
+ * `page-<slug>` placeholder it composes on. A third SIBLING field, on the
68
+ * same terms as the two above: validated whole and dropped whole by the
69
+ * receiver, so a malformed page list costs the page list only.
70
+ *
71
+ * NOT behind the designer gate below. The list is route paths and page
72
+ * names — public URLs, the same class of fact as the placeholder slugs —
73
+ * so there is nothing to withhold, and riding every frame means a brand
74
+ * that registers pages and nothing else still gets them listed on a
75
+ * document whose only frame went out before the designer flag arrived.
76
+ */
77
+ customPages?: CustomPageCatalog;
63
78
 
64
79
  /**
65
80
  * Which kinds of state pin THIS build can perform, so the editor can offer
@@ -135,6 +150,11 @@ const flush = () => {
135
150
  actionCapabilities: ACTION_CAPABILITIES
136
151
  };
137
152
 
153
+ // Omitted entirely when nothing is registered: a build with no brand pages
154
+ // must look exactly like one built before the field existed.
155
+ const customPages = getCustomPageCatalog();
156
+ if (customPages) data.customPages = customPages;
157
+
138
158
  // Being framed is NOT enough to hand the catalog over. A storefront is
139
159
  // legitimately framed in production — payment providers, marketplace
140
160
  // previews — and the catalog is brand IP: every block name, every setting