@akinon/pz-theme 2.0.113-rc.0 → 2.0.113
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 +8 -35
- package/package.json +4 -4
- package/src/__tests__/custom-catalog-announce.test.ts +40 -0
- package/src/custom/__tests__/register-custom-pages.test.ts +77 -0
- package/src/custom/index.ts +19 -1
- package/src/custom/page-catalog.ts +64 -0
- package/src/custom/page-types.ts +91 -0
- package/src/custom/register.ts +94 -0
- package/src/index.ts +7 -0
- package/src/placeholder-registry.ts +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,40 +1,13 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.113
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
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.113
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
- ca9e69e: ZERO-5088: implement custom page catalog management
|
|
9
|
+
- @akinon/next@2.0.113
|
|
10
|
+
- @akinon/theme-contract@2.0.113
|
|
38
11
|
|
|
39
12
|
## 2.0.112
|
|
40
13
|
|
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
|
|
4
|
+
"version": "2.0.113",
|
|
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
|
|
11
|
+
"@akinon/next": "2.0.113",
|
|
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
|
|
16
|
+
"@akinon/theme-contract": "2.0.113",
|
|
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
|
|
24
|
+
"@akinon/next": "2.0.113",
|
|
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
|
+
});
|
package/src/custom/index.ts
CHANGED
|
@@ -53,7 +53,11 @@ export type {
|
|
|
53
53
|
|
|
54
54
|
export type { CustomPartDeclaration, CustomPartsDeclaration } from './parts';
|
|
55
55
|
|
|
56
|
-
export {
|
|
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
|
+
};
|
package/src/custom/register.ts
CHANGED
|
@@ -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
|