@examplary/ui 1.75.1 → 1.76.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/AGENTS.md +3 -3
- package/dist/components/rich-text/rich-text-display.js +2 -2
- package/dist/components/rich-text/tiptap/image.js +3 -0
- package/dist/components/rich-text/tiptap/mathematics-component.js +5 -5
- package/dist/components/ui/color-picker.js +1 -1
- package/dist/components/web-components/inline-math.js +3 -3
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +1 -0
- package/dist/lib/sanitize-mathml.d.ts +6 -0
- package/dist/lib/sanitize-mathml.js +149 -0
- package/package.json +4 -2
package/AGENTS.md
CHANGED
|
@@ -111,7 +111,7 @@ Import from `@examplary/ui`. Uses Tailwind CSS v4. Combine classes with the expo
|
|
|
111
111
|
|
|
112
112
|
## Buttons & Badges
|
|
113
113
|
|
|
114
|
-
**Button** - Props: `variant` (default | primary | secondary | destructive | white | plain | dashed | rounded | roundedPrimary | roundedSecondary | roundedWhite), `size` (default | sm | lg | icon | icon-sm | icon-lg), `asChild`.
|
|
114
|
+
**Button** - Props: `variant` (default | primary | secondary | destructive | white | plain | roundedPlain | dashed | rounded | roundedPrimary | roundedSecondary | roundedWhite), `size` (default | sm | lg | icon | icon-sm | icon-lg), `asChild`.
|
|
115
115
|
|
|
116
116
|
```tsx
|
|
117
117
|
<Button variant="primary" size="sm">
|
|
@@ -119,7 +119,7 @@ Import from `@examplary/ui`. Uses Tailwind CSS v4. Combine classes with the expo
|
|
|
119
119
|
</Button>
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
-
**Badge** - Status tag. Props: `variant` (default | blue | bright | positive | orange | secondary).
|
|
122
|
+
**Badge** - Status tag. Props: `variant` (default | blue | bright | positive | orange | secondary | destructive).
|
|
123
123
|
|
|
124
124
|
## Overlays
|
|
125
125
|
|
|
@@ -215,4 +215,4 @@ Import from `@examplary/ui`. Uses Tailwind CSS v4. Combine classes with the expo
|
|
|
215
215
|
## Utilities
|
|
216
216
|
|
|
217
217
|
- `cn(...classes)` - Merges Tailwind classes (clsx + tailwind-merge).
|
|
218
|
-
- `registerWebComponents()` - Registers `<file-attachment>`, `<page-clipping>`, `<inline-math>` custom elements for rich text rendering.
|
|
218
|
+
- `registerWebComponents()` - Registers `<file-attachment>`, `<page-clipping>`, `<inline-math>`, `<content-reference>` custom elements for rich text rendering.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { createElement, useMemo, useRef } from "react";
|
|
3
|
-
import {
|
|
3
|
+
import { latexToSafeMathMl } from "../../lib/sanitize-mathml";
|
|
4
4
|
import { cn } from "../../utils";
|
|
5
5
|
export const convertHtmlToRichText = (html) => {
|
|
6
6
|
// Replace <inline-math> with MathML generated from the LaTeX content
|
|
7
7
|
return (html?.replace?.(/<inline-math>([\s\S]*?)<\/inline-math>/g, (match, content) => {
|
|
8
8
|
try {
|
|
9
|
-
return `<math>${
|
|
9
|
+
return `<math>${latexToSafeMathMl(content)}</math>`;
|
|
10
10
|
}
|
|
11
11
|
catch {
|
|
12
12
|
// If conversion fails, keep the original inline-math markup so that
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useMemo, useState } from "react";
|
|
3
3
|
import { NodeViewWrapper } from "@tiptap/react";
|
|
4
4
|
import { EditIcon } from "lucide-react";
|
|
5
|
-
import {
|
|
5
|
+
import { latexToSafeMathMl } from "../../../lib/sanitize-mathml.js";
|
|
6
6
|
import { cn } from "../../../utils.js";
|
|
7
7
|
import { Popover, PopoverTrigger, PopoverContent } from "../../ui/popover.js";
|
|
8
8
|
import "mathlive";
|
|
@@ -14,12 +14,12 @@ export const MathematicsEditComponent = ({ node, editor }) => {
|
|
|
14
14
|
export const InlineMathView = ({ children, onEdit, }) => {
|
|
15
15
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
16
16
|
const [value, setValue] = useState(children || "");
|
|
17
|
-
const
|
|
17
|
+
const mathml = useMemo(() => {
|
|
18
18
|
try {
|
|
19
|
-
return
|
|
19
|
+
return latexToSafeMathMl(value);
|
|
20
20
|
}
|
|
21
21
|
catch {
|
|
22
|
-
return
|
|
22
|
+
return null;
|
|
23
23
|
}
|
|
24
24
|
}, [value]);
|
|
25
25
|
return (_jsxs(Popover, { open: menuOpen, onOpenChange: (open) => {
|
|
@@ -35,7 +35,7 @@ export const InlineMathView = ({ children, onEdit, }) => {
|
|
|
35
35
|
// no-op
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
-
}, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("span", { className: cn("rounded-2xl bg-pink-50 min-h-6 px-1.5 py-0.25 inline-flex gap-2.5 items-center cursor-pointer hover:bg-pink-200 group transition-all", menuOpen && "bg-pink-200"), children: [_jsx("math", { dangerouslySetInnerHTML: { __html:
|
|
38
|
+
}, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("span", { className: cn("rounded-2xl bg-pink-50 min-h-6 px-1.5 py-0.25 inline-flex gap-2.5 items-center cursor-pointer hover:bg-pink-200 group transition-all", menuOpen && "bg-pink-200"), children: [mathml === null ? (value) : (_jsx("math", { dangerouslySetInnerHTML: { __html: mathml } })), _jsx(EditIcon, { className: cn("text-pink-500 size-3 hidden group-hover:block shrink-0", menuOpen && "block") })] }) }), _jsx(PopoverContent, { align: "center", side: "bottom", sideOffset: -30, collisionPadding: 12, className: "border border-black p-0 bg-white rounded-md shadow-md max-w-none", onPointerDownOutside: (event) => {
|
|
39
39
|
const mathKeyboard = document.querySelector(".ML__keyboard");
|
|
40
40
|
if (mathKeyboard?.contains(event.target)) {
|
|
41
41
|
// Prevent closing when clicking on the math keyboard
|
|
@@ -36,7 +36,7 @@ const ColorPicker = ({ value, swatches = defaultSwatches, defaultValue = "#fffff
|
|
|
36
36
|
}
|
|
37
37
|
};
|
|
38
38
|
const hasSwatchSelected = useMemo(() => availableSwatches.includes(parsedValue), [availableSwatches, parsedValue]);
|
|
39
|
-
return (_jsxs("div", { className: cn("flex flex-wrap gap-2", className), children: [availableSwatches.map((color) => (_jsx(Button, { type: "button", style: { backgroundColor: color }, variant: "roundedWhite", size: "sm", className: cn("size-8 p-0", color === parsedValue && "border-black", swatchClassName), onClick: () => onChangeProxy(color) }, color))), allowCustom && (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "roundedWhite", size: "sm", className: cn("h-8", !hasSwatchSelected && "border-black"), style: {
|
|
39
|
+
return (_jsxs("div", { className: cn("flex flex-wrap gap-2", className), children: [availableSwatches.map((color) => (_jsx(Button, { type: "button", style: { backgroundColor: color }, variant: "roundedWhite", size: "sm", className: cn("size-8 p-0", color === parsedValue && "border-black", swatchClassName), onClick: () => onChangeProxy(color) }, color))), allowCustom && (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "roundedWhite", size: "sm", className: cn("h-8 ml-auto", !hasSwatchSelected && "border-black"), style: {
|
|
40
40
|
backgroundColor: !hasSwatchSelected ? parsedValue : "",
|
|
41
41
|
}, children: [_jsx(PipetteIcon, {}), customLabelTitle] }) }), _jsxs(PopoverContent, { className: "rounded-xl bg-white p-4", children: [_jsx(HexColorPicker, { color: parsedValue, onChange: onChangeProxy, className: "w-full!" }), _jsx(Input, { className: "w-full mt-2", maxLength: 7, onChange: (e) => {
|
|
42
42
|
onChange(e?.currentTarget?.value);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import {
|
|
2
|
+
import { latexToSafeMathMl } from "../../lib/sanitize-mathml.js";
|
|
3
3
|
// Custom element for use in rich text editors
|
|
4
4
|
export class InlineMathElement extends HTMLElement {
|
|
5
5
|
observer;
|
|
@@ -19,9 +19,9 @@ export class InlineMathElement extends HTMLElement {
|
|
|
19
19
|
this.observer?.disconnect();
|
|
20
20
|
}
|
|
21
21
|
render() {
|
|
22
|
-
const latex = this.textContent;
|
|
22
|
+
const latex = this.textContent ?? "";
|
|
23
23
|
try {
|
|
24
|
-
this.innerHTML = `<math>${
|
|
24
|
+
this.innerHTML = `<math>${latexToSafeMathMl(latex)}</math>`;
|
|
25
25
|
}
|
|
26
26
|
catch (error) {
|
|
27
27
|
this.textContent = error.message;
|
package/dist/lib/index.d.ts
CHANGED
package/dist/lib/index.js
CHANGED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const sanitizeMathMl: (markup: string) => string;
|
|
2
|
+
/**
|
|
3
|
+
* Converts LaTeX to MathML that is safe to inject as markup. Throws whenever
|
|
4
|
+
* `convertLatexToMathMl` does, so callers keep their own fallback rendering.
|
|
5
|
+
*/
|
|
6
|
+
export declare const latexToSafeMathMl: (latex: string) => string;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { convertLatexToMathMl } from "mathlive";
|
|
2
|
+
/**
|
|
3
|
+
* MathLive interpolates LaTeX arguments straight into the MathML it returns —
|
|
4
|
+
* `\enclose{...}` lands in a `notation` attribute, unescaped — so its output
|
|
5
|
+
* can carry event handlers or entire HTML elements into whatever renders it.
|
|
6
|
+
* See https://github.com/arnog/mathlive/security/advisories/GHSA-fm7p-gw32-828p.
|
|
7
|
+
*
|
|
8
|
+
* Everything below re-serializes the markup from scratch: only known MathML
|
|
9
|
+
* elements and presentational attributes survive, attribute values are quoted
|
|
10
|
+
* and escaped, and all remaining text is escaped.
|
|
11
|
+
*/
|
|
12
|
+
const ALLOWED_ELEMENTS = new Set([
|
|
13
|
+
"menclose",
|
|
14
|
+
"merror",
|
|
15
|
+
"mfrac",
|
|
16
|
+
"mi",
|
|
17
|
+
"mn",
|
|
18
|
+
"mo",
|
|
19
|
+
"mover",
|
|
20
|
+
"mpadded",
|
|
21
|
+
"mphantom",
|
|
22
|
+
"mroot",
|
|
23
|
+
"mrow",
|
|
24
|
+
"ms",
|
|
25
|
+
"mspace",
|
|
26
|
+
"msqrt",
|
|
27
|
+
"mstyle",
|
|
28
|
+
"msub",
|
|
29
|
+
"msubsup",
|
|
30
|
+
"msup",
|
|
31
|
+
"mtable",
|
|
32
|
+
"mtd",
|
|
33
|
+
"mtext",
|
|
34
|
+
"mtr",
|
|
35
|
+
"munder",
|
|
36
|
+
"munderover",
|
|
37
|
+
]);
|
|
38
|
+
const ALLOWED_ATTRIBUTES = new Set([
|
|
39
|
+
"accent",
|
|
40
|
+
"accentunder",
|
|
41
|
+
"columnalign",
|
|
42
|
+
"columnspan",
|
|
43
|
+
"depth",
|
|
44
|
+
"dir",
|
|
45
|
+
"displaystyle",
|
|
46
|
+
"fence",
|
|
47
|
+
"height",
|
|
48
|
+
"linethickness",
|
|
49
|
+
"lspace",
|
|
50
|
+
"mathbackground",
|
|
51
|
+
"mathcolor",
|
|
52
|
+
"mathvariant",
|
|
53
|
+
"maxsize",
|
|
54
|
+
"minsize",
|
|
55
|
+
"notation",
|
|
56
|
+
"rowalign",
|
|
57
|
+
"rowspan",
|
|
58
|
+
"rspace",
|
|
59
|
+
"scriptlevel",
|
|
60
|
+
"separator",
|
|
61
|
+
"stretchy",
|
|
62
|
+
"symmetric",
|
|
63
|
+
"width",
|
|
64
|
+
]);
|
|
65
|
+
const LOOKS_LIKE_TAG = /^<\/?[a-zA-Z]/;
|
|
66
|
+
const TAG_START = /^<(\/?)([a-zA-Z][a-zA-Z0-9-]*)/;
|
|
67
|
+
const ATTRIBUTE = /([a-zA-Z_:][-.:\w]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'`=<>]+)))?/g;
|
|
68
|
+
const BARE_AMPERSAND = /&(?![a-zA-Z][a-zA-Z0-9]*;|#[0-9]+;|#[xX][0-9a-fA-F]+;)/g;
|
|
69
|
+
const TRAILING_SLASH = /\/\s*$/;
|
|
70
|
+
const escapeText = (value) => value
|
|
71
|
+
.replace(BARE_AMPERSAND, "&")
|
|
72
|
+
.replace(/</g, "<")
|
|
73
|
+
.replace(/>/g, ">");
|
|
74
|
+
const escapeAttributeValue = (value) => escapeText(value).replace(/"/g, """);
|
|
75
|
+
// Quoted attribute values may contain ">", so the tag only ends on a ">" that
|
|
76
|
+
// sits outside of quotes — the same rule the HTML parser applies.
|
|
77
|
+
const findTagEnd = (markup, start) => {
|
|
78
|
+
let quote;
|
|
79
|
+
for (let index = start; index < markup.length; index++) {
|
|
80
|
+
const character = markup[index];
|
|
81
|
+
if (quote) {
|
|
82
|
+
if (character === quote)
|
|
83
|
+
quote = undefined;
|
|
84
|
+
}
|
|
85
|
+
else if (character === '"' || character === "'") {
|
|
86
|
+
quote = character;
|
|
87
|
+
}
|
|
88
|
+
else if (character === ">") {
|
|
89
|
+
return index;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return -1;
|
|
93
|
+
};
|
|
94
|
+
const buildOpeningTag = (element, body) => {
|
|
95
|
+
const attributes = [];
|
|
96
|
+
for (const attribute of body
|
|
97
|
+
.replace(TRAILING_SLASH, "")
|
|
98
|
+
.matchAll(ATTRIBUTE)) {
|
|
99
|
+
const name = attribute[1].toLowerCase();
|
|
100
|
+
if (!ALLOWED_ATTRIBUTES.has(name))
|
|
101
|
+
continue;
|
|
102
|
+
const value = attribute[2] ?? attribute[3] ?? attribute[4] ?? "";
|
|
103
|
+
attributes.push(` ${name}="${escapeAttributeValue(value)}"`);
|
|
104
|
+
}
|
|
105
|
+
const selfClosing = TRAILING_SLASH.test(body);
|
|
106
|
+
return `<${element}${attributes.join("")}${selfClosing ? "/" : ""}>`;
|
|
107
|
+
};
|
|
108
|
+
export const sanitizeMathMl = (markup) => {
|
|
109
|
+
let result = "";
|
|
110
|
+
let index = 0;
|
|
111
|
+
while (index < markup.length) {
|
|
112
|
+
const tagStart = markup.indexOf("<", index);
|
|
113
|
+
if (tagStart === -1) {
|
|
114
|
+
result += escapeText(markup.slice(index));
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
result += escapeText(markup.slice(index, tagStart));
|
|
118
|
+
// Testing a three-character window first keeps a run of stray "<" linear
|
|
119
|
+
if (!LOOKS_LIKE_TAG.test(markup.slice(tagStart, tagStart + 3))) {
|
|
120
|
+
result += "<";
|
|
121
|
+
index = tagStart + 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const tagEnd = findTagEnd(markup, tagStart);
|
|
125
|
+
if (tagEnd === -1) {
|
|
126
|
+
result += escapeText(markup.slice(tagStart));
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
const tag = markup.slice(tagStart, tagEnd + 1);
|
|
130
|
+
const start = tag.match(TAG_START);
|
|
131
|
+
index = tagEnd + 1;
|
|
132
|
+
if (!start) {
|
|
133
|
+
result += escapeText(tag);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const element = start[2].toLowerCase();
|
|
137
|
+
if (ALLOWED_ELEMENTS.has(element)) {
|
|
138
|
+
result += start[1]
|
|
139
|
+
? `</${element}>`
|
|
140
|
+
: buildOpeningTag(element, tag.slice(start[0].length, -1));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Converts LaTeX to MathML that is safe to inject as markup. Throws whenever
|
|
147
|
+
* `convertLatexToMathMl` does, so callers keep their own fallback rendering.
|
|
148
|
+
*/
|
|
149
|
+
export const latexToSafeMathMl = (latex) => sanitizeMathMl(convertLatexToMathMl(latex));
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@examplary/ui",
|
|
3
3
|
"description": "UI components for the Examplary testing platform.",
|
|
4
4
|
"packageManager": "yarn@4.5.3",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.76.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
|
8
8
|
"files": [
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
14
|
"dev": "yarn build --watch",
|
|
15
|
+
"test": "vitest",
|
|
15
16
|
"release": "semantic-release -e semantic-release-monorepo",
|
|
16
17
|
"build": "yarn build:js && yarn build:styles",
|
|
17
18
|
"build:js": "tsc ./src/index.ts --outDir dist --skipLibCheck --declaration --jsx react-jsx --esModuleInterop --moduleResolution bundler --module es2020",
|
|
@@ -92,7 +93,8 @@
|
|
|
92
93
|
"devDependencies": {
|
|
93
94
|
"@tailwindcss/cli": "^4.3.0",
|
|
94
95
|
"tailwindcss": "^4.2.1",
|
|
95
|
-
"typescript": "^6.0.3"
|
|
96
|
+
"typescript": "^6.0.3",
|
|
97
|
+
"vitest": "^4.1.10"
|
|
96
98
|
},
|
|
97
99
|
"homepage": "https://examplary.ai/developers",
|
|
98
100
|
"author": {
|