@charcuterie/ui 4.19.0 → 4.20.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/README.md +2 -0
- package/dist/htmlEditor/HtmlEditor.d.ts +25 -0
- package/dist/htmlEditor/HtmlEditor.d.ts.map +1 -0
- package/dist/htmlEditor/HtmlEditor.js +231 -0
- package/dist/htmlEditor/HtmlEditor.js.map +1 -0
- package/dist/htmlEditor/HtmlView.d.ts +10 -0
- package/dist/htmlEditor/HtmlView.d.ts.map +1 -0
- package/dist/htmlEditor/HtmlView.js +28 -0
- package/dist/htmlEditor/HtmlView.js.map +1 -0
- package/dist/htmlEditor/htmlContentStyles.d.ts +3 -0
- package/dist/htmlEditor/htmlContentStyles.d.ts.map +1 -0
- package/dist/htmlEditor/htmlContentStyles.js +3 -0
- package/dist/htmlEditor/htmlContentStyles.js.map +1 -0
- package/dist/htmlEditor/index.d.ts +8 -0
- package/dist/htmlEditor/index.d.ts.map +1 -0
- package/dist/htmlEditor/index.js +4 -0
- package/dist/htmlEditor/index.js.map +1 -0
- package/dist/htmlEditor/safeHtml.d.ts +12 -0
- package/dist/htmlEditor/safeHtml.d.ts.map +1 -0
- package/dist/htmlEditor/safeHtml.js +137 -0
- package/dist/htmlEditor/safeHtml.js.map +1 -0
- package/dist/htmlEditor/view.d.ts +6 -0
- package/dist/htmlEditor/view.d.ts.map +1 -0
- package/dist/htmlEditor/view.js +3 -0
- package/dist/htmlEditor/view.js.map +1 -0
- package/package.json +52 -2
- package/src/htmlEditor/HtmlEditor.mdx +61 -0
- package/src/htmlEditor/HtmlEditor.stories.tsx +192 -0
- package/src/htmlEditor/HtmlEditor.test.tsx +296 -0
- package/src/htmlEditor/HtmlEditor.tsx +420 -0
- package/src/htmlEditor/HtmlView.mdx +37 -0
- package/src/htmlEditor/HtmlView.stories.tsx +119 -0
- package/src/htmlEditor/HtmlView.test.tsx +88 -0
- package/src/htmlEditor/HtmlView.tsx +71 -0
- package/src/htmlEditor/htmlContentStyles.ts +3 -0
- package/src/htmlEditor/htmlDocument.storyHelpers.ts +7 -0
- package/src/htmlEditor/index.ts +7 -0
- package/src/htmlEditor/safeHtml.test.ts +123 -0
- package/src/htmlEditor/safeHtml.ts +187 -0
- package/src/htmlEditor/view.ts +5 -0
- package/src/sourceRules.test.ts +22 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react"
|
|
2
|
+
import { createElement } from "react"
|
|
3
|
+
|
|
4
|
+
import { TextLink } from "../TextLink/TextLink.tsx"
|
|
5
|
+
import { toClassName } from "../toClassName.ts"
|
|
6
|
+
import { HTML_CONTENT_CLASS } from "./htmlContentStyles.ts"
|
|
7
|
+
import type { HtmlContentNode } from "./safeHtml.ts"
|
|
8
|
+
import { toHtmlContent } from "./safeHtml.ts"
|
|
9
|
+
|
|
10
|
+
export type HtmlViewProps = {
|
|
11
|
+
className?: string
|
|
12
|
+
/** The readable document's accessible name. */
|
|
13
|
+
label: string
|
|
14
|
+
value: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const toStyle = (
|
|
18
|
+
value: string | undefined,
|
|
19
|
+
): CSSProperties =>
|
|
20
|
+
Object.fromEntries(
|
|
21
|
+
(value ?? "")
|
|
22
|
+
.split(";")
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.map((declaration) => {
|
|
25
|
+
const [name, content] = declaration.split(":")
|
|
26
|
+
return [
|
|
27
|
+
name === "text-align" ? "textAlign" : name,
|
|
28
|
+
content,
|
|
29
|
+
]
|
|
30
|
+
}),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const renderNode = (
|
|
34
|
+
node: HtmlContentNode,
|
|
35
|
+
key: number,
|
|
36
|
+
): ReactNode => {
|
|
37
|
+
if ("text" in node) return node.text
|
|
38
|
+
const { style, ...attributes } = node.attributes
|
|
39
|
+
const children = node.children.map(renderNode)
|
|
40
|
+
if (node.tag === "a" && attributes.href !== undefined)
|
|
41
|
+
return (
|
|
42
|
+
<TextLink
|
|
43
|
+
href={attributes.href}
|
|
44
|
+
key={key}
|
|
45
|
+
style={toStyle(style)}
|
|
46
|
+
title={attributes.title}
|
|
47
|
+
>
|
|
48
|
+
{children}
|
|
49
|
+
</TextLink>
|
|
50
|
+
)
|
|
51
|
+
return createElement(
|
|
52
|
+
node.tag,
|
|
53
|
+
{ ...attributes, key, style: toStyle(style) },
|
|
54
|
+
children.length ? children : undefined,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** HTML is a distinct stored format. No editor and no innerHTML. */
|
|
59
|
+
export const HtmlView = ({
|
|
60
|
+
className,
|
|
61
|
+
label,
|
|
62
|
+
value,
|
|
63
|
+
}: HtmlViewProps): ReactNode => (
|
|
64
|
+
<div
|
|
65
|
+
aria-label={label}
|
|
66
|
+
className={toClassName(HTML_CONTENT_CLASS, className)}
|
|
67
|
+
role="document"
|
|
68
|
+
>
|
|
69
|
+
{toHtmlContent(value).map(renderNode)}
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** Identical document paint in the reader and the editing surface. */
|
|
2
|
+
export const HTML_CONTENT_CLASS =
|
|
3
|
+
"min-w-0 wrap-anywhere text-content-primary [&_p]:my-2 [&_div]:my-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:ps-6 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:ps-6 [&_li]:my-1 [&_blockquote]:my-3 [&_blockquote]:border-s-2 [&_blockquote]:border-border-default [&_blockquote]:ps-4 [&_blockquote]:text-content-secondary [&_pre]:whitespace-pre-wrap [&_pre]:rounded-md [&_pre]:bg-surface-raised [&_pre]:p-3 [&_code]:font-mono [&_h1]:my-3 [&_h1]:text-2xl [&_h1]:font-bold [&_h2]:my-3 [&_h2]:text-xl [&_h2]:font-bold [&_h3]:my-3 [&_h3]:text-lg [&_h3]:font-bold [&_h4]:my-2 [&_h4]:font-semibold [&_h5]:my-2 [&_h5]:font-semibold [&_h6]:my-2 [&_h6]:font-semibold [&_a]:text-intent-accent-content [&_a]:underline [&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-md [&_hr]:my-3 [&_hr]:border-border-default"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const HTML_DOCUMENT = `<p><strong>Workshop fixture</strong> & setup notes.</p><p>Keep the <em>revision</em> and <u>material</u> together.</p><ul><li>Inspect the mounting holes.</li><li>Store the source file.</li></ul><ol start="3"><li>Build a fresh output.</li><li>Record the result.</li></ol><p style="text-align:center"><a href="https://example.invalid/recipe">Read the recipe</a></p><blockquote><p>Keep a traceable print recipe.</p></blockquote>`
|
|
2
|
+
|
|
3
|
+
export const ORIGINAL_HTML = `<DIV data-old-format='retained'><p class='legacy'>Original & untouched</p></DIV>`
|
|
4
|
+
|
|
5
|
+
export const HOSTILE_HTML = `<p onclick="alert(1)">Visible notes</p><script>alert(1)</script><style>body{display:none}</style><iframe src="https://example.invalid/embed"></iframe><a href="javascript:alert(1)">Unsafe link</a><img src="data:text/html,attack" onerror="alert(1)"><span style="color:currentcolor;background:url(https://example.invalid/tracker);position:fixed">Only safe text styling</span><svg><a href="javascript:alert(1)">Foreign content</a></svg>`
|
|
6
|
+
|
|
7
|
+
export const DOCUMENT_IMAGE = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="240" height="80"><rect width="240" height="80" rx="8" fill="steelblue"/><circle cx="120" cy="40" r="24" fill="white"/></svg>')}`
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Opt-in rich HTML surfaces; no HTML conversion in markdown components. */
|
|
2
|
+
export type { HtmlEditorProps } from "./HtmlEditor.tsx"
|
|
3
|
+
export { HtmlEditor } from "./HtmlEditor.tsx"
|
|
4
|
+
export type { HtmlViewProps } from "./HtmlView.tsx"
|
|
5
|
+
export { HtmlView } from "./HtmlView.tsx"
|
|
6
|
+
export type { HtmlContentNode } from "./safeHtml.ts"
|
|
7
|
+
export { toHtmlContent, toSafeHtml } from "./safeHtml.ts"
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createElement } from "react"
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server"
|
|
3
|
+
import { expect, test } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { HtmlView } from "./HtmlView.tsx"
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
toHtmlContent,
|
|
9
|
+
toSafeHtml,
|
|
10
|
+
toSafeHtmlColour,
|
|
11
|
+
} from "./safeHtml.ts"
|
|
12
|
+
|
|
13
|
+
test("the reader renders safe HTML on the server without a DOM or editor runtime", () => {
|
|
14
|
+
expect(typeof window).toBe("undefined")
|
|
15
|
+
const html = renderToStaticMarkup(
|
|
16
|
+
createElement(HtmlView, {
|
|
17
|
+
label: "Server notes",
|
|
18
|
+
value:
|
|
19
|
+
'<p onclick="attack()">Stored & safe</p><script>attack()</script><a href="javascript:attack()">Read</a>',
|
|
20
|
+
}),
|
|
21
|
+
)
|
|
22
|
+
expect(html).toContain("Stored & safe")
|
|
23
|
+
expect(html).toContain('role="document"')
|
|
24
|
+
expect(html).not.toMatch(
|
|
25
|
+
/onclick|<script|href="javascript/,
|
|
26
|
+
)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("keeps readable formatting while removing executable HTML and arbitrary source props", () => {
|
|
30
|
+
expect(
|
|
31
|
+
toSafeHtml(
|
|
32
|
+
'<div id="clobber" class="overlay"><p onclick="attack()"><strong>Keep</strong> & read.</p><script>attack()</script><style>body{display:none}</style><iframe src="/embed">hidden</iframe><svg><text>hidden</text></svg><math><mtext>hidden</mtext></math></div>',
|
|
33
|
+
),
|
|
34
|
+
).toBe(
|
|
35
|
+
"<div><p><strong>Keep</strong> & read.</p></div>",
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test.each([
|
|
40
|
+
"javascript:attack()",
|
|
41
|
+
"javascript:attack()",
|
|
42
|
+
"JaVa	Script:attack()",
|
|
43
|
+
"data:text/html,attack",
|
|
44
|
+
"file:///private",
|
|
45
|
+
"vbscript:attack()",
|
|
46
|
+
])(
|
|
47
|
+
"decodes and rejects an unsafe link scheme: %s",
|
|
48
|
+
(href) => {
|
|
49
|
+
expect(
|
|
50
|
+
toSafeHtml(
|
|
51
|
+
`<a href="${href}" target="_blank" onclick="attack()">Read</a>`,
|
|
52
|
+
),
|
|
53
|
+
).toBe('<a rel="noopener noreferrer">Read</a>')
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
test.each([
|
|
58
|
+
"https://example.invalid/notes",
|
|
59
|
+
"/notes",
|
|
60
|
+
"./notes",
|
|
61
|
+
"#section",
|
|
62
|
+
"mailto:help@example.invalid",
|
|
63
|
+
"tel:1234",
|
|
64
|
+
])("keeps a permitted link: %s", (href) => {
|
|
65
|
+
expect(toSafeHtml(`<a href="${href}">Read</a>`)).toBe(
|
|
66
|
+
`<a href="${href}" rel="noopener noreferrer">Read</a>`,
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test("image attributes are closed and rejected image sources disappear", () => {
|
|
71
|
+
expect(
|
|
72
|
+
toSafeHtml(
|
|
73
|
+
'<img src="/photo.png" alt="A & B" onerror="attack()" srcset="/tracker 2x" width="999999"><img src="javascript:attack()"><img src="data:text/html,attack"><img src="">',
|
|
74
|
+
),
|
|
75
|
+
).toBe('<img src="/photo.png" alt="A & B">')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("retains only document colour and alignment; CSS cannot load resources or escape the surface", () => {
|
|
79
|
+
expect(
|
|
80
|
+
toSafeHtml(
|
|
81
|
+
'<p style="position:fixed;inset:0;background:url(/tracker);color:rgb(20, 30, 40);text-align:center;font-size:9999px">Notes</p>',
|
|
82
|
+
),
|
|
83
|
+
).toBe(
|
|
84
|
+
'<p style="color:rgb(20, 30, 40);text-align:center">Notes</p>',
|
|
85
|
+
)
|
|
86
|
+
expect(
|
|
87
|
+
toSafeHtml(
|
|
88
|
+
'<span style="color:expression(attack());background-image:url(/tracker)">Notes</span>',
|
|
89
|
+
),
|
|
90
|
+
).toBe("<span>Notes</span>")
|
|
91
|
+
expect(
|
|
92
|
+
toSafeHtmlColour("red;position:fixed"),
|
|
93
|
+
).toBeUndefined()
|
|
94
|
+
expect(toSafeHtmlColour("url(/tracker)")).toBeUndefined()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("an HTML5 parse, rather than regex replacement, contains malformed foreign content", () => {
|
|
98
|
+
const safe = toSafeHtml(
|
|
99
|
+
'<math><mtext><table><mglyph><style><!--</style><img title="--><img src=x onerror=attack()>">',
|
|
100
|
+
)
|
|
101
|
+
expect(safe).not.toMatch(/onerror|<math|<style|<mglyph/)
|
|
102
|
+
expect(toSafeHtml(safe)).toBe(safe)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("text entities and repeated sanitization retain visible meaning without writing back to the input", () => {
|
|
106
|
+
const original =
|
|
107
|
+
"<P class='old'>5 < 6 & \"quoted\"</P>"
|
|
108
|
+
const safe = toSafeHtml(original)
|
|
109
|
+
expect(safe).toBe(
|
|
110
|
+
"<p>5 < 6 & "quoted"</p>",
|
|
111
|
+
)
|
|
112
|
+
expect(toSafeHtml(safe)).toBe(safe)
|
|
113
|
+
expect(toHtmlContent(original)).toEqual([
|
|
114
|
+
{
|
|
115
|
+
tag: "p",
|
|
116
|
+
attributes: {},
|
|
117
|
+
children: [{ text: '5 < 6 & "quoted"' }],
|
|
118
|
+
},
|
|
119
|
+
])
|
|
120
|
+
expect(original).toBe(
|
|
121
|
+
"<P class='old'>5 < 6 & \"quoted\"</P>",
|
|
122
|
+
)
|
|
123
|
+
})
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { DefaultTreeAdapterMap } from "parse5"
|
|
2
|
+
import { parseFragment } from "parse5"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
toSafeImageUrl,
|
|
6
|
+
toSafeLinkUrl,
|
|
7
|
+
} from "../safeUrls.ts"
|
|
8
|
+
|
|
9
|
+
export type HtmlContentNode =
|
|
10
|
+
| { text: string }
|
|
11
|
+
| {
|
|
12
|
+
attributes: Record<string, string>
|
|
13
|
+
children: HtmlContentNode[]
|
|
14
|
+
tag: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// One format policy for rendering, initial editor content, paste,
|
|
18
|
+
// and serialized edits. Parsing HTML5 before filtering also decodes
|
|
19
|
+
// character references in URLs; an encoded scheme cannot evade the
|
|
20
|
+
// shared URL guard. Nothing from the source becomes a React prop.
|
|
21
|
+
const TAGS = new Set([
|
|
22
|
+
"p",
|
|
23
|
+
"div",
|
|
24
|
+
"br",
|
|
25
|
+
"strong",
|
|
26
|
+
"b",
|
|
27
|
+
"em",
|
|
28
|
+
"i",
|
|
29
|
+
"u",
|
|
30
|
+
"s",
|
|
31
|
+
"strike",
|
|
32
|
+
"span",
|
|
33
|
+
"a",
|
|
34
|
+
"img",
|
|
35
|
+
"ul",
|
|
36
|
+
"ol",
|
|
37
|
+
"li",
|
|
38
|
+
"blockquote",
|
|
39
|
+
"h1",
|
|
40
|
+
"h2",
|
|
41
|
+
"h3",
|
|
42
|
+
"h4",
|
|
43
|
+
"h5",
|
|
44
|
+
"h6",
|
|
45
|
+
"pre",
|
|
46
|
+
"code",
|
|
47
|
+
"hr",
|
|
48
|
+
])
|
|
49
|
+
const DROP_CONTENT = new Set([
|
|
50
|
+
"script",
|
|
51
|
+
"style",
|
|
52
|
+
"template",
|
|
53
|
+
"iframe",
|
|
54
|
+
"object",
|
|
55
|
+
"embed",
|
|
56
|
+
"svg",
|
|
57
|
+
"math",
|
|
58
|
+
"noscript",
|
|
59
|
+
])
|
|
60
|
+
const VOID_TAGS = new Set(["br", "hr", "img"])
|
|
61
|
+
|
|
62
|
+
export const toSafeHtmlColour = (
|
|
63
|
+
value: string,
|
|
64
|
+
): string | undefined => {
|
|
65
|
+
const colour = value.trim().toLowerCase()
|
|
66
|
+
return /^(#[\da-f]{3}|#[\da-f]{4}|#[\da-f]{6}|#[\da-f]{8}|[a-z]+|(?:rgb|rgba|hsl|hsla)\([\d.,%\s+-]+\))$/.test(
|
|
67
|
+
colour,
|
|
68
|
+
)
|
|
69
|
+
? colour
|
|
70
|
+
: undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const toSafeStyle = (value: string): string =>
|
|
74
|
+
value
|
|
75
|
+
.split(";")
|
|
76
|
+
.flatMap((declaration) => {
|
|
77
|
+
const [rawName, ...parts] = declaration.split(":")
|
|
78
|
+
const name = rawName?.trim().toLowerCase()
|
|
79
|
+
const content = parts.join(":").trim().toLowerCase()
|
|
80
|
+
|
|
81
|
+
if (
|
|
82
|
+
name === "text-align" &&
|
|
83
|
+
/^(start|end|left|right|center|justify)$/.test(
|
|
84
|
+
content,
|
|
85
|
+
)
|
|
86
|
+
) {
|
|
87
|
+
return [`text-align:${content}`]
|
|
88
|
+
}
|
|
89
|
+
if (
|
|
90
|
+
name === "color" &&
|
|
91
|
+
toSafeHtmlColour(content) !== undefined
|
|
92
|
+
) {
|
|
93
|
+
return [`color:${content}`]
|
|
94
|
+
}
|
|
95
|
+
return []
|
|
96
|
+
})
|
|
97
|
+
.join(";")
|
|
98
|
+
|
|
99
|
+
const toSafeAttributes = (
|
|
100
|
+
tag: string,
|
|
101
|
+
attributes: DefaultTreeAdapterMap["element"]["attrs"],
|
|
102
|
+
): Record<string, string> => {
|
|
103
|
+
const source = Object.fromEntries(
|
|
104
|
+
attributes
|
|
105
|
+
.filter((attribute) => !attribute.namespace)
|
|
106
|
+
.map(({ name, value }) => [name, value]),
|
|
107
|
+
)
|
|
108
|
+
const result: Record<string, string> = {}
|
|
109
|
+
const style = toSafeStyle(source.style ?? "")
|
|
110
|
+
if (style !== "") result.style = style
|
|
111
|
+
|
|
112
|
+
if (tag === "a") {
|
|
113
|
+
const href = toSafeLinkUrl(source.href ?? "")
|
|
114
|
+
if (source.href !== undefined && href !== undefined)
|
|
115
|
+
result.href = href
|
|
116
|
+
if (source.title) result.title = source.title
|
|
117
|
+
// Source target/rel attributes never reach the page.
|
|
118
|
+
result.rel = "noopener noreferrer"
|
|
119
|
+
}
|
|
120
|
+
if (tag === "img") {
|
|
121
|
+
const src = toSafeImageUrl(source.src ?? "")
|
|
122
|
+
if (src) result.src = src
|
|
123
|
+
result.alt = source.alt ?? ""
|
|
124
|
+
if (source.title) result.title = source.title
|
|
125
|
+
}
|
|
126
|
+
if (tag === "ol" && /^\d{1,6}$/.test(source.start ?? ""))
|
|
127
|
+
result.start = source.start ?? "1"
|
|
128
|
+
return result
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const toSafeNodes = (
|
|
132
|
+
nodes: DefaultTreeAdapterMap["childNode"][],
|
|
133
|
+
): HtmlContentNode[] =>
|
|
134
|
+
nodes.flatMap((node): HtmlContentNode[] => {
|
|
135
|
+
if (node.nodeName === "#text" && "value" in node)
|
|
136
|
+
return [{ text: node.value }]
|
|
137
|
+
if (!("tagName" in node)) return []
|
|
138
|
+
if (
|
|
139
|
+
DROP_CONTENT.has(node.tagName) ||
|
|
140
|
+
node.namespaceURI !== "http://www.w3.org/1999/xhtml"
|
|
141
|
+
)
|
|
142
|
+
return []
|
|
143
|
+
const children = toSafeNodes(node.childNodes)
|
|
144
|
+
if (!TAGS.has(node.tagName)) return children
|
|
145
|
+
const attributes = toSafeAttributes(
|
|
146
|
+
node.tagName,
|
|
147
|
+
node.attrs,
|
|
148
|
+
)
|
|
149
|
+
// A rejected image is absent, rather than a broken image or an
|
|
150
|
+
// empty src that makes another request for the document itself.
|
|
151
|
+
if (node.tagName === "img" && !attributes.src) return []
|
|
152
|
+
return [{ attributes, children, tag: node.tagName }]
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
export const toHtmlContent = (
|
|
156
|
+
value: string,
|
|
157
|
+
): HtmlContentNode[] =>
|
|
158
|
+
toSafeNodes(parseFragment(value).childNodes)
|
|
159
|
+
|
|
160
|
+
const escapeHtml = (value: string) =>
|
|
161
|
+
value.replace(
|
|
162
|
+
/[&<>"]/g,
|
|
163
|
+
(character) =>
|
|
164
|
+
({
|
|
165
|
+
"&": "&",
|
|
166
|
+
"<": "<",
|
|
167
|
+
">": ">",
|
|
168
|
+
'"': """,
|
|
169
|
+
})[character] ?? character,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
const serialize = (node: HtmlContentNode): string => {
|
|
173
|
+
if ("text" in node) return escapeHtml(node.text)
|
|
174
|
+
const attributes = Object.entries(node.attributes)
|
|
175
|
+
.map(
|
|
176
|
+
([name, value]) => ` ${name}="${escapeHtml(value)}"`,
|
|
177
|
+
)
|
|
178
|
+
.join("")
|
|
179
|
+
const start = `<${node.tag}${attributes}>`
|
|
180
|
+
return VOID_TAGS.has(node.tag)
|
|
181
|
+
? start
|
|
182
|
+
: `${start}${node.children.map(serialize).join("")}</${node.tag}>`
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A display/edit projection, never an instruction to rewrite stored HTML. */
|
|
186
|
+
export const toSafeHtml = (value: string): string =>
|
|
187
|
+
toHtmlContent(value).map(serialize).join("")
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Read HTML without importing or installing the rich editor core. */
|
|
2
|
+
export type { HtmlViewProps } from "./HtmlView.tsx"
|
|
3
|
+
export { HtmlView } from "./HtmlView.tsx"
|
|
4
|
+
export type { HtmlContentNode } from "./safeHtml.ts"
|
|
5
|
+
export { toHtmlContent, toSafeHtml } from "./safeHtml.ts"
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -782,6 +782,28 @@ const ENTRY_POINT_RUNTIMES: Record<
|
|
|
782
782
|
// silently, which is how it shipped broken in three repos.
|
|
783
783
|
"tailwind-merge",
|
|
784
784
|
],
|
|
785
|
+
// Retained HTML has its own format family and optional rich editor
|
|
786
|
+
// peers. The main entry never reaches this parser or editor core.
|
|
787
|
+
"./html-view": [
|
|
788
|
+
"@charcuterie/tokens",
|
|
789
|
+
"parse5",
|
|
790
|
+
"react",
|
|
791
|
+
"tailwind-merge",
|
|
792
|
+
],
|
|
793
|
+
"./html-editor": [
|
|
794
|
+
"@charcuterie/logic",
|
|
795
|
+
"@charcuterie/logic/browser",
|
|
796
|
+
"@charcuterie/tokens",
|
|
797
|
+
"@floating-ui/react",
|
|
798
|
+
"@tiptap/extension-image",
|
|
799
|
+
"@tiptap/extension-text-align",
|
|
800
|
+
"@tiptap/extension-text-style",
|
|
801
|
+
"@tiptap/react",
|
|
802
|
+
"@tiptap/starter-kit",
|
|
803
|
+
"parse5",
|
|
804
|
+
"react",
|
|
805
|
+
"tailwind-merge",
|
|
806
|
+
],
|
|
785
807
|
// The opt-in live-preview editor. Every `@codemirror/*` and
|
|
786
808
|
// `@lezer/*` package appears **only** here, as an optional peer,
|
|
787
809
|
// which is the entire reason it is a subpath: measured at ~176 KB
|