@examplary/ui 1.75.1 → 1.75.2
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/dist/components/rich-text/rich-text-display.js +2 -2
- package/dist/components/rich-text/tiptap/mathematics-component.js +5 -5
- 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
|
@@ -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
|
|
@@ -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.75.
|
|
5
|
+
"version": "1.75.2",
|
|
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": {
|