@akinon/pz-theme 2.0.79-beta.1 → 2.0.79-rc.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.
package/CHANGELOG.md CHANGED
@@ -1,20 +1,38 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.79-beta.1
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [cbbbfd757]
8
- - @akinon/next@2.0.79-beta.1
9
- - @akinon/theme-contract@2.0.79-beta.1
10
-
11
- ## 2.0.79-beta.0
12
-
13
- ### Patch Changes
14
-
15
- - Updated dependencies [cbbbfd757]
16
- - @akinon/next@2.0.79-beta.0
17
- - @akinon/theme-contract@2.0.79-beta.0
3
+ ## 2.0.79-rc.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [86187b1c]
8
+ - Updated dependencies [0cf9ea239]
9
+ - Updated dependencies [324f97d55]
10
+ - Updated dependencies [51ea06888]
11
+ - Updated dependencies
12
+ - Updated dependencies [2fea4143]
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 [e9598c71]
28
+ - Updated dependencies [4998a9631]
29
+ - Updated dependencies [804d2bd6c]
30
+ - Updated dependencies [3909d3224]
31
+ - Updated dependencies [6a3d8a631]
32
+ - Updated dependencies [e18836b20]
33
+ - Updated dependencies [b1111d7b]
34
+ - @akinon/next@2.0.79-rc.0
35
+ - @akinon/theme-contract@2.0.79-rc.0
18
36
 
19
37
  ## 2.0.78
20
38
 
package/package.json CHANGED
@@ -1,26 +1,26 @@
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.79-beta.1",
4
+ "version": "2.0.79-rc.0",
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.79-beta.1",
11
+ "@akinon/next": "2.0.79-rc.0",
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.79-beta.1",
16
+ "@akinon/theme-contract": "2.0.79-rc.0",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
19
  "tailwind-merge": "^2.5.4",
20
20
  "zod": "4.4.1"
21
21
  },
22
22
  "devDependencies": {
23
- "@akinon/next": "2.0.79-beta.1",
23
+ "@akinon/next": "2.0.79-rc.0",
24
24
  "@testing-library/jest-dom": "5.16.4",
25
25
  "@testing-library/react": "16.3.2",
26
26
  "@testing-library/user-event": "14.1.1",
@@ -27,17 +27,12 @@ jest.mock('react', () => {
27
27
  const getWidgetData = jest.fn();
28
28
  const getWidgetSchemaData = jest.fn();
29
29
  const getCollectionWidgetData = jest.fn();
30
- // Named preview snapshot reader (draft mode only). Resolves null by default:
31
- // "not a preview", the live path.
32
- const getPreviewSnapshot = jest.fn(async () => null as unknown);
33
30
 
34
31
  jest.mock('@akinon/next/data/server', () => ({
35
32
  getWidgetData: (...args: unknown[]) => getWidgetData(...args),
36
33
  getWidgetSchemaData: (...args: unknown[]) => getWidgetSchemaData(...args),
37
34
  getCollectionWidgetData: (...args: unknown[]) =>
38
- getCollectionWidgetData(...args),
39
- getPreviewSnapshot: (...args: unknown[]) =>
40
- (getPreviewSnapshot as (...a: unknown[]) => unknown)(...args)
35
+ getCollectionWidgetData(...args)
41
36
  }));
42
37
 
43
38
  jest.mock(
@@ -128,76 +123,3 @@ describe('shared placeholder widget fetch', () => {
128
123
  expect(attempts).toBe(3);
129
124
  });
130
125
  });
131
-
132
- describe('named preview snapshots', () => {
133
- beforeEach(() => {
134
- getWidgetData.mockReset();
135
- getWidgetSchemaData.mockReset();
136
- getCollectionWidgetData.mockReset();
137
- getPreviewSnapshot.mockReset();
138
- getPreviewSnapshot.mockResolvedValue(null);
139
- });
140
-
141
- const blob = {
142
- version: 1,
143
- themeSettings: { mainMenuSticky: true },
144
- dataSources: [],
145
- sections: [
146
- {
147
- id: 'sec-preview',
148
- type: 'custom',
149
- label: 'Preview hero',
150
- properties: {},
151
- styles: {},
152
- blocks: [],
153
- order: 0
154
- }
155
- ]
156
- };
157
-
158
- it('composes the slot and loads the sections from the blob, never the live widget', async () => {
159
- getPreviewSnapshot.mockResolvedValue(blob);
160
- getWidgetData.mockResolvedValue(placeholderPayload('preview-slot-a'));
161
-
162
- const state = await getSlugCompositionState('preview-slot-a');
163
- const loaded = await loadPlaceholderSections('preview-slot-a');
164
-
165
- expect(state).toEqual({ kind: 'composed', sectionCount: 1 });
166
- expect(loaded?.sections.map((s) => s.id)).toEqual(['sec-preview']);
167
- expect(loaded?.placeholderId).toBe('preview-slot-a');
168
- expect(loaded?.themeSettings).toEqual({ mainMenuSticky: true });
169
- // The blob owns the render: no per-section or theme-config reads.
170
- expect(getWidgetData).not.toHaveBeenCalled();
171
- expect(getWidgetSchemaData).not.toHaveBeenCalled();
172
- });
173
-
174
- it('an empty blob withdraws the slot even when live is composed', async () => {
175
- getPreviewSnapshot.mockResolvedValue({ ...blob, sections: [] });
176
- getWidgetData.mockResolvedValue(placeholderPayload('preview-slot-b'));
177
-
178
- expect(await getSlugCompositionState('preview-slot-b')).toEqual({
179
- kind: 'empty'
180
- });
181
- expect((await loadPlaceholderSections('preview-slot-b'))?.sections).toEqual(
182
- []
183
- );
184
- });
185
-
186
- it('falls through to the live path without a blob or when the reader throws', async () => {
187
- getPreviewSnapshot.mockRejectedValue(new Error('backend down'));
188
- getWidgetData.mockResolvedValue(placeholderPayload('preview-slot-c'));
189
- getWidgetSchemaData.mockResolvedValue({
190
- schema: { 'sec-a': { metadata: { blocks: [] } } }
191
- });
192
-
193
- expect(await getSlugCompositionState('preview-slot-c')).toEqual({
194
- kind: 'composed',
195
- sectionCount: 1
196
- });
197
- expect(
198
- (await loadPlaceholderSections('preview-slot-c'))?.sections.map(
199
- (s) => s.id
200
- )
201
- ).toEqual(['sec-a']);
202
- });
203
- });
@@ -27,14 +27,6 @@ jest.mock('../theme-config-source', () => ({
27
27
  getThemeConfig: () => themeConfig()
28
28
  }));
29
29
 
30
- // A named preview's snapshot owns the theme settings; null = not a preview.
31
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
- const previewSnapshot = jest.fn<any, [string]>(() => null);
33
-
34
- jest.mock('../preview-snapshot-source', () => ({
35
- getPlaceholderPreviewSnapshot: (slug: string) => previewSnapshot(slug)
36
- }));
37
-
38
30
  // The placeholder is an async server component that reaches for the data
39
31
  // layer; what the slot decides about itself is what is under test.
40
32
  jest.mock('../../theme-placeholder', () => ({
@@ -229,35 +221,3 @@ describe('ChromeSlot sticky', () => {
229
221
  expect(themeConfig).not.toHaveBeenCalled();
230
222
  });
231
223
  });
232
-
233
- describe('ChromeSlot sticky in a named preview', () => {
234
- afterEach(() => {
235
- cleanup();
236
- previewSnapshot.mockReset();
237
- previewSnapshot.mockReturnValue(null);
238
- });
239
-
240
- it('follows the preview snapshot, not the live theme-config', async () => {
241
- compositionState.mockResolvedValue(composed);
242
- themeConfig.mockResolvedValue({ settings: { mainMenuSticky: false } });
243
- previewSnapshot.mockResolvedValue({
244
- version: 1,
245
- sections: [{ id: 'sec' }],
246
- themeSettings: { mainMenuSticky: true }
247
- });
248
-
249
- await renderSlot({ slug: 'header', fallback: <div>brand</div> });
250
-
251
- expect(slotElement('header').dataset.sticky).toBe('true');
252
- });
253
-
254
- it('falls back to the live theme-config when the preview has no blob', async () => {
255
- compositionState.mockResolvedValue(composed);
256
- themeConfig.mockResolvedValue({ settings: { mainMenuSticky: true } });
257
- previewSnapshot.mockResolvedValue(null);
258
-
259
- await renderSlot({ slug: 'header', fallback: <div>brand</div> });
260
-
261
- expect(slotElement('header').dataset.sticky).toBe('true');
262
- });
263
- });
@@ -2,7 +2,6 @@ import React from 'react';
2
2
  import ThemePlaceholder from '../theme-placeholder';
3
3
  import { warnAboutUncomposedSlot } from './composition-state';
4
4
  import { getSlugCompositionState } from './composition-source';
5
- import { getPlaceholderPreviewSnapshot } from './preview-snapshot-source';
6
5
  import { getThemeConfig } from './theme-config-source';
7
6
  import { ChromeFallback } from './chrome-fallback';
8
7
  import { ChromeScrollState } from './scroll-state';
@@ -80,16 +79,9 @@ export default async function ChromeSlot({
80
79
  warnAboutUncomposedSlot(slug, state);
81
80
 
82
81
  // Only read when the mounting code said nothing, and memoized per request, so
83
- // a page full of slots costs at most one theme-config read. In a named
84
- // preview the snapshot blob owns the theme settings (theme-config is never
85
- // preview-transformed), so the pin must follow the blob the placeholder
86
- // renders from — otherwise "sticky header" toggled in the preview would
87
- // only show once published.
82
+ // a page full of slots costs at most one theme-config read.
88
83
  const themeSettings =
89
- sticky === undefined
90
- ? (await getPlaceholderPreviewSnapshot(slug))?.themeSettings ??
91
- (await getThemeConfig()).settings
92
- : null;
84
+ sticky === undefined ? (await getThemeConfig()).settings : null;
93
85
  const pinned = resolveSlotSticky({
94
86
  slug,
95
87
  sticky,
@@ -1,7 +1,6 @@
1
1
  import * as React from 'react';
2
2
 
3
3
  import { CompositionState, readCompositionState } from './composition-state';
4
- import { getPlaceholderPreviewSnapshot } from './preview-snapshot-source';
5
4
  import { getWidgetDataWithRetry } from './retrying-widget-data';
6
5
 
7
6
  // `React.cache` exists on the RSC-capable React this package renders under;
@@ -50,16 +49,6 @@ export const getPlaceholderWidget = cache
50
49
  */
51
50
  const load = async (slug: string): Promise<CompositionState> => {
52
51
  try {
53
- // A named preview (draft mode) composes from its snapshot, not from the
54
- // live placeholder: a slug composed only in the preview must still take
55
- // the slot over there, and one emptied in the preview must give it back.
56
- const preview = await getPlaceholderPreviewSnapshot(slug);
57
- if (preview) {
58
- return preview.sections.length > 0
59
- ? { kind: 'composed', sectionCount: preview.sections.length }
60
- : { kind: 'empty' };
61
- }
62
-
63
52
  const data = await getPlaceholderWidget(slug);
64
53
 
65
54
  return readCompositionState(data);
@@ -8,10 +8,6 @@ import { Section } from './theme-section';
8
8
  import { devWarn } from './utils/dev-warn';
9
9
  import { validateThemeSectionsInDev } from './schemas/theme-schema';
10
10
  import { getPlaceholderWidget } from './chrome/composition-source';
11
- import {
12
- getPlaceholderPreviewSnapshot,
13
- PreviewSnapshot
14
- } from './chrome/preview-snapshot-source';
15
11
  import { getThemeConfig, ThemeConfig } from './chrome/theme-config-source';
16
12
 
17
13
  const { getWidgetData } = serverData;
@@ -62,135 +58,6 @@ const getWidgetSchemaData = async <T,>(args: {
62
58
  return directFetch<T>(`/widgets/${args.widgetSlug}/schema/`);
63
59
  };
64
60
 
65
- /**
66
- * Resolves a section's data source reference into the object the renderer
67
- * expects, fetching collection data for collection-backed sources. Shared by
68
- * the live render path and the preview-snapshot path so the two cannot drift.
69
- */
70
- async function resolveSectionDataSource(
71
- dataSourceId: string | undefined,
72
- dataSources: ThemeConfig['dataSources'],
73
- sectionSlug: string
74
- ): Promise<any | undefined> {
75
- if (!dataSourceId) {
76
- return undefined;
77
- }
78
-
79
- const dataSource = dataSources.find((ds) => ds.id === dataSourceId);
80
-
81
- if (!dataSource) {
82
- devWarn(
83
- 'ThemePlaceholder',
84
- `Section "${sectionSlug}" references data source "${dataSourceId}" which was not found; rendering placeholder items`
85
- );
86
- return undefined;
87
- }
88
-
89
- if (!dataSource.details) {
90
- return dataSource;
91
- }
92
-
93
- // Only fetch collection data if we have a valid widget slug
94
- // (not the section's own slug which contains config, not products)
95
- const collectionSlug = dataSource.details.collection?.slug;
96
- if (!collectionSlug) {
97
- // The saved products from the editor (details.collection.products)
98
- // will be used as fallback
99
- return dataSource;
100
- }
101
-
102
- try {
103
- let collectionData = await getWidgetData<{
104
- [key: string]: unknown;
105
- }>({
106
- slug: collectionSlug
107
- });
108
-
109
- if (!collectionData) {
110
- collectionData = await getCollectionWidgetData<{
111
- [key: string]: unknown;
112
- }>({
113
- slug: collectionSlug
114
- });
115
- }
116
-
117
- if (collectionData) {
118
- return {
119
- ...dataSource,
120
- details: {
121
- ...dataSource.details,
122
- collection: {
123
- ...dataSource.details.collection,
124
- data: collectionData
125
- }
126
- }
127
- };
128
- }
129
- } catch (error) {
130
- console.error(
131
- `Error fetching widget data for section ${sectionSlug}:`,
132
- error
133
- );
134
- }
135
-
136
- return dataSource;
137
- }
138
-
139
- /**
140
- * Preview (draft mode): the theme editor writes the whole placeholder as one
141
- * snapshot blob — sections arrive in render shape, so none of the per-widget
142
- * fetches of the live path happen. The blob also owns the theme settings and
143
- * data sources it was saved with.
144
- */
145
- async function loadFromPreviewSnapshot(
146
- slug: string,
147
- snapshot: PreviewSnapshot
148
- ): Promise<LoadedPlaceholder> {
149
- const dataSources: ThemeConfig['dataSources'] = Array.isArray(
150
- snapshot.dataSources
151
- )
152
- ? (snapshot.dataSources as any[])
153
- : [];
154
-
155
- const resolved = await Promise.all(
156
- snapshot.sections.map(async (raw: any): Promise<Section | null> => {
157
- if (!raw?.id) {
158
- return null;
159
- }
160
-
161
- return {
162
- id: raw.id,
163
- type: raw.type || 'default',
164
- name: raw.name || raw.id,
165
- label: raw.label || raw.id,
166
- properties: raw.properties || {},
167
- styles: raw.styles || {},
168
- blocks: Array.isArray(raw.blocks) ? raw.blocks : [],
169
- order: raw.order ?? 0,
170
- hidden: raw.hidden || false,
171
- dataSourceId: raw.dataSourceId,
172
- dataSource: await resolveSectionDataSource(
173
- raw.dataSourceId,
174
- dataSources,
175
- raw.id
176
- )
177
- };
178
- })
179
- );
180
-
181
- const sections = resolved.filter((s): s is Section => s !== null);
182
-
183
- validateThemeSectionsInDev(sections);
184
-
185
- return {
186
- sections,
187
- placeholderId: slug,
188
- dataSources,
189
- themeSettings:
190
- (snapshot.themeSettings as Record<string, unknown> | null) ?? null
191
- };
192
- }
193
-
194
61
  export interface LoadedPlaceholder {
195
62
  sections: Section[];
196
63
  placeholderId: string;
@@ -214,12 +81,6 @@ export async function loadPlaceholderSections(
214
81
  slug: string
215
82
  ): Promise<LoadedPlaceholder | null> {
216
83
  try {
217
- // Absent/malformed blob (or no draft mode) falls through to the live path.
218
- const previewSnapshot = await getPlaceholderPreviewSnapshot(slug);
219
- if (previewSnapshot) {
220
- return loadFromPreviewSnapshot(slug, previewSnapshot);
221
- }
222
-
223
84
  // theme-config is optional for placeholder rendering, and the reader is
224
85
  // memoized per request — a page with several placeholders, or a ChromeSlot
225
86
  // asking the same question to decide whether it is pinned, reads it once.
@@ -363,11 +224,60 @@ export async function loadPlaceholderSections(
363
224
  (getBlockValue('dataSourceId') as string | undefined) ||
364
225
  (getBlockValue('selectedDataSourceId') as string | undefined);
365
226
 
366
- const dataSourceWithData = await resolveSectionDataSource(
367
- dataSourceId,
368
- dataSources,
369
- sectionSlug
370
- );
227
+ const dataSource = dataSourceId
228
+ ? dataSources.find((ds) => ds.id === dataSourceId)
229
+ : undefined;
230
+
231
+ if (dataSourceId && !dataSource) {
232
+ devWarn(
233
+ 'ThemePlaceholder',
234
+ `Section "${sectionSlug}" references data source "${dataSourceId}" which was not found; rendering placeholder items`
235
+ );
236
+ }
237
+
238
+ let dataSourceWithData = dataSource;
239
+ if (dataSource && dataSource.details) {
240
+ // Only fetch collection data if we have a valid widget slug
241
+ // (not the section's own slug which contains config, not products)
242
+ const collectionSlug = dataSource.details.collection?.slug;
243
+ if (collectionSlug) {
244
+ try {
245
+ let collectionData = await getWidgetData<{
246
+ [key: string]: unknown;
247
+ }>({
248
+ slug: collectionSlug
249
+ });
250
+
251
+ if (!collectionData) {
252
+ collectionData = await getCollectionWidgetData<{
253
+ [key: string]: unknown;
254
+ }>({
255
+ slug: collectionSlug
256
+ });
257
+ }
258
+
259
+ if (collectionData) {
260
+ dataSourceWithData = {
261
+ ...dataSource,
262
+ details: {
263
+ ...dataSource.details,
264
+ collection: {
265
+ ...dataSource.details.collection,
266
+ data: collectionData
267
+ }
268
+ }
269
+ };
270
+ }
271
+ } catch (error) {
272
+ console.error(
273
+ `Error fetching widget data for section ${sectionSlug}:`,
274
+ error
275
+ );
276
+ }
277
+ }
278
+ // If no collection slug, the saved products from editor
279
+ // (details.collection.products) will be used as fallback
280
+ }
371
281
 
372
282
  const section: Section = {
373
283
  id: sectionSlug,
@@ -1,51 +0,0 @@
1
- import * as React from 'react';
2
- import * as serverData from '@akinon/next/data/server';
3
-
4
- /**
5
- * Whole-placeholder snapshot the theme editor writes for a named preview
6
- * (`<slug>-preview-<id>`, `preview_snapshot` attribute). Sections arrive in
7
- * render shape. See akinon-next's `getPreviewSnapshot` for the contract.
8
- */
9
- export interface PreviewSnapshot {
10
- version: number;
11
- savedAt?: string;
12
- themeSettings?: Record<string, unknown> | null;
13
- dataSources?: unknown[];
14
- sections: unknown[];
15
- }
16
-
17
- type GetPreviewSnapshot = (args: {
18
- slug: string;
19
- }) => Promise<PreviewSnapshot | null>;
20
-
21
- const fetchPreviewSnapshot = async (
22
- slug: string
23
- ): Promise<PreviewSnapshot | null> => {
24
- // v1 cores predate widget preview: no reader means never a preview. The
25
- // reader itself answers null outside draft mode or without a preview id.
26
- const fn = (
27
- serverData as unknown as { getPreviewSnapshot?: GetPreviewSnapshot }
28
- ).getPreviewSnapshot;
29
- if (!fn) return null;
30
-
31
- try {
32
- return (await fn({ slug })) ?? null;
33
- } catch {
34
- // A failed preview read must fall back to the live render, never blank it.
35
- return null;
36
- }
37
- };
38
-
39
- // `React.cache` exists on the RSC-capable React this package renders under;
40
- // fall back to the raw function on older cores, where correctness is identical
41
- // and the only cost is a second fetch.
42
- const cache = (React as { cache?: <T>(fn: T) => T }).cache;
43
-
44
- /**
45
- * The one preview-snapshot read per placeholder per request — shared by the
46
- * slot's composed/uncomposed decision and the section loader so the two can
47
- * never disagree about which content a preview shows.
48
- */
49
- export const getPlaceholderPreviewSnapshot = cache
50
- ? cache(fetchPreviewSnapshot)
51
- : fetchPreviewSnapshot;