@akinon/pz-theme 2.0.43-rc.0 → 2.0.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +14 -31
  2. package/package.json +4 -4
  3. package/src/__tests__/checkout-pieces.test.tsx +420 -0
  4. package/src/__tests__/checkout-slot-coverage.test.ts +181 -0
  5. package/src/__tests__/chrome-empty-zone.test.tsx +3 -3
  6. package/src/__tests__/code-owned-region.test.tsx +30 -6
  7. package/src/__tests__/composable-region.test.tsx +481 -0
  8. package/src/__tests__/part-styles.test.ts +24 -4
  9. package/src/__tests__/registry-parity.test.ts +3 -0
  10. package/src/__tests__/route-slug.test.ts +11 -0
  11. package/src/blocks/__tests__/checkout-hosted-parts.test.ts +203 -0
  12. package/src/blocks/__tests__/checkout-outlet-blocks.test.tsx +623 -0
  13. package/src/blocks/block-renderer-registry.tsx +6 -0
  14. package/src/blocks/checkout-flow-outlet-block.tsx +104 -0
  15. package/src/blocks/checkout-outlet-base.tsx +241 -0
  16. package/src/blocks/checkout-steps-outlet-block.tsx +57 -0
  17. package/src/blocks/checkout-summary-outlet-block.tsx +43 -0
  18. package/src/chrome/checkout-piece.tsx +145 -0
  19. package/src/chrome/checkout-pieces-provider.tsx +28 -0
  20. package/src/chrome/checkout-pieces.ts +173 -0
  21. package/src/chrome/chrome-fallback.tsx +8 -26
  22. package/src/chrome/chrome-slot.tsx +25 -26
  23. package/src/chrome/code-owned-region.tsx +21 -9
  24. package/src/chrome/composable-region-client.tsx +228 -0
  25. package/src/chrome/composable-region.tsx +58 -0
  26. package/src/chrome/composition-source.ts +34 -0
  27. package/src/chrome/composition-state.ts +49 -0
  28. package/src/chrome/live-checkout-page-context.tsx +3 -2
  29. package/src/chrome/placeholder-slugs.ts +14 -4
  30. package/src/chrome/route-slug.ts +6 -3
  31. package/src/hooks/use-designer-features.tsx +41 -10
  32. package/src/index.ts +6 -0
  33. package/src/utils/__tests__/checkout-safety.test.ts +374 -0
  34. package/src/utils/checkout-safety.ts +177 -0
  35. package/src/utils/index.ts +47 -8
  36. package/src/utils/part-styles.ts +4 -0
  37. package/src/utils/visibility-rules.ts +31 -8
@@ -0,0 +1,203 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * The checkout outlet blocks style parts they do not render: the code-owned
6
+ * checkout teleports its markup into them, and each outlet declares the parts
7
+ * it HOSTS via an exported `HOSTED_PARTS` literal that the editor's
8
+ * descriptor conformance also reads. A manifest nobody stamps would put dead
9
+ * entries in the editor panel; a stamp nobody declares would be unstylable —
10
+ * this suite keeps declaration and reality pointing at each other:
11
+ *
12
+ * - every manifest name is stamped (`partAttrs('…')`) somewhere in the app's
13
+ * checkout views;
14
+ * - every stamped name is declared by at least one outlet manifest
15
+ * (`summary-*` legitimately by two: the mobile summary sheet re-renders the
16
+ * summary inside the flow subtree, and part rules are block-scoped);
17
+ * - the full vocabulary is pinned inline, so a rename fails loudly here
18
+ * before it silently orphans published compositions;
19
+ * - stamps are INERT (I4): `partAttrs` is the only stamping route, and no
20
+ * live runtime — app or pz-theme outside designer-only modules — queries
21
+ * `[data-part` from the DOM.
22
+ */
23
+
24
+ const repoRoot = path.resolve(__dirname, '../../../../..');
25
+ const blocksDir = path.resolve(__dirname, '..');
26
+ const pzThemeSrcDir = path.resolve(__dirname, '../..');
27
+ const appSrcDir = path.join(repoRoot, 'apps', 'projectzeronext', 'src');
28
+ const appCheckoutDir = path.join(appSrcDir, 'views', 'checkout');
29
+
30
+ const OUTLET_MANIFEST_FILES = [
31
+ 'checkout-flow-outlet-block.tsx',
32
+ 'checkout-steps-outlet-block.tsx',
33
+ 'checkout-summary-outlet-block.tsx'
34
+ ];
35
+
36
+ /**
37
+ * The reserved checkout part vocabulary. Changing it is a contract change:
38
+ * update the outlet manifests, the app stamps, the editor part groups AND
39
+ * this pin together — published compositions reference these names.
40
+ */
41
+ const RESERVED_PART_NAMES = [
42
+ 'agreement',
43
+ 'continue-button',
44
+ 'edit-button',
45
+ 'payment-option',
46
+ 'payment-option-icon',
47
+ 'payment-options',
48
+ 'payment-panel',
49
+ 'place-order-button',
50
+ 'section',
51
+ 'section-header',
52
+ 'section-number',
53
+ 'section-title',
54
+ 'step',
55
+ 'step-icon',
56
+ 'step-label',
57
+ 'step-number',
58
+ 'steps',
59
+ 'summary',
60
+ 'summary-edit',
61
+ 'summary-header',
62
+ 'summary-item',
63
+ 'summary-row',
64
+ 'summary-row-label',
65
+ 'summary-row-value',
66
+ 'summary-title',
67
+ 'summary-total',
68
+ 'summary-total-label',
69
+ 'summary-total-value'
70
+ ];
71
+
72
+ const PART_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
73
+
74
+ const readManifest = (fileName: string): string[] => {
75
+ const source = fs.readFileSync(path.join(blocksDir, fileName), 'utf8');
76
+ const hosted = /HOSTED_PARTS\s*(?::[^=]*)?=\s*\[([\s\S]*?)\]/.exec(source);
77
+ if (!hosted) return [];
78
+
79
+ return Array.from(hosted[1].matchAll(/'([^']+)'/g), (match) => match[1]);
80
+ };
81
+
82
+ const walkSources = (dir: string): string[] => {
83
+ const files: string[] = [];
84
+
85
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
86
+ const full = path.join(dir, entry.name);
87
+
88
+ if (entry.isDirectory()) {
89
+ if (entry.name === 'node_modules') continue;
90
+ files.push(...walkSources(full));
91
+ } else if (/\.(ts|tsx)$/.test(entry.name)) {
92
+ files.push(full);
93
+ }
94
+ }
95
+
96
+ return files;
97
+ };
98
+
99
+ const isTestFile = (file: string): boolean =>
100
+ /__tests__/.test(file) || /\.test\.(ts|tsx)$/.test(file);
101
+
102
+ const manifests = Object.fromEntries(
103
+ OUTLET_MANIFEST_FILES.map((file) => [file, readManifest(file)])
104
+ ) as Record<string, string[]>;
105
+
106
+ const manifestNames = new Set(Object.values(manifests).flat());
107
+
108
+ // Runtime sources only. A test may legitimately write a part name into a
109
+ // string template or assert on a raw attribute; what this suite pins is what
110
+ // the SHIPPED checkout markup stamps.
111
+ const checkoutSources = walkSources(appCheckoutDir)
112
+ .filter((file) => !isTestFile(file))
113
+ .map((file) => ({
114
+ file: path.relative(repoRoot, file),
115
+ source: fs.readFileSync(file, 'utf8')
116
+ }));
117
+
118
+ const stampedNames = new Set(
119
+ checkoutSources.flatMap(({ source }) =>
120
+ Array.from(source.matchAll(/partAttrs\(\s*'([^']+)'/g), (m) => m[1])
121
+ )
122
+ );
123
+
124
+ describe('checkout hosted-parts manifests', () => {
125
+ it('finds a non-empty HOSTED_PARTS literal in each outlet block', () => {
126
+ for (const file of OUTLET_MANIFEST_FILES) {
127
+ expect(manifests[file].length).toBeGreaterThan(0);
128
+ }
129
+ });
130
+
131
+ it('uses only well-formed kebab-case part names, everywhere', () => {
132
+ for (const name of [
133
+ ...Array.from(manifestNames),
134
+ ...Array.from(stampedNames)
135
+ ]) {
136
+ expect(name).toMatch(PART_NAME_PATTERN);
137
+ }
138
+ });
139
+
140
+ it('pins the full reserved vocabulary — renames fail loudly', () => {
141
+ expect(Array.from(manifestNames).sort()).toEqual(RESERVED_PART_NAMES);
142
+ });
143
+
144
+ it('declares every stamped part in at least one outlet manifest', () => {
145
+ const undeclared = Array.from(stampedNames).filter(
146
+ (name) => !manifestNames.has(name)
147
+ );
148
+
149
+ expect(undeclared).toEqual([]);
150
+ });
151
+
152
+ it('stamps every declared part at least once in the checkout views', () => {
153
+ const unstamped = Array.from(manifestNames).filter(
154
+ (name) => !stampedNames.has(name)
155
+ );
156
+
157
+ expect(unstamped).toEqual([]);
158
+ });
159
+ });
160
+
161
+ describe('checkout part stamps are inert (I4)', () => {
162
+ it('routes every app stamp through partAttrs — no raw data-part attribute', () => {
163
+ const offenders = checkoutSources
164
+ .filter(({ source }) => source.includes('data-part='))
165
+ .map(({ file }) => file);
166
+
167
+ expect(offenders).toEqual([]);
168
+ });
169
+
170
+ // The attributes are a styling contract, not a runtime signal: nothing on
171
+ // the live path may branch on them. Designer-only modules (the queries are
172
+ // gated behind the editor-proven designer flag) are the sole exemption.
173
+ const DESIGNER_ONLY_PZ_THEME_FILES = [
174
+ path.join('hooks', 'use-designer-features.tsx'),
175
+ path.join('components', 'part-state-preview.tsx'),
176
+ path.join('blocks', 'checkout-outlet-base.tsx')
177
+ ];
178
+
179
+ const PART_QUERY_PATTERN =
180
+ /(?:querySelectorAll|querySelector|closest|matches)\s*(?:<[^>\n]*>)?\s*\(\s*[`'"]\[data-part/;
181
+
182
+ it('never queries [data-part from the app runtime', () => {
183
+ const offenders = walkSources(appSrcDir)
184
+ .filter((file) => !isTestFile(file))
185
+ .filter((file) => PART_QUERY_PATTERN.test(fs.readFileSync(file, 'utf8')))
186
+ .map((file) => path.relative(repoRoot, file));
187
+
188
+ expect(offenders).toEqual([]);
189
+ });
190
+
191
+ it('never queries [data-part from pz-theme outside designer-only modules', () => {
192
+ const offenders = walkSources(pzThemeSrcDir)
193
+ .filter((file) => !isTestFile(file))
194
+ .filter((file) => {
195
+ const relative = path.relative(pzThemeSrcDir, file);
196
+ return !DESIGNER_ONLY_PZ_THEME_FILES.includes(relative);
197
+ })
198
+ .filter((file) => PART_QUERY_PATTERN.test(fs.readFileSync(file, 'utf8')))
199
+ .map((file) => path.relative(repoRoot, file));
200
+
201
+ expect(offenders).toEqual([]);
202
+ });
203
+ });