@akinon/pz-theme 2.0.71-beta.0 → 2.0.71
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 +7 -4
- package/package.json +4 -4
- package/src/__tests__/custom-catalog-announce.test.ts +276 -0
- package/src/blocks/group-block.tsx +9 -57
- package/src/blocks/render-composed-children.tsx +104 -0
- package/src/custom/README.md +249 -0
- package/src/custom/__tests__/define.test.tsx +619 -0
- package/src/custom/__tests__/module-boundaries.test.ts +156 -0
- package/src/custom/__tests__/public-api.test.tsx +427 -0
- package/src/custom/__tests__/slot-node-wire.test.tsx +309 -0
- package/src/custom/__tests__/slot-nodes.test.ts +144 -0
- package/src/custom/__tests__/theme-editable.test.tsx +486 -0
- package/src/custom/__tests__/theme-slot.test.tsx +387 -0
- package/src/custom/__tests__/use-theme-setting.test.tsx +260 -0
- package/src/custom/block-context.ts +64 -0
- package/src/custom/block-host.tsx +162 -0
- package/src/custom/catalog.ts +97 -0
- package/src/custom/collect.ts +342 -0
- package/src/custom/define.tsx +172 -0
- package/src/custom/editable-tags.ts +120 -0
- package/src/custom/index.ts +105 -0
- package/src/custom/parts.ts +108 -0
- package/src/custom/property-types.ts +70 -0
- package/src/custom/register.ts +545 -0
- package/src/custom/slot-nodes.ts +119 -0
- package/src/custom/theme-editable.tsx +342 -0
- package/src/custom/theme-slot.tsx +210 -0
- package/src/custom/types.ts +251 -0
- package/src/custom/use-theme-setting.ts +225 -0
- package/src/index.ts +66 -0
- package/src/placeholder-registry.ts +168 -16
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `'use client'` boundary inside `src/custom`, checked from source.
|
|
6
|
+
*
|
|
7
|
+
* Registration is client-only, but this folder is not. The directive-free
|
|
8
|
+
* modules here — `catalog.ts`, `types.ts`, `collect.ts`, `define.tsx`, the
|
|
9
|
+
* barrel — are reachable from the server graph and are actually read from it:
|
|
10
|
+
* `placeholder-registry.ts` calls `getCustomCatalog` without a directive of its
|
|
11
|
+
* own, and the server-only package root re-exports the barrel. React replaces
|
|
12
|
+
* each export of a `'use client'` module with a reference that throws
|
|
13
|
+
* "Attempted to call … from the server" the moment it is invoked, so one call
|
|
14
|
+
* from a directive-free module into a client one breaks whichever consumer is
|
|
15
|
+
* holding the server-side copy — and no jsdom test can see it, because jest has
|
|
16
|
+
* no RSC graph at all.
|
|
17
|
+
*
|
|
18
|
+
* What is refused is CALLING across the line, not importing across it: a client
|
|
19
|
+
* component referenced as an element type resolves fine from either graph. That
|
|
20
|
+
* is the distinction `bindHost` in `register.ts` leans on when it hands
|
|
21
|
+
* `CustomBlockHost` to `createElement` rather than calling it. The rule is also
|
|
22
|
+
* one-directional — a client module imports a plain one freely — and type-only
|
|
23
|
+
* imports are erased before the bundler sees them.
|
|
24
|
+
*/
|
|
25
|
+
const CUSTOM_DIR = path.resolve(__dirname, '..');
|
|
26
|
+
|
|
27
|
+
const sourceFiles = (): string[] =>
|
|
28
|
+
fs
|
|
29
|
+
.readdirSync(CUSTOM_DIR)
|
|
30
|
+
.filter((entry) => /\.tsx?$/.test(entry))
|
|
31
|
+
.sort();
|
|
32
|
+
|
|
33
|
+
const read = (file: string): string =>
|
|
34
|
+
fs.readFileSync(path.join(CUSTOM_DIR, file), 'utf8');
|
|
35
|
+
|
|
36
|
+
const isClientModule = (file: string): boolean =>
|
|
37
|
+
/^['"]use client['"];/.test(read(file).trimStart());
|
|
38
|
+
|
|
39
|
+
/** Sibling value imports as `{ bindings, specifier }`. Type imports erase. */
|
|
40
|
+
const valueImports = (
|
|
41
|
+
source: string
|
|
42
|
+
): Array<{ bindings: string[]; specifier: string }> =>
|
|
43
|
+
Array.from(
|
|
44
|
+
source.matchAll(/^import\s+(?!type\s)([^;]*?)\s+from\s+'(\.[^']*)';/gm)
|
|
45
|
+
)
|
|
46
|
+
.filter((match) => !match[2].startsWith('../'))
|
|
47
|
+
.map((match) => ({
|
|
48
|
+
specifier: match[2],
|
|
49
|
+
bindings: Array.from(match[1].matchAll(/[A-Za-z_$][\w$]*/g))
|
|
50
|
+
.map((binding) => binding[0])
|
|
51
|
+
.filter((name) => name !== 'type')
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
const resolved = (specifier: string): string | null =>
|
|
55
|
+
['.ts', '.tsx']
|
|
56
|
+
.map((extension) => `${specifier.replace(/^\.\//, '')}${extension}`)
|
|
57
|
+
.find((candidate) => fs.existsSync(path.join(CUSTOM_DIR, candidate))) ??
|
|
58
|
+
null;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Source with its comments removed.
|
|
62
|
+
*
|
|
63
|
+
* Every file here explains its boundary in prose, and the prose names the very
|
|
64
|
+
* symbols the scan looks for — an unstripped comment reports the rule's own
|
|
65
|
+
* documentation as a violation of it.
|
|
66
|
+
*/
|
|
67
|
+
const code = (source: string): string =>
|
|
68
|
+
source.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' ');
|
|
69
|
+
|
|
70
|
+
const isCalled = (source: string, name: string): boolean =>
|
|
71
|
+
new RegExp(String.raw`\b${name}\s*\(`).test(code(source));
|
|
72
|
+
|
|
73
|
+
describe('src/custom module boundaries', () => {
|
|
74
|
+
it('has the client modules the render path needs and no more', () => {
|
|
75
|
+
// A directive here is not free — it decides which graph can call the file.
|
|
76
|
+
// Listing them makes adding one a deliberate edit.
|
|
77
|
+
expect(sourceFiles().filter(isClientModule)).toEqual([
|
|
78
|
+
'block-context.ts',
|
|
79
|
+
'block-host.tsx',
|
|
80
|
+
'register.ts',
|
|
81
|
+
'theme-editable.tsx',
|
|
82
|
+
'theme-slot.tsx',
|
|
83
|
+
'use-theme-setting.ts'
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The renderer registry may only be reached from a client module.
|
|
89
|
+
*
|
|
90
|
+
* `blocks/block-renderer-registry.tsx` imports all ~99 block modules, and
|
|
91
|
+
* most of them call hooks without carrying a directive — they have never
|
|
92
|
+
* needed one, because the only path that reaches them is `theme-block.tsx`,
|
|
93
|
+
* which is a client module. Importing the registry from a directive-free
|
|
94
|
+
* module puts that whole tree into the importer's graph, and a brand barrel
|
|
95
|
+
* imported from a server layout then fails the BRAND's build at
|
|
96
|
+
* `accordion-block.tsx:1` with "You're importing a module that depends on
|
|
97
|
+
* `useState` into a React Server Component module". `register.ts` carries
|
|
98
|
+
* `'use client'` for exactly this reason, and that is what makes registration
|
|
99
|
+
* client-only.
|
|
100
|
+
*
|
|
101
|
+
* This is precisely the case the first version of this file could not see:
|
|
102
|
+
* it filtered `../` specifiers out, and the offending import is
|
|
103
|
+
* `../blocks/block-renderer-registry`. It cost nothing in either suite —
|
|
104
|
+
* jest has no RSC graph, so 1741 tests stayed green over a defect that broke
|
|
105
|
+
* the first brand build attempted.
|
|
106
|
+
*/
|
|
107
|
+
it('reaches the block renderer registry only from a client module', () => {
|
|
108
|
+
const withoutTypeImports = (source: string): string =>
|
|
109
|
+
code(source).replace(/import\s+type[^;]*;/g, ' ');
|
|
110
|
+
|
|
111
|
+
const offenders = sourceFiles()
|
|
112
|
+
.filter((file) => !isClientModule(file))
|
|
113
|
+
.filter((file) =>
|
|
114
|
+
/from\s+'\.\.\/blocks\/block-renderer-registry'/.test(
|
|
115
|
+
withoutTypeImports(read(file))
|
|
116
|
+
)
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(offenders).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('never calls into a client module from a server-reachable one', () => {
|
|
123
|
+
const offences: string[] = [];
|
|
124
|
+
|
|
125
|
+
sourceFiles()
|
|
126
|
+
.filter((file) => !isClientModule(file))
|
|
127
|
+
.forEach((file) => {
|
|
128
|
+
const source = read(file);
|
|
129
|
+
|
|
130
|
+
valueImports(source).forEach(({ bindings, specifier }) => {
|
|
131
|
+
const target = resolved(specifier);
|
|
132
|
+
if (!target || !isClientModule(target)) return;
|
|
133
|
+
|
|
134
|
+
bindings
|
|
135
|
+
.filter((name) => isCalled(source, name))
|
|
136
|
+
.forEach((name) => {
|
|
137
|
+
offences.push(`${file} calls ${name}() from ${target}`);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(offences).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('keeps the barrel free of a directive of its own', () => {
|
|
146
|
+
// Still worth pinning under client-only registration, for the opposite
|
|
147
|
+
// reason to the one it was written for. The barrel publishes more than the
|
|
148
|
+
// client half: `getCustomCatalog`, `isCustomThemeType` and
|
|
149
|
+
// `CUSTOM_TYPE_PREFIX` all come from directive-free modules, and the
|
|
150
|
+
// server-only package root re-exports every one of them. A directive here
|
|
151
|
+
// would turn those into client references, so a server module calling
|
|
152
|
+
// `isCustomThemeType` from `@akinon/pz-theme` would throw. `register.ts`
|
|
153
|
+
// carries its own directive already, so the barrel would gain nothing.
|
|
154
|
+
expect(isClientModule('index.ts')).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { render, screen } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
// The recursive renderer, stubbed with a countable marker so a slot's children
|
|
7
|
+
// are assertable without every block renderer being dragged into the
|
|
8
|
+
// assertion. Same stand-in `group-block.test.tsx` uses, and for the same
|
|
9
|
+
// reason — `renderComposedChildren` is now shared by both.
|
|
10
|
+
jest.mock('../../theme-block', () => ({
|
|
11
|
+
__esModule: true,
|
|
12
|
+
default: ({ block }: { block: { id: string } }) => (
|
|
13
|
+
<div data-testid="slot-child" data-id={block.id} />
|
|
14
|
+
)
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import blockRendererRegistry from '../../blocks/block-renderer-registry';
|
|
18
|
+
import type { BlockRendererProps } from '../../blocks/block-renderer-registry';
|
|
19
|
+
import { resetCustomCatalog } from '../catalog';
|
|
20
|
+
import { resetCustomCollection } from '../collect';
|
|
21
|
+
|
|
22
|
+
// Through the barrel, never the individual modules. That is the point of this
|
|
23
|
+
// file: the three pieces each have their own suite, and every one of them
|
|
24
|
+
// imports its subject directly, so a barrel that exported nothing at all would
|
|
25
|
+
// still leave 86 green tests behind — while `@akinon/pz-theme/src/custom`, the
|
|
26
|
+
// path a brand actually types, resolved to nothing.
|
|
27
|
+
import {
|
|
28
|
+
CUSTOM_SLOT_BLOCK_TYPE,
|
|
29
|
+
CUSTOM_TYPE_PREFIX,
|
|
30
|
+
defineCustomBlock,
|
|
31
|
+
getCustomCatalog,
|
|
32
|
+
isCustomThemeType,
|
|
33
|
+
partAttrs,
|
|
34
|
+
registerCustomBlocks,
|
|
35
|
+
SLOT_NAME_PROPERTY,
|
|
36
|
+
ThemeEditable,
|
|
37
|
+
ThemeSlot,
|
|
38
|
+
useThemeSetting
|
|
39
|
+
} from '../index';
|
|
40
|
+
import * as customBarrel from '../index';
|
|
41
|
+
|
|
42
|
+
const rendererMap = (): Map<string, unknown> =>
|
|
43
|
+
(blockRendererRegistry as unknown as { renderers: Map<string, unknown> })
|
|
44
|
+
.renderers;
|
|
45
|
+
|
|
46
|
+
const clearCustomRenderers = (): void => {
|
|
47
|
+
const map = rendererMap();
|
|
48
|
+
Array.from(map.keys())
|
|
49
|
+
.filter(isCustomThemeType)
|
|
50
|
+
.forEach((type) => map.delete(type));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** The ticket's own example, unaltered. If this stops compiling, the API broke. */
|
|
54
|
+
function PromoStrip() {
|
|
55
|
+
const tone = useThemeSetting('tone', {
|
|
56
|
+
type: 'select',
|
|
57
|
+
label: 'Tone',
|
|
58
|
+
options: ['light', 'dark'],
|
|
59
|
+
default: 'light'
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
data-testid="promo"
|
|
65
|
+
className={tone === 'dark' ? 'bg-black' : 'bg-white'}
|
|
66
|
+
{...partAttrs('shell')}
|
|
67
|
+
>
|
|
68
|
+
<ThemeEditable name="art">
|
|
69
|
+
<img alt="Promo art" src="/default.jpg" />
|
|
70
|
+
</ThemeEditable>
|
|
71
|
+
<ThemeEditable name="headline">
|
|
72
|
+
<h2>New season</h2>
|
|
73
|
+
</ThemeEditable>
|
|
74
|
+
<ThemeSlot />
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Registered without `defineCustomBlock`, which the barrel also has to accept. */
|
|
80
|
+
const BestSellers = () => <div data-testid="best-sellers" />;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The two ways a field ends up with no `field-<key>` element of its own: a
|
|
84
|
+
* link's destination rides on the one `<a>` its label already claimed, and a
|
|
85
|
+
* developer who wrote their own `data-part` keeps it.
|
|
86
|
+
*/
|
|
87
|
+
function LinkCard() {
|
|
88
|
+
return (
|
|
89
|
+
<div>
|
|
90
|
+
<ThemeEditable name="cta">
|
|
91
|
+
<a href="/sale">Shop now</a>
|
|
92
|
+
</ThemeEditable>
|
|
93
|
+
<ThemeEditable name="note">
|
|
94
|
+
<p data-part="footnote">Ends Friday</p>
|
|
95
|
+
</ThemeEditable>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The multi-slot shape end to end: three regions, one of them styled through a
|
|
102
|
+
* declared part. Written as a brand would write it, so the whole path is
|
|
103
|
+
* exercised — declaration, the slot names threaded through the host, and the
|
|
104
|
+
* slot nodes read back out of the composition.
|
|
105
|
+
*/
|
|
106
|
+
function MegaMenu() {
|
|
107
|
+
return (
|
|
108
|
+
<div>
|
|
109
|
+
<div {...partAttrs('row')}>
|
|
110
|
+
<ThemeSlot name="promo" label="Promo" />
|
|
111
|
+
</div>
|
|
112
|
+
<ThemeSlot name="links" label="Links" />
|
|
113
|
+
<ThemeSlot name="footer" label="Footer" />
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const PROMO = 'custom:acme:promo-strip';
|
|
119
|
+
const LINK_CARD = 'custom:acme:link-card';
|
|
120
|
+
const MEGA_MENU = 'custom:acme:mega-menu';
|
|
121
|
+
|
|
122
|
+
const slotNode = (name: string, childId: string) => ({
|
|
123
|
+
id: `slot-${name}`,
|
|
124
|
+
type: CUSTOM_SLOT_BLOCK_TYPE,
|
|
125
|
+
label: name,
|
|
126
|
+
properties: { [SLOT_NAME_PROPERTY]: name },
|
|
127
|
+
styles: {},
|
|
128
|
+
order: 0,
|
|
129
|
+
hidden: false,
|
|
130
|
+
blocks: [
|
|
131
|
+
{
|
|
132
|
+
id: childId,
|
|
133
|
+
type: 'text',
|
|
134
|
+
label: childId,
|
|
135
|
+
properties: {},
|
|
136
|
+
styles: {},
|
|
137
|
+
order: 0,
|
|
138
|
+
hidden: false
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const props = (over: Partial<BlockRendererProps> = {}): BlockRendererProps => ({
|
|
144
|
+
block: {
|
|
145
|
+
id: 'b1',
|
|
146
|
+
type: PROMO,
|
|
147
|
+
label: 'Promo',
|
|
148
|
+
properties: {},
|
|
149
|
+
styles: {},
|
|
150
|
+
order: 0,
|
|
151
|
+
hidden: false
|
|
152
|
+
},
|
|
153
|
+
placeholderId: 'header',
|
|
154
|
+
sectionId: 's1',
|
|
155
|
+
isDesigner: false,
|
|
156
|
+
...over
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const renderRegistered = (
|
|
160
|
+
type: string,
|
|
161
|
+
over: Partial<BlockRendererProps> = {}
|
|
162
|
+
) => {
|
|
163
|
+
const Renderer = blockRendererRegistry.getRenderer(type);
|
|
164
|
+
if (!Renderer) throw new Error(`No renderer registered for ${type}`);
|
|
165
|
+
|
|
166
|
+
return render(<Renderer {...props(over)} />);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
beforeEach(() => {
|
|
170
|
+
registerCustomBlocks('acme', {
|
|
171
|
+
'promo-strip': defineCustomBlock(PromoStrip),
|
|
172
|
+
'best-sellers': BestSellers,
|
|
173
|
+
'link-card': LinkCard,
|
|
174
|
+
'mega-menu': defineCustomBlock(MegaMenu, {
|
|
175
|
+
label: 'Mega menu',
|
|
176
|
+
slots: [
|
|
177
|
+
{ name: 'promo', label: 'Promo' },
|
|
178
|
+
{ name: 'links', label: 'Links' },
|
|
179
|
+
{ name: 'footer', label: 'Footer' }
|
|
180
|
+
],
|
|
181
|
+
parts: { row: { states: ['hover', 'open'] } }
|
|
182
|
+
})
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
afterEach(() => {
|
|
187
|
+
clearCustomRenderers();
|
|
188
|
+
resetCustomCatalog();
|
|
189
|
+
resetCustomCollection();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('the documented barrel', () => {
|
|
193
|
+
it('registers both entries under the vendor from the call, not the component name', () => {
|
|
194
|
+
// Minification renames `PromoStrip` to a single letter; the record key is
|
|
195
|
+
// the only source that survives a production build.
|
|
196
|
+
expect(blockRendererRegistry.hasRenderer(PROMO)).toBe(true);
|
|
197
|
+
expect(blockRendererRegistry.hasRenderer('custom:acme:best-sellers')).toBe(
|
|
198
|
+
true
|
|
199
|
+
);
|
|
200
|
+
expect(PROMO.startsWith(CUSTOM_TYPE_PREFIX)).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('renders the merchant value, the authored default and the slot in one pass', () => {
|
|
204
|
+
renderRegistered(PROMO, {
|
|
205
|
+
block: {
|
|
206
|
+
...props().block,
|
|
207
|
+
properties: {
|
|
208
|
+
tone: { desktop: 'dark' },
|
|
209
|
+
headline: { desktop: 'Autumn' }
|
|
210
|
+
},
|
|
211
|
+
blocks: [
|
|
212
|
+
{
|
|
213
|
+
id: 'c1',
|
|
214
|
+
type: 'text',
|
|
215
|
+
label: 'c1',
|
|
216
|
+
properties: {},
|
|
217
|
+
styles: {},
|
|
218
|
+
order: 0,
|
|
219
|
+
hidden: false
|
|
220
|
+
}
|
|
221
|
+
]
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// useThemeSetting -> the stored value.
|
|
226
|
+
expect(screen.getByTestId('promo')).toHaveClass('bg-black');
|
|
227
|
+
// ThemeEditable -> the stored value where one exists...
|
|
228
|
+
expect(screen.getByRole('heading')).toHaveTextContent('Autumn');
|
|
229
|
+
// ...and the authored JSX where none does.
|
|
230
|
+
expect(screen.getByRole('img')).toHaveAttribute('src', '/default.jpg');
|
|
231
|
+
// ThemeSlot -> block.blocks, through the shared child pipeline.
|
|
232
|
+
expect(screen.getByTestId('slot-child')).toHaveAttribute('data-id', 'c1');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('leaves a developer-authored part alone and stamps its own on the fields', () => {
|
|
236
|
+
renderRegistered(PROMO);
|
|
237
|
+
|
|
238
|
+
expect(screen.getByTestId('promo')).toHaveAttribute('data-part', 'shell');
|
|
239
|
+
expect(screen.getByRole('heading')).toHaveAttribute(
|
|
240
|
+
'data-part',
|
|
241
|
+
'field-headline'
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('collects a descriptor the editor could offer from a block that declared nothing', () => {
|
|
246
|
+
renderRegistered(PROMO);
|
|
247
|
+
|
|
248
|
+
const catalog = getCustomCatalog();
|
|
249
|
+
const descriptor = catalog?.blocks.find((entry) => entry.type === PROMO);
|
|
250
|
+
|
|
251
|
+
expect(catalog?.vendor).toBe('acme');
|
|
252
|
+
// The hook's key and both wrapped elements, from one render.
|
|
253
|
+
expect(descriptor?.settings.map((setting) => setting.key)).toEqual(
|
|
254
|
+
expect.arrayContaining(['tone', 'art', 'headline'])
|
|
255
|
+
);
|
|
256
|
+
// Every part the catalog advertises is one the DOM above actually carries.
|
|
257
|
+
expect(descriptor?.parts).toEqual(
|
|
258
|
+
expect.arrayContaining(['field-art', 'field-headline'])
|
|
259
|
+
);
|
|
260
|
+
// `<ThemeSlot />` alone makes the block droppable — no declaration needed.
|
|
261
|
+
expect(descriptor?.hasChildren).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('a block with several named slots', () => {
|
|
266
|
+
it('gives each slot its own node children and mixes none of them', () => {
|
|
267
|
+
const { container } = renderRegistered(MEGA_MENU, {
|
|
268
|
+
block: {
|
|
269
|
+
...props().block,
|
|
270
|
+
type: MEGA_MENU,
|
|
271
|
+
blocks: [
|
|
272
|
+
slotNode('promo', 'in-promo'),
|
|
273
|
+
slotNode('links', 'in-links'),
|
|
274
|
+
slotNode('footer', 'in-footer')
|
|
275
|
+
]
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(
|
|
280
|
+
Array.from(container.querySelectorAll('[data-testid="slot-child"]')).map(
|
|
281
|
+
(node) => node.getAttribute('data-id')
|
|
282
|
+
)
|
|
283
|
+
).toEqual(['in-promo', 'in-links', 'in-footer']);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('renders the slots that do exist when the others have no node yet', () => {
|
|
287
|
+
// The state every composition starts in: the merchant has opened one
|
|
288
|
+
// region and not the others. Nothing crashes and nothing else appears.
|
|
289
|
+
const { container } = renderRegistered(MEGA_MENU, {
|
|
290
|
+
block: {
|
|
291
|
+
...props().block,
|
|
292
|
+
type: MEGA_MENU,
|
|
293
|
+
blocks: [slotNode('links', 'in-links')]
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(
|
|
298
|
+
Array.from(container.querySelectorAll('[data-testid="slot-child"]')).map(
|
|
299
|
+
(node) => node.getAttribute('data-id')
|
|
300
|
+
)
|
|
301
|
+
).toEqual(['in-links']);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('announces the slots and the declared part with its states', () => {
|
|
305
|
+
const descriptor = getCustomCatalog()?.blocks.find(
|
|
306
|
+
(entry) => entry.type === MEGA_MENU
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
expect(descriptor?.slots.map((slot) => slot.name)).toEqual([
|
|
310
|
+
'promo',
|
|
311
|
+
'links',
|
|
312
|
+
'footer'
|
|
313
|
+
]);
|
|
314
|
+
expect(descriptor?.parts).toEqual(['row']);
|
|
315
|
+
expect(descriptor?.partStates).toEqual({ row: ['hover', 'open'] });
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe('the advertised parts and the rendered DOM', () => {
|
|
320
|
+
const partsIn = (container: HTMLElement): string[] =>
|
|
321
|
+
Array.from(container.querySelectorAll('[data-part]')).map(
|
|
322
|
+
(node) => node.getAttribute('data-part') as string
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const descriptorFor = (type: string) =>
|
|
326
|
+
getCustomCatalog()?.blocks.find((entry) => entry.type === type);
|
|
327
|
+
|
|
328
|
+
it('advertises exactly the field parts the promo strip renders', () => {
|
|
329
|
+
const { container } = renderRegistered(PROMO);
|
|
330
|
+
|
|
331
|
+
// Set equality, not containment. A part in the catalog that no node
|
|
332
|
+
// answers to is a control the editor offers with nothing behind it — the
|
|
333
|
+
// styles a merchant saves against it generate a selector that matches
|
|
334
|
+
// nothing, and the click that should open it never resolves.
|
|
335
|
+
expect(new Set(descriptorFor(PROMO)?.parts)).toEqual(
|
|
336
|
+
new Set(partsIn(container).filter((part) => part.startsWith('field-')))
|
|
337
|
+
);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('advertises no part for a field with no element of its own', () => {
|
|
341
|
+
const { container } = renderRegistered(LINK_CARD, {
|
|
342
|
+
block: { ...props().block, type: LINK_CARD }
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const descriptor = descriptorFor(LINK_CARD);
|
|
346
|
+
|
|
347
|
+
// Both fields reach the panel...
|
|
348
|
+
expect(descriptor?.settings.map((setting) => setting.key)).toEqual([
|
|
349
|
+
'cta',
|
|
350
|
+
'ctaHref',
|
|
351
|
+
'note'
|
|
352
|
+
]);
|
|
353
|
+
// ...and only the one element that carries `field-<key>` is advertised as
|
|
354
|
+
// a part. `ctaHref` shares the `<a>` with `cta`, and `note` gave its part
|
|
355
|
+
// up to the developer's own `footnote`.
|
|
356
|
+
expect(descriptor?.parts).toEqual(['field-cta']);
|
|
357
|
+
expect(partsIn(container)).toEqual(['field-cta', 'footnote']);
|
|
358
|
+
expect(new Set(descriptor?.parts)).toEqual(
|
|
359
|
+
new Set(partsIn(container).filter((part) => part.startsWith('field-')))
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe('the curated package root', () => {
|
|
365
|
+
/**
|
|
366
|
+
* Parsed from source rather than imported, exactly as
|
|
367
|
+
* `src/__tests__/registry-parity.test.ts` parses the registry — and here the
|
|
368
|
+
* reason is not weight but poison: `src/index.ts` re-exports
|
|
369
|
+
* `theme-placeholder`, which imports `server-only`, so the root entry throws
|
|
370
|
+
* "cannot be imported from a Client Component module" the moment a jsdom
|
|
371
|
+
* test (or a brand's client root) touches it. That constraint is why
|
|
372
|
+
* `@akinon/pz-theme/src/custom` is the documented path for block files, and
|
|
373
|
+
* it is also what makes the hand-written list in `src/index.ts` untestable
|
|
374
|
+
* by import: a renamed symbol there compiles fine and fails at the brand.
|
|
375
|
+
*/
|
|
376
|
+
const rootSource = fs.readFileSync(
|
|
377
|
+
path.resolve(__dirname, '../../index.ts'),
|
|
378
|
+
'utf8'
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const barrelSource = fs.readFileSync(
|
|
382
|
+
path.resolve(__dirname, '../index.ts'),
|
|
383
|
+
'utf8'
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
/** The names inside every `export { … } from '…'` clause a pattern selects. */
|
|
387
|
+
const exportedNames = (source: string, clause: RegExp): string[] =>
|
|
388
|
+
Array.from(source.matchAll(clause))
|
|
389
|
+
.flatMap((match) => match[1].split(','))
|
|
390
|
+
.map((name) => name.trim())
|
|
391
|
+
.filter(Boolean);
|
|
392
|
+
|
|
393
|
+
// `export type {` cannot match the runtime pattern: it requires `export {`
|
|
394
|
+
// with no word between, so the two lists stay disjoint.
|
|
395
|
+
const RUNTIME_FROM_CUSTOM = /export \{([^}]*)\} from '\.\/custom';/g;
|
|
396
|
+
const TYPES_FROM_CUSTOM = /export type \{([^}]*)\} from '\.\/custom';/g;
|
|
397
|
+
const ANY_TYPE_CLAUSE = /export type \{([^}]*)\} from '[^']*';/g;
|
|
398
|
+
|
|
399
|
+
it('names only bindings the custom barrel actually exports', () => {
|
|
400
|
+
const names = exportedNames(rootSource, RUNTIME_FROM_CUSTOM);
|
|
401
|
+
|
|
402
|
+
expect(names.length).toBeGreaterThan(0);
|
|
403
|
+
names.forEach((name) => {
|
|
404
|
+
expect(Object.keys(customBarrel)).toContain(name);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('names only types the custom barrel actually exports', () => {
|
|
409
|
+
const rootTypes = exportedNames(rootSource, TYPES_FROM_CUSTOM);
|
|
410
|
+
const barrelTypes = exportedNames(barrelSource, ANY_TYPE_CLAUSE);
|
|
411
|
+
|
|
412
|
+
// Source-level because a type has no runtime binding to compare — the
|
|
413
|
+
// erased half of the surface would otherwise go completely unguarded.
|
|
414
|
+
expect(rootTypes.length).toBeGreaterThan(0);
|
|
415
|
+
expect(barrelTypes).toEqual(expect.arrayContaining(rootTypes));
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('does not publish partAttrs twice from one entry point', () => {
|
|
419
|
+
// `export * from './utils'` already puts it on the root. The custom barrel
|
|
420
|
+
// re-exports it so a block file needs one import; naming it in the root
|
|
421
|
+
// block too would be a second spelling of the same symbol.
|
|
422
|
+
expect(exportedNames(rootSource, RUNTIME_FROM_CUSTOM)).not.toContain(
|
|
423
|
+
'partAttrs'
|
|
424
|
+
);
|
|
425
|
+
expect(customBarrel.partAttrs).toBe(partAttrs);
|
|
426
|
+
});
|
|
427
|
+
});
|