@dfosco/storyboard-react 4.0.0-beta.9 → 4.0.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/package.json +6 -3
- package/src/AuthModal/AuthModal.jsx +134 -0
- package/src/AuthModal/AuthModal.module.css +221 -0
- package/src/BranchBar/BranchBar.jsx +56 -0
- package/src/BranchBar/BranchBar.module.css +230 -0
- package/src/BranchBar/useBranches.js +79 -0
- package/src/CommandPalette/CommandPalette.jsx +936 -0
- package/src/CommandPalette/CreateDialog.jsx +219 -0
- package/src/CommandPalette/command-palette.css +111 -0
- package/src/Icon.jsx +180 -0
- package/src/Viewfinder.jsx +1104 -57
- package/src/Viewfinder.module.css +1107 -149
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +142 -19
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +807 -251
- package/src/canvas/CanvasPage.module.css +98 -50
- package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
- package/src/canvas/CanvasToolbar.jsx +2 -2
- package/src/canvas/MarqueeOverlay.jsx +20 -0
- package/src/canvas/PageSelector.jsx +239 -0
- package/src/canvas/PageSelector.module.css +165 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/canvasApi.js +22 -8
- package/src/canvas/canvasTheme.js +96 -52
- package/src/canvas/componentIsolate.jsx +33 -7
- package/src/canvas/useCanvas.js +9 -8
- package/src/canvas/useCanvas.test.js +4 -4
- package/src/canvas/useMarqueeSelect.js +187 -0
- package/src/canvas/useMarqueeSelect.test.js +78 -0
- package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
- package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/canvas/widgets/ComponentWidget.jsx +42 -10
- package/src/canvas/widgets/ComponentWidget.module.css +6 -5
- package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
- package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
- package/src/canvas/widgets/LinkPreview.jsx +297 -11
- package/src/canvas/widgets/LinkPreview.module.css +386 -18
- package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +86 -5
- package/src/canvas/widgets/MarkdownBlock.module.css +64 -15
- package/src/canvas/widgets/PrototypeEmbed.jsx +96 -145
- package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
- package/src/canvas/widgets/StickyNote.module.css +5 -0
- package/src/canvas/widgets/StickyNote.test.jsx +9 -9
- package/src/canvas/widgets/StoryWidget.jsx +277 -0
- package/src/canvas/widgets/StoryWidget.module.css +211 -0
- package/src/canvas/widgets/WidgetChrome.jsx +76 -20
- package/src/canvas/widgets/WidgetChrome.module.css +2 -6
- package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
- package/src/canvas/widgets/codepenUrl.js +75 -0
- package/src/canvas/widgets/codepenUrl.test.js +76 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
- package/src/canvas/widgets/embedOverlay.module.css +35 -0
- package/src/canvas/widgets/embedTheme.js +138 -39
- package/src/canvas/widgets/githubUrl.js +82 -0
- package/src/canvas/widgets/githubUrl.test.js +74 -0
- package/src/canvas/widgets/iframeDevLogs.js +49 -0
- package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/pasteRules.js +295 -0
- package/src/canvas/widgets/pasteRules.test.js +474 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
- package/src/canvas/widgets/widgetConfig.js +16 -5
- package/src/canvas/widgets/widgetConfig.test.js +34 -12
- package/src/context.jsx +145 -16
- package/src/hooks/useSceneData.js +4 -2
- package/src/hooks/useThemeState.js +61 -0
- package/src/hooks/useThemeState.test.js +66 -0
- package/src/index.js +10 -0
- package/src/story/StoryPage.jsx +117 -0
- package/src/story/StoryPage.module.css +18 -0
- package/src/vite/data-plugin.js +348 -66
- package/src/vite/data-plugin.test.js +405 -5
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const GH_HOST_RE = /^(www\.)?github\.com$/i
|
|
2
|
+
const ISSUE_PATH_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/?$/
|
|
3
|
+
const DISCUSSION_PATH_RE = /^\/([^/]+)\/([^/]+)\/discussions\/(\d+)\/?$/
|
|
4
|
+
const PULL_REQUEST_PATH_RE = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/
|
|
5
|
+
const ISSUE_COMMENT_HASH_RE = /^#issuecomment-(\d+)$/i
|
|
6
|
+
const DISCUSSION_COMMENT_HASH_RE = /^#discussioncomment-(\d+)$/i
|
|
7
|
+
|
|
8
|
+
function toNumber(raw) {
|
|
9
|
+
const value = Number.parseInt(raw, 10)
|
|
10
|
+
return Number.isFinite(value) ? value : null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse supported GitHub embed URLs (issues, discussions, comments).
|
|
15
|
+
* @param {string} rawUrl
|
|
16
|
+
* @returns {null | {
|
|
17
|
+
* kind: 'issue' | 'discussion' | 'comment',
|
|
18
|
+
* parentKind: 'issue' | 'discussion',
|
|
19
|
+
* owner: string,
|
|
20
|
+
* repo: string,
|
|
21
|
+
* number: number,
|
|
22
|
+
* commentId?: number
|
|
23
|
+
* }}
|
|
24
|
+
*/
|
|
25
|
+
export function parseGitHubUrl(rawUrl) {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = new URL(rawUrl)
|
|
28
|
+
if (!GH_HOST_RE.test(parsed.hostname)) return null
|
|
29
|
+
|
|
30
|
+
const issueMatch = parsed.pathname.match(ISSUE_PATH_RE)
|
|
31
|
+
if (issueMatch) {
|
|
32
|
+
const [, owner, repo, numberRaw] = issueMatch
|
|
33
|
+
const number = toNumber(numberRaw)
|
|
34
|
+
if (!number) return null
|
|
35
|
+
|
|
36
|
+
const commentMatch = parsed.hash.match(ISSUE_COMMENT_HASH_RE)
|
|
37
|
+
if (commentMatch) {
|
|
38
|
+
const commentId = toNumber(commentMatch[1])
|
|
39
|
+
if (!commentId) return null
|
|
40
|
+
return { kind: 'comment', parentKind: 'issue', owner, repo, number, commentId }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (parsed.hash) return null
|
|
44
|
+
return { kind: 'issue', parentKind: 'issue', owner, repo, number }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const discussionMatch = parsed.pathname.match(DISCUSSION_PATH_RE)
|
|
48
|
+
if (discussionMatch) {
|
|
49
|
+
const [, owner, repo, numberRaw] = discussionMatch
|
|
50
|
+
const number = toNumber(numberRaw)
|
|
51
|
+
if (!number) return null
|
|
52
|
+
|
|
53
|
+
const commentMatch = parsed.hash.match(DISCUSSION_COMMENT_HASH_RE)
|
|
54
|
+
if (commentMatch) {
|
|
55
|
+
const commentId = toNumber(commentMatch[1])
|
|
56
|
+
if (!commentId) return null
|
|
57
|
+
return { kind: 'comment', parentKind: 'discussion', owner, repo, number, commentId }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (parsed.hash) return null
|
|
61
|
+
return { kind: 'discussion', parentKind: 'discussion', owner, repo, number }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const prMatch = parsed.pathname.match(PULL_REQUEST_PATH_RE)
|
|
65
|
+
if (prMatch) {
|
|
66
|
+
const [, owner, repo, numberRaw] = prMatch
|
|
67
|
+
const number = toNumber(numberRaw)
|
|
68
|
+
if (!number) return null
|
|
69
|
+
|
|
70
|
+
if (parsed.hash) return null
|
|
71
|
+
return { kind: 'pull_request', parentKind: 'pull_request', owner, repo, number }
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isGitHubEmbedUrl(rawUrl) {
|
|
81
|
+
return parseGitHubUrl(rawUrl) !== null
|
|
82
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { isGitHubEmbedUrl, parseGitHubUrl } from './githubUrl.js'
|
|
3
|
+
|
|
4
|
+
describe('parseGitHubUrl', () => {
|
|
5
|
+
it('classifies issue URLs', () => {
|
|
6
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/issues/12')).toEqual({
|
|
7
|
+
kind: 'issue',
|
|
8
|
+
parentKind: 'issue',
|
|
9
|
+
owner: 'dfosco',
|
|
10
|
+
repo: 'storyboard',
|
|
11
|
+
number: 12,
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('classifies discussion URLs', () => {
|
|
16
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/discussions/99')).toEqual({
|
|
17
|
+
kind: 'discussion',
|
|
18
|
+
parentKind: 'discussion',
|
|
19
|
+
owner: 'dfosco',
|
|
20
|
+
repo: 'storyboard',
|
|
21
|
+
number: 99,
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('classifies issue comment URLs', () => {
|
|
26
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/issues/12#issuecomment-345')).toEqual({
|
|
27
|
+
kind: 'comment',
|
|
28
|
+
parentKind: 'issue',
|
|
29
|
+
owner: 'dfosco',
|
|
30
|
+
repo: 'storyboard',
|
|
31
|
+
number: 12,
|
|
32
|
+
commentId: 345,
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('classifies discussion comment URLs', () => {
|
|
37
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/discussions/99#discussioncomment-888')).toEqual({
|
|
38
|
+
kind: 'comment',
|
|
39
|
+
parentKind: 'discussion',
|
|
40
|
+
owner: 'dfosco',
|
|
41
|
+
repo: 'storyboard',
|
|
42
|
+
number: 99,
|
|
43
|
+
commentId: 888,
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('classifies pull request URLs', () => {
|
|
48
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/pull/12')).toEqual({
|
|
49
|
+
kind: 'pull_request',
|
|
50
|
+
parentKind: 'pull_request',
|
|
51
|
+
owner: 'dfosco',
|
|
52
|
+
repo: 'storyboard',
|
|
53
|
+
number: 12,
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('rejects unsupported paths and hashes', () => {
|
|
58
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/issues/12#random')).toBeNull()
|
|
59
|
+
expect(parseGitHubUrl('https://example.com/dfosco/storyboard/issues/12')).toBeNull()
|
|
60
|
+
expect(parseGitHubUrl('not a url')).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('isGitHubEmbedUrl', () => {
|
|
65
|
+
it('returns true for supported GitHub URLs', () => {
|
|
66
|
+
expect(isGitHubEmbedUrl('https://github.com/dfosco/storyboard/issues/12')).toBe(true)
|
|
67
|
+
expect(isGitHubEmbedUrl('https://github.com/dfosco/storyboard/discussions/99#discussioncomment-888')).toBe(true)
|
|
68
|
+
expect(isGitHubEmbedUrl('https://github.com/dfosco/storyboard/pull/1')).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('returns false for unsupported URLs', () => {
|
|
72
|
+
expect(isGitHubEmbedUrl('https://example.com')).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
let loadedIframeCount = 0
|
|
4
|
+
|
|
5
|
+
function isDevRuntime() {
|
|
6
|
+
return typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function toText(value) {
|
|
10
|
+
return value ? String(value) : '(no src)'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function logIframeEvent(event, count, meta) {
|
|
14
|
+
console.log(`[storyboard][iframe] ${event} | count=${count} | ${meta.widget}`, {
|
|
15
|
+
event,
|
|
16
|
+
count,
|
|
17
|
+
widget: meta.widget,
|
|
18
|
+
src: toText(meta.src),
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Dev-only iframe load/unload logging with a live count of mounted iframes.
|
|
24
|
+
*/
|
|
25
|
+
export function useIframeDevLogs({ widget, loaded, src }) {
|
|
26
|
+
const metaRef = useRef({ widget, src })
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
metaRef.current = { widget, src }
|
|
30
|
+
}, [widget, src])
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!loaded) return
|
|
34
|
+
|
|
35
|
+
loadedIframeCount += 1
|
|
36
|
+
if (isDevRuntime()) {
|
|
37
|
+
const meta = metaRef.current
|
|
38
|
+
logIframeEvent('loaded', loadedIframeCount, meta)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return () => {
|
|
42
|
+
loadedIframeCount = Math.max(0, loadedIframeCount - 1)
|
|
43
|
+
if (isDevRuntime()) {
|
|
44
|
+
const meta = metaRef.current
|
|
45
|
+
logIframeEvent('unloaded', loadedIframeCount, meta)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}, [loaded])
|
|
49
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { render } from '@testing-library/react'
|
|
3
|
+
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
4
|
+
|
|
5
|
+
function Probe({ widget = 'Probe', loaded = false, src = '/test' }) {
|
|
6
|
+
useIframeDevLogs({ widget, loaded, src })
|
|
7
|
+
return null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('useIframeDevLogs', () => {
|
|
11
|
+
let logSpy
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
window.__SB_LOCAL_DEV__ = true
|
|
15
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
logSpy.mockRestore()
|
|
20
|
+
delete window.__SB_LOCAL_DEV__
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('logs iframe load and unload with tally', () => {
|
|
24
|
+
const { rerender, unmount } = render(<Probe loaded={false} src="/alpha" />)
|
|
25
|
+
rerender(<Probe loaded src="/alpha" />)
|
|
26
|
+
rerender(<Probe loaded={false} src="/alpha" />)
|
|
27
|
+
|
|
28
|
+
expect(logSpy).toHaveBeenNthCalledWith(
|
|
29
|
+
1,
|
|
30
|
+
'[storyboard][iframe] loaded | count=1 | Probe',
|
|
31
|
+
{ event: 'loaded', count: 1, widget: 'Probe', src: '/alpha' },
|
|
32
|
+
)
|
|
33
|
+
expect(logSpy).toHaveBeenNthCalledWith(
|
|
34
|
+
2,
|
|
35
|
+
'[storyboard][iframe] unloaded | count=0 | Probe',
|
|
36
|
+
{ event: 'unloaded', count: 0, widget: 'Probe', src: '/alpha' },
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
unmount()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('tracks tally across multiple loaded iframes', () => {
|
|
43
|
+
const first = render(<Probe widget="PrototypeEmbed" loaded src="/proto" />)
|
|
44
|
+
const second = render(<Probe widget="FigmaEmbed" loaded src="/figma" />)
|
|
45
|
+
|
|
46
|
+
expect(logSpy).toHaveBeenNthCalledWith(
|
|
47
|
+
1,
|
|
48
|
+
'[storyboard][iframe] loaded | count=1 | PrototypeEmbed',
|
|
49
|
+
{ event: 'loaded', count: 1, widget: 'PrototypeEmbed', src: '/proto' },
|
|
50
|
+
)
|
|
51
|
+
expect(logSpy).toHaveBeenNthCalledWith(
|
|
52
|
+
2,
|
|
53
|
+
'[storyboard][iframe] loaded | count=2 | FigmaEmbed',
|
|
54
|
+
{ event: 'loaded', count: 2, widget: 'FigmaEmbed', src: '/figma' },
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
first.unmount()
|
|
58
|
+
expect(logSpy).toHaveBeenNthCalledWith(
|
|
59
|
+
3,
|
|
60
|
+
'[storyboard][iframe] unloaded | count=1 | PrototypeEmbed',
|
|
61
|
+
{ event: 'unloaded', count: 1, widget: 'PrototypeEmbed', src: '/proto' },
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
second.unmount()
|
|
65
|
+
expect(logSpy).toHaveBeenNthCalledWith(
|
|
66
|
+
4,
|
|
67
|
+
'[storyboard][iframe] unloaded | count=0 | FigmaEmbed',
|
|
68
|
+
{ event: 'unloaded', count: 0, widget: 'FigmaEmbed', src: '/figma' },
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('does not log outside local dev runtime', () => {
|
|
73
|
+
window.__SB_LOCAL_DEV__ = false
|
|
74
|
+
const { rerender, unmount } = render(<Probe loaded={false} src="/off" />)
|
|
75
|
+
rerender(<Probe loaded src="/off" />)
|
|
76
|
+
rerender(<Probe loaded={false} src="/off" />)
|
|
77
|
+
|
|
78
|
+
expect(logSpy).not.toHaveBeenCalled()
|
|
79
|
+
unmount()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -4,6 +4,8 @@ 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 CodePenEmbed from './CodePenEmbed.jsx'
|
|
8
|
+
import StoryWidget from './StoryWidget.jsx'
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Maps widget type strings to their React components.
|
|
@@ -16,6 +18,8 @@ export const widgetRegistry = {
|
|
|
16
18
|
'link-preview': LinkPreview,
|
|
17
19
|
'image': ImageWidget,
|
|
18
20
|
'figma-embed': FigmaEmbed,
|
|
21
|
+
'codepen-embed': CodePenEmbed,
|
|
22
|
+
'story': StoryWidget,
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
/**
|
|
@@ -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 }
|