@dfosco/storyboard 0.6.9 → 0.6.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard",
3
- "version": "0.6.9",
3
+ "version": "0.6.11",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -54,22 +54,24 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
54
54
 
55
55
  const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
56
56
  const baseSegment = basePath.replace(/^\//, '')
57
- // Iframe URL is mode-dependent:
58
- // - DEV: route through the isolated prototypes.html entry so a
59
- // broken prototype's transform/HMR errors stay inside the iframe
60
- // and never poison the canvas (see .agents/plans/vite-isolation.md).
61
- // prototypes.html uses createHashRouter, so /MyProto/SignupForm
62
- // becomes prototypes.html?proto=MyProto#/MyProto/SignupForm.
63
- // - PROD: load the prototype path directly through the canvas SPA's
64
- // BrowserRouter (e.g. /MyProto/SignupForm). prototypes.html is a
65
- // build-time isolation artifact it must not leak into deployed
66
- // URLs (breaks deep links, exposes build internals). basePath
67
- // already carries the /branch--xxx/ prefix on branch deploys, so
68
- // this works for main and branch deploys alike.
69
- // External http(s) URLs are left alone.
70
- const rawSrc = useMemo(() => {
71
- if (!src) return ''
72
- if (/^https?:\/\//.test(src)) return src
57
+ // Two URLs are derived from `src`:
58
+ // - rawSrc the iframe URL. In DEV this routes through the isolated
59
+ // prototypes.html entry so a broken prototype's transform/HMR errors
60
+ // stay inside the iframe (see .agents/plans/vite-isolation.md):
61
+ // /MyProto/SignupForm becomes prototypes.html?proto=MyProto#/MyProto/SignupForm.
62
+ // In PROD it loads the prototype path directly through the canvas SPA
63
+ // (prototypes.html is a build-time isolation artifact that must not
64
+ // leak into deployed URLs).
65
+ // - externalSrcthe URL used by "Open in new tab". Always direct
66
+ // (`${basePath}/<protoPath>`), never prototypes.html, even in dev
67
+ // opening prototypes.html#/... in a fresh tab is a leaky surprise
68
+ // for users navigating from the canvas.
69
+ // External http(s) URLs are left alone in both cases. basePath already
70
+ // carries the /branch--xxx/ prefix on branch deploys, so both work for
71
+ // main and branch deploys alike.
72
+ const { rawSrc, externalSrc } = useMemo(() => {
73
+ if (!src) return { rawSrc: '', externalSrc: '' }
74
+ if (/^https?:\/\//.test(src)) return { rawSrc: src, externalSrc: src }
73
75
  const cleaned = src.replace(/^\/branch--[^/]+/, '')
74
76
  let normalized
75
77
  if (baseSegment && cleaned.startsWith(basePath)) normalized = cleaned
@@ -77,7 +79,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
77
79
  else normalized = `${basePath}${cleaned}`
78
80
  // Strip basePath so we can split path/query/hash cleanly and
79
81
  // re-anchor for whichever mode we're in. Any pre-existing #hash on
80
- // the original src is preserved as the iframe URL's hash.
82
+ // the original src is preserved.
81
83
  const withoutBase = baseSegment && normalized.startsWith(basePath)
82
84
  ? normalized.slice(basePath.length) || '/'
83
85
  : normalized
@@ -86,18 +88,20 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
86
88
  const pathAndQuery = hashIdx >= 0 ? withoutBase.slice(0, hashIdx) : withoutBase
87
89
  const routePath = pathAndQuery.startsWith('/') ? pathAndQuery : `/${pathAndQuery}`
88
90
  const suffix = innerHash ? `#${innerHash}` : ''
91
+ // Direct path through the canvas SPA — used in prod for the iframe and
92
+ // always for "Open in new tab".
93
+ const directUrl = `${basePath}${routePath}${suffix}`
89
94
  if (import.meta.env.PROD) {
90
- // Direct path through the canvas SPA — no prototypes.html in
91
- // production URLs.
92
- return `${basePath}${routePath}${suffix}`
95
+ return { rawSrc: directUrl, externalSrc: directUrl }
93
96
  }
94
- // Dev: prototypes.html with ?proto= narrowing. The consumer's
97
+ // Dev iframe: prototypes.html with ?proto= narrowing. The consumer's
95
98
  // prototypes-entry.jsx reads ?proto= and calls getRoutesForProto();
96
99
  // older scaffolds harmlessly ignore the param and load the full tree.
97
100
  const pathOnly = pathAndQuery.split('?')[0]
98
101
  const protoName = pathOnly.split('/').filter(Boolean)[0] || ''
99
102
  const queryStr = protoName ? `?proto=${encodeURIComponent(protoName)}` : ''
100
- return `${basePath}/prototypes.html${queryStr}#${routePath}${suffix}`
103
+ const iframeUrl = `${basePath}/prototypes.html${queryStr}#${routePath}${suffix}`
104
+ return { rawSrc: iframeUrl, externalSrc: directUrl }
101
105
  }, [src, basePath, baseSegment])
102
106
 
103
107
  const scale = zoom / 100
@@ -332,7 +336,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
332
336
  } else if (actionId === 'split-screen') {
333
337
  setExpandMode('split')
334
338
  } else if (actionId === 'open-external') {
335
- if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
339
+ if (externalSrc) window.open(externalSrc, '_blank', 'noopener')
336
340
  } else if (actionId === 'refresh-frame') {
337
341
  const iframe = iframeRef.current
338
342
  if (iframe) {
@@ -347,7 +351,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
347
351
  onUpdate?.({ zoom: Math.max(25, zoom - step) })
348
352
  }
349
353
  },
350
- }), [rawSrc, zoom, onUpdate])
354
+ }), [externalSrc, zoom, onUpdate])
351
355
 
352
356
  function handlePickRoute(route) {
353
357
  onUpdate?.({ src: route })
@@ -132,6 +132,14 @@ function matchStoryRoute(pathname) {
132
132
  * Strip the app's sub-path prefix (e.g. /storyboard) from the pathname.
133
133
  * React Router's basename strips the branch prefix but not the app name prefix
134
134
  * when the app runs under a nested base path.
135
+ *
136
+ * Two prefixing scenarios are handled:
137
+ * 1. Branch deploys — BASE_URL is `/branch--xxx/{devDomain}/`, so we strip
138
+ * whatever app sub-path remains after the branch prefix.
139
+ * 2. Local dev via Caddy proxy — BASE_URL is `/` (Caddy strips `/{devDomain}/`
140
+ * before forwarding to the dev server), but window.location.pathname is
141
+ * still `/{devDomain}/...`. React Router's basename `/` doesn't strip it,
142
+ * so we rely on the injected `__SB_DEV_DOMAIN__` global instead.
135
143
  */
136
144
  function stripBasePath(pathname) {
137
145
  let p = pathname.replace(/\/+$/, '') || '/'
@@ -146,6 +154,18 @@ function stripBasePath(pathname) {
146
154
  p = p.slice(subPath.length) || '/'
147
155
  }
148
156
  }
157
+ // Local dev via Caddy proxy: window.location.pathname includes the
158
+ // /{devDomain}/ prefix that Caddy strips before reaching the dev server.
159
+ // BASE_URL is `/` in this scenario, so the block above is a no-op.
160
+ if (typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ && window.__SB_DEV_DOMAIN__) {
161
+ const devDomain = String(window.__SB_DEV_DOMAIN__).replace(/^\/+|\/+$/g, '')
162
+ if (devDomain) {
163
+ const devPrefix = '/' + devDomain
164
+ if (p === devPrefix || p.startsWith(devPrefix + '/')) {
165
+ p = p.slice(devPrefix.length) || '/'
166
+ }
167
+ }
168
+ }
149
169
  return p
150
170
  }
151
171
 
@@ -293,4 +293,51 @@ describe('StoryboardProvider', () => {
293
293
  expect(screen.getByText('Canvas not found')).toBeInTheDocument()
294
294
  expect(screen.getByRole('link', { name: /go to index page/i })).toHaveAttribute('href', '/')
295
295
  })
296
+
297
+ it('strips dev-domain prefix in local dev (Caddy proxy) so canvas routes resolve', () => {
298
+ // Local dev via Caddy: window.location.pathname includes the /{devDomain}/
299
+ // prefix even though BASE_URL is `/` (the proxy strips it before forwarding
300
+ // to the dev server). Without stripping, isCanvasPath returns false and the
301
+ // 404 never renders — the canvas page just stays blank.
302
+ const origLocalDev = window.__SB_LOCAL_DEV__
303
+ const origDomain = window.__SB_DEV_DOMAIN__
304
+ window.__SB_LOCAL_DEV__ = true
305
+ window.__SB_DEV_DOMAIN__ = 'storyboard'
306
+ try {
307
+ mockUseLocation.mockReturnValue({ pathname: '/storyboard/canvas/unknown-board', search: '', hash: '' })
308
+
309
+ render(
310
+ <StoryboardProvider>
311
+ <ContextReader />
312
+ </StoryboardProvider>,
313
+ )
314
+
315
+ expect(screen.getByText('Canvas not found')).toBeInTheDocument()
316
+ } finally {
317
+ window.__SB_LOCAL_DEV__ = origLocalDev
318
+ window.__SB_DEV_DOMAIN__ = origDomain
319
+ }
320
+ })
321
+
322
+ it('does not strip dev-domain prefix when __SB_LOCAL_DEV__ is not set', () => {
323
+ // Same URL shape, but production-like: prefix should be left alone so it
324
+ // doesn't accidentally match canvas routes for unrelated path segments.
325
+ const origDomain = window.__SB_DEV_DOMAIN__
326
+ window.__SB_DEV_DOMAIN__ = 'storyboard'
327
+ try {
328
+ mockUseLocation.mockReturnValue({ pathname: '/storyboard/canvas/unknown-board', search: '', hash: '' })
329
+
330
+ render(
331
+ <StoryboardProvider>
332
+ <ContextReader />
333
+ </StoryboardProvider>,
334
+ )
335
+
336
+ // isCanvasPath sees `/storyboard/canvas/...` → does NOT start with `/canvas/`
337
+ // → no 404, just renders flow context
338
+ expect(screen.queryByText('Canvas not found')).not.toBeInTheDocument()
339
+ } finally {
340
+ window.__SB_DEV_DOMAIN__ = origDomain
341
+ }
342
+ })
296
343
  })