@charcuterie/ui 4.18.3 → 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/DataTable/DataTable.d.ts +6 -1
- package/dist/DataTable/DataTable.d.ts.map +1 -1
- package/dist/DataTable/DataTable.js +104 -8
- package/dist/DataTable/DataTable.js.map +1 -1
- package/dist/MetricCard/MetricCard.d.ts +14 -0
- package/dist/MetricCard/MetricCard.d.ts.map +1 -0
- package/dist/MetricCard/MetricCard.js +10 -0
- package/dist/MetricCard/MetricCard.js.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.js +1 -1
- package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -1
- 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/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/styles.css +46 -0
- package/package.json +52 -2
- package/src/DataTable/DataTable.mdx +9 -2
- package/src/DataTable/DataTable.stories.tsx +50 -0
- package/src/DataTable/DataTable.test.tsx +82 -0
- package/src/DataTable/DataTable.tsx +194 -17
- package/src/MetricCard/MetricCard.mdx +26 -0
- package/src/MetricCard/MetricCard.stories.tsx +104 -0
- package/src/MetricCard/MetricCard.test.tsx +64 -0
- package/src/MetricCard/MetricCard.tsx +51 -0
- package/src/SortableTableHeader/SortableTableHeader.tsx +1 -1
- 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/index.ts +3 -0
- package/src/markdownEditorCodeMirror/MarkdownView.test.tsx +8 -4
- package/src/sourceRules.test.ts +24 -1
- package/src/styles.css +46 -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/index.ts
CHANGED
|
@@ -158,6 +158,7 @@ export {
|
|
|
158
158
|
export type {
|
|
159
159
|
DataTableColumn,
|
|
160
160
|
DataTableProps,
|
|
161
|
+
DataTableReflowAt,
|
|
161
162
|
DataTableSelection,
|
|
162
163
|
DataTableSort,
|
|
163
164
|
} from "./DataTable/DataTable.tsx"
|
|
@@ -316,6 +317,8 @@ export type {
|
|
|
316
317
|
MenuProps,
|
|
317
318
|
} from "./Menu/Menu.tsx"
|
|
318
319
|
export { Menu } from "./Menu/Menu.tsx"
|
|
320
|
+
export type { MetricCardProps } from "./MetricCard/MetricCard.tsx"
|
|
321
|
+
export { MetricCard } from "./MetricCard/MetricCard.tsx"
|
|
319
322
|
export type { ModalProps } from "./Modal/Modal.tsx"
|
|
320
323
|
export { Modal } from "./Modal/Modal.tsx"
|
|
321
324
|
export type {
|
|
@@ -367,10 +367,14 @@ test.each([
|
|
|
367
367
|
async (_case, name) => {
|
|
368
368
|
const { canvas } = await mountStory(NestedLinkText)
|
|
369
369
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
370
|
+
// CodeMirror finishes its viewport decorations on a scheduled frame.
|
|
371
|
+
// Wait for the accessible anchor rather than assuming mount means paint.
|
|
372
|
+
const link = await waitFor(() =>
|
|
373
|
+
expectAgentDrivable(canvas, {
|
|
374
|
+
name,
|
|
375
|
+
role: "link",
|
|
376
|
+
}),
|
|
377
|
+
)
|
|
374
378
|
|
|
375
379
|
await expect(link.tagName).toBe("A")
|
|
376
380
|
},
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -621,7 +621,8 @@ test("the barrel is the only place components are re-exported", async () => {
|
|
|
621
621
|
// ValuePairs owns compact label-over-value summaries.
|
|
622
622
|
// Chart shares portable SVG geometry with Preact and image consumers.
|
|
623
623
|
// TableViewport owns readable comparisons and full-window expansion.
|
|
624
|
-
|
|
624
|
+
// MetricCard shares prominent values and category-coloured icons through Card.
|
|
625
|
+
expect(componentNames.length).toBe(75)
|
|
625
626
|
|
|
626
627
|
for (const name of componentNames) {
|
|
627
628
|
expect(barrel).toContain(`export { ${name} }`)
|
|
@@ -781,6 +782,28 @@ const ENTRY_POINT_RUNTIMES: Record<
|
|
|
781
782
|
// silently, which is how it shipped broken in three repos.
|
|
782
783
|
"tailwind-merge",
|
|
783
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
|
+
],
|
|
784
807
|
// The opt-in live-preview editor. Every `@codemirror/*` and
|
|
785
808
|
// `@lezer/*` package appears **only** here, as an optional peer,
|
|
786
809
|
// which is the entire reason it is a subpath: measured at ~176 KB
|
package/src/styles.css
CHANGED
|
@@ -260,3 +260,49 @@
|
|
|
260
260
|
th:first-child {
|
|
261
261
|
z-index: 3;
|
|
262
262
|
}
|
|
263
|
+
|
|
264
|
+
/* Column width utilities describe comparison columns, never labelled list cells.
|
|
265
|
+
These rules are unlayered, like the component's existing structural styles,
|
|
266
|
+
so a consumer's cq-lg:w-32 cannot crush a list reflowing at cq-xl. */
|
|
267
|
+
.charcuterie-data-table[data-layout="stacked"]
|
|
268
|
+
> table
|
|
269
|
+
> :is(thead, tbody)
|
|
270
|
+
> tr
|
|
271
|
+
> :is(th, td) {
|
|
272
|
+
inline-size: auto;
|
|
273
|
+
min-inline-size: 0;
|
|
274
|
+
max-inline-size: none;
|
|
275
|
+
}
|
|
276
|
+
@container (width < 32rem) {
|
|
277
|
+
.charcuterie-data-table[data-reflow-at="md"]
|
|
278
|
+
> table
|
|
279
|
+
> :is(thead, tbody)
|
|
280
|
+
> tr
|
|
281
|
+
> :is(th, td) {
|
|
282
|
+
inline-size: auto;
|
|
283
|
+
min-inline-size: 0;
|
|
284
|
+
max-inline-size: none;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
@container (width < 48rem) {
|
|
288
|
+
.charcuterie-data-table[data-reflow-at="lg"]
|
|
289
|
+
> table
|
|
290
|
+
> :is(thead, tbody)
|
|
291
|
+
> tr
|
|
292
|
+
> :is(th, td) {
|
|
293
|
+
inline-size: auto;
|
|
294
|
+
min-inline-size: 0;
|
|
295
|
+
max-inline-size: none;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
@container (width < 64rem) {
|
|
299
|
+
.charcuterie-data-table[data-reflow-at="xl"]
|
|
300
|
+
> table
|
|
301
|
+
> :is(thead, tbody)
|
|
302
|
+
> tr
|
|
303
|
+
> :is(th, td) {
|
|
304
|
+
inline-size: auto;
|
|
305
|
+
min-inline-size: 0;
|
|
306
|
+
max-inline-size: none;
|
|
307
|
+
}
|
|
308
|
+
}
|