@shoppexio/builder-runtime 0.1.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/dist/attributes.d.ts +8 -0
- package/dist/attributes.d.ts.map +1 -0
- package/dist/attributes.js +17 -0
- package/dist/builder-runtime.test.d.ts +2 -0
- package/dist/builder-runtime.test.d.ts.map +1 -0
- package/dist/builder-runtime.test.js +115 -0
- package/dist/content.d.ts +13 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +39 -0
- package/dist/css-vars.d.ts +6 -0
- package/dist/css-vars.d.ts.map +1 -0
- package/dist/css-vars.js +97 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/layout.d.ts +9 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +40 -0
- package/dist/react-runtime.test.d.ts +2 -0
- package/dist/react-runtime.test.d.ts.map +1 -0
- package/dist/react-runtime.test.js +292 -0
- package/dist/react.d.ts +302 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +408 -0
- package/dist/style-slots.d.ts +12 -0
- package/dist/style-slots.d.ts.map +1 -0
- package/dist/style-slots.js +31 -0
- package/package.json +91 -0
- package/src/attributes.ts +23 -0
- package/src/builder-runtime.test.ts +143 -0
- package/src/content.ts +58 -0
- package/src/css-vars.ts +124 -0
- package/src/index.ts +6 -0
- package/src/jsdom.d.ts +6 -0
- package/src/layout.ts +55 -0
- package/src/react-runtime.test.tsx +430 -0
- package/src/react.tsx +550 -0
- package/src/style-slots.ts +46 -0
package/src/react.tsx
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import type { BlockInstance, BuilderSelection, BuilderSettings, Breakpoint, StyleSlotId } from '@shoppex/builder-contracts';
|
|
2
|
+
import {
|
|
3
|
+
BuilderSettingsSchema,
|
|
4
|
+
PreviewMessageSchema,
|
|
5
|
+
createEmptyBuilderSettings,
|
|
6
|
+
migrateLegacyBuilderSettings,
|
|
7
|
+
} from '@shoppex/builder-contracts';
|
|
8
|
+
import {
|
|
9
|
+
createElement,
|
|
10
|
+
createContext,
|
|
11
|
+
useContext,
|
|
12
|
+
useEffect,
|
|
13
|
+
useMemo,
|
|
14
|
+
useRef,
|
|
15
|
+
useState,
|
|
16
|
+
type ComponentType,
|
|
17
|
+
type ElementType,
|
|
18
|
+
type ReactNode,
|
|
19
|
+
} from 'react';
|
|
20
|
+
import {
|
|
21
|
+
getBlockSettingValue,
|
|
22
|
+
getBuilderContentList,
|
|
23
|
+
getBuilderContentRecord,
|
|
24
|
+
getBuilderContentString,
|
|
25
|
+
getBuilderContentValue,
|
|
26
|
+
} from './content.js';
|
|
27
|
+
import { createBuilderCss } from './css-vars.js';
|
|
28
|
+
import { getPageBlocks, getPageLayout, getVisiblePageBlocks } from './layout.js';
|
|
29
|
+
import { resolveStyleSlotValue } from './style-slots.js';
|
|
30
|
+
|
|
31
|
+
type BuilderRuntimeContextValue = {
|
|
32
|
+
settings: BuilderSettings;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const BUILDER_SETTINGS_WINDOW_KEY = '__SHOPPEX_BUILDER_SETTINGS__';
|
|
36
|
+
const BUILDER_STATE_EVENT_NAME = 'shoppex:builder-state';
|
|
37
|
+
const BUILDER_BLOCK_SELECTOR = '[data-builder-block]';
|
|
38
|
+
const BUILDER_CONTENT_SELECTOR = '[data-builder-content]';
|
|
39
|
+
const BUILDER_SELECTED_SELECTOR = '[data-builder-selected="true"]';
|
|
40
|
+
|
|
41
|
+
const BuilderRuntimeContext = createContext<BuilderRuntimeContextValue | null>(null);
|
|
42
|
+
const BuilderBlockContext = createContext<BlockInstance | null>(null);
|
|
43
|
+
|
|
44
|
+
export function BuilderRuntimeProvider({ settings, children }: { settings: BuilderSettings; children: ReactNode }) {
|
|
45
|
+
const value = useMemo(() => ({ settings }), [settings]);
|
|
46
|
+
return <BuilderRuntimeContext.Provider value={value}>{children}</BuilderRuntimeContext.Provider>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function BuilderBlockProvider({ block, children }: { block: BlockInstance; children: ReactNode }) {
|
|
50
|
+
return <BuilderBlockContext.Provider value={block}>{children}</BuilderBlockContext.Provider>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function BuilderRuntimePreviewProvider({
|
|
54
|
+
initialSettings,
|
|
55
|
+
children,
|
|
56
|
+
}: {
|
|
57
|
+
initialSettings?: unknown;
|
|
58
|
+
children: ReactNode;
|
|
59
|
+
}) {
|
|
60
|
+
const [settings, setSettings] = useState(() => parseInitialBuilderSettings(initialSettings));
|
|
61
|
+
const settingsRevisionRef = useRef(settings.revision);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
settingsRevisionRef.current = settings.revision;
|
|
65
|
+
}, [settings.revision]);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const currentWindow = window as Window & {
|
|
69
|
+
__SHOPPEX_BUILDER_SETTINGS__?: BuilderSettings;
|
|
70
|
+
};
|
|
71
|
+
const parentOrigin = getPreviewParentOrigin(document.referrer);
|
|
72
|
+
const isTrustedPreviewEmbed = isTrustedBuilderPreviewEmbed(window.location, parentOrigin);
|
|
73
|
+
const removeInspectorStyles = isTrustedPreviewEmbed ? installBuilderPreviewInspectorStyles() : () => {};
|
|
74
|
+
const removeHoverInspector = isTrustedPreviewEmbed ? installBuilderPreviewHoverInspector() : () => {};
|
|
75
|
+
|
|
76
|
+
const postToParent = (event: MessageEvent<unknown> | null, response: unknown) => {
|
|
77
|
+
const target = event?.source && 'postMessage' in event.source ? event.source : window.parent;
|
|
78
|
+
if (!target || target === window) return;
|
|
79
|
+
const targetOrigin = parentOrigin ?? event?.origin ?? '*';
|
|
80
|
+
(target as { postMessage: (message: unknown, targetOrigin: string) => void }).postMessage(
|
|
81
|
+
response,
|
|
82
|
+
targetOrigin || '*',
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const applySettings = (input: unknown): { status: 'applied'; settings: BuilderSettings } | { status: 'invalid' | 'stale' } => {
|
|
87
|
+
const parsed = BuilderSettingsSchema.safeParse(input);
|
|
88
|
+
if (!parsed.success) return { status: 'invalid' };
|
|
89
|
+
if (parsed.data.revision < settingsRevisionRef.current) return { status: 'stale' };
|
|
90
|
+
|
|
91
|
+
settingsRevisionRef.current = parsed.data.revision;
|
|
92
|
+
setSettings(parsed.data);
|
|
93
|
+
return { status: 'applied', settings: parsed.data };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
applySettings(currentWindow[BUILDER_SETTINGS_WINDOW_KEY]);
|
|
97
|
+
|
|
98
|
+
const handleBuilderState = (event: Event) => {
|
|
99
|
+
const detail = event instanceof CustomEvent ? event.detail : null;
|
|
100
|
+
applySettings(detail);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handlePreviewMessage = (event: MessageEvent<unknown>) => {
|
|
104
|
+
if (!isTrustedPreviewEmbed) return;
|
|
105
|
+
if (parentOrigin && event.origin !== parentOrigin) return;
|
|
106
|
+
if (!parentOrigin && event.source !== window.parent) return;
|
|
107
|
+
|
|
108
|
+
const parsed = PreviewMessageSchema.safeParse(event.data);
|
|
109
|
+
if (!parsed.success) return;
|
|
110
|
+
const message = parsed.data;
|
|
111
|
+
|
|
112
|
+
if (message.type === 'REQUEST_READY') {
|
|
113
|
+
postToParent(event, { type: 'READY', revision: settingsRevisionRef.current });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (message.type === 'APPLY_STATE') {
|
|
118
|
+
const result = applySettings(message.state);
|
|
119
|
+
postToParent(
|
|
120
|
+
event,
|
|
121
|
+
result.status === 'applied'
|
|
122
|
+
? { type: 'APPLIED', revision: message.revision }
|
|
123
|
+
: {
|
|
124
|
+
type: 'APPLY_FAILED',
|
|
125
|
+
revision: message.revision,
|
|
126
|
+
error: result.status === 'stale' ? 'Stale builder revision' : 'Invalid builder state message',
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (message.type === 'RELOAD') {
|
|
133
|
+
postToParent(event, { type: 'APPLIED', revision: message.revision });
|
|
134
|
+
window.setTimeout(() => window.location.reload(), 0);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (message.type === 'SELECT_ELEMENT') {
|
|
139
|
+
selectBuilderElement(message.selection.blockId);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const handleBuilderClick = (event: MouseEvent) => {
|
|
144
|
+
if (!isTrustedPreviewEmbed) return;
|
|
145
|
+
const target = event.target;
|
|
146
|
+
if (!(target instanceof Element)) return;
|
|
147
|
+
|
|
148
|
+
const blockElement = target.closest<HTMLElement>(BUILDER_BLOCK_SELECTOR);
|
|
149
|
+
if (!blockElement) return;
|
|
150
|
+
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
event.stopPropagation();
|
|
153
|
+
|
|
154
|
+
const selection = createBuilderSelection(blockElement, target);
|
|
155
|
+
if (!selection.blockId) return;
|
|
156
|
+
|
|
157
|
+
postToParent(null, {
|
|
158
|
+
type: 'ELEMENT_CLICKED',
|
|
159
|
+
revision: settingsRevisionRef.current,
|
|
160
|
+
selection,
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
if (isTrustedPreviewEmbed) {
|
|
165
|
+
postToParent(null, { type: 'READY', revision: settingsRevisionRef.current });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
window.addEventListener(BUILDER_STATE_EVENT_NAME, handleBuilderState);
|
|
169
|
+
window.addEventListener('message', handlePreviewMessage);
|
|
170
|
+
window.addEventListener('click', handleBuilderClick, true);
|
|
171
|
+
return () => {
|
|
172
|
+
removeInspectorStyles();
|
|
173
|
+
removeHoverInspector();
|
|
174
|
+
window.removeEventListener(BUILDER_STATE_EVENT_NAME, handleBuilderState);
|
|
175
|
+
window.removeEventListener('message', handlePreviewMessage);
|
|
176
|
+
window.removeEventListener('click', handleBuilderClick, true);
|
|
177
|
+
};
|
|
178
|
+
}, []);
|
|
179
|
+
|
|
180
|
+
return <BuilderRuntimeProvider settings={settings}>{children}</BuilderRuntimeProvider>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function BuilderRuntimeStyle({ selector = ':root' }: { selector?: string }) {
|
|
184
|
+
const css = useBuilderCss(selector);
|
|
185
|
+
if (!css) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return <style data-shoppex-builder-runtime="v2">{css}</style>;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export type BuilderBlockComponent<TContext = unknown> = ComponentType<{
|
|
193
|
+
block: BlockInstance;
|
|
194
|
+
context: TContext;
|
|
195
|
+
}>;
|
|
196
|
+
|
|
197
|
+
export type BuilderBlockRegistry<TContext = unknown> = Record<string, BuilderBlockComponent<TContext>>;
|
|
198
|
+
|
|
199
|
+
export function BuilderBlockFrame<TElement extends ElementType = 'div'>({
|
|
200
|
+
as,
|
|
201
|
+
pageId,
|
|
202
|
+
block,
|
|
203
|
+
className,
|
|
204
|
+
children,
|
|
205
|
+
}: {
|
|
206
|
+
as?: TElement;
|
|
207
|
+
pageId: string;
|
|
208
|
+
block: BlockInstance;
|
|
209
|
+
className?: string;
|
|
210
|
+
children: ReactNode;
|
|
211
|
+
}) {
|
|
212
|
+
return createElement(
|
|
213
|
+
as ?? 'div',
|
|
214
|
+
{
|
|
215
|
+
className,
|
|
216
|
+
'data-page-id': pageId,
|
|
217
|
+
'data-builder-block': block.id,
|
|
218
|
+
'data-builder-block-type': block.type,
|
|
219
|
+
},
|
|
220
|
+
children,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function BuilderPage<TContext = unknown>({
|
|
225
|
+
pageId,
|
|
226
|
+
blocks,
|
|
227
|
+
registry,
|
|
228
|
+
context,
|
|
229
|
+
fallback = null,
|
|
230
|
+
}: {
|
|
231
|
+
pageId: string;
|
|
232
|
+
blocks?: BlockInstance[];
|
|
233
|
+
registry: BuilderBlockRegistry<TContext>;
|
|
234
|
+
context: TContext;
|
|
235
|
+
fallback?: ReactNode;
|
|
236
|
+
}) {
|
|
237
|
+
const runtimeBlocks = useVisibleBuilderPageBlocks(pageId);
|
|
238
|
+
const pageBlocks = blocks ?? runtimeBlocks;
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<>
|
|
242
|
+
{pageBlocks.map((block) => {
|
|
243
|
+
const Component = registry[block.type];
|
|
244
|
+
if (!Component) {
|
|
245
|
+
return fallback;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<BuilderBlockProvider key={block.id} block={block}>
|
|
250
|
+
<Component block={block} context={context} />
|
|
251
|
+
</BuilderBlockProvider>
|
|
252
|
+
);
|
|
253
|
+
})}
|
|
254
|
+
</>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function useBuilderRuntime(): BuilderRuntimeContextValue {
|
|
259
|
+
const context = useContext(BuilderRuntimeContext);
|
|
260
|
+
if (!context) {
|
|
261
|
+
throw new Error('useBuilderRuntime must be used inside BuilderRuntimeProvider');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return context;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function useBuilderContent(path: string, fallback?: string): string | undefined {
|
|
268
|
+
const scopedValue = useScopedBuilderContentValue(path);
|
|
269
|
+
if (typeof scopedValue === 'string') return scopedValue;
|
|
270
|
+
return getBuilderContentString(useBuilderRuntime().settings, path, fallback);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function useBuilderContentValue(path: string): unknown {
|
|
274
|
+
const scopedValue = useScopedBuilderContentValue(path);
|
|
275
|
+
if (scopedValue !== undefined) return scopedValue;
|
|
276
|
+
return getBuilderContentValue(useBuilderRuntime().settings, path);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function useBuilderContentList<T = unknown>(path: string, fallback: T[] = []): T[] {
|
|
280
|
+
const scopedValue = useScopedBuilderContentValue(path);
|
|
281
|
+
if (Array.isArray(scopedValue)) return scopedValue as T[];
|
|
282
|
+
return getBuilderContentList<T>(useBuilderRuntime().settings, path, fallback);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function useBuilderContentRecord(): Record<string, unknown> {
|
|
286
|
+
const block = useContext(BuilderBlockContext);
|
|
287
|
+
const content = getBuilderContentRecord(useBuilderRuntime().settings);
|
|
288
|
+
if (!block) return content;
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
...content,
|
|
292
|
+
[block.type]: {
|
|
293
|
+
...(typeof content[block.type] === 'object' && content[block.type] !== null
|
|
294
|
+
? content[block.type] as Record<string, unknown>
|
|
295
|
+
: {}),
|
|
296
|
+
...block.settings,
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function useBuilderBlockSetting<T = unknown>(input: {
|
|
302
|
+
pageId: string;
|
|
303
|
+
blockId: string;
|
|
304
|
+
path: string;
|
|
305
|
+
fallback: T;
|
|
306
|
+
}): T {
|
|
307
|
+
return getBlockSettingValue<T>(useBuilderRuntime().settings, input);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function useBuilderPageLayout(pageId: string) {
|
|
311
|
+
return getPageLayout(useBuilderRuntime().settings, pageId);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function useBuilderPageBlocks(pageId: string) {
|
|
315
|
+
return getPageBlocks(useBuilderRuntime().settings, pageId);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function useVisibleBuilderPageBlocks(pageId: string) {
|
|
319
|
+
return getVisiblePageBlocks(useBuilderRuntime().settings, pageId);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function useBuilderStyleSlot(
|
|
323
|
+
slotId: StyleSlotId,
|
|
324
|
+
input: { breakpoint?: Breakpoint; fallback?: unknown } = {},
|
|
325
|
+
): unknown {
|
|
326
|
+
return resolveStyleSlotValue(useBuilderRuntime().settings, slotId, input);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function useBuilderCss(selector = ':root'): string {
|
|
330
|
+
return createBuilderCss(useBuilderRuntime().settings, selector);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function useScopedBuilderContentValue(path: string): unknown {
|
|
334
|
+
const block = useContext(BuilderBlockContext);
|
|
335
|
+
if (!block) return undefined;
|
|
336
|
+
|
|
337
|
+
if (Object.prototype.hasOwnProperty.call(block.settings, path)) {
|
|
338
|
+
return block.settings[path];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const shortPath = path.startsWith(`${block.type}.`) ? path.slice(block.type.length + 1) : path.split('.').at(-1);
|
|
342
|
+
if (shortPath && Object.prototype.hasOwnProperty.call(block.settings, shortPath)) {
|
|
343
|
+
return block.settings[shortPath];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const nested = shortPath ? getNestedBuilderSetting(block.settings, shortPath) : undefined;
|
|
347
|
+
return nested ?? getNestedBuilderSetting(block.settings, path);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function getNestedBuilderSetting(record: Record<string, unknown>, path: string): unknown {
|
|
351
|
+
let current: unknown = record;
|
|
352
|
+
for (const segment of path.split('.')) {
|
|
353
|
+
if (!current || typeof current !== 'object') {
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
current = (current as Record<string, unknown>)[segment];
|
|
357
|
+
}
|
|
358
|
+
return current;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function parseInitialBuilderSettings(input: unknown): BuilderSettings {
|
|
362
|
+
const parsed = BuilderSettingsSchema.safeParse(input);
|
|
363
|
+
if (parsed.success) return parsed.data;
|
|
364
|
+
|
|
365
|
+
const candidate = unwrapBuilderSettingsInput(input);
|
|
366
|
+
const candidateParsed = BuilderSettingsSchema.safeParse(candidate);
|
|
367
|
+
if (candidateParsed.success) return candidateParsed.data;
|
|
368
|
+
|
|
369
|
+
if (!isRecord(candidate) || !isRecord(candidate.theme)) {
|
|
370
|
+
return createEmptyBuilderSettings(0);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const revision = parseBuilderRevision(candidate.revision);
|
|
374
|
+
const migrated = migrateLegacyBuilderSettings({ theme: candidate.theme }, revision);
|
|
375
|
+
const mixedCandidate = BuilderSettingsSchema.safeParse({
|
|
376
|
+
...migrated,
|
|
377
|
+
theme: {
|
|
378
|
+
...migrated.theme,
|
|
379
|
+
pages: Array.isArray(candidate.theme.pages) ? candidate.theme.pages : migrated.theme.pages,
|
|
380
|
+
terms: isRecord(candidate.theme.terms) ? candidate.theme.terms : migrated.theme.terms,
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
return mixedCandidate.success ? mixedCandidate.data : migrated;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function unwrapBuilderSettingsInput(input: unknown): unknown {
|
|
388
|
+
if (!isRecord(input)) return input;
|
|
389
|
+
if (isRecord(input.builder_settings)) return input.builder_settings;
|
|
390
|
+
if (isRecord(input.builderSettings)) return input.builderSettings;
|
|
391
|
+
return input;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function parseBuilderRevision(input: unknown): number {
|
|
395
|
+
return typeof input === 'number' && Number.isInteger(input) && input >= 0 ? input : 0;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
399
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function getPreviewParentOrigin(referrer: string): string | null {
|
|
403
|
+
if (!referrer) return null;
|
|
404
|
+
try {
|
|
405
|
+
return new URL(referrer).origin;
|
|
406
|
+
} catch {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function isTrustedBuilderPreviewEmbed(location: Location, parentOrigin: string | null): boolean {
|
|
412
|
+
if (window.parent === window || !parentOrigin) return false;
|
|
413
|
+
if (!hasBuilderPreviewMode(location)) return false;
|
|
414
|
+
return isTrustedBuilderPreviewParentOrigin(parentOrigin);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function hasBuilderPreviewMode(location: Location): boolean {
|
|
418
|
+
const searchParams = new URLSearchParams(location.search);
|
|
419
|
+
return (
|
|
420
|
+
searchParams.get('shoppex-preview-mode') === 'theme'
|
|
421
|
+
|| searchParams.get('shoppex-preview-mode') === 'builder'
|
|
422
|
+
|| searchParams.get('shoppex-preview') === 'builder'
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function isTrustedBuilderPreviewParentOrigin(origin: string): boolean {
|
|
427
|
+
try {
|
|
428
|
+
const parsed = new URL(origin);
|
|
429
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
430
|
+
return (
|
|
431
|
+
hostname === 'dashboard.shoppex.io'
|
|
432
|
+
|| hostname === 'dashboard.shoppex.test'
|
|
433
|
+
|| hostname === 'localhost'
|
|
434
|
+
|| hostname === '127.0.0.1'
|
|
435
|
+
|| hostname === '::1'
|
|
436
|
+
|| hostname.endsWith('.localhost')
|
|
437
|
+
|| hostname.endsWith('.vercel.app')
|
|
438
|
+
|| hostname.endsWith('.vercel.run')
|
|
439
|
+
);
|
|
440
|
+
} catch {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function selectBuilderElement(blockId: string | undefined): void {
|
|
446
|
+
document
|
|
447
|
+
.querySelectorAll<HTMLElement>(BUILDER_SELECTED_SELECTOR)
|
|
448
|
+
.forEach((node) => node.removeAttribute('data-builder-selected'));
|
|
449
|
+
|
|
450
|
+
if (!blockId) return;
|
|
451
|
+
|
|
452
|
+
const target = document.querySelector<HTMLElement>(
|
|
453
|
+
`[data-builder-block="${CSS.escape(blockId)}"]`,
|
|
454
|
+
);
|
|
455
|
+
if (!target) return;
|
|
456
|
+
|
|
457
|
+
target.setAttribute('data-builder-selected', 'true');
|
|
458
|
+
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function createBuilderSelection(blockElement: HTMLElement, target: Element): BuilderSelection {
|
|
462
|
+
const contentElement = target.closest<HTMLElement>(BUILDER_CONTENT_SELECTOR);
|
|
463
|
+
const slotElement = target.closest<HTMLElement>('[data-builder-slot]');
|
|
464
|
+
const linkElement = target.closest<HTMLAnchorElement>('a[href]');
|
|
465
|
+
const selection: BuilderSelection = {
|
|
466
|
+
blockId: blockElement.getAttribute('data-builder-block') ?? undefined,
|
|
467
|
+
blockType: blockElement.getAttribute('data-builder-block-type') ?? undefined,
|
|
468
|
+
pageId: blockElement.getAttribute('data-page-id') ?? undefined,
|
|
469
|
+
contentPath: contentElement?.getAttribute('data-builder-content') ?? undefined,
|
|
470
|
+
slotId: slotElement?.getAttribute('data-builder-slot') ?? undefined,
|
|
471
|
+
elementType: inferBuilderElementType(target, blockElement, contentElement, slotElement),
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
if (linkElement && blockElement.contains(linkElement)) {
|
|
475
|
+
selection.href = linkElement.getAttribute('href') ?? '';
|
|
476
|
+
selection.target = linkElement.getAttribute('target') ?? '';
|
|
477
|
+
selection.rel = linkElement.getAttribute('rel') ?? '';
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return selection;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function inferBuilderElementType(
|
|
484
|
+
target: Element,
|
|
485
|
+
blockElement: HTMLElement,
|
|
486
|
+
contentElement: HTMLElement | null,
|
|
487
|
+
slotElement: HTMLElement | null,
|
|
488
|
+
): BuilderSelection['elementType'] {
|
|
489
|
+
const element = contentElement ?? target;
|
|
490
|
+
if (element.closest('a[href]')) return 'link';
|
|
491
|
+
if (element.closest('button')) return 'button';
|
|
492
|
+
if (element instanceof HTMLImageElement || element.closest('img')) return 'image';
|
|
493
|
+
if (slotElement) return 'style';
|
|
494
|
+
return element === blockElement ? 'block' : 'text';
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function installBuilderPreviewInspectorStyles(): () => void {
|
|
498
|
+
const style = document.createElement('style');
|
|
499
|
+
style.setAttribute('data-shoppex-builder-inspector', 'v2');
|
|
500
|
+
style.textContent = `
|
|
501
|
+
[data-builder-block] {
|
|
502
|
+
position: relative;
|
|
503
|
+
}
|
|
504
|
+
[data-builder-block][data-builder-selected="true"] {
|
|
505
|
+
outline: 2px solid #2563eb;
|
|
506
|
+
outline-offset: 4px;
|
|
507
|
+
}
|
|
508
|
+
[data-builder-block][data-builder-hovered="true"] {
|
|
509
|
+
outline: 1px dashed #2563eb;
|
|
510
|
+
outline-offset: 4px;
|
|
511
|
+
cursor: pointer;
|
|
512
|
+
}
|
|
513
|
+
`;
|
|
514
|
+
document.head.appendChild(style);
|
|
515
|
+
return () => style.remove();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function installBuilderPreviewHoverInspector(): () => void {
|
|
519
|
+
let hovered: HTMLElement | null = null;
|
|
520
|
+
|
|
521
|
+
const clearHovered = () => {
|
|
522
|
+
hovered?.removeAttribute('data-builder-hovered');
|
|
523
|
+
hovered = null;
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const handleMouseOver = (event: MouseEvent) => {
|
|
527
|
+
const target = event.target;
|
|
528
|
+
if (!(target instanceof Element)) return;
|
|
529
|
+
const blockElement = target.closest<HTMLElement>(BUILDER_BLOCK_SELECTOR);
|
|
530
|
+
if (blockElement === hovered) return;
|
|
531
|
+
clearHovered();
|
|
532
|
+
if (!blockElement) return;
|
|
533
|
+
hovered = blockElement;
|
|
534
|
+
hovered.setAttribute('data-builder-hovered', 'true');
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const handleMouseOut = (event: MouseEvent) => {
|
|
538
|
+
const relatedTarget = event.relatedTarget;
|
|
539
|
+
if (relatedTarget instanceof Node && hovered?.contains(relatedTarget)) return;
|
|
540
|
+
clearHovered();
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
window.addEventListener('mouseover', handleMouseOver, true);
|
|
544
|
+
window.addEventListener('mouseout', handleMouseOut, true);
|
|
545
|
+
return () => {
|
|
546
|
+
clearHovered();
|
|
547
|
+
window.removeEventListener('mouseover', handleMouseOver, true);
|
|
548
|
+
window.removeEventListener('mouseout', handleMouseOut, true);
|
|
549
|
+
};
|
|
550
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { BlockInstance, BuilderSettings, Breakpoint, StyleSlotId, StyleSlots } from '@shoppex/builder-contracts';
|
|
2
|
+
|
|
3
|
+
const BREAKPOINT_ORDER: Breakpoint[] = ['base', 'sm', 'md', 'lg', 'xl'];
|
|
4
|
+
|
|
5
|
+
export function getStyleSlotValue(settings: BuilderSettings, slotId: StyleSlotId, input: { block?: BlockInstance } = {}): unknown {
|
|
6
|
+
return input.block?.style_overrides?.[slotId] ?? settings.theme.style_slots[slotId];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function resolveStyleSlotValue(
|
|
10
|
+
settings: BuilderSettings,
|
|
11
|
+
slotId: StyleSlotId,
|
|
12
|
+
input: { block?: BlockInstance; breakpoint?: Breakpoint; fallback?: unknown } = {},
|
|
13
|
+
): unknown {
|
|
14
|
+
const value = getStyleSlotValue(settings, slotId, input);
|
|
15
|
+
|
|
16
|
+
if (!isResponsiveRecord(value)) {
|
|
17
|
+
return value ?? input.fallback;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const breakpoint = input.breakpoint ?? 'base';
|
|
21
|
+
const breakpointIndex = BREAKPOINT_ORDER.indexOf(breakpoint);
|
|
22
|
+
|
|
23
|
+
for (let index = breakpointIndex; index >= 0; index -= 1) {
|
|
24
|
+
const candidate = value[BREAKPOINT_ORDER[index]];
|
|
25
|
+
if (candidate !== undefined) {
|
|
26
|
+
return candidate;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return input.fallback;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function mergeStyleSlots(base: StyleSlots, overrides: StyleSlots | undefined): StyleSlots {
|
|
34
|
+
return {
|
|
35
|
+
...base,
|
|
36
|
+
...(overrides ?? {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isResponsiveRecord(value: unknown): value is Partial<Record<Breakpoint, unknown>> {
|
|
41
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return BREAKPOINT_ORDER.some((breakpoint) => Object.prototype.hasOwnProperty.call(value, breakpoint));
|
|
46
|
+
}
|