@dfosco/storyboard-react 4.0.0-beta.13 → 4.0.0-beta.15

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.
@@ -4,6 +4,7 @@ import PrototypeEmbed from './PrototypeEmbed.jsx'
4
4
  import LinkPreview from './LinkPreview.jsx'
5
5
  import ImageWidget from './ImageWidget.jsx'
6
6
  import FigmaEmbed from './FigmaEmbed.jsx'
7
+ import StoryWidget from './StoryWidget.jsx'
7
8
 
8
9
  /**
9
10
  * Maps widget type strings to their React components.
@@ -16,6 +17,7 @@ export const widgetRegistry = {
16
17
  'link-preview': LinkPreview,
17
18
  'image': ImageWidget,
18
19
  'figma-embed': FigmaEmbed,
20
+ 'story': StoryWidget,
19
21
  }
20
22
 
21
23
  /**
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Paste Rules — config-driven paste routing for canvas widgets.
3
+ *
4
+ * All paste routing is defined in paste.config.json (packages/core).
5
+ * Each rule declares a match condition and a widget type + prop template.
6
+ * Rules are evaluated in order — first match wins.
7
+ *
8
+ * Image paste and widget-ref paste remain in CanvasPage.jsx because they
9
+ * require clipboard / canvas API access that doesn't belong here.
10
+ */
11
+
12
+ import pasteConfig from '@dfosco/storyboard-core/paste.config.json'
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Branch-prefix pattern (matches /branch--<name> at start of pathname)
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Paste context — captures origin + base-path once per effect cycle
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Build a paste context object that URL rules can query.
26
+ *
27
+ * @param {string} origin - `window.location.origin`
28
+ * @param {string} basePath - `import.meta.env.BASE_URL` with trailing slash stripped
29
+ * @returns {PasteContext}
30
+ */
31
+ export function createPasteContext(origin, basePath) {
32
+ const normalizedBase = basePath.replace(/\/$/, '')
33
+
34
+ return {
35
+ origin,
36
+ basePath: normalizedBase,
37
+ baseUrl: origin + normalizedBase,
38
+
39
+ /**
40
+ * Check whether a raw URL string points at the same Storyboard origin,
41
+ * accounting for branch-deploy prefixes.
42
+ * Uses parsed URL comparison (not string prefix) to avoid host spoofing.
43
+ */
44
+ isSameOrigin(text) {
45
+ const parsed = this.parseUrl(text)
46
+ if (!parsed || parsed.origin !== origin) return false
47
+ const pathname = parsed.pathname
48
+ if (normalizedBase && (pathname === normalizedBase || pathname.startsWith(normalizedBase + '/'))) return true
49
+ if (!normalizedBase) return true
50
+ return BRANCH_PREFIX_RE.test(pathname)
51
+ },
52
+
53
+ /**
54
+ * Strip the base path (or any branch prefix) from a pathname to produce a
55
+ * portable prototype `src` value.
56
+ */
57
+ extractSrc(pathname) {
58
+ if (normalizedBase && pathname.startsWith(normalizedBase)) {
59
+ return pathname.slice(normalizedBase.length) || '/'
60
+ }
61
+ const m = pathname.match(BRANCH_PREFIX_RE)
62
+ if (m) return pathname.slice(m[0].length) || '/'
63
+ return pathname
64
+ },
65
+
66
+ /**
67
+ * Parse text as an http(s) URL. Returns the URL object or null.
68
+ */
69
+ parseUrl(text) {
70
+ try {
71
+ const url = new URL(text)
72
+ return (url.protocol === 'http:' || url.protocol === 'https:') ? url : null
73
+ } catch {
74
+ return null
75
+ }
76
+ },
77
+ }
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Template variable resolution
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /**
85
+ * Build the set of template variables available to prop templates.
86
+ *
87
+ * @param {string} text - raw pasted text
88
+ * @param {URL|null} parsed - parsed URL (null for non-URL text)
89
+ * @param {PasteContext} ctx
90
+ * @returns {Record<string, string>}
91
+ */
92
+ export function buildTemplateVars(text, parsed, ctx) {
93
+ const pathname = parsed?.pathname ?? ''
94
+ return {
95
+ $url: text,
96
+ $text: text,
97
+ $pathname: pathname,
98
+ $src: ctx.extractSrc(pathname),
99
+ $search: parsed?.search ?? '',
100
+ $hash: parsed?.hash ?? '',
101
+ $hostname: parsed?.hostname ?? '',
102
+ $origin: parsed?.origin ?? '',
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Apply URL sanitization to a value per the sanitize spec.
108
+ *
109
+ * @param {string} value - the resolved URL string
110
+ * @param {{ stripParams?: string[], normalizeHost?: string }} spec
111
+ * @returns {string}
112
+ */
113
+ export function sanitizeUrl(value, spec) {
114
+ try {
115
+ const url = new URL(value)
116
+ if (spec.normalizeHost) url.hostname = spec.normalizeHost
117
+ if (Array.isArray(spec.stripParams)) {
118
+ for (const p of spec.stripParams) url.searchParams.delete(p)
119
+ }
120
+ return url.toString()
121
+ } catch {
122
+ return value
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Resolve a single prop value from config.
128
+ * - Plain values (string, number, boolean) are returned as-is.
129
+ * - Objects with `template` are resolved from template vars.
130
+ * - Objects with `sanitize` have URL sanitization applied after template resolution.
131
+ *
132
+ * @param {*} propDef - the prop definition from config
133
+ * @param {Record<string, string>} vars - template variables
134
+ * @returns {*}
135
+ */
136
+ export function resolvePropValue(propDef, vars) {
137
+ if (propDef == null) return propDef
138
+
139
+ // Object with template key → resolve template + optional sanitize
140
+ if (typeof propDef === 'object' && propDef.template) {
141
+ let value = propDef.template
142
+ for (const [varName, varValue] of Object.entries(vars)) {
143
+ value = value.replaceAll(varName, varValue)
144
+ }
145
+ if (propDef.sanitize) {
146
+ value = sanitizeUrl(value, propDef.sanitize)
147
+ }
148
+ return value
149
+ }
150
+
151
+ // Plain string — substitute template vars
152
+ if (typeof propDef === 'string') {
153
+ let value = propDef
154
+ for (const [varName, varValue] of Object.entries(vars)) {
155
+ value = value.replaceAll(varName, varValue)
156
+ }
157
+ return value
158
+ }
159
+
160
+ // Numbers, booleans, etc. — pass through
161
+ return propDef
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Rule compilation
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /**
169
+ * Compile a single rule from paste.config.json into a callable
170
+ * `{ name, match, resolve }` object.
171
+ *
172
+ * Match conditions (all must pass when combined):
173
+ * - `hostname` — regex tested against parsed URL hostname
174
+ * - `pathname` — regex tested against parsed URL pathname
175
+ * - `pattern` — regex tested against the full pasted text
176
+ * - `sameOrigin` — boolean; delegates to ctx.isSameOrigin()
177
+ * - `isUrl` — boolean; true if text is a valid http(s) URL
178
+ * - `any` — boolean; always matches (catch-all)
179
+ *
180
+ * @param {object} ruleDef
181
+ * @returns {{ name: string, match: Function, resolve: Function } | null}
182
+ */
183
+ export function compileRule(ruleDef) {
184
+ if (!ruleDef || !ruleDef.match || !ruleDef.widget) return null
185
+
186
+ const { match: matchDef, widget, props: propsDef = {}, name = 'unnamed' } = ruleDef
187
+
188
+ // Pre-compile regexes
189
+ const matchers = []
190
+
191
+ if (matchDef.hostname) {
192
+ try {
193
+ const re = new RegExp(matchDef.hostname)
194
+ matchers.push((text, parsed) => parsed !== null && re.test(parsed.hostname))
195
+ } catch {
196
+ console.warn(`[pasteRules] Invalid hostname regex in rule "${name}": ${matchDef.hostname}`)
197
+ return null
198
+ }
199
+ }
200
+
201
+ if (matchDef.pathname) {
202
+ try {
203
+ const re = new RegExp(matchDef.pathname)
204
+ matchers.push((text, parsed) => parsed !== null && re.test(parsed.pathname))
205
+ } catch {
206
+ console.warn(`[pasteRules] Invalid pathname regex in rule "${name}": ${matchDef.pathname}`)
207
+ return null
208
+ }
209
+ }
210
+
211
+ if (matchDef.pattern) {
212
+ try {
213
+ const re = new RegExp(matchDef.pattern)
214
+ matchers.push((text) => re.test(text))
215
+ } catch {
216
+ console.warn(`[pasteRules] Invalid pattern regex in rule "${name}": ${matchDef.pattern}`)
217
+ return null
218
+ }
219
+ }
220
+
221
+ if (matchDef.sameOrigin) {
222
+ matchers.push((text, parsed, ctx) => ctx.isSameOrigin(text))
223
+ }
224
+
225
+ if (matchDef.isUrl) {
226
+ matchers.push((text, parsed) => parsed !== null)
227
+ }
228
+
229
+ if (matchDef.any) {
230
+ matchers.push(() => true)
231
+ }
232
+
233
+ if (matchers.length === 0) {
234
+ console.warn(`[pasteRules] Rule "${name}" has no valid match conditions`)
235
+ return null
236
+ }
237
+
238
+ return {
239
+ name,
240
+ match(text, parsed, ctx) {
241
+ return matchers.every(fn => fn(text, parsed, ctx))
242
+ },
243
+ resolve(text, parsed, ctx) {
244
+ const vars = buildTemplateVars(text, parsed, ctx)
245
+ const resolvedProps = {}
246
+ for (const [key, def] of Object.entries(propsDef)) {
247
+ resolvedProps[key] = resolvePropValue(def, vars)
248
+ }
249
+ return { type: widget, props: resolvedProps }
250
+ },
251
+ }
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Compile rules from paste.config.json at import time
256
+ // ---------------------------------------------------------------------------
257
+
258
+ const COMPILED_RULES = (pasteConfig.rules || [])
259
+ .map(compileRule)
260
+ .filter(Boolean)
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Main resolver
264
+ // ---------------------------------------------------------------------------
265
+
266
+ /**
267
+ * Resolve pasted text into a widget `{ type, props }` by running through
268
+ * ordered rules from paste.config.json. Override rules (if any) run first.
269
+ *
270
+ * @param {string} text - trimmed clipboard text
271
+ * @param {PasteContext} context - from `createPasteContext()`
272
+ * @param {object[]} [overrideRules] - raw rule objects from storyboard.config.json canvas.pasteRules
273
+ * @returns {{ type: string, props: object } | null}
274
+ */
275
+ export function resolvePaste(text, context, overrideRules = []) {
276
+ const parsed = context.parseUrl(text)
277
+
278
+ // Compile any runtime override rules (from storyboard.config.json)
279
+ const overrides = overrideRules.map(compileRule).filter(Boolean)
280
+ const allRules = [...overrides, ...COMPILED_RULES]
281
+
282
+ for (const rule of allRules) {
283
+ if (rule.match(text, parsed, context)) {
284
+ return rule.resolve(text, parsed, context)
285
+ }
286
+ }
287
+
288
+ return null
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Exports for testing
293
+ // ---------------------------------------------------------------------------
294
+
295
+ export { COMPILED_RULES, BRANCH_PREFIX_RE }