@weaverse/hydrogen 5.17.0 → 5.18.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/index.js +83 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +83 -1
- package/dist/index.mjs.map +1 -1
- package/dist/types/WeaverseHydrogenRoot.d.ts +218 -0
- package/dist/types/WeaverseHydrogenRoot.d.ts.map +1 -0
- package/dist/types/components/index.d.ts +4 -0
- package/dist/types/components/index.d.ts.map +1 -0
- package/dist/types/components/main.d.ts +7 -0
- package/dist/types/components/main.d.ts.map +1 -0
- package/dist/types/hooks/theme-text-context.d.ts +2 -0
- package/dist/types/hooks/theme-text-context.d.ts.map +1 -0
- package/dist/types/hooks/translation-context.d.ts +138 -0
- package/dist/types/hooks/translation-context.d.ts.map +1 -0
- package/dist/types/hooks/use-weaverse-data-context.d.ts +29 -0
- package/dist/types/hooks/use-weaverse-data-context.d.ts.map +1 -0
- package/dist/types/index.d.ts +99 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/seo.d.ts +43 -0
- package/dist/types/seo.d.ts.map +1 -0
- package/dist/types/types.d.ts +826 -0
- package/dist/types/types.d.ts.map +1 -0
- package/dist/types/utils/index.d.ts +44 -0
- package/dist/types/utils/index.d.ts.map +1 -0
- package/dist/types/utils/normalize-page-url.d.ts +16 -0
- package/dist/types/utils/normalize-page-url.d.ts.map +1 -0
- package/dist/types/utils/pick-weaverse-data.d.ts +44 -0
- package/dist/types/utils/pick-weaverse-data.d.ts.map +1 -0
- package/dist/types/utils/pixel.d.ts +16 -0
- package/dist/types/utils/pixel.d.ts.map +1 -0
- package/dist/types/utils/studio-request-info.d.ts +17 -0
- package/dist/types/utils/studio-request-info.d.ts.map +1 -0
- package/dist/types/utils/studio-script-src.d.ts +78 -0
- package/dist/types/utils/studio-script-src.d.ts.map +1 -0
- package/dist/types/utils/sync-reused-instance.d.ts +21 -0
- package/dist/types/utils/sync-reused-instance.d.ts.map +1 -0
- package/dist/types/utils/theme-text-store.d.ts +2 -0
- package/dist/types/utils/theme-text-store.d.ts.map +1 -0
- package/dist/types/utils/translation-store.d.ts +40 -0
- package/dist/types/utils/translation-store.d.ts.map +1 -0
- package/dist/types/utils/use-studio.d.ts +33 -0
- package/dist/types/utils/use-studio.d.ts.map +1 -0
- package/dist/types/utils/use-theme-settings-store.d.ts +39 -0
- package/dist/types/utils/use-theme-settings-store.d.ts.map +1 -0
- package/dist/types/weaverse-client.d.ts +227 -0
- package/dist/types/weaverse-client.d.ts.map +1 -0
- package/package.json +7 -6
- package/src/WeaverseHydrogenRoot.tsx +649 -0
- package/src/components/index.ts +10 -0
- package/src/components/main.tsx +28 -0
- package/src/hooks/theme-text-context.tsx +14 -0
- package/src/hooks/translation-context.tsx +275 -0
- package/src/hooks/use-weaverse-data-context.ts +69 -0
- package/src/index.ts +179 -0
- package/src/seo.ts +125 -0
- package/src/types.ts +1020 -0
- package/src/utils/index.ts +351 -0
- package/src/utils/normalize-page-url.ts +44 -0
- package/src/utils/pick-weaverse-data.ts +89 -0
- package/src/utils/pixel.ts +71 -0
- package/src/utils/studio-request-info.ts +66 -0
- package/src/utils/studio-script-src.ts +162 -0
- package/src/utils/sync-reused-instance.ts +57 -0
- package/src/utils/theme-text-store.ts +1 -0
- package/src/utils/translation-store.ts +65 -0
- package/src/utils/use-studio.ts +168 -0
- package/src/utils/use-theme-settings-store.ts +160 -0
- package/src/weaverse-client.ts +1179 -0
- package/dist/index.d.mts +0 -1269
- package/dist/index.d.ts +0 -1269
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/** Rendering mode and host details used to select a Studio bridge script. */
|
|
2
|
+
export interface StudioMode {
|
|
3
|
+
/** Whether the storefront is running inside the visual editor. */
|
|
4
|
+
isDesignMode?: boolean
|
|
5
|
+
/** Whether a component preview is being rendered. */
|
|
6
|
+
isPreviewMode?: boolean
|
|
7
|
+
/** Whether a saved page revision is being previewed. */
|
|
8
|
+
isRevisionPreview?: boolean
|
|
9
|
+
/** Trusted origin serving Studio bridge assets. */
|
|
10
|
+
weaverseHost: string
|
|
11
|
+
/** Studio asset version appended to the script URL. */
|
|
12
|
+
weaverseVersion: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DEFAULT_WEAVERSE_HOST = 'https://studio.weaverse.io'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Weaverse-controlled apex domains the Studio bridge may be served from
|
|
19
|
+
* (matched on the host itself and any subdomain).
|
|
20
|
+
*/
|
|
21
|
+
const TRUSTED_STUDIO_DOMAINS = ['weaverse.io', 'weaverse.dev']
|
|
22
|
+
|
|
23
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
24
|
+
return (
|
|
25
|
+
hostname === 'localhost' ||
|
|
26
|
+
hostname === '127.0.0.1' ||
|
|
27
|
+
hostname.endsWith('.localhost')
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TrustedStudioHostOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Also trust loopback hosts (`localhost` / `127.0.0.1` / `*.localhost`) over
|
|
34
|
+
* http or https.
|
|
35
|
+
*
|
|
36
|
+
* Only safe when the **storefront itself** is loopback (genuine local-builder
|
|
37
|
+
* dev). A production https page can still load a loopback script — loopback is
|
|
38
|
+
* a "potentially trustworthy" origin and is not blocked as mixed content — so
|
|
39
|
+
* honoring a query-supplied loopback host on a public storefront would let a
|
|
40
|
+
* crafted link run whatever a visitor's local service serves inside the
|
|
41
|
+
* storefront origin. MUST stay off on the server, where it is an SSRF vector
|
|
42
|
+
* for page/theme fetches.
|
|
43
|
+
*/
|
|
44
|
+
allowLoopback?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether `host` is an origin we trust to serve the Studio bridge script (or,
|
|
49
|
+
* server-side, to resolve page/theme data).
|
|
50
|
+
*
|
|
51
|
+
* The bridge executes inside the storefront document and the host can drive
|
|
52
|
+
* server-side fetches, so a host taken from an attacker-controllable source —
|
|
53
|
+
* e.g. a crafted `?weaverseHost=` query — is both an XSS and an SSRF vector.
|
|
54
|
+
* Callers MUST reject untrusted hosts before acting on them. Trusts
|
|
55
|
+
* `weaverse.io`/`weaverse.dev` and their subdomains over https; loopback is
|
|
56
|
+
* opt-in (see {@link TrustedStudioHostOptions.allowLoopback}).
|
|
57
|
+
*/
|
|
58
|
+
export function isTrustedStudioHost(
|
|
59
|
+
host: string,
|
|
60
|
+
options?: TrustedStudioHostOptions
|
|
61
|
+
): boolean {
|
|
62
|
+
let parsed: URL
|
|
63
|
+
try {
|
|
64
|
+
parsed = new URL(host)
|
|
65
|
+
} catch {
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
let { hostname, protocol } = parsed
|
|
69
|
+
// Loopback (opt-in) is the only place http is allowed — a local builder
|
|
70
|
+
// serves the bridge over http and there is no cleartext-downgrade risk to a
|
|
71
|
+
// private address. Every other trusted host must be https so server-side
|
|
72
|
+
// page/theme fetches are never downgraded to cleartext.
|
|
73
|
+
if (options?.allowLoopback && isLoopbackHostname(hostname)) {
|
|
74
|
+
return protocol === 'http:' || protocol === 'https:'
|
|
75
|
+
}
|
|
76
|
+
if (protocol !== 'https:') {
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
return TRUSTED_STUDIO_DOMAINS.some(
|
|
80
|
+
(domain) => hostname === domain || hostname.endsWith(`.${domain}`)
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build the Studio bridge script URL for the current mode, or `null` when the
|
|
86
|
+
* storefront is not being previewed inside Weaverse Studio.
|
|
87
|
+
*
|
|
88
|
+
* This is the single gate that keeps the bridge off production storefronts:
|
|
89
|
+
* a normal visitor's URL carries none of the design/preview markers, so the
|
|
90
|
+
* result is `null` and no script is ever requested.
|
|
91
|
+
*
|
|
92
|
+
* Shared by the page-scoped `useStudio` (which also binds the page for
|
|
93
|
+
* editing) and the root-level `useStudioConnect` (which only needs the script
|
|
94
|
+
* to load so Studio's `checkWeaversePage()` handshake can answer on routes
|
|
95
|
+
* with no Weaverse content).
|
|
96
|
+
*/
|
|
97
|
+
export function getStudioScriptSrc(mode: StudioMode): string | null {
|
|
98
|
+
let {
|
|
99
|
+
isDesignMode,
|
|
100
|
+
isPreviewMode,
|
|
101
|
+
isRevisionPreview,
|
|
102
|
+
weaverseHost,
|
|
103
|
+
weaverseVersion,
|
|
104
|
+
} = mode
|
|
105
|
+
let base = `${weaverseHost}/static/studio/hydrogen`
|
|
106
|
+
let version = `?v=${weaverseVersion}`
|
|
107
|
+
if (isRevisionPreview || isPreviewMode) {
|
|
108
|
+
return `${base}/preview.js${version}`
|
|
109
|
+
}
|
|
110
|
+
if (isDesignMode) {
|
|
111
|
+
return `${base}/index.js${version}`
|
|
112
|
+
}
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Resolve the Studio bridge script URL from a URL query string — the markers
|
|
118
|
+
* Studio attaches when it drives the preview iframe (`isDesignMode`,
|
|
119
|
+
* `isPreviewMode`, `__revisionId`, `weaverseHost`, `weaverseVersion`) — or
|
|
120
|
+
* `null` outside Studio.
|
|
121
|
+
*
|
|
122
|
+
* `storefrontHostname` is the document's own hostname: a loopback Studio host
|
|
123
|
+
* is only honored when the storefront itself is loopback (genuine local-builder
|
|
124
|
+
* dev), so a crafted public link can't load a script from a visitor's loopback
|
|
125
|
+
* service into the storefront origin.
|
|
126
|
+
*
|
|
127
|
+
* The `weaverseHost` query is attacker-controllable, so a host that is not a
|
|
128
|
+
* {@link isTrustedStudioHost trusted origin} resolves to `null` (no bridge) —
|
|
129
|
+
* it is never silently replaced with production Studio, which would otherwise
|
|
130
|
+
* inject the wrong bridge for self-hosted/staging deployments. Such deployments
|
|
131
|
+
* (a non-`weaverse.io` `WEAVERSE_HOST`) therefore don't load the root bridge on
|
|
132
|
+
* content-less routes; the page-scoped {@link getStudioScriptSrc} via
|
|
133
|
+
* `useStudio` still uses the configured host on content routes.
|
|
134
|
+
*
|
|
135
|
+
* Pure (no DOM) so the root-level connect's production gate stays unit-testable
|
|
136
|
+
* and needs no loader data.
|
|
137
|
+
*/
|
|
138
|
+
export function resolveStudioScriptSrc(
|
|
139
|
+
search: string,
|
|
140
|
+
storefrontHostname = ''
|
|
141
|
+
): string | null {
|
|
142
|
+
let params = new URLSearchParams(search)
|
|
143
|
+
// Match the server's getRequestQueries(), which keeps the LAST value of a
|
|
144
|
+
// duplicated key. Studio appends its own control params, so a pre-existing
|
|
145
|
+
// (stale/untrusted) `weaverseHost` earlier in the URL must not win here, or
|
|
146
|
+
// the root bridge would be skipped while the loader uses the later host.
|
|
147
|
+
let last = (key: string) => params.getAll(key).at(-1)
|
|
148
|
+
let host = last('weaverseHost')
|
|
149
|
+
if (host) {
|
|
150
|
+
let allowLoopback = isLoopbackHostname(storefrontHostname)
|
|
151
|
+
if (!isTrustedStudioHost(host, { allowLoopback })) {
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return getStudioScriptSrc({
|
|
156
|
+
isDesignMode: last('isDesignMode') === 'true',
|
|
157
|
+
isPreviewMode: last('isPreviewMode') === 'true',
|
|
158
|
+
isRevisionPreview: params.has('__revisionId'),
|
|
159
|
+
weaverseHost: host || DEFAULT_WEAVERSE_HOST,
|
|
160
|
+
weaverseVersion: last('weaverseVersion') || '',
|
|
161
|
+
})
|
|
162
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { WeaverseHydrogenParams } from '../types'
|
|
2
|
+
import type { WeaverseHydrogen } from '../WeaverseHydrogenRoot'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Apply a fresh loader payload to a reused Weaverse instance.
|
|
6
|
+
*
|
|
7
|
+
* With requestInfo normalized to the browser URL, same-URL revalidations
|
|
8
|
+
* (cart mutations, `useRevalidator()`, locale-neutral re-fetches) reuse the
|
|
9
|
+
* cached `window.__weaverses[pageId]` instance instead of constructing a new
|
|
10
|
+
* one — which preserves section state, but would silently drop the fresh
|
|
11
|
+
* `data`/`dataContext` the loaders just returned. Sync them here.
|
|
12
|
+
*
|
|
13
|
+
* Design mode is excluded by the caller: Studio owns the instance data there
|
|
14
|
+
* (unsaved drafts must not be clobbered) and applies updates via
|
|
15
|
+
* `refreshStudio` instead.
|
|
16
|
+
*
|
|
17
|
+
* Change detection uses JSON comparison — loader payloads are
|
|
18
|
+
* wire-serialized, so key order is stable between runs; a false mismatch
|
|
19
|
+
* only costs one extra re-render.
|
|
20
|
+
*/
|
|
21
|
+
export function syncReusedInstance(
|
|
22
|
+
weaverse: WeaverseHydrogen,
|
|
23
|
+
params: WeaverseHydrogenParams
|
|
24
|
+
) {
|
|
25
|
+
weaverse.requestInfo = params.requestInfo
|
|
26
|
+
weaverse.internal = params.internal
|
|
27
|
+
|
|
28
|
+
let dataChanged =
|
|
29
|
+
JSON.stringify(weaverse.data) !== JSON.stringify(params.data)
|
|
30
|
+
let contextChanged =
|
|
31
|
+
JSON.stringify(weaverse.dataContext ?? null) !==
|
|
32
|
+
JSON.stringify(params.dataContext ?? null)
|
|
33
|
+
|
|
34
|
+
if (contextChanged) {
|
|
35
|
+
// Assign before notifying any item store: components resolve data
|
|
36
|
+
// connectors against `weaverse.dataContext` at render time, so the
|
|
37
|
+
// fresh context must be in place when re-renders flush.
|
|
38
|
+
weaverse.dataContext = params.dataContext ?? null
|
|
39
|
+
}
|
|
40
|
+
if (dataChanged) {
|
|
41
|
+
// State-preserving: existing item stores receive `setData` (and emit),
|
|
42
|
+
// missing ones are created — no full instance teardown.
|
|
43
|
+
weaverse.setProjectData(params.data)
|
|
44
|
+
} else if (contextChanged) {
|
|
45
|
+
// Context-only change: memoized item components subscribe to their item
|
|
46
|
+
// store snapshot, and snapshot identity follows the `_store` reference —
|
|
47
|
+
// a bare `triggerUpdate()` would notify with an identical snapshot and
|
|
48
|
+
// `useSyncExternalStore` would skip the re-render. `setData({})` swaps
|
|
49
|
+
// the store reference so connectors re-resolve against the new context.
|
|
50
|
+
for (const { id } of params.data?.items || []) {
|
|
51
|
+
weaverse.itemInstances.get(id)?.setData({})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (dataChanged || contextChanged) {
|
|
55
|
+
weaverse.triggerUpdate()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ThemeTextStore, TranslationStore } from './translation-store'
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal mutable store for design-mode static text overrides.
|
|
3
|
+
*
|
|
4
|
+
* useSyncExternalStore-compatible. Passed into TranslationProvider which
|
|
5
|
+
* subscribes internally and merges overrides into merchantOverrides.
|
|
6
|
+
* The studio bridge calls updateOverrides() via weaverse.internal.translationStore
|
|
7
|
+
* (also exposed as the deprecated weaverse.internal.themeTextStore alias).
|
|
8
|
+
*
|
|
9
|
+
* Shape: flat Record<string, string> — dot-notation keys map to values
|
|
10
|
+
* (e.g. "cart.title" → "Shopping Cart").
|
|
11
|
+
*/
|
|
12
|
+
export class TranslationStore {
|
|
13
|
+
/** Current flat translation overrides keyed by dot-path. */
|
|
14
|
+
overrides: Record<string, string> = {}
|
|
15
|
+
private listeners: Set<() => void> = new Set()
|
|
16
|
+
|
|
17
|
+
/** Replaces all overrides and notifies subscribers. */
|
|
18
|
+
setOverrides = (overrides: Record<string, string>) => {
|
|
19
|
+
this.overrides = { ...overrides }
|
|
20
|
+
this.emit()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Merges changed overrides and notifies subscribers. */
|
|
24
|
+
updateOverrides = (newOverrides: Record<string, string>) => {
|
|
25
|
+
this.overrides = { ...this.overrides, ...newOverrides }
|
|
26
|
+
this.emit()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Returns the current client snapshot for `useSyncExternalStore`. */
|
|
30
|
+
getSnapshot = () => this.overrides
|
|
31
|
+
|
|
32
|
+
/** Returns the current server snapshot for `useSyncExternalStore`. */
|
|
33
|
+
getServerSnapshot = () => this.overrides
|
|
34
|
+
|
|
35
|
+
/** Subscribes to override changes and returns an unsubscribe callback. */
|
|
36
|
+
subscribe = (callback: () => void) => {
|
|
37
|
+
this.listeners.add(callback)
|
|
38
|
+
return () => {
|
|
39
|
+
this.listeners.delete(callback)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private emit = () => {
|
|
44
|
+
for (const listener of Array.from(this.listeners)) {
|
|
45
|
+
try {
|
|
46
|
+
listener()
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error('TranslationStore: Listener error', error)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Legacy constructor alias retained for existing theme text integrations.
|
|
56
|
+
* @deprecated Use {@link TranslationStore} instead. This alias remains available
|
|
57
|
+
* for backward compatibility.
|
|
58
|
+
*/
|
|
59
|
+
export const ThemeTextStore = TranslationStore
|
|
60
|
+
/**
|
|
61
|
+
* Legacy type alias retained for existing theme text integrations.
|
|
62
|
+
* @deprecated Use {@link TranslationStore} instead. This alias remains available
|
|
63
|
+
* for backward compatibility.
|
|
64
|
+
*/
|
|
65
|
+
export type ThemeTextStore = TranslationStore
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { loadScript } from '@weaverse/react'
|
|
2
|
+
import { useEffect } from 'react'
|
|
3
|
+
import {
|
|
4
|
+
useLocation,
|
|
5
|
+
useNavigate,
|
|
6
|
+
useNavigation,
|
|
7
|
+
useRevalidator,
|
|
8
|
+
} from 'react-router'
|
|
9
|
+
import { useTranslation } from '../hooks/translation-context'
|
|
10
|
+
import type { WeaverseHydrogen } from '../index'
|
|
11
|
+
import { hasWeaverseStudio } from '../types'
|
|
12
|
+
import {
|
|
13
|
+
observeNavigation,
|
|
14
|
+
registerPixelInstance,
|
|
15
|
+
shouldFirePixel,
|
|
16
|
+
} from './pixel'
|
|
17
|
+
import { getStudioScriptSrc, resolveStudioScriptSrc } from './studio-script-src'
|
|
18
|
+
import { useThemeSettingsStore } from './use-theme-settings-store'
|
|
19
|
+
|
|
20
|
+
const STUDIO_READY_POLL_MS = 50
|
|
21
|
+
const STUDIO_READY_TIMEOUT_MS = 5000
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve once `window.weaverseStudio` is available, or `null` on timeout.
|
|
25
|
+
* The studio script sets the global when it *executes*; `loadScript` can
|
|
26
|
+
* resolve earlier for tags it did not create (e.g. SSR-injected markup),
|
|
27
|
+
* so never assume readiness right after the script promise settles.
|
|
28
|
+
*/
|
|
29
|
+
function waitForStudio(): Promise<Window['weaverseStudio'] | null> {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
let startedAt = Date.now()
|
|
32
|
+
function check() {
|
|
33
|
+
if (hasWeaverseStudio(window)) {
|
|
34
|
+
return resolve(window.weaverseStudio)
|
|
35
|
+
}
|
|
36
|
+
if (Date.now() - startedAt >= STUDIO_READY_TIMEOUT_MS) {
|
|
37
|
+
console.warn('[Weaverse] Studio script did not initialize in time')
|
|
38
|
+
return resolve(null)
|
|
39
|
+
}
|
|
40
|
+
setTimeout(check, STUDIO_READY_POLL_MS)
|
|
41
|
+
}
|
|
42
|
+
check()
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Load the Studio bridge script on every design/preview render so Studio's
|
|
48
|
+
* `checkWeaversePage()` handshake is answered instead of timing out — crucially
|
|
49
|
+
* on routes where the page-scoped {@link useStudio} never mounts: 404s, error
|
|
50
|
+
* boundaries, and non-Weaverse routes. The bridge registers its RPC endpoint
|
|
51
|
+
* and `window.weaverseStudio` the moment the script executes, independent of any
|
|
52
|
+
* page binding. Without this, a content-less route never loads the script and
|
|
53
|
+
* Studio reports a false "Connection lost" instead of "this page has no Weaverse
|
|
54
|
+
* content set up".
|
|
55
|
+
*
|
|
56
|
+
* `loadScript` is idempotent, so on content routes this coexists with the
|
|
57
|
+
* page-scoped {@link useStudio} (same src) — that hook still binds the page via
|
|
58
|
+
* `init`. Gating this on whether a route "has Weaverse data" is deliberately
|
|
59
|
+
* avoided: route matches can't tell whether the page-scoped bridge actually
|
|
60
|
+
* mounts (an `ErrorBoundary` replacing a data route, or data passed via the
|
|
61
|
+
* `data` prop), and suppressing the script there would reintroduce the timeout
|
|
62
|
+
* this fixes. Loading the script is harmless; it only registers the responder.
|
|
63
|
+
*
|
|
64
|
+
* Known limitation: on a route whose `weaverseData` is a deferred Promise, the
|
|
65
|
+
* script can load before the page streams in, so `checkWeaversePage()` may
|
|
66
|
+
* briefly observe `NOT_WEAVERSE_PAGE`. The complete fix is a "loading" state in
|
|
67
|
+
* `checkWeaversePage` so the handshake retries rather than latching — that lives
|
|
68
|
+
* in the Studio script, not here. Tracked in Weaverse/builder#2515.
|
|
69
|
+
*
|
|
70
|
+
* Mounted via {@link withWeaverse}, which must be the root route's `Layout`
|
|
71
|
+
* export so the bridge renders even when a route's `ErrorBoundary` replaces the
|
|
72
|
+
* page. Outside Studio (no design/preview params) it is a no-op.
|
|
73
|
+
*/
|
|
74
|
+
export function useStudioConnect() {
|
|
75
|
+
let { search } = useLocation()
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
// Loopback Studio hosts are only honored when the storefront itself is
|
|
78
|
+
// loopback (see resolveStudioScriptSrc), so pass the document's hostname.
|
|
79
|
+
let hostname = typeof window === 'undefined' ? '' : window.location.hostname
|
|
80
|
+
let src = resolveStudioScriptSrc(search, hostname)
|
|
81
|
+
if (src) {
|
|
82
|
+
loadScript(src).catch(console.error)
|
|
83
|
+
}
|
|
84
|
+
}, [search])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function useStudio(weaverse: WeaverseHydrogen) {
|
|
88
|
+
let { revalidate } = useRevalidator()
|
|
89
|
+
let navigation = useNavigation()
|
|
90
|
+
let { pathname, search } = useLocation()
|
|
91
|
+
let navigate = useNavigate()
|
|
92
|
+
let themeSettingsStore = useThemeSettingsStore()
|
|
93
|
+
let { translationStore, themeTextStore, merchantOverrides } = useTranslation()
|
|
94
|
+
let {
|
|
95
|
+
isDesignMode,
|
|
96
|
+
weaverseHost,
|
|
97
|
+
weaverseVersion,
|
|
98
|
+
isPreviewMode,
|
|
99
|
+
isRevisionPreview,
|
|
100
|
+
} = weaverse
|
|
101
|
+
|
|
102
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: only revalidate on pathname/search changes
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (navigation.state !== 'idle') {
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
let src = getStudioScriptSrc({
|
|
108
|
+
isDesignMode,
|
|
109
|
+
isPreviewMode,
|
|
110
|
+
isRevisionPreview,
|
|
111
|
+
weaverseHost,
|
|
112
|
+
weaverseVersion,
|
|
113
|
+
})
|
|
114
|
+
if (!src) {
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
if (isRevisionPreview || isPreviewMode) {
|
|
118
|
+
loadScript(src).catch(console.error)
|
|
119
|
+
} else {
|
|
120
|
+
weaverse.internal = {
|
|
121
|
+
...weaverse.internal,
|
|
122
|
+
navigate,
|
|
123
|
+
revalidate,
|
|
124
|
+
merchantOverrides,
|
|
125
|
+
themeSettingsStore,
|
|
126
|
+
translationStore,
|
|
127
|
+
// Deprecated alias for existing Studio bridge integrations.
|
|
128
|
+
themeTextStore,
|
|
129
|
+
}
|
|
130
|
+
loadScript(src)
|
|
131
|
+
.then(waitForStudio)
|
|
132
|
+
.then((studio) => studio?.init(weaverse))
|
|
133
|
+
.catch(console.error)
|
|
134
|
+
}
|
|
135
|
+
}, [pathname, search, navigation.state])
|
|
136
|
+
usePixel(weaverse)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function usePixel(context: WeaverseHydrogen) {
|
|
140
|
+
let { projectId, pageId, isDesignMode, weaverseHost } = context
|
|
141
|
+
let { key: navigationKey } = useLocation()
|
|
142
|
+
// Observe every navigation so a persistent layout-level instance forgets
|
|
143
|
+
// the previous navigation's fired pages on a real key change — but NOT on
|
|
144
|
+
// an in-place remount that keeps the same history entry (e.g. the
|
|
145
|
+
// `data={null}` suppress/restore path), which must not double-count.
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (!(projectId && pageId) || isDesignMode) {
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
observeNavigation(navigationKey)
|
|
151
|
+
}, [navigationKey, projectId, pageId, isDesignMode])
|
|
152
|
+
|
|
153
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: fire once on mount
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (!(projectId && pageId) || isDesignMode) {
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
// Register BEFORE deciding to fire: the navigation state must live at
|
|
159
|
+
// least as long as some Weaverse instance is mounted (see pixel.ts).
|
|
160
|
+
let unregister = registerPixelInstance()
|
|
161
|
+
if (shouldFirePixel(navigationKey, pageId)) {
|
|
162
|
+
let img = new Image()
|
|
163
|
+
img.onload = () => img.remove()
|
|
164
|
+
img.src = `${weaverseHost}/api/public/px?projectId=${projectId}&pageId=${pageId}`
|
|
165
|
+
}
|
|
166
|
+
return unregister
|
|
167
|
+
}, [])
|
|
168
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { isBrowser } from '@weaverse/react'
|
|
2
|
+
import { createContext, useContext, useEffect, useRef } from 'react'
|
|
3
|
+
import { useRouteLoaderData } from 'react-router'
|
|
4
|
+
import type {
|
|
5
|
+
HydrogenThemeSchema,
|
|
6
|
+
HydrogenThemeSettings,
|
|
7
|
+
PublicEnv,
|
|
8
|
+
} from '../types'
|
|
9
|
+
|
|
10
|
+
/** Root-loader theme data used to initialize a {@link ThemeSettingsStore}. */
|
|
11
|
+
export interface WeaverseThemeData {
|
|
12
|
+
/** Storefront-safe environment values, when available. */
|
|
13
|
+
publicEnv?: PublicEnv
|
|
14
|
+
/** Theme schema exposed to Studio, when available. */
|
|
15
|
+
schema?: HydrogenThemeSchema
|
|
16
|
+
/** Published theme setting values. */
|
|
17
|
+
theme: HydrogenThemeSettings
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Mutable, subscribable store for theme settings received from the root loader. */
|
|
21
|
+
export class ThemeSettingsStore {
|
|
22
|
+
/** Current merged theme setting values. */
|
|
23
|
+
settings: HydrogenThemeSettings = {}
|
|
24
|
+
/** Theme schema exposed to Studio, when available. */
|
|
25
|
+
schema?: HydrogenThemeSchema
|
|
26
|
+
/** Storefront-safe environment values, when available. */
|
|
27
|
+
publicEnv?: PublicEnv
|
|
28
|
+
private listeners: Set<() => void> = new Set()
|
|
29
|
+
private hasWarnedListenerCount = false
|
|
30
|
+
private isDestroyed = false
|
|
31
|
+
|
|
32
|
+
/** Creates a theme settings store from root-loader theme data. */
|
|
33
|
+
constructor(data: WeaverseThemeData) {
|
|
34
|
+
const { theme, schema, publicEnv } = data || {}
|
|
35
|
+
this.settings = { ...theme }
|
|
36
|
+
this.schema = schema
|
|
37
|
+
this.publicEnv = publicEnv
|
|
38
|
+
if (isBrowser) {
|
|
39
|
+
window.__weaverseThemeSettingsStore = this
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Merges setting changes into the current snapshot and notifies subscribers. */
|
|
44
|
+
updateThemeSettings = (newSettings: HydrogenThemeSettings) => {
|
|
45
|
+
if (this.isDestroyed) {
|
|
46
|
+
console.warn('ThemeSettingsStore: Cannot update destroyed store')
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.settings = {
|
|
51
|
+
...this.settings,
|
|
52
|
+
...newSettings,
|
|
53
|
+
}
|
|
54
|
+
this.emit()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Returns the current client snapshot for `useSyncExternalStore`. */
|
|
58
|
+
getSnapshot = () => this.settings
|
|
59
|
+
|
|
60
|
+
/** Returns the current server snapshot for `useSyncExternalStore`. */
|
|
61
|
+
getServerSnapshot = () => this.settings
|
|
62
|
+
|
|
63
|
+
/** Subscribes to setting changes and returns an unsubscribe callback. */
|
|
64
|
+
subscribe = (callback: () => void) => {
|
|
65
|
+
if (this.isDestroyed) {
|
|
66
|
+
console.warn('ThemeSettingsStore: Cannot subscribe to destroyed store')
|
|
67
|
+
return () => {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.listeners.add(callback)
|
|
71
|
+
|
|
72
|
+
if (!this.hasWarnedListenerCount && this.listeners.size > 500) {
|
|
73
|
+
this.hasWarnedListenerCount = true
|
|
74
|
+
console.warn(
|
|
75
|
+
`ThemeSettingsStore: ${this.listeners.size} listeners detected. ` +
|
|
76
|
+
'This may indicate a performance issue. Consider using fewer useThemeSettings() calls.'
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return () => {
|
|
81
|
+
this.listeners.delete(callback)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private emit = () => {
|
|
86
|
+
if (this.isDestroyed) {
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const currentListeners = Array.from(this.listeners)
|
|
91
|
+
|
|
92
|
+
for (const listener of currentListeners) {
|
|
93
|
+
try {
|
|
94
|
+
listener()
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error('ThemeSettingsStore: Listener error', error)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Clears subscribers and detaches the browser-global store reference. */
|
|
102
|
+
destroy = () => {
|
|
103
|
+
if (this.isDestroyed) {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.listeners.clear()
|
|
108
|
+
this.isDestroyed = true
|
|
109
|
+
|
|
110
|
+
if (isBrowser && window.__weaverseThemeSettingsStore === this) {
|
|
111
|
+
window.__weaverseThemeSettingsStore = undefined
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const ThemeSettingsStoreContext =
|
|
117
|
+
createContext<ThemeSettingsStore | null>(null)
|
|
118
|
+
ThemeSettingsStoreContext.displayName = 'ThemeSettingsStoreContext'
|
|
119
|
+
|
|
120
|
+
export function useCreateThemeSettingsStore(): ThemeSettingsStore {
|
|
121
|
+
const data = useRouteLoaderData('root') as {
|
|
122
|
+
weaverseTheme: WeaverseThemeData
|
|
123
|
+
}
|
|
124
|
+
const storeRef = useRef<ThemeSettingsStore | null>(null)
|
|
125
|
+
|
|
126
|
+
if (!storeRef.current) {
|
|
127
|
+
storeRef.current = new ThemeSettingsStore(data?.weaverseTheme)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const theme = data?.weaverseTheme?.theme
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (storeRef.current && theme) {
|
|
133
|
+
storeRef.current.updateThemeSettings(theme)
|
|
134
|
+
}
|
|
135
|
+
}, [theme])
|
|
136
|
+
|
|
137
|
+
return storeRef.current
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function useThemeSettingsStore(): ThemeSettingsStore {
|
|
141
|
+
const fromContext = useContext(ThemeSettingsStoreContext)
|
|
142
|
+
const data = useRouteLoaderData('root') as {
|
|
143
|
+
weaverseTheme: WeaverseThemeData
|
|
144
|
+
}
|
|
145
|
+
const fallbackRef = useRef<ThemeSettingsStore | null>(null)
|
|
146
|
+
|
|
147
|
+
if (!(fallbackRef.current || fromContext)) {
|
|
148
|
+
fallbackRef.current = new ThemeSettingsStore(data?.weaverseTheme)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const store = fromContext ?? fallbackRef.current!
|
|
152
|
+
const theme = data?.weaverseTheme?.theme
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (!fromContext && theme) {
|
|
155
|
+
store.updateThemeSettings(theme)
|
|
156
|
+
}
|
|
157
|
+
}, [fromContext, store, theme])
|
|
158
|
+
|
|
159
|
+
return store
|
|
160
|
+
}
|