@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 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 { 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
@@ -40,6 +40,9 @@ const Image = Node.create({
40
40
  height: {
41
41
  default: null,
42
42
  },
43
+ "data-graph": {
44
+ default: null,
45
+ },
43
46
  };
44
47
  },
45
48
  parseHTML() {
@@ -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
@@ -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 { 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.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": {