@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.
Files changed (41) hide show
  1. package/README.md +2 -0
  2. package/dist/htmlEditor/HtmlEditor.d.ts +25 -0
  3. package/dist/htmlEditor/HtmlEditor.d.ts.map +1 -0
  4. package/dist/htmlEditor/HtmlEditor.js +231 -0
  5. package/dist/htmlEditor/HtmlEditor.js.map +1 -0
  6. package/dist/htmlEditor/HtmlView.d.ts +10 -0
  7. package/dist/htmlEditor/HtmlView.d.ts.map +1 -0
  8. package/dist/htmlEditor/HtmlView.js +28 -0
  9. package/dist/htmlEditor/HtmlView.js.map +1 -0
  10. package/dist/htmlEditor/htmlContentStyles.d.ts +3 -0
  11. package/dist/htmlEditor/htmlContentStyles.d.ts.map +1 -0
  12. package/dist/htmlEditor/htmlContentStyles.js +3 -0
  13. package/dist/htmlEditor/htmlContentStyles.js.map +1 -0
  14. package/dist/htmlEditor/index.d.ts +8 -0
  15. package/dist/htmlEditor/index.d.ts.map +1 -0
  16. package/dist/htmlEditor/index.js +4 -0
  17. package/dist/htmlEditor/index.js.map +1 -0
  18. package/dist/htmlEditor/safeHtml.d.ts +12 -0
  19. package/dist/htmlEditor/safeHtml.d.ts.map +1 -0
  20. package/dist/htmlEditor/safeHtml.js +137 -0
  21. package/dist/htmlEditor/safeHtml.js.map +1 -0
  22. package/dist/htmlEditor/view.d.ts +6 -0
  23. package/dist/htmlEditor/view.d.ts.map +1 -0
  24. package/dist/htmlEditor/view.js +3 -0
  25. package/dist/htmlEditor/view.js.map +1 -0
  26. package/package.json +52 -2
  27. package/src/htmlEditor/HtmlEditor.mdx +61 -0
  28. package/src/htmlEditor/HtmlEditor.stories.tsx +192 -0
  29. package/src/htmlEditor/HtmlEditor.test.tsx +296 -0
  30. package/src/htmlEditor/HtmlEditor.tsx +420 -0
  31. package/src/htmlEditor/HtmlView.mdx +37 -0
  32. package/src/htmlEditor/HtmlView.stories.tsx +119 -0
  33. package/src/htmlEditor/HtmlView.test.tsx +88 -0
  34. package/src/htmlEditor/HtmlView.tsx +71 -0
  35. package/src/htmlEditor/htmlContentStyles.ts +3 -0
  36. package/src/htmlEditor/htmlDocument.storyHelpers.ts +7 -0
  37. package/src/htmlEditor/index.ts +7 -0
  38. package/src/htmlEditor/safeHtml.test.ts +123 -0
  39. package/src/htmlEditor/safeHtml.ts +187 -0
  40. package/src/htmlEditor/view.ts +5 -0
  41. 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> &amp; 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 &amp; 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="java&#x73;cript: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 &amp; safe</p><script>attack()</script><a href="javascript:attack()">Read</a>',
20
+ }),
21
+ )
22
+ expect(html).toContain("Stored &amp; 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> &amp; 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> &amp; read.</p></div>",
36
+ )
37
+ })
38
+
39
+ test.each([
40
+ "javascript:attack()",
41
+ "java&#x73;cript:attack()",
42
+ "JaVa&#x09;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 &amp; 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 &amp; 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 &lt; 6 &amp; \"quoted\"</P>"
108
+ const safe = toSafeHtml(original)
109
+ expect(safe).toBe(
110
+ "<p>5 &lt; 6 &amp; &quot;quoted&quot;</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 &lt; 6 &amp; \"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
+ "&": "&amp;",
166
+ "<": "&lt;",
167
+ ">": "&gt;",
168
+ '"': "&quot;",
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"
@@ -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