@akinon/pz-theme 2.0.65-rc.0 → 2.0.66-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * The server-side theme-config read path must not reach a `'use client'`
6
+ * module.
7
+ *
8
+ * A Server Component importing a VALUE from a client module does not get the
9
+ * value: React hands back a client reference. `OVERLAY_WIDTH_UNITS` arrived as
10
+ * a function, `allowed.includes(...)` threw inside the registry parser, and the
11
+ * reader's catch turned that into an entirely empty theme config on the
12
+ * storefront — no modals, no theme settings, no data sources — with nothing
13
+ * logged. A composed modal simply never opened, and the trigger looked broken.
14
+ *
15
+ * No runtime test can catch this: Jest resolves a `'use client'` module like
16
+ * any other, so the constants are real arrays under test and every assertion
17
+ * passes while the storefront is broken. The import graph is the only place
18
+ * the mistake is visible, so this walks it from source.
19
+ */
20
+
21
+ const SRC_ROOT = path.resolve(__dirname, '../..');
22
+
23
+ /** Entry points that are read during a server render. */
24
+ const SERVER_ENTRY_POINTS = [
25
+ 'chrome/theme-config-source.ts',
26
+ 'overlays/modal-registry.ts'
27
+ ];
28
+
29
+ const CANDIDATE_EXTENSIONS = ['.ts', '.tsx', '/index.ts', '/index.tsx'];
30
+
31
+ const resolveImport = (fromFile: string, specifier: string): string | null => {
32
+ // Only relative imports are ours to police; a bare specifier is a package,
33
+ // and a package's own client boundaries are its business.
34
+ if (!specifier.startsWith('.')) return null;
35
+
36
+ const base = path.resolve(path.dirname(fromFile), specifier);
37
+
38
+ for (const extension of CANDIDATE_EXTENSIONS) {
39
+ const candidate = `${base}${extension}`;
40
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
41
+ return candidate;
42
+ }
43
+ }
44
+
45
+ return null;
46
+ };
47
+
48
+ const IMPORT_PATTERN = /(?:from|import)\s*['"]([^'"]+)['"]/g;
49
+
50
+ /**
51
+ * Type-only imports are erased at compile time, so they cannot carry a client
52
+ * reference and a server module may freely import a type from a client module.
53
+ */
54
+ const isTypeOnlyImport = (source: string, index: number): boolean => {
55
+ const statementStart = source.lastIndexOf('import', index);
56
+ if (statementStart === -1) return false;
57
+
58
+ return /^import\s+type\b/.test(source.slice(statementStart, index));
59
+ };
60
+
61
+ const isClientModule = (source: string): boolean =>
62
+ /^\s*(['"])use client\1/.test(source);
63
+
64
+ const collectReachableModules = (entryPoints: string[]): Map<string, string> => {
65
+ const visited = new Map<string, string>();
66
+ const queue = entryPoints.map((entry) => path.join(SRC_ROOT, entry));
67
+
68
+ while (queue.length > 0) {
69
+ const file = queue.shift() as string;
70
+ if (visited.has(file)) continue;
71
+
72
+ const source = fs.readFileSync(file, 'utf8');
73
+ visited.set(file, source);
74
+
75
+ for (const match of source.matchAll(IMPORT_PATTERN)) {
76
+ if (isTypeOnlyImport(source, match.index ?? 0)) continue;
77
+
78
+ const resolved = resolveImport(file, match[1]);
79
+ if (resolved) queue.push(resolved);
80
+ }
81
+ }
82
+
83
+ return visited;
84
+ };
85
+
86
+ describe('server-side theme-config read path', () => {
87
+ it('reaches no "use client" module', () => {
88
+ const reachable = collectReachableModules(SERVER_ENTRY_POINTS);
89
+
90
+ const clientModules = Array.from(reachable.entries())
91
+ .filter(([, source]) => isClientModule(source))
92
+ .map(([file]) => path.relative(SRC_ROOT, file));
93
+
94
+ expect(clientModules).toEqual([]);
95
+ });
96
+
97
+ it('sees the overlay tokens as real values, not client references', () => {
98
+ // Guards the specific shape of the original break: the parser calls
99
+ // `.includes` on this array and `.slice` on the colour.
100
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
101
+ const tokens = require('../overlay-tokens');
102
+
103
+ expect(Array.isArray(tokens.OVERLAY_WIDTH_UNITS)).toBe(true);
104
+ expect(typeof tokens.OVERLAY_BACKDROP_COLOR).toBe('string');
105
+ expect(typeof tokens.OVERLAY_BACKDROP_OPACITY).toBe('number');
106
+ });
107
+
108
+ it('keeps the tokens importable from the client surface too', () => {
109
+ // The re-export is what stops this fix from breaking every existing
110
+ // `from './overlay-surface'` import.
111
+ const surfaceSource = fs.readFileSync(
112
+ path.join(SRC_ROOT, 'overlays/overlay-surface.tsx'),
113
+ 'utf8'
114
+ );
115
+
116
+ expect(surfaceSource).toMatch(/export\s*{[^}]*OVERLAY_WIDTH_UNITS/);
117
+ expect(surfaceSource).toMatch(/from '\.\/overlay-tokens'/);
118
+ });
119
+ });
@@ -1,12 +1,15 @@
1
1
  import { modalSlug } from '../chrome/placeholder-slugs';
2
2
  import { devWarn } from '../utils/dev-warn';
3
3
  import type { VisibilityRules } from '../utils/visibility-rules';
4
+ // From `overlay-tokens`, NOT from `overlay-surface`: this module is parsed on
5
+ // the server, and a value imported from a `'use client'` module arrives as a
6
+ // client reference rather than the value itself.
4
7
  import {
5
8
  OVERLAY_BACKDROP_COLOR,
6
9
  OVERLAY_BACKDROP_OPACITY,
7
10
  OVERLAY_WIDTH_UNITS,
8
11
  type OverlayWidthUnit
9
- } from './overlay-surface';
12
+ } from './overlay-tokens';
10
13
 
11
14
  /**
12
15
  * The modal registry: what modals this theme has, read off `theme-config`.
@@ -2,6 +2,11 @@
2
2
 
3
3
  import React from 'react';
4
4
  import { partAttrs } from '../utils';
5
+ import {
6
+ OVERLAY_BACKDROP_COLOR,
7
+ OVERLAY_BACKDROP_OPACITY
8
+ } from './overlay-tokens';
9
+ import type { OverlayPresentation, OverlayWidthUnit } from './overlay-tokens';
5
10
 
6
11
  /**
7
12
  * The overlay surface: how a panel arrives over the page, and what it looks
@@ -22,18 +27,18 @@ import { partAttrs } from '../utils';
22
27
  * `panel-legacy-golden.json` pins byte for byte. New callers pass their own.
23
28
  */
24
29
 
25
- export type OverlayPresentation = 'modal' | 'drawer' | 'sheet' | 'anchored';
26
-
27
- export const OVERLAY_PRESENTATIONS: OverlayPresentation[] = [
28
- 'modal',
29
- 'drawer',
30
- 'sheet',
31
- 'anchored'
32
- ];
33
-
34
- export type OverlayWidthUnit = 'px' | '%' | 'vw';
35
-
36
- export const OVERLAY_WIDTH_UNITS: OverlayWidthUnit[] = ['px', '%', 'vw'];
30
+ /**
31
+ * Re-exported from `overlay-tokens.ts`, which owns them so the server-side
32
+ * registry parser can import them without going through this client module.
33
+ * Client callers keep importing from here.
34
+ */
35
+ export {
36
+ OVERLAY_PRESENTATIONS,
37
+ OVERLAY_WIDTH_UNITS,
38
+ OVERLAY_BACKDROP_COLOR,
39
+ OVERLAY_BACKDROP_OPACITY
40
+ } from './overlay-tokens';
41
+ export type { OverlayPresentation, OverlayWidthUnit } from './overlay-tokens';
37
42
 
38
43
  /**
39
44
  * The overlay width as CSS. Unit-less callers (the panel block, whose markup
@@ -58,10 +63,6 @@ export const clampOverlayNumber = (
58
63
  return Math.min(max, Math.max(min, numeric));
59
64
  };
60
65
 
61
- /** Backdrop paint defaults — what every published surface shipped with. */
62
- export const OVERLAY_BACKDROP_COLOR = '#000000';
63
- export const OVERLAY_BACKDROP_OPACITY = 50;
64
-
65
66
  const HEX_COLOR_PATTERN = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
66
67
 
67
68
  /**
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The overlay vocabulary: presentations, width units and backdrop defaults.
3
+ *
4
+ * Deliberately NOT in `overlay-surface.tsx`, which is a `'use client'` module.
5
+ * `modal-registry.ts` normalises the registry on the SERVER — it is what
6
+ * `theme-config-source.ts` reads `theme_config` through — and a server module
7
+ * importing a value from a client module does not receive the value: React
8
+ * hands back a client reference, so `OVERLAY_WIDTH_UNITS` arrives as a
9
+ * function and `allowed.includes(...)` throws. That throw used to surface as
10
+ * an entirely EMPTY theme config on the storefront (no modals, no settings, no
11
+ * data sources), because the registry is parsed first and the reader's catch
12
+ * swallows the reason.
13
+ *
14
+ * So the shared vocabulary lives in a module both sides can import. Anything
15
+ * here must stay free of React and of client-only APIs; `overlay-surface.tsx`
16
+ * re-exports it so client callers keep their existing import path.
17
+ */
18
+
19
+ export type OverlayPresentation = 'modal' | 'drawer' | 'sheet' | 'anchored';
20
+
21
+ export const OVERLAY_PRESENTATIONS: OverlayPresentation[] = [
22
+ 'modal',
23
+ 'drawer',
24
+ 'sheet',
25
+ 'anchored'
26
+ ];
27
+
28
+ export type OverlayWidthUnit = 'px' | '%' | 'vw';
29
+
30
+ export const OVERLAY_WIDTH_UNITS: OverlayWidthUnit[] = ['px', '%', 'vw'];
31
+
32
+ /** Backdrop paint defaults — what every published surface shipped with. */
33
+ export const OVERLAY_BACKDROP_COLOR = '#000000';
34
+ export const OVERLAY_BACKDROP_OPACITY = 50;