@weaverse/next 0.1.0-alpha.12 → 0.1.0-alpha.14

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 (106) hide show
  1. package/README.md +125 -23
  2. package/dist/{chunk-MQ6N5ODA.mjs → chunk-NL22OVD7.mjs} +93 -4
  3. package/dist/chunk-NL22OVD7.mjs.map +1 -0
  4. package/dist/index.js +98 -4
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +32 -26
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/server.js +158 -2
  9. package/dist/server.js.map +1 -1
  10. package/dist/server.mjs +126 -5
  11. package/dist/server.mjs.map +1 -1
  12. package/dist/types/client.d.ts +16 -0
  13. package/dist/types/client.d.ts.map +1 -0
  14. package/dist/types/hooks.d.ts +20 -0
  15. package/dist/types/hooks.d.ts.map +1 -0
  16. package/dist/types/index.d.ts +34 -0
  17. package/dist/types/index.d.ts.map +1 -0
  18. package/dist/types/item.d.ts +54 -0
  19. package/dist/types/item.d.ts.map +1 -0
  20. package/dist/types/loader.d.ts +25 -0
  21. package/dist/types/loader.d.ts.map +1 -0
  22. package/dist/types/main.d.ts +11 -0
  23. package/dist/types/main.d.ts.map +1 -0
  24. package/dist/types/provider.d.ts +82 -0
  25. package/dist/types/provider.d.ts.map +1 -0
  26. package/dist/types/registry.d.ts +15 -0
  27. package/dist/types/registry.d.ts.map +1 -0
  28. package/dist/types/renderer.d.ts +20 -0
  29. package/dist/types/renderer.d.ts.map +1 -0
  30. package/dist/types/request-info.d.ts +8 -0
  31. package/dist/types/request-info.d.ts.map +1 -0
  32. package/dist/types/revalidate-item.d.ts +81 -0
  33. package/dist/types/revalidate-item.d.ts.map +1 -0
  34. package/dist/types/root-provider.d.ts +61 -0
  35. package/dist/types/root-provider.d.ts.map +1 -0
  36. package/dist/types/runtime.d.ts +113 -0
  37. package/dist/types/runtime.d.ts.map +1 -0
  38. package/dist/types/seo.d.ts +55 -0
  39. package/dist/types/seo.d.ts.map +1 -0
  40. package/dist/types/server/configs.d.ts +27 -0
  41. package/dist/types/server/configs.d.ts.map +1 -0
  42. package/dist/types/server/normalize-page-url.d.ts +19 -0
  43. package/dist/types/server/normalize-page-url.d.ts.map +1 -0
  44. package/dist/types/server/revalidate-handler.d.ts +55 -0
  45. package/dist/types/server/revalidate-handler.d.ts.map +1 -0
  46. package/dist/types/server/server-client.d.ts +21 -0
  47. package/dist/types/server/server-client.d.ts.map +1 -0
  48. package/dist/types/server.d.ts +19 -0
  49. package/dist/types/server.d.ts.map +1 -0
  50. package/dist/types/studio-bridge.d.ts +16 -0
  51. package/dist/types/studio-bridge.d.ts.map +1 -0
  52. package/dist/types/studio-connect.d.ts +27 -0
  53. package/dist/types/studio-connect.d.ts.map +1 -0
  54. package/dist/types/studio-router.d.ts +50 -0
  55. package/dist/types/studio-router.d.ts.map +1 -0
  56. package/dist/types/studio-script-src.d.ts +12 -0
  57. package/dist/types/studio-script-src.d.ts.map +1 -0
  58. package/dist/types/theme-settings-store.d.ts +17 -0
  59. package/dist/types/theme-settings-store.d.ts.map +1 -0
  60. package/dist/types/translation-context.d.ts +156 -0
  61. package/dist/types/translation-context.d.ts.map +1 -0
  62. package/dist/types/translation-store.d.ts +44 -0
  63. package/dist/types/translation-store.d.ts.map +1 -0
  64. package/dist/types/types.d.ts +661 -0
  65. package/dist/types/types.d.ts.map +1 -0
  66. package/dist/types/use-weaverse-next-studio.d.ts +34 -0
  67. package/dist/types/use-weaverse-next-studio.d.ts.map +1 -0
  68. package/dist/types/utils.d.ts +12 -0
  69. package/dist/types/utils.d.ts.map +1 -0
  70. package/package.json +7 -6
  71. package/src/client.ts +101 -0
  72. package/src/hooks.ts +50 -0
  73. package/src/index.ts +153 -0
  74. package/src/item.ts +151 -0
  75. package/src/loader.ts +135 -0
  76. package/src/main.tsx +45 -0
  77. package/src/provider.tsx +225 -0
  78. package/src/registry.ts +63 -0
  79. package/src/renderer.tsx +99 -0
  80. package/src/request-info.ts +95 -0
  81. package/src/revalidate-item.ts +210 -0
  82. package/src/root-provider.tsx +181 -0
  83. package/src/runtime.ts +521 -0
  84. package/src/seo.ts +157 -0
  85. package/src/server/configs.ts +182 -0
  86. package/src/server/normalize-page-url.ts +65 -0
  87. package/src/server/revalidate-handler.ts +357 -0
  88. package/src/server/server-client.ts +727 -0
  89. package/src/server.ts +67 -0
  90. package/src/studio-bridge.tsx +82 -0
  91. package/src/studio-connect.tsx +88 -0
  92. package/src/studio-router.ts +85 -0
  93. package/src/studio-script-src.ts +125 -0
  94. package/src/theme-settings-store.ts +46 -0
  95. package/src/translation-context.tsx +299 -0
  96. package/src/translation-store.ts +69 -0
  97. package/src/types.ts +748 -0
  98. package/src/use-weaverse-next-studio.tsx +106 -0
  99. package/src/utils.ts +37 -0
  100. package/dist/chunk-MQ6N5ODA.mjs.map +0 -1
  101. package/dist/index.d.mts +0 -592
  102. package/dist/index.d.ts +0 -592
  103. package/dist/seo-CrEFl_Up.d.mts +0 -516
  104. package/dist/seo-CrEFl_Up.d.ts +0 -516
  105. package/dist/server.d.mts +0 -100
  106. package/dist/server.d.ts +0 -100
package/src/runtime.ts ADDED
@@ -0,0 +1,521 @@
1
+ import { Weaverse } from '@weaverse/react'
2
+ import {
3
+ collectDeferredItemUpdates,
4
+ ensureNextItemConstructor,
5
+ type WeaverseNextItem,
6
+ } from './item'
7
+ import { buildWeaverseNextRequestInfo } from './request-info'
8
+ import { createWeaverseNextThemeSettingsStore } from './theme-settings-store'
9
+ import { TranslationStore } from './translation-store'
10
+ import type {
11
+ WeaverseNextClient,
12
+ WeaverseNextLoaderData,
13
+ WeaverseNextPageData,
14
+ WeaverseNextRequestInfo,
15
+ WeaverseNextRuntimeConfig,
16
+ WeaverseNextRuntimeInternal,
17
+ WeaverseNextTranslationChanges,
18
+ WeaverseNextTranslationEntry,
19
+ WeaverseNextTranslationMap,
20
+ } from './types'
21
+
22
+ declare global {
23
+ interface Window {
24
+ __weaverse?: WeaverseNextRuntime
25
+ __weaverses?: Record<string, WeaverseNextRuntime>
26
+ }
27
+ }
28
+
29
+ type StudioBoundRuntime = WeaverseNextRuntime & {
30
+ __weaverseNextStudioBound?: boolean
31
+ __weaverseNextRequestKey?: string
32
+ __weaverseNextLatestData?: WeaverseNextRenderablePage
33
+ }
34
+
35
+ function getRuntimeWindow(): Window | undefined {
36
+ return typeof window === 'undefined' ? undefined : window
37
+ }
38
+
39
+ function getRenderablePage(
40
+ data: WeaverseNextLoaderData
41
+ ): WeaverseNextRenderablePage {
42
+ let page = data.page
43
+ let rootId =
44
+ page.rootId ??
45
+ page.items.find((item) => item.type === 'main')?.id ??
46
+ page.items[0]?.id ??
47
+ page.id
48
+ return { ...page, rootId }
49
+ }
50
+
51
+ function getConfigString(
52
+ configs: Record<string, unknown> | undefined,
53
+ key: string
54
+ ): string | undefined {
55
+ let value = configs?.[key]
56
+ return typeof value === 'string' ? value : undefined
57
+ }
58
+
59
+ function getRecordString(
60
+ record: Record<string, unknown> | undefined,
61
+ key: string
62
+ ): string | undefined {
63
+ let value = record?.[key]
64
+ return typeof value === 'string' ? value : undefined
65
+ }
66
+
67
+ function resolveProjectId(
68
+ client: WeaverseNextClient | undefined,
69
+ data: WeaverseNextLoaderData,
70
+ fallback: string
71
+ ): string {
72
+ let projectId = client?.projectId
73
+ if (projectId) {
74
+ return projectId
75
+ }
76
+
77
+ let configProjectId = getConfigString(data.configs, 'projectId')
78
+ if (configProjectId) {
79
+ return configProjectId
80
+ }
81
+
82
+ return getRecordString(data.project, 'id') ?? fallback
83
+ }
84
+
85
+ function resolveDataContext(config: WeaverseNextRuntimeConfig) {
86
+ if (config.dataContext) {
87
+ return config.dataContext
88
+ }
89
+ if (config.client?.dataContext) {
90
+ return config.client.dataContext
91
+ }
92
+ return {}
93
+ }
94
+
95
+ function getRuntimeKey(pageId: string, requestInfo: WeaverseNextRequestInfo) {
96
+ return `${pageId}:${requestInfo.pathname}:${requestInfo.search}`
97
+ }
98
+
99
+ function registerRuntime(runtime: WeaverseNextRuntime) {
100
+ let runtimeWindow = getRuntimeWindow()
101
+ if (!runtimeWindow) {
102
+ return
103
+ }
104
+
105
+ runtimeWindow.__weaverses ??= {}
106
+ runtimeWindow.__weaverses[runtime.pageId] = runtime
107
+ runtimeWindow.__weaverse = runtime
108
+ }
109
+
110
+ /**
111
+ * Lifecycle invariant: every item store rendered by a runtime must point back
112
+ * at that same runtime (`item.weaverse === runtime`).
113
+ *
114
+ * Core keeps `Weaverse.itemInstances` process-wide and keyed by item id only,
115
+ * so a client navigation that recreates the runtime under a new request key
116
+ * (e.g. `/` → `/fr-fr`) reuses the existing stores and merely calls
117
+ * `setData()` on them — their `weaverse` back-reference is never updated and
118
+ * keeps pointing at the previous runtime. Anything an item reads through that
119
+ * reference then stays stale: `WeaverseNextItem.getSnapShot()` reads
120
+ * `this.weaverse.translationMap` (locale sidecar), and `Element` /
121
+ * `requestInfo` consumers read the old locale's runtime until a full reload
122
+ * builds clean instances.
123
+ *
124
+ * Rebind only the items belonging to the freshly rendered page — other
125
+ * runtimes co-located on the same document own their own item ids and must
126
+ * keep their bindings.
127
+ */
128
+ function rebindPageItemsToRuntime(runtime: WeaverseNextRuntime) {
129
+ let items = (runtime.data as WeaverseNextPageData | undefined)?.items
130
+ if (!items) {
131
+ return
132
+ }
133
+ for (let { id } of items) {
134
+ let instance = runtime.itemInstances.get(id) as WeaverseNextItem | undefined
135
+ if (instance && instance.weaverse !== runtime) {
136
+ instance.weaverse = runtime
137
+ }
138
+ }
139
+ }
140
+
141
+ /** Weaverse page data with the root component resolved for rendering. */
142
+ export interface WeaverseNextRenderablePage extends WeaverseNextPageData {
143
+ /** Component item ID used as the root of the rendered page tree. */
144
+ rootId: string
145
+ }
146
+
147
+ /**
148
+ * Browser runtime that adapts a Next.js loader payload to `@weaverse/react`
149
+ * and exposes the navigation, settings, and translation state used by Studio.
150
+ */
151
+ export class WeaverseNextRuntime extends Weaverse {
152
+ /** Stable ID of the page owned by this runtime. */
153
+ pageId: string
154
+ /** Mutable callbacks and stores consumed by the Studio bridge. */
155
+ internal: WeaverseNextRuntimeInternal
156
+ /** Path, query, and locale metadata for Studio navigation. */
157
+ requestInfo: WeaverseNextRequestInfo
158
+ /** Whether this runtime renders a component preview. */
159
+ isPreviewMode: boolean
160
+ /** Whether this runtime renders a saved revision. */
161
+ isRevisionPreview: boolean
162
+ /** Component type rendered in section preview mode. */
163
+ sectionType?: string
164
+
165
+ // ─── Item-level translation sidecar (design mode only) ─────────────
166
+ /**
167
+ * Translation map received from Builder in design mode, keyed by item ID.
168
+ * `WeaverseNextItem.getSnapShot()` overlays these onto the data handed to
169
+ * components, so translations render without mutating the base `_store`.
170
+ */
171
+ translationMap: WeaverseNextTranslationMap = {}
172
+ /** Locale associated with the item-level translation sidecar. */
173
+ translationLocale = String()
174
+ /** Weaverse language ID used when saving translation changes. */
175
+ translationLanguageId = String()
176
+
177
+ /**
178
+ * Fresh page data captured while React was rendering. Runtime reuse happens
179
+ * inside the renderer's render phase (`useMemo`), where applying it through
180
+ * `setProjectData()` would emit item updates to `useSyncExternalStore`
181
+ * subscribers mid-render. The renderer applies it post-commit via
182
+ * `flushRenderPhaseUpdates()`. Never set on design-mode runtimes — the live
183
+ * Studio tree owns the page data there.
184
+ */
185
+ pendingProjectData?: WeaverseNextRenderablePage
186
+
187
+ /**
188
+ * Reused item instances whose data was refreshed while this runtime was
189
+ * constructed during render (core `initProject()` calls `setData()` on
190
+ * them). Their data is already fresh; only the subscriber notification was
191
+ * deferred to `flushRenderPhaseUpdates()`.
192
+ */
193
+ pendingItemUpdates: WeaverseNextItem[] = []
194
+
195
+ /** Create a browser runtime from a loaded page and its request context. */
196
+ constructor(config: WeaverseNextRuntimeConfig) {
197
+ let { client, data } = config
198
+ let page = getRenderablePage(data)
199
+ let configs = data.configs
200
+ let requestContext = client?.requestContext
201
+ let projectId = resolveProjectId(client, data, page.id)
202
+
203
+ ensureNextItemConstructor()
204
+ super({
205
+ projectId,
206
+ data: page,
207
+ isDesignMode: requestContext?.isDesignMode ?? false,
208
+ weaverseHost: getConfigString(configs, 'weaverseHost'),
209
+ weaverseVersion: getConfigString(configs, 'weaverseVersion'),
210
+ })
211
+
212
+ this.pageId = page.id
213
+ this.dataContext = resolveDataContext(config)
214
+ this.requestInfo = buildWeaverseNextRequestInfo(requestContext)
215
+ this.isDesignMode = requestContext?.isDesignMode ?? false
216
+ this.isPreviewMode = requestContext?.isPreviewMode ?? false
217
+ this.isRevisionPreview = requestContext?.isRevisionPreview ?? false
218
+ this.sectionType = requestContext?.sectionType
219
+
220
+ // One store instance backs both the canonical `translationStore` and the
221
+ // deprecated `themeTextStore` alias, so Builder's `updateStaticText()` RPC
222
+ // (which reads `internal.themeTextStore`) mutates the same store the
223
+ // `TranslationProvider` subscribes to.
224
+ let translationStore = config.translationStore ?? new TranslationStore()
225
+
226
+ this.internal = {
227
+ merchantOverrides: config.merchantOverrides,
228
+ navigate: config.navigate,
229
+ pageAssignment: data.pageAssignment,
230
+ project: data.project,
231
+ revalidate: config.revalidate,
232
+ themeSettingsStore:
233
+ config.themeSettingsStore ??
234
+ createWeaverseNextThemeSettingsStore({
235
+ schema: client?.themeSchema,
236
+ settings: client?.themeSettings,
237
+ }),
238
+ translationStore,
239
+ themeTextStore: translationStore,
240
+ }
241
+
242
+ // Read any item-level translation sidecar the loader attached to the page
243
+ // data. Runs after `super()` has built `this.data` and the item stores.
244
+ this.extractTranslationSidecar()
245
+ }
246
+
247
+ /**
248
+ * Pull `translationMap` / `translationLocale` / `translationLanguageId` off
249
+ * the current page data (Builder attaches them in design mode). Unlike
250
+ * Hydrogen, this also clears the sidecar to defaults when the new page data
251
+ * carries none, so a reused runtime / project-data swap can't leave a stale
252
+ * map overlaying the fresh tree.
253
+ */
254
+ extractTranslationSidecar = () => {
255
+ let pageData = this.data as WeaverseNextPageData | undefined
256
+ let translationMap = pageData?.translationMap as
257
+ | WeaverseNextTranslationMap
258
+ | undefined
259
+ if (translationMap) {
260
+ this.translationMap = translationMap
261
+ this.translationLocale = (pageData?.translationLocale as string) || ''
262
+ this.translationLanguageId =
263
+ (pageData?.translationLanguageId as string) || ''
264
+ } else {
265
+ this.translationMap = {}
266
+ this.translationLocale = ''
267
+ this.translationLanguageId = ''
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Replace the translation sidecar (Builder pushes this when translation data
273
+ * arrives or the active language changes) and refresh every item so their
274
+ * merged snapshots recompute.
275
+ */
276
+ setTranslationSidecar = (
277
+ map: WeaverseNextTranslationMap = {},
278
+ locale = '',
279
+ languageId = ''
280
+ ) => {
281
+ this.translationMap = map
282
+ this.translationLocale = locale
283
+ this.translationLanguageId = languageId
284
+ this.refreshAllItems()
285
+ }
286
+
287
+ /**
288
+ * Update a single translatable field for one item. Assigns a new per-item
289
+ * entry object so `WeaverseNextItem.getSnapShot()`'s translation-ref cache
290
+ * invalidates, then triggers only that item's subscribers.
291
+ */
292
+ updateTranslation = (
293
+ itemId: string,
294
+ key: string,
295
+ originalValue: string,
296
+ translatedValue: string
297
+ ) => {
298
+ if (!this.translationMap[itemId]) {
299
+ this.translationMap[itemId] = {}
300
+ }
301
+ // New entry reference so the item's snapshot cache detects the change.
302
+ this.translationMap[itemId] = {
303
+ ...this.translationMap[itemId],
304
+ [key]: { originalValue, translatedValue },
305
+ }
306
+ let instance = this.itemInstances.get(itemId)
307
+ if (instance) {
308
+ instance.triggerUpdate()
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Collect translation changes for the save flow. Returns `undefined` unless
314
+ * both the locale and language ID are set and at least one entry exists;
315
+ * each entry key is namespaced as `data.<field>` to match Builder.
316
+ */
317
+ getTranslationChanges = (): WeaverseNextTranslationChanges | undefined => {
318
+ let languageId = this.translationLanguageId
319
+ if (!(this.translationLocale && languageId)) {
320
+ return
321
+ }
322
+
323
+ let entries: WeaverseNextTranslationEntry[] = []
324
+ for (let [itemId, fields] of Object.entries(this.translationMap)) {
325
+ for (let [key, entry] of Object.entries(fields)) {
326
+ entries.push({
327
+ itemId,
328
+ key: `data.${key}`,
329
+ originalValue: entry.originalValue,
330
+ translatedValue: entry.translatedValue,
331
+ })
332
+ }
333
+ }
334
+
335
+ if (entries.length === 0) {
336
+ return
337
+ }
338
+ return { languageId, entries }
339
+ }
340
+
341
+ /**
342
+ * Re-extract the translation sidecar whenever project data is replaced (route
343
+ * change / non-design reuse), before rebuilding item stores. Takes the
344
+ * renderable page shape (`rootId` resolved) that `getRenderablePage()` and
345
+ * the reuse branch pass in.
346
+ */
347
+ setProjectData = (data: WeaverseNextRenderablePage) => {
348
+ this.data = data
349
+ this.extractTranslationSidecar()
350
+ this.initProject()
351
+ }
352
+
353
+ /**
354
+ * Apply updates deferred off the render phase: swap in any pending page data
355
+ * captured on runtime reuse, then notify items whose data was refreshed
356
+ * during construction. The renderer calls this from a layout effect right
357
+ * after commit, so subscribers re-render before paint (no content flash) and
358
+ * without React's setState-in-render warning.
359
+ */
360
+ flushRenderPhaseUpdates = () => {
361
+ let pendingData = this.pendingProjectData
362
+ if (pendingData) {
363
+ this.pendingProjectData = undefined
364
+ this.setProjectData(pendingData)
365
+ }
366
+
367
+ let pendingItems = this.pendingItemUpdates
368
+ if (pendingItems.length > 0) {
369
+ this.pendingItemUpdates = []
370
+ for (let item of pendingItems) {
371
+ item.triggerUpdate()
372
+ }
373
+ }
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Create a browser runtime or reuse the runtime already registered for the
379
+ * same page and request. Reuse preserves unsaved Studio state in design mode.
380
+ */
381
+ export function createWeaverseNextRuntime(
382
+ config: WeaverseNextRuntimeConfig
383
+ ): WeaverseNextRuntime {
384
+ let requestInfo = buildWeaverseNextRequestInfo(config.client?.requestContext)
385
+ let page = getRenderablePage(config.data)
386
+ let runtimeWindow = getRuntimeWindow()
387
+ let existing = runtimeWindow?.__weaverses?.[page.id] as
388
+ | StudioBoundRuntime
389
+ | undefined
390
+ let requestKey = getRuntimeKey(page.id, requestInfo)
391
+
392
+ if (existing?.__weaverseNextRequestKey === requestKey) {
393
+ let nextIsDesignMode = config.client?.requestContext?.isDesignMode ?? false
394
+ // In design mode the live Studio runtime owns the page tree, including
395
+ // unsaved drafts. Reapplying loader `page` data here would clobber those
396
+ // edits, so leave the project data untouched and let
397
+ // `bindWeaverseNextStudioRuntime` push the latest state via `refreshStudio`.
398
+ // Published / preview reuse has no draft state, so fresh data still wins —
399
+ // but this factory runs during React render (the renderer's `useMemo`),
400
+ // and `setProjectData()` → `initProject()` → `setData()` would emit item
401
+ // updates to `useSyncExternalStore` subscribers mid-render (React's
402
+ // setState-in-render warning). Stash the page instead; the renderer
403
+ // applies it post-commit via `flushRenderPhaseUpdates()`.
404
+ if (existing.isDesignMode || nextIsDesignMode) {
405
+ existing.pendingProjectData = undefined
406
+ } else {
407
+ existing.pendingProjectData = page
408
+ }
409
+ // Always capture the latest server payload. In design mode the live tree
410
+ // (`existing.data`) is deliberately left untouched above, so it goes stale
411
+ // after an RSC refresh — `bindWeaverseNextStudioRuntime` must report this
412
+ // fresh payload to `refreshStudio`, not the stale tree, or a revalidation
413
+ // (e.g. resource-picker pick) merges back old per-item `loaderData` and the
414
+ // preview keeps rendering the previous resource. Mirrors Hydrogen, which
415
+ // always passes the fresh loader params to `refreshStudio`.
416
+ existing.__weaverseNextLatestData = page
417
+ existing.dataContext = resolveDataContext(config)
418
+ existing.requestInfo = requestInfo
419
+ existing.projectId = resolveProjectId(config.client, config.data, page.id)
420
+ existing.isDesignMode = nextIsDesignMode
421
+ existing.isPreviewMode =
422
+ config.client?.requestContext?.isPreviewMode ?? false
423
+ existing.isRevisionPreview =
424
+ config.client?.requestContext?.isRevisionPreview ?? false
425
+ existing.sectionType = config.client?.requestContext?.sectionType
426
+ existing.weaverseHost =
427
+ getConfigString(config.data.configs, 'weaverseHost') ??
428
+ existing.weaverseHost
429
+ existing.weaverseVersion =
430
+ getConfigString(config.data.configs, 'weaverseVersion') ??
431
+ existing.weaverseVersion
432
+ existing.internal.pageAssignment = config.data.pageAssignment
433
+ existing.internal.project = config.data.project
434
+ if (config.navigate) {
435
+ existing.internal.navigate = config.navigate
436
+ }
437
+ if (config.revalidate) {
438
+ existing.internal.revalidate = config.revalidate
439
+ }
440
+ if (config.themeSettingsStore) {
441
+ existing.internal.themeSettingsStore = config.themeSettingsStore
442
+ }
443
+ if (config.translationStore) {
444
+ existing.internal.translationStore = config.translationStore
445
+ // Keep the deprecated alias pointing at the same adopted instance.
446
+ existing.internal.themeTextStore = config.translationStore
447
+ }
448
+ // Always assign, including `undefined`, so locale/navigation changes can
449
+ // clear previously attached merchant overrides instead of leaking stale
450
+ // static-text values into the next render.
451
+ existing.internal.merchantOverrides = config.merchantOverrides
452
+ registerRuntime(existing)
453
+ return existing
454
+ }
455
+
456
+ // Constructing a fresh runtime also happens during render, and core
457
+ // `initProject()` reuses process-wide item instances holding the same item
458
+ // ids (locale/client navigation recreates the runtime under a new request
459
+ // key) by calling `setData()` on them. Defer those subscriber emits to the
460
+ // renderer's post-commit `flushRenderPhaseUpdates()` as well.
461
+ let [runtime, refreshedItems] = collectDeferredItemUpdates(
462
+ () => new WeaverseNextRuntime(config) as StudioBoundRuntime
463
+ )
464
+ runtime.pendingItemUpdates = refreshedItems
465
+ // Reused stores are still bound to the runtime that built them; rebind before
466
+ // returning so consumers read snapshots through this runtime (see
467
+ // `rebindPageItemsToRuntime`). Rebinding touches no subscriber, so it stays
468
+ // render-phase safe alongside the deferred item updates above.
469
+ rebindPageItemsToRuntime(runtime)
470
+ runtime.__weaverseNextRequestKey = requestKey
471
+ runtime.__weaverseNextLatestData = page
472
+ registerRuntime(runtime)
473
+ return runtime
474
+ }
475
+
476
+ /**
477
+ * Bind a design-mode runtime to the global Builder Studio bridge.
478
+ *
479
+ * @returns `true` when Studio was initialized or refreshed, otherwise `false`.
480
+ */
481
+ export function bindWeaverseNextStudioRuntime(runtime: WeaverseNextRuntime) {
482
+ if (!runtime.isDesignMode) {
483
+ return false
484
+ }
485
+
486
+ let studio = getRuntimeWindow()?.weaverseStudio
487
+ if (!studio) {
488
+ return false
489
+ }
490
+
491
+ let boundRuntime = runtime as StudioBoundRuntime
492
+ // Builder's bridge tracks a single active runtime (`studio.weaverse`). When
493
+ // an already-bound runtime is reused after navigating away and back (Home ->
494
+ // PDP -> Home), the bridge is still on the other URL, and Builder ignores
495
+ // `refreshStudio` for a non-active pageId — Studio would stay disconnected
496
+ // (outline skeleton, publish disabled). Re-init only when the active runtime
497
+ // belongs to a different URL; same-URL co-located runtimes must stay on the
498
+ // refresh path so the SDK does not override Builder's editable-instance
499
+ // choice.
500
+ let activeRuntime = studio.weaverse as WeaverseNextRuntime | undefined
501
+ let activeRequestInfo = activeRuntime?.requestInfo
502
+ let isActiveUrl =
503
+ !activeRequestInfo ||
504
+ (activeRequestInfo.pathname === runtime.requestInfo.pathname &&
505
+ activeRequestInfo.search === runtime.requestInfo.search)
506
+ if (!(boundRuntime.__weaverseNextStudioBound && isActiveUrl)) {
507
+ studio.init?.(runtime)
508
+ boundRuntime.__weaverseNextStudioBound = true
509
+ return true
510
+ }
511
+
512
+ // Report the latest server payload (fresh per-item `loaderData`), not the
513
+ // live tree: Builder's `refreshStudio` merges the editor draft's structural
514
+ // edits back on top and only takes the loader payload from this data.
515
+ studio.refreshStudio?.({
516
+ data: boundRuntime.__weaverseNextLatestData ?? runtime.data,
517
+ pageId: runtime.pageId,
518
+ requestInfo: runtime.requestInfo,
519
+ })
520
+ return true
521
+ }
package/src/seo.ts ADDED
@@ -0,0 +1,157 @@
1
+ import type { PageSEOData } from '@weaverse/schema'
2
+ import type { Metadata } from 'next'
3
+ import type { WeaverseNextLoaderData, WeaverseNextPageData } from './types'
4
+
5
+ /** Object containing an optional Weaverse page for SEO extraction. */
6
+ export interface WeaverseNextSeoPageContainer {
7
+ /** Page whose published SEO fields should be formatted. */
8
+ page?: WeaverseNextPageData | null
9
+ }
10
+
11
+ /** Input accepted by {@link getWeaverseNextSeoMetadata}. */
12
+ export type WeaverseNextSeoInput =
13
+ | WeaverseNextLoaderData
14
+ | WeaverseNextPageData
15
+ | WeaverseNextSeoPageContainer
16
+ | null
17
+ | undefined
18
+
19
+ /** Open Graph metadata accepted by Next.js's `Metadata` API. */
20
+ export type NextOpenGraph = NonNullable<Metadata['openGraph']>
21
+
22
+ /** Twitter card metadata accepted by Next.js's `Metadata` API. */
23
+ export type NextTwitter = NonNullable<Metadata['twitter']>
24
+
25
+ /**
26
+ * Weaverse page SEO expressed as a subset of Next's `Metadata`. Assignable to
27
+ * `Metadata` as-is: `const metadata: Metadata = getWeaverseNextSeoMetadata(data)`.
28
+ */
29
+ export interface WeaverseNextSeoMetadata {
30
+ /** Canonical URL settings for Next's metadata API. */
31
+ alternates?: {
32
+ /** Preferred URL for search indexing. */
33
+ canonical?: string
34
+ }
35
+ /** Page meta description. */
36
+ description?: string
37
+ /** Page meta keywords. */
38
+ keywords?: string
39
+ /** Open Graph metadata normalized to Next's supported union. */
40
+ openGraph?: NextOpenGraph
41
+ /** Search crawler indexing and link-following directives. */
42
+ robots: {
43
+ /** Whether crawlers may follow links on the page. */
44
+ follow: boolean
45
+ /** Whether crawlers may index the page. */
46
+ index: boolean
47
+ }
48
+ /** Page title. */
49
+ title?: string
50
+ /** Twitter card metadata normalized for Next's metadata API. */
51
+ twitter?: NextTwitter
52
+ }
53
+
54
+ function hasOpenGraphContent(seo: PageSEOData['openGraph']): boolean {
55
+ return Boolean(seo?.title || seo?.description || seo?.image || seo?.type)
56
+ }
57
+
58
+ function hasTwitterContent(seo: PageSEOData['twitter']): boolean {
59
+ return Boolean(seo?.title || seo?.description || seo?.image || seo?.cardType)
60
+ }
61
+
62
+ function normalizeTwitterCard(
63
+ cardType: NonNullable<PageSEOData['twitter']>['cardType']
64
+ ): 'summary' | 'summary_large_image' {
65
+ return cardType === 'summary_large_image' ? 'summary_large_image' : 'summary'
66
+ }
67
+
68
+ /**
69
+ * Map a Builder Open Graph type onto Next's `OpenGraphType` union. Every
70
+ * Builder value except `product` exists in Next's union and is preserved
71
+ * verbatim; `product` is not modeled by Next and makes `generateMetadata()`
72
+ * throw, so it degrades to `website` — the closest renderable equivalent.
73
+ */
74
+ function normalizeOpenGraphType(
75
+ type: NonNullable<PageSEOData['openGraph']>['type']
76
+ ): 'article' | 'profile' | 'video.other' | 'website' {
77
+ if (type === 'article' || type === 'profile' || type === 'video.other') {
78
+ return type
79
+ }
80
+ return 'website'
81
+ }
82
+
83
+ /**
84
+ * Convert Builder SEO fields into metadata accepted by Next.js.
85
+ *
86
+ * Unsupported Open Graph product types and incomplete Twitter app/player
87
+ * cards are safely downgraded to renderable Next.js variants.
88
+ */
89
+ export function formatWeaverseNextSeoMetadata(
90
+ seo: PageSEOData | null | undefined
91
+ ): WeaverseNextSeoMetadata {
92
+ let index = seo?.robots?.index !== false
93
+ let follow = seo?.robots?.follow !== false
94
+ let metadata: WeaverseNextSeoMetadata = {
95
+ robots: { index, follow },
96
+ }
97
+
98
+ if (!seo) {
99
+ return metadata
100
+ }
101
+
102
+ if (seo.title) {
103
+ metadata.title = seo.title
104
+ }
105
+ if (seo.description) {
106
+ metadata.description = seo.description
107
+ }
108
+ if (seo.keywords) {
109
+ metadata.keywords = seo.keywords
110
+ }
111
+ if (seo.canonicalUrl) {
112
+ metadata.alternates = { canonical: seo.canonicalUrl }
113
+ }
114
+
115
+ let openGraph = seo.openGraph
116
+ if (hasOpenGraphContent(openGraph)) {
117
+ metadata.openGraph = {
118
+ title: openGraph?.title,
119
+ description: openGraph?.description,
120
+ images: openGraph?.image ? [openGraph.image] : undefined,
121
+ type: normalizeOpenGraphType(openGraph?.type),
122
+ }
123
+ }
124
+
125
+ let twitter = seo.twitter
126
+ if (hasTwitterContent(twitter)) {
127
+ // Builder does not collect the descriptors Next requires for `app` cards
128
+ // (Next throws when they are missing) or `player` cards (which render an
129
+ // empty, useless card without a player URL and dimensions). Downgrade both
130
+ // to `summary` rather than emitting metadata Next cannot render.
131
+ metadata.twitter = {
132
+ card: normalizeTwitterCard(twitter?.cardType),
133
+ title: twitter?.title,
134
+ description: twitter?.description,
135
+ images: twitter?.image ? [twitter.image] : undefined,
136
+ }
137
+ }
138
+
139
+ return metadata
140
+ }
141
+
142
+ /**
143
+ * Read page SEO from loader data or a page payload and format it for Next.js
144
+ * `generateMetadata`. Missing data returns indexable/followable robot defaults.
145
+ */
146
+ export function getWeaverseNextSeoMetadata(
147
+ data: WeaverseNextSeoInput
148
+ ): WeaverseNextSeoMetadata {
149
+ if (!data) {
150
+ return formatWeaverseNextSeoMetadata(null)
151
+ }
152
+ let page =
153
+ 'items' in data && 'id' in data
154
+ ? (data as WeaverseNextPageData)
155
+ : ((data as WeaverseNextSeoPageContainer).page ?? null)
156
+ return formatWeaverseNextSeoMetadata(page?.seo ?? null)
157
+ }