@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.
@@ -1,12 +1,12 @@
1
1
  "use client";
2
2
  import { createElement, useMemo, useRef } from "react";
3
- import { convertLatexToMathMl } from "mathlive";
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>${convertLatexToMathMl(content)}</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 { convertLatexToMathMl } from "mathlive";
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 html = useMemo(() => {
17
+ const mathml = useMemo(() => {
18
18
  try {
19
- return convertLatexToMathMl(value);
19
+ return latexToSafeMathMl(value);
20
20
  }
21
21
  catch {
22
- return value;
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: html } }), _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) => {
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 { convertLatexToMathMl } from "mathlive";
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>${convertLatexToMathMl(latex)}</math>`;
24
+ this.innerHTML = `<math>${latexToSafeMathMl(latex)}</math>`;
25
25
  }
26
26
  catch (error) {
27
27
  this.textContent = error.message;
@@ -1 +1,2 @@
1
+ export * from "./sanitize-mathml";
1
2
  export * from "./use-resizable";
package/dist/lib/index.js CHANGED
@@ -1 +1,2 @@
1
+ export * from "./sanitize-mathml";
1
2
  export * from "./use-resizable";
@@ -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, "&amp;")
72
+ .replace(/</g, "&lt;")
73
+ .replace(/>/g, "&gt;");
74
+ const escapeAttributeValue = (value) => escapeText(value).replace(/"/g, "&quot;");
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 += "&lt;";
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.1",
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": {