@akinon/pz-theme 2.0.162-beta.0 → 2.0.162
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 +4 -4
- package/package.json +4 -4
- package/readme.md +22 -0
- package/src/__tests__/custom-catalog-announce.test.ts +110 -0
- package/src/chrome/composition-source.ts +7 -2
- package/src/chrome/preview-snapshot-source.ts +5 -1
- package/src/content-capabilities.ts +41 -0
- package/src/load-placeholder-sections.ts +13 -3
- package/src/placeholder-registry.ts +42 -1
- package/src/region/__tests__/editor-handshake.test.ts +144 -0
- package/src/region/__tests__/region-contract.test.ts +500 -0
- package/src/region/__tests__/region-placeholder-readers.test.ts +431 -0
- package/src/region/__tests__/region-source.test.ts +621 -0
- package/src/region/editor-handshake.ts +60 -0
- package/src/region/region-contract.ts +184 -0
- package/src/region/region-source.ts +360 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.162
|
|
3
|
+
## 2.0.162
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
- @akinon/next@2.0.162
|
|
9
|
-
- @akinon/theme-contract@2.0.162
|
|
7
|
+
- 8758f3a3: ZERO-5391: Per-country placeholder content. When the request locale names a country (`en-ae` → `ae`) and the `theme-regions` registry lists a placeholder for it, the placeholder renders from that country's copy (`<slug>-region-<cc>`; in draft mode its `-preview-<id>` blob first) instead of the shared default, with the data-source records the copy carries (`theme_region_dataSources`) filling in any id theme-config lacks (theme-config's record wins when it has the id). Unlisted placeholders, language-only locales and a missing or unreadable registry keep today's reads; a server process that has read the registry keeps using it for up to 15 minutes while reads fail. `PLACEHOLDERS_AVAILABLE` now carries `contentCapabilities`, `localeUrlStrategy` and `defaultLocale`, and placeholders always report their canonical slug as `placeholderId`.
|
|
8
|
+
- @akinon/next@2.0.162
|
|
9
|
+
- @akinon/theme-contract@2.0.162
|
|
10
10
|
|
|
11
11
|
## 2.0.161
|
|
12
12
|
|
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.162
|
|
4
|
+
"version": "2.0.162",
|
|
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.162
|
|
11
|
+
"@akinon/next": "2.0.162",
|
|
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.162
|
|
16
|
+
"@akinon/theme-contract": "2.0.162",
|
|
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.162
|
|
24
|
+
"@akinon/next": "2.0.162",
|
|
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",
|
package/readme.md
CHANGED
|
@@ -26,6 +26,28 @@ export default async function Page() {
|
|
|
26
26
|
|
|
27
27
|
Developer-authored sections and blocks are written against `@akinon/pz-theme/src/custom` — see [`src/custom/README.md`](./src/custom/README.md) and [`src/custom/README-sections.md`](./src/custom/README-sections.md). Reference examples live in `examples/header/` at the root of the pz-next repository; they are not shipped in the npm package.
|
|
28
28
|
|
|
29
|
+
## Per-country content (ZERO-5391)
|
|
30
|
+
|
|
31
|
+
The theme editor can give one country its own copy of a placeholder (a page body, the header or footer, a custom page, a modal) while every other visitor keeps the shared content. pz-theme serves it without any change in the brand's code:
|
|
32
|
+
|
|
33
|
+
- The country ("region") is the last part of the request locale when that part is a two-letter code: `en-ae` and `ar-ae` both read `ae`, `zh-hant-tw` reads `tw`. A language-only locale (`en`, `tr`) has no region, and its requests make no new reads.
|
|
34
|
+
- The editor lists each country's customised placeholders in the `theme-regions` widget. A listed placeholder renders from `<slug>-region-<cc>`, with the data-source records that copy carries (`theme_region_dataSources`) filling in any id theme-config lacks; theme-config's record wins when it has the id. An unlisted placeholder is never looked up under a country slug.
|
|
35
|
+
- Country content that cannot be read is replaced by the shared content, which is what the country's visitors saw before the area was customised. That covers a listed copy that does not answer or has no section order, and a registry that is missing or unreadable (a server process that has read the registry before keeps using that one for a while; see below).
|
|
36
|
+
- Placeholders report their canonical slug (`header`, never `header-region-ae`) as `placeholderId`.
|
|
37
|
+
- Every `PLACEHOLDERS_AVAILABLE` frame carries `contentCapabilities: ['region-content']`, `localeUrlStrategy` and `defaultLocale` (from `settings.localization`). The editor offers countries only to a storefront that announces the capability, does not use `LocaleUrlStrategy.Subdomain` (switching the preview's locale would change its origin) and has at least two audiences: its distinct countries, plus one if any locale is language-only.
|
|
38
|
+
|
|
39
|
+
There is no on-demand revalidation, so changes reach visitors as caches expire:
|
|
40
|
+
|
|
41
|
+
- Customising or reverting an area: up to about two minutes (the registry is cached for 60 s in Redis and kept for 60 s per server process), plus the page's ISR window. An area reverted and customised again inside the widget cache window can briefly show its previous country content.
|
|
42
|
+
- When a registry read fails, is unreadable or finds no registry, a server process that has read one before keeps serving that one for up to 15 minutes after its last successful read, trying again every 10 seconds, so an outage does not flip countries back to the shared content. A registry that is deleted on purpose stays in effect just as long.
|
|
43
|
+
- Publishing a country's edits: the same caching as any widget (Redis 900 s, then ISR).
|
|
44
|
+
|
|
45
|
+
Requirements and known limits:
|
|
46
|
+
|
|
47
|
+
- Live country content works on every core this package supports.
|
|
48
|
+
- Country previews (the editor's draft, "View on site" and signed draft links in a country locale) need `@akinon/next` 1.126.15 or later and the `[pz]` route segment, as named previews do. Without them a country's draft cannot be previewed on the storefront, and its pages show live content.
|
|
49
|
+
- A signed draft link opened in a country locale shows the shared DRAFT in the areas that country has not customised, while the editor's canvas shows the shared LIVE content there. Customised areas show the country's own draft in both.
|
|
50
|
+
|
|
29
51
|
## Using pz-theme on the v1 line
|
|
30
52
|
|
|
31
53
|
`@akinon/pz-theme` and `@akinon/theme-contract` have a single release line. They are developed on `main` and published with the 2.x packages; the `v1` branch has no copy of them and publishes no 1.x version. A brand still on v1 (Next 14.2, React 18, `@akinon/next` 1.x) installs the same releases as a v2 brand.
|
|
@@ -460,4 +460,114 @@ describe('PLACEHOLDERS_AVAILABLE announce', () => {
|
|
|
460
460
|
]);
|
|
461
461
|
expect(second.placeholders).toEqual(['header', 'footer']);
|
|
462
462
|
});
|
|
463
|
+
|
|
464
|
+
// ZERO-5391 — per-country content.
|
|
465
|
+
|
|
466
|
+
it('announces per-country content and the locale settings before the editor proves itself', () => {
|
|
467
|
+
const { registry } = load();
|
|
468
|
+
|
|
469
|
+
registry.registerPlaceholder('header');
|
|
470
|
+
jest.advanceTimersByTime(100);
|
|
471
|
+
|
|
472
|
+
// On the gated frame, like the action list: for a build with no custom
|
|
473
|
+
// catalog it is the only frame the editor ever gets, and a frame without
|
|
474
|
+
// the capability keeps the editor's country picker hidden.
|
|
475
|
+
//
|
|
476
|
+
// Literals rather than the constants, for the same reason as the action
|
|
477
|
+
// list: these pin the wire values. The locale values are the ones
|
|
478
|
+
// jest/__mocks__/settings.js carries.
|
|
479
|
+
const [payload] = frames();
|
|
480
|
+
expect(payload.contentCapabilities).toEqual(['region-content']);
|
|
481
|
+
expect(payload.localeUrlStrategy).toBe('hide-default-locale');
|
|
482
|
+
expect(payload.defaultLocale).toBe('en-us');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('keeps the per-country fields on both frames of the designer re-announce', () => {
|
|
486
|
+
const { registry, register } = load();
|
|
487
|
+
|
|
488
|
+
register.registerCustomBlocks('acme', { 'promo-strip': Promo });
|
|
489
|
+
registry.registerPlaceholder('header');
|
|
490
|
+
jest.advanceTimersByTime(100);
|
|
491
|
+
|
|
492
|
+
(window as { __themeEditorDesigner?: boolean }).__themeEditorDesigner =
|
|
493
|
+
true;
|
|
494
|
+
window.dispatchEvent(
|
|
495
|
+
new MessageEvent('message', {
|
|
496
|
+
origin: EDITOR_ORIGIN,
|
|
497
|
+
source: window.parent as Window,
|
|
498
|
+
data: { type: 'SET_THEME_EDITOR_COOKIE', data: true }
|
|
499
|
+
})
|
|
500
|
+
);
|
|
501
|
+
jest.advanceTimersByTime(100);
|
|
502
|
+
|
|
503
|
+
// Every frame, not just the first. The editor replaces what it holds with
|
|
504
|
+
// each announce, so a later frame without the capability would take the
|
|
505
|
+
// country picker away halfway through a session.
|
|
506
|
+
expect(postMessage).toHaveBeenCalledTimes(2);
|
|
507
|
+
expect(
|
|
508
|
+
frames().map(
|
|
509
|
+
({ contentCapabilities, localeUrlStrategy, defaultLocale }) => ({
|
|
510
|
+
contentCapabilities,
|
|
511
|
+
localeUrlStrategy,
|
|
512
|
+
defaultLocale
|
|
513
|
+
})
|
|
514
|
+
)
|
|
515
|
+
).toEqual([
|
|
516
|
+
{
|
|
517
|
+
contentCapabilities: ['region-content'],
|
|
518
|
+
localeUrlStrategy: 'hide-default-locale',
|
|
519
|
+
defaultLocale: 'en-us'
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
contentCapabilities: ['region-content'],
|
|
523
|
+
localeUrlStrategy: 'hide-default-locale',
|
|
524
|
+
defaultLocale: 'en-us'
|
|
525
|
+
}
|
|
526
|
+
]);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('announces the locale settings the build actually routes by', () => {
|
|
530
|
+
// The stub's strategy is also the fallback, so only settings of another
|
|
531
|
+
// shape prove the frame reads them rather than a constant.
|
|
532
|
+
jest.doMock('settings', () => ({
|
|
533
|
+
localization: { localeUrlStrategy: 'subdomain', defaultLocaleValue: 'tr' }
|
|
534
|
+
}));
|
|
535
|
+
try {
|
|
536
|
+
const { registry } = load();
|
|
537
|
+
|
|
538
|
+
registry.registerPlaceholder('header');
|
|
539
|
+
jest.advanceTimersByTime(100);
|
|
540
|
+
|
|
541
|
+
const [payload] = frames();
|
|
542
|
+
expect(payload.localeUrlStrategy).toBe('subdomain');
|
|
543
|
+
expect(payload.defaultLocale).toBe('tr');
|
|
544
|
+
expect(payload.contentCapabilities).toEqual(['region-content']);
|
|
545
|
+
} finally {
|
|
546
|
+
jest.dontMock('settings');
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('still announces when the settings cannot be read', () => {
|
|
551
|
+
jest.doMock('settings', () => ({
|
|
552
|
+
get localization() {
|
|
553
|
+
throw new Error('settings are not readable');
|
|
554
|
+
}
|
|
555
|
+
}));
|
|
556
|
+
try {
|
|
557
|
+
const { registry } = load();
|
|
558
|
+
|
|
559
|
+
registry.registerPlaceholder('header');
|
|
560
|
+
jest.advanceTimersByTime(100);
|
|
561
|
+
|
|
562
|
+
// The frame is what every editor load waits for. Losing it to a settings
|
|
563
|
+
// read would cost the whole canvas, not just the country picker.
|
|
564
|
+
expect(postMessage).toHaveBeenCalledTimes(1);
|
|
565
|
+
const [payload] = frames();
|
|
566
|
+
expect(payload.placeholders).toEqual(['header']);
|
|
567
|
+
expect(payload.localeUrlStrategy).toBe('hide-default-locale');
|
|
568
|
+
expect(payload.defaultLocale).toBe('');
|
|
569
|
+
} finally {
|
|
570
|
+
jest.dontMock('settings');
|
|
571
|
+
}
|
|
572
|
+
});
|
|
463
573
|
});
|
|
@@ -3,6 +3,7 @@ import * as React from 'react';
|
|
|
3
3
|
import { CompositionState, readCompositionState } from './composition-state';
|
|
4
4
|
import { getPlaceholderPreviewSnapshot } from './preview-snapshot-source';
|
|
5
5
|
import { getWidgetDataWithRetry } from './retrying-widget-data';
|
|
6
|
+
import { readMarketPlaceholderCopy } from '../region/region-source'; // ZERO-5391
|
|
6
7
|
|
|
7
8
|
// `React.cache` exists on the RSC-capable React this package renders under;
|
|
8
9
|
// fall back to the raw function on older cores, where correctness is identical
|
|
@@ -20,9 +21,13 @@ export type PlaceholderWidget = {
|
|
|
20
21
|
const fetchPlaceholderWidget = async (
|
|
21
22
|
slug: string
|
|
22
23
|
): Promise<PlaceholderWidget> =>
|
|
23
|
-
|
|
24
|
+
// ZERO-5391: a placeholder the request's region has its own copy of reads
|
|
25
|
+
// that copy; anything else, and a copy that does not answer, reads the
|
|
26
|
+
// shared default exactly as before.
|
|
27
|
+
((await readMarketPlaceholderCopy(slug)) as PlaceholderWidget) ??
|
|
28
|
+
((await getWidgetDataWithRetry<{
|
|
24
29
|
attributes?: { theme_editor_placeholder?: unknown };
|
|
25
|
-
}>(slug)) as PlaceholderWidget;
|
|
30
|
+
}>(slug)) as PlaceholderWidget);
|
|
26
31
|
|
|
27
32
|
/**
|
|
28
33
|
* The one fetch of a placeholder widget per request.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import * as serverData from '@akinon/next/data/server';
|
|
3
|
+
import { resolveRegionSlug } from '../region/region-source'; // ZERO-5391
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Whole-placeholder snapshot the theme editor writes for a named preview
|
|
@@ -31,7 +32,10 @@ const fetchPreviewSnapshot = async (
|
|
|
31
32
|
if (!fn) return null;
|
|
32
33
|
|
|
33
34
|
try {
|
|
34
|
-
|
|
35
|
+
// ZERO-5391: a placeholder the request's region customised previews from
|
|
36
|
+
// that region's own blob; without one the loader falls through to the
|
|
37
|
+
// region's LIVE copy (getPlaceholderWidget), never to the default's blob.
|
|
38
|
+
return (await fn({ slug: await resolveRegionSlug(slug) })) ?? null;
|
|
35
39
|
} catch {
|
|
36
40
|
// A failed preview read must fall back to the live render, never blank it.
|
|
37
41
|
return null;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { REGION_CONTENT_CAPABILITY } from './region/region-contract';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What content this build can serve differently by AUDIENCE, announced to the
|
|
5
|
+
* editor (ZERO-5391).
|
|
6
|
+
*
|
|
7
|
+
* The third sibling of `action-capabilities.ts` and `signal-capabilities.ts`.
|
|
8
|
+
* Those say what a stored action will do and which moments the renderer can
|
|
9
|
+
* report; this one says whether the storefront can tell its visitors apart
|
|
10
|
+
* when it picks the content a placeholder renders.
|
|
11
|
+
*
|
|
12
|
+
* - `region-content` a placeholder renders from its country copy
|
|
13
|
+
* (`<slug>-region-<cc>`; in draft mode that copy's
|
|
14
|
+
* `-preview-<id>` blob first) for a request whose locale
|
|
15
|
+
* names the country (`en-ae` → `ae`), whenever the
|
|
16
|
+
* `theme-regions` registry lists the placeholder for it
|
|
17
|
+
* (`region/region-source.ts`). The editor offers its
|
|
18
|
+
* country picker, and with it every write to a
|
|
19
|
+
* `-region-` slug, only to a storefront that says so: on
|
|
20
|
+
* an older theme those writes would save and render
|
|
21
|
+
* nothing.
|
|
22
|
+
*
|
|
23
|
+
* Names rather than a version number, for the reason the action capabilities
|
|
24
|
+
* are: the editor must not have to know which release added what. A build that
|
|
25
|
+
* does not send the list is read as serving one content to every visitor,
|
|
26
|
+
* which is exactly what every renderer shipped before this one did.
|
|
27
|
+
*
|
|
28
|
+
* Unlike its siblings this list does not spell its string out. The name lives
|
|
29
|
+
* in the region contract (`region/region-contract.ts`), whose copy in the
|
|
30
|
+
* editor is held byte-identical to it by a parity test on both sides, so the
|
|
31
|
+
* two can only be renamed together — there and never here.
|
|
32
|
+
*
|
|
33
|
+
* Its own module rather than a constant beside the reader that implements it:
|
|
34
|
+
* the placeholder registry announces this from the client, and it must not
|
|
35
|
+
* have to pull the server-only region reader in to say one list of strings.
|
|
36
|
+
*/
|
|
37
|
+
export type ContentCapability = typeof REGION_CONTENT_CAPABILITY;
|
|
38
|
+
|
|
39
|
+
export const CONTENT_CAPABILITIES: readonly ContentCapability[] = [
|
|
40
|
+
REGION_CONTENT_CAPABILITY
|
|
41
|
+
];
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
PreviewSnapshot
|
|
14
14
|
} from './chrome/preview-snapshot-source';
|
|
15
15
|
import { getThemeConfig, ThemeConfig } from './chrome/theme-config-source';
|
|
16
|
+
import { withRegionDataSources } from './region/region-source'; // ZERO-5391
|
|
16
17
|
import { readCustomBreakpoints } from './utils/breakpoints';
|
|
17
18
|
// One acceptance rule for this loader and the designer canvas's client fetch
|
|
18
19
|
// (utils/live-rows.ts), so the two cannot take different answers.
|
|
@@ -295,6 +296,11 @@ export async function loadPlaceholderSections(
|
|
|
295
296
|
);
|
|
296
297
|
}
|
|
297
298
|
|
|
299
|
+
// ZERO-5391: a region copy carries the data-source records its sections
|
|
300
|
+
// reference, so they resolve even while the shared theme-config is still
|
|
301
|
+
// cached without them; any other widget keeps theme-config's list.
|
|
302
|
+
const effectiveDataSources = withRegionDataSources(dataSources, data);
|
|
303
|
+
|
|
298
304
|
const sections: Section[] = [];
|
|
299
305
|
|
|
300
306
|
if (data?.attributes?.theme_editor_placeholder) {
|
|
@@ -460,7 +466,7 @@ export async function loadPlaceholderSections(
|
|
|
460
466
|
|
|
461
467
|
const dataSourceWithData = await resolveSectionDataSource(
|
|
462
468
|
dataSourceId,
|
|
463
|
-
|
|
469
|
+
effectiveDataSources, // ZERO-5391
|
|
464
470
|
sectionSlug,
|
|
465
471
|
sectionData
|
|
466
472
|
);
|
|
@@ -504,8 +510,12 @@ export async function loadPlaceholderSections(
|
|
|
504
510
|
|
|
505
511
|
return {
|
|
506
512
|
sections,
|
|
507
|
-
|
|
508
|
-
|
|
513
|
+
// ZERO-5391: always the canonical slug. A region copy
|
|
514
|
+
// (`<slug>-region-<cc>`) or a draft-mode per-widget preview copy answers
|
|
515
|
+
// under its own slug, while the editor and every frame address
|
|
516
|
+
// placeholders canonically.
|
|
517
|
+
placeholderId: slug,
|
|
518
|
+
dataSources: effectiveDataSources, // ZERO-5391
|
|
509
519
|
themeSettings,
|
|
510
520
|
customBreakpoints
|
|
511
521
|
};
|
|
@@ -13,6 +13,15 @@ import {
|
|
|
13
13
|
STATE_PIN_CAPABILITIES,
|
|
14
14
|
type StatePinCapability
|
|
15
15
|
} from './state-pin-capabilities';
|
|
16
|
+
// ZERO-5391: the per-country content handshake.
|
|
17
|
+
import {
|
|
18
|
+
CONTENT_CAPABILITIES,
|
|
19
|
+
type ContentCapability
|
|
20
|
+
} from './content-capabilities';
|
|
21
|
+
import {
|
|
22
|
+
defaultLocaleForEditor,
|
|
23
|
+
localeUrlStrategyForEditor
|
|
24
|
+
} from './region/editor-handshake';
|
|
16
25
|
import type { CustomSectionCatalog } from './custom/section-types';
|
|
17
26
|
import type { CustomPageCatalog } from './custom/page-types';
|
|
18
27
|
import type { CustomCatalog } from './custom/types';
|
|
@@ -119,6 +128,32 @@ interface PlaceholdersAvailablePayload {
|
|
|
119
128
|
* without the field would put it back to reporting nothing.
|
|
120
129
|
*/
|
|
121
130
|
signalCapabilities?: readonly SignalCapability[];
|
|
131
|
+
|
|
132
|
+
// ZERO-5391: the three fields below are the per-country content handshake.
|
|
133
|
+
/**
|
|
134
|
+
* Which content THIS build can serve differently by audience (see
|
|
135
|
+
* `content-capabilities.ts`). The editor offers its country picker only to
|
|
136
|
+
* a storefront that lists `region-content`.
|
|
137
|
+
*
|
|
138
|
+
* Same terms as `actionCapabilities` above and on every frame for the same
|
|
139
|
+
* reason: the editor replaces what it holds with each frame, so a frame
|
|
140
|
+
* without the field would hide the picker again halfway through a session.
|
|
141
|
+
*/
|
|
142
|
+
contentCapabilities?: readonly ContentCapability[];
|
|
143
|
+
/**
|
|
144
|
+
* `settings.localization.localeUrlStrategy`, 'hide-default-locale' when
|
|
145
|
+
* unset (see `region/editor-handshake.ts`). The editor moves its canvas
|
|
146
|
+
* between countries by switching the preview's locale, which under
|
|
147
|
+
* 'subdomain' would mean another origin. Every frame, on the same terms.
|
|
148
|
+
*/
|
|
149
|
+
localeUrlStrategy?: string;
|
|
150
|
+
/**
|
|
151
|
+
* `settings.localization.defaultLocaleValue`: the locale the hide-*
|
|
152
|
+
* strategies serve without a path prefix, which the editor needs to link a
|
|
153
|
+
* country's page and to choose the locale a country switch lands on. Every
|
|
154
|
+
* frame, on the same terms.
|
|
155
|
+
*/
|
|
156
|
+
defaultLocale?: string;
|
|
122
157
|
}
|
|
123
158
|
|
|
124
159
|
const isFramed = (): boolean =>
|
|
@@ -163,7 +198,13 @@ const flush = () => {
|
|
|
163
198
|
// Outside the designer gate for the same reason, and for one more: the
|
|
164
199
|
// panel it gates is the merchant's, so a slow handshake must not be the
|
|
165
200
|
// difference between a section being trackable and not.
|
|
166
|
-
signalCapabilities: SIGNAL_CAPABILITIES
|
|
201
|
+
signalCapabilities: SIGNAL_CAPABILITIES,
|
|
202
|
+
// ZERO-5391: outside the designer gate for the same reasons. A fact about
|
|
203
|
+
// this theme version and two public locale settings: what decides whether
|
|
204
|
+
// the editor offers per-country content, and how it switches countries.
|
|
205
|
+
contentCapabilities: CONTENT_CAPABILITIES,
|
|
206
|
+
localeUrlStrategy: localeUrlStrategyForEditor(),
|
|
207
|
+
defaultLocale: defaultLocaleForEditor()
|
|
167
208
|
};
|
|
168
209
|
|
|
169
210
|
// Omitted entirely when nothing is registered: a build with no brand pages
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZERO-5391 — the locale settings the storefront hands the editor on
|
|
3
|
+
* PLACEHOLDERS_AVAILABLE, read from the brand's `settings` module.
|
|
4
|
+
*
|
|
5
|
+
* `settings` resolves to `jest/__mocks__/settings.js` unless a test swaps it.
|
|
6
|
+
* Each swap loads a fresh copy of the module under test, and `afterEach`
|
|
7
|
+
* removes the swap so the next test sees the shared stub again.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Type-only, so the module under test is still loaded fresh per test below;
|
|
11
|
+
// it also keeps this file a module rather than a global script.
|
|
12
|
+
import type * as HandshakeModule from '../editor-handshake';
|
|
13
|
+
|
|
14
|
+
type Handshake = typeof HandshakeModule;
|
|
15
|
+
|
|
16
|
+
const load = (settingsModule?: unknown): Handshake => {
|
|
17
|
+
let loaded: Handshake | undefined;
|
|
18
|
+
jest.isolateModules(() => {
|
|
19
|
+
if (settingsModule !== undefined) {
|
|
20
|
+
jest.doMock('settings', () => settingsModule);
|
|
21
|
+
}
|
|
22
|
+
loaded = require('../editor-handshake');
|
|
23
|
+
});
|
|
24
|
+
return loaded!;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('editor handshake settings', () => {
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
jest.dontMock('settings');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('reads the storefront settings the jest stub carries', () => {
|
|
33
|
+
const handshake = load();
|
|
34
|
+
|
|
35
|
+
expect(handshake.localeUrlStrategyForEditor()).toBe('hide-default-locale');
|
|
36
|
+
expect(handshake.defaultLocaleForEditor()).toBe('en-us');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('passes the configured strategy and default locale through', () => {
|
|
40
|
+
// The brand's settings.js is CommonJS, so its default import is the
|
|
41
|
+
// module object itself.
|
|
42
|
+
const handshake = load({
|
|
43
|
+
localization: { localeUrlStrategy: 'subdomain', defaultLocaleValue: 'tr' }
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(handshake.localeUrlStrategyForEditor()).toBe('subdomain');
|
|
47
|
+
expect(handshake.defaultLocaleForEditor()).toBe('tr');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('reads an ES-module settings default export the same way', () => {
|
|
51
|
+
const handshake = load({
|
|
52
|
+
__esModule: true,
|
|
53
|
+
default: {
|
|
54
|
+
localization: {
|
|
55
|
+
localeUrlStrategy: 'show-all-locales',
|
|
56
|
+
defaultLocaleValue: 'en-ae'
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(handshake.localeUrlStrategyForEditor()).toBe('show-all-locales');
|
|
62
|
+
expect(handshake.defaultLocaleForEditor()).toBe('en-ae');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it.each([
|
|
66
|
+
['empty settings', {}],
|
|
67
|
+
['no localization values', { localization: {} }],
|
|
68
|
+
['a null localization', { localization: null }],
|
|
69
|
+
['an undefined default export', { __esModule: true, default: undefined }]
|
|
70
|
+
])('falls back to the defaults for %s', (_label, settingsModule) => {
|
|
71
|
+
const handshake = load(settingsModule);
|
|
72
|
+
|
|
73
|
+
// 'hide-default-locale' is what the core's locale middleware routes by
|
|
74
|
+
// when the strategy is unset, so the editor is told what really happens.
|
|
75
|
+
expect(handshake.localeUrlStrategyForEditor()).toBe('hide-default-locale');
|
|
76
|
+
expect(handshake.defaultLocaleForEditor()).toBe('');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('treats an empty or non-string strategy as unset', () => {
|
|
80
|
+
expect(
|
|
81
|
+
load({
|
|
82
|
+
localization: { localeUrlStrategy: '' }
|
|
83
|
+
}).localeUrlStrategyForEditor()
|
|
84
|
+
).toBe('hide-default-locale');
|
|
85
|
+
expect(
|
|
86
|
+
load({
|
|
87
|
+
localization: { localeUrlStrategy: 42 }
|
|
88
|
+
}).localeUrlStrategyForEditor()
|
|
89
|
+
).toBe('hide-default-locale');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('sends the default locale only when it is a string', () => {
|
|
93
|
+
expect(
|
|
94
|
+
load({
|
|
95
|
+
localization: { defaultLocaleValue: ['en'] }
|
|
96
|
+
}).defaultLocaleForEditor()
|
|
97
|
+
).toBe('');
|
|
98
|
+
expect(
|
|
99
|
+
load({
|
|
100
|
+
localization: { defaultLocaleValue: '' }
|
|
101
|
+
}).defaultLocaleForEditor()
|
|
102
|
+
).toBe('');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('answers the defaults instead of throwing when the settings read throws', () => {
|
|
106
|
+
// The announce these values ride is the frame every editor load waits
|
|
107
|
+
// for; a settings read must never be able to take it down.
|
|
108
|
+
const handshake = load({
|
|
109
|
+
get localization() {
|
|
110
|
+
throw new Error('settings are not readable');
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(handshake.localeUrlStrategyForEditor()).toBe('hide-default-locale');
|
|
115
|
+
expect(handshake.defaultLocaleForEditor()).toBe('');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('reads nothing at module scope and the current settings on every call', () => {
|
|
119
|
+
const settingsModule = {
|
|
120
|
+
localization: {
|
|
121
|
+
localeUrlStrategy: 'hide-all-locales',
|
|
122
|
+
defaultLocaleValue: 'en'
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const read = jest.fn(() => settingsModule.localization);
|
|
126
|
+
const handshake = load({
|
|
127
|
+
get localization() {
|
|
128
|
+
return read();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(read).not.toHaveBeenCalled();
|
|
133
|
+
expect(handshake.localeUrlStrategyForEditor()).toBe('hide-all-locales');
|
|
134
|
+
|
|
135
|
+
settingsModule.localization = {
|
|
136
|
+
localeUrlStrategy: 'show-all-locales',
|
|
137
|
+
defaultLocaleValue: 'tr'
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
expect(handshake.localeUrlStrategyForEditor()).toBe('show-all-locales');
|
|
141
|
+
expect(handshake.defaultLocaleForEditor()).toBe('tr');
|
|
142
|
+
expect(read).toHaveBeenCalledTimes(3);
|
|
143
|
+
});
|
|
144
|
+
});
|