@dfosco/storyboard 0.11.8 → 0.12.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 +7 -4
- package/src/core/data/dotPath.js +5 -0
- package/src/core/devtools/sceneDebug.js +11 -1
- package/src/core/mountStoryboardCore.js +2 -1
- package/src/internals/canvas/widgets/LinkPreview.jsx +2 -8
- package/src/internals/canvas/widgets/markdown/markdownRender.js +2 -8
- package/src/internals/canvas/widgets/markdown/markdownSanitize.js +60 -0
- package/src/internals/vite/data-plugin.js +5 -4
- package/src/internals/vite/data-plugin.test.js +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Storyboard prototyping framework — core engine, React integration, and canvas",
|
|
@@ -114,9 +114,12 @@
|
|
|
114
114
|
"iconoir": "^7.11.0",
|
|
115
115
|
"jsonc-parser": "^3.3.1",
|
|
116
116
|
"lucide-react": "^1.8.0",
|
|
117
|
-
"
|
|
117
|
+
"rehype-raw": "^7.0.0",
|
|
118
|
+
"rehype-sanitize": "^6.0.0",
|
|
119
|
+
"rehype-stringify": "^10.0.1",
|
|
118
120
|
"remark-gfm": "^4.0.1",
|
|
119
|
-
"remark-
|
|
121
|
+
"remark-parse": "^11.0.0",
|
|
122
|
+
"remark-rehype": "^11.1.2",
|
|
120
123
|
"tailwind-merge": "^3.5.0",
|
|
121
124
|
"ws": "^8.21.0",
|
|
122
125
|
"zod": "^3.23.8"
|
|
@@ -200,4 +203,4 @@
|
|
|
200
203
|
"optionalDependencies": {
|
|
201
204
|
"node-pty": "^1.0.0"
|
|
202
205
|
}
|
|
203
|
-
}
|
|
206
|
+
}
|
package/src/core/data/dotPath.js
CHANGED
|
@@ -41,6 +41,11 @@ export function deepClone(val) {
|
|
|
41
41
|
*/
|
|
42
42
|
export function setByPath(obj, path, value) {
|
|
43
43
|
const segments = path.split('.')
|
|
44
|
+
// Guard against prototype pollution: refuse paths that traverse into
|
|
45
|
+
// special object internals. Override params can come from the URL.
|
|
46
|
+
if (segments.some((seg) => seg === '__proto__' || seg === 'constructor' || seg === 'prototype')) {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
44
49
|
let current = obj
|
|
45
50
|
for (let i = 0; i < segments.length - 1; i++) {
|
|
46
51
|
const seg = segments[i]
|
|
@@ -11,6 +11,16 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { loadFlow } from '../data/loader.js'
|
|
13
13
|
|
|
14
|
+
/** Escape a string for safe interpolation into HTML text/attribute contexts. */
|
|
15
|
+
function escapeHtml(value) {
|
|
16
|
+
return String(value)
|
|
17
|
+
.replace(/&/g, '&')
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
.replace(/"/g, '"')
|
|
21
|
+
.replace(/'/g, ''')
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
const STYLES = `
|
|
15
25
|
.sb-scene-debug {
|
|
16
26
|
padding: 16px;
|
|
@@ -89,7 +99,7 @@ export function mountFlowDebug(container, flowName) {
|
|
|
89
99
|
el.innerHTML = `
|
|
90
100
|
<div class="sb-scene-debug-error">
|
|
91
101
|
<div class="sb-scene-debug-error-title">Error loading flow</div>
|
|
92
|
-
<p class="sb-scene-debug-error-message">${error}</p>
|
|
102
|
+
<p class="sb-scene-debug-error-message">${escapeHtml(error)}</p>
|
|
93
103
|
</div>`
|
|
94
104
|
} else {
|
|
95
105
|
const title = document.createElement('h2')
|
|
@@ -827,9 +827,10 @@ function showToast(message, route, basePath, filePath) {
|
|
|
827
827
|
})
|
|
828
828
|
|
|
829
829
|
const href = route?.startsWith('/') ? (basePath.replace(/\/$/, '') + route) : route
|
|
830
|
+
const escAttr = (v) => String(v).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
|
830
831
|
let html = `<span style="font-weight:500">✓ ${message.replace(/</g, '<')}</span>`
|
|
831
832
|
if (href) {
|
|
832
|
-
html += `<a href="${href}" style="color:var(--color-primary, #0969da);text-decoration:underline;font-size:0.8125rem">Open canvas</a>`
|
|
833
|
+
html += `<a href="${escAttr(href)}" style="color:var(--color-primary, #0969da);text-decoration:underline;font-size:0.8125rem">Open canvas</a>`
|
|
833
834
|
}
|
|
834
835
|
if (filePath) {
|
|
835
836
|
html += `<span style="font-size:0.75rem;color:var(--color-muted, #64748b)">To edit your component, go to <code style="background:var(--color-muted, #f1f5f9);padding:1px 4px;border-radius:3px;font-size:0.75rem">${filePath.replace(/</g, '<')}</code></span>`
|
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle } from 'react'
|
|
2
|
-
import { remark } from 'remark'
|
|
3
|
-
import remarkGfm from 'remark-gfm'
|
|
4
|
-
import remarkHtml from 'remark-html'
|
|
5
2
|
import { GitBranchIcon, GitMergeIcon, GitPullRequestClosedIcon, GitPullRequestDraftIcon, GitPullRequestIcon, MarkGithubIcon } from '@primer/octicons-react'
|
|
6
3
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
7
4
|
import ResizeHandle from './ResizeHandle.jsx'
|
|
@@ -9,6 +6,7 @@ import { readProp, linkPreviewSchema } from './widgetProps.js'
|
|
|
9
6
|
import ExpandedPane from './ExpandedPane.jsx'
|
|
10
7
|
import { findAllConnectedSplitTargets, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
|
|
11
8
|
import { useExpandOverride } from './useExpandOverride.js'
|
|
9
|
+
import { markdownToSafeHtml } from './markdown/markdownSanitize.js'
|
|
12
10
|
import styles from './LinkPreview.module.css'
|
|
13
11
|
|
|
14
12
|
const VIDEO_URL_LINE_RE = /^<p>\s*(https?:\/\/[^\s<]+\.(mp4|mov|webm|ogg)(?:\?[^\s<]*)?)\s*<\/p>$/gim
|
|
@@ -69,11 +67,7 @@ function postProcessHtml(html) {
|
|
|
69
67
|
|
|
70
68
|
function renderMarkdown(text) {
|
|
71
69
|
if (!text) return ''
|
|
72
|
-
|
|
73
|
-
.use(remarkGfm)
|
|
74
|
-
.use(remarkHtml, { sanitize: false })
|
|
75
|
-
.processSync(text)
|
|
76
|
-
return postProcessHtml(String(result))
|
|
70
|
+
return postProcessHtml(markdownToSafeHtml(text))
|
|
77
71
|
}
|
|
78
72
|
|
|
79
73
|
function timeAgo(dateStr) {
|
|
@@ -5,17 +5,11 @@
|
|
|
5
5
|
* Split from `MarkdownEditor.jsx` so the latter only exports React
|
|
6
6
|
* components — keeping Fast Refresh happy and the surface area minimal.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
9
|
-
import remarkGfm from 'remark-gfm'
|
|
10
|
-
import remarkHtml from 'remark-html'
|
|
8
|
+
import { markdownToSafeHtml } from './markdownSanitize.js'
|
|
11
9
|
|
|
12
10
|
export function renderMarkdown(text) {
|
|
13
11
|
if (!text) return ''
|
|
14
|
-
|
|
15
|
-
.use(remarkGfm)
|
|
16
|
-
.use(remarkHtml, { sanitize: false })
|
|
17
|
-
.processSync(text)
|
|
18
|
-
return String(result).replace(/<a\s/g, '<a target="_blank" rel="noopener noreferrer" ')
|
|
12
|
+
return markdownToSafeHtml(text).replace(/<a\s/g, '<a target="_blank" rel="noopener noreferrer" ')
|
|
19
13
|
}
|
|
20
14
|
|
|
21
15
|
let hljsPromise = null
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared markdown → safe-HTML pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Renders markdown (with GFM) to HTML while preserving inline HTML that
|
|
5
|
+
* users legitimately embed in markdown widgets and GitHub issue/PR bodies
|
|
6
|
+
* (`<video>`, `<audio>`, `<details>`, …), but runs every node through a
|
|
7
|
+
* trusted allow-list sanitizer (`rehype-sanitize` / `hast-util-sanitize`).
|
|
8
|
+
*
|
|
9
|
+
* Because the sanitizer is allow-list based, anything not explicitly
|
|
10
|
+
* permitted is dropped: `<script>`/`<iframe>`, every `on*` event handler,
|
|
11
|
+
* and dangerous URL protocols (`javascript:`, `data:`) are statically
|
|
12
|
+
* removed. The extra tags re-admitted below are all *inert* elements, and
|
|
13
|
+
* only safe attributes (no event handlers) with `http(s)`-only `src`/`poster`
|
|
14
|
+
* are allowed.
|
|
15
|
+
*/
|
|
16
|
+
import { unified } from 'unified'
|
|
17
|
+
import remarkParse from 'remark-parse'
|
|
18
|
+
import remarkGfm from 'remark-gfm'
|
|
19
|
+
import remarkRehype from 'remark-rehype'
|
|
20
|
+
import rehypeRaw from 'rehype-raw'
|
|
21
|
+
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
|
22
|
+
import rehypeStringify from 'rehype-stringify'
|
|
23
|
+
|
|
24
|
+
const schema = {
|
|
25
|
+
...defaultSchema,
|
|
26
|
+
// `details`/`summary` are already in the default allow-list; add the inert
|
|
27
|
+
// media elements consumers embed in markdown.
|
|
28
|
+
tagNames: [...new Set([...(defaultSchema.tagNames || []), 'video', 'audio', 'track'])],
|
|
29
|
+
attributes: {
|
|
30
|
+
...defaultSchema.attributes,
|
|
31
|
+
video: ['src', 'poster', 'controls', 'loop', 'muted', 'autoPlay', 'playsInline', 'preload', 'width', 'height'],
|
|
32
|
+
audio: ['src', 'controls', 'loop', 'muted', 'autoPlay', 'preload'],
|
|
33
|
+
source: [...(defaultSchema.attributes?.source || []), 'src', 'type'],
|
|
34
|
+
track: ['src', 'kind', 'srcLang', 'label', 'default'],
|
|
35
|
+
},
|
|
36
|
+
protocols: {
|
|
37
|
+
...defaultSchema.protocols,
|
|
38
|
+
// Restrict media sources to http(s) — blocks `javascript:`/`data:` URLs.
|
|
39
|
+
src: ['http', 'https'],
|
|
40
|
+
poster: ['http', 'https'],
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const processor = unified()
|
|
45
|
+
.use(remarkParse)
|
|
46
|
+
.use(remarkGfm)
|
|
47
|
+
.use(remarkRehype, { allowDangerousHtml: true })
|
|
48
|
+
.use(rehypeRaw)
|
|
49
|
+
.use(rehypeSanitize, schema)
|
|
50
|
+
.use(rehypeStringify)
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Render markdown to sanitized HTML.
|
|
54
|
+
* @param {string} text Raw markdown.
|
|
55
|
+
* @returns {string} Safe HTML string.
|
|
56
|
+
*/
|
|
57
|
+
export function markdownToSafeHtml(text) {
|
|
58
|
+
if (!text) return ''
|
|
59
|
+
return String(processor.processSync(text))
|
|
60
|
+
}
|
|
@@ -1341,12 +1341,13 @@ export default function storyboardDataPlugin() {
|
|
|
1341
1341
|
return {
|
|
1342
1342
|
optimizeDeps: {
|
|
1343
1343
|
// @dfosco/storyboard is excluded (virtual module), so Vite
|
|
1344
|
-
// can't trace into its deps. Include the
|
|
1345
|
-
// Vite pre-bundles the full chain — covers all transitive
|
|
1346
|
-
// packages (debug, extend, etc.) without whack-a-mole.
|
|
1344
|
+
// can't trace into its deps. Include the markdown pipeline entry
|
|
1345
|
+
// points so Vite pre-bundles the full chain — covers all transitive
|
|
1346
|
+
// CJS packages (debug, extend, etc.) without whack-a-mole.
|
|
1347
1347
|
include: [
|
|
1348
1348
|
'cmdk',
|
|
1349
|
-
'remark', 'remark-gfm', 'remark-
|
|
1349
|
+
'remark-parse', 'remark-gfm', 'remark-rehype',
|
|
1350
|
+
'rehype-raw', 'rehype-sanitize', 'rehype-stringify',
|
|
1350
1351
|
'use-sync-external-store/shim', 'use-sync-external-store/shim/with-selector',
|
|
1351
1352
|
'feather-icons', '@primer/octicons', 'ansi-to-html',
|
|
1352
1353
|
// @primer/react ≥38 ships pre-compiled with React Compiler and
|
|
@@ -53,12 +53,12 @@ describe('storyboardDataPlugin', () => {
|
|
|
53
53
|
expect(config.optimizeDeps.exclude).toContain('@dfosco/storyboard')
|
|
54
54
|
})
|
|
55
55
|
|
|
56
|
-
it('config() includes
|
|
56
|
+
it('config() includes markdown pipeline stack in optimizeDeps so Vite pre-bundles transitive CJS deps', () => {
|
|
57
57
|
const plugin = storyboardDataPlugin()
|
|
58
58
|
const config = plugin.config()
|
|
59
|
-
expect(config.optimizeDeps.include).toContain('remark')
|
|
59
|
+
expect(config.optimizeDeps.include).toContain('remark-parse')
|
|
60
60
|
expect(config.optimizeDeps.include).toContain('remark-gfm')
|
|
61
|
-
expect(config.optimizeDeps.include).toContain('
|
|
61
|
+
expect(config.optimizeDeps.include).toContain('rehype-sanitize')
|
|
62
62
|
})
|
|
63
63
|
|
|
64
64
|
it("resolveId returns resolved ID for 'virtual:storyboard-data-index'", () => {
|