@mulmoclaude/markdown-utils 1.4.0 → 2.0.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.
@@ -6,6 +6,20 @@ export interface MathRenderLabels {
6
6
  loadFailed: (error: string) => string;
7
7
  renderFailed: (error: string) => string;
8
8
  }
9
+ /** A host's EXTRA pass over one formula's markup, run between two
10
+ * `sanitizeMathSvg` passes and never in place of one. Receives what the
11
+ * baseline left (`<mjx-container>` wrapping the `<svg>`, plus the
12
+ * assistive `<math>` twin) and returns a narrower version of it — what
13
+ * it returns is sanitised again on the way out, so a hardener can only
14
+ * ever take things away.
15
+ *
16
+ * Two things it must keep, or the formula degrades: the `<svg>` element
17
+ * itself (`adoptFormula` returns null without it, and the placeholder
18
+ * becomes an error box), and — for inline math to sit on the text
19
+ * baseline — the root `<svg>`'s own `style="vertical-align: …"`. A
20
+ * policy that strips `style` everywhere still renders; the formula just
21
+ * sits slightly high. */
22
+ export type MathHardener = (markup: string) => string;
9
23
  /** DOMPurify pass over one formula's SVG. See the SANITISATION note at
10
24
  * the top of the file: this is the only thing standing between a
11
25
  * `\href{javascript:…}` in an arbitrary `.md` and a clickable payload
@@ -24,5 +38,9 @@ export declare function adoptFormula(markup: string): {
24
38
  * failed one is replaced by an `.math-error` box, so neither matches
25
39
  * a second time. `labels` defaults to English fallbacks so the pure
26
40
  * module remains callable from tests / node environments without an
27
- * i18n runtime. */
28
- export declare function renderMathNodes(root: Element | Document | null | undefined, labels?: MathRenderLabels): Promise<void>;
41
+ * i18n runtime. `harden` is an EXTRA pass over each formula's markup,
42
+ * run between two `sanitizeMathSvg` passes rather than instead of one,
43
+ * so it can only ever narrow what reaches the page (see the HOST note
44
+ * at the top of the file); it defaults to leaving the sanitised markup
45
+ * alone. */
46
+ export declare function renderMathNodes(root: Element | Document | null | undefined, labels?: MathRenderLabels, harden?: MathHardener): Promise<void>;
@@ -49,6 +49,35 @@
49
49
  // extension; the sanitiser is a boundary that holds regardless, and it
50
50
  // keeps a legitimate `\href{https://…}` working while dropping the
51
51
  // `javascript:` one.
52
+ //
53
+ // A HOST MAY NEED A STRICTER POLICY THAN THE DEFAULT, which is why
54
+ // `renderMathNodes` takes an extra pass. `sanitizeMathSvg` runs DOMPurify with
55
+ // its defaults, and those keep `class` and `style` — while the TeX `html`
56
+ // package puts BOTH under the author's control:
57
+ //
58
+ // $\style{position:fixed;inset:0;background:#fff}{x}$ → <g style="position: fixed; …">
59
+ // $\class{fixed inset-0 bg-white}{x}$ → <g class=" fixed inset-0 bg-white">
60
+ //
61
+ // Inside an `<svg>` those declarations are largely inert — CSS box
62
+ // positioning does not apply to SVG child elements, and the root `<svg>`
63
+ // clips its own overflow — so this is not a hole in THIS app, where the
64
+ // markdown is a file on the user's own disk. It is a hole in the
65
+ // invariant of a host that renders STRANGER-WRITTEN markdown on a
66
+ // signed-in origin and has therefore banned author-controlled `class` /
67
+ // `style` outright (mulmoserver's article renderer bans both precisely
68
+ // because a utility-CSS framework turns a class name into positioning).
69
+ // Such a host passes its own function.
70
+ //
71
+ // That function runs BETWEEN two `sanitizeMathSvg` passes, never instead of one.
72
+ // The baseline is not a default a caller can decline: a host writing the targeted
73
+ // transformer this feature exists for — strip `class` and `style`, keep the rest —
74
+ // would otherwise silently re-admit the `\href{javascript:…}` the baseline is
75
+ // there to stop (codex, #2983). And the pass AFTER it is what makes the return
76
+ // type honest: a hardener hands back an arbitrary STRING, so nothing in the type
77
+ // says it only removed things, and the markup it returns is what reaches the
78
+ // document (coderabbit, #2983). Sanitising on both sides is what makes "may only
79
+ // tighten" a property of the code rather than a sentence in a docstring — the
80
+ // cost is one more DOMPurify pass per formula, against a MathJax typeset.
52
81
  import DOMPurify from "dompurify";
53
82
  import { parseMarkupBody } from "../dom/adoptSvg.js";
54
83
  const DEFAULT_LABELS = {
@@ -106,11 +135,12 @@ async function buildTypesetter() {
106
135
  OutputJax: new SVG({ fontCache: "none" }),
107
136
  });
108
137
  return {
109
- render: (tex, display) => {
138
+ render: (tex, display, harden) => {
110
139
  const node = doc.convert(tex, { display });
111
140
  if (!(node instanceof LiteElement))
112
141
  throw new Error("MathJax returned an unexpected node type");
113
- return sanitizeMathSvg(adaptor.outerHTML(node));
142
+ // Baseline, host policy, baseline. Never the host policy alone.
143
+ return sanitizeMathSvg(harden(sanitizeMathSvg(adaptor.outerHTML(node))));
114
144
  },
115
145
  };
116
146
  }
@@ -170,14 +200,14 @@ function placeLoadError(nodes, err, labels) {
170
200
  function pendingNodes(root) {
171
201
  return Array.from(root.querySelectorAll("[data-math-pending]"));
172
202
  }
173
- function renderOne(node, typesetter, labels) {
203
+ function renderOne(node, typesetter, labels, harden) {
174
204
  // `textContent` gives us the raw TeX — we escaped it going in and
175
205
  // DOMPurify preserves text verbatim, so entity decoding is
176
206
  // browser-native from the DOM read.
177
207
  const source = node.textContent ?? "";
178
208
  const display = node.dataset.mathDisplay === "1";
179
209
  try {
180
- const formula = adoptFormula(typesetter.render(source, display));
210
+ const formula = adoptFormula(typesetter.render(source, display, harden));
181
211
  if (!formula)
182
212
  throw new Error("MathJax produced malformed SVG");
183
213
  // Keep the placeholder's own element (a `<div>` for block math, a
@@ -199,8 +229,12 @@ function renderOne(node, typesetter, labels) {
199
229
  * failed one is replaced by an `.math-error` box, so neither matches
200
230
  * a second time. `labels` defaults to English fallbacks so the pure
201
231
  * module remains callable from tests / node environments without an
202
- * i18n runtime. */
203
- export async function renderMathNodes(root, labels = DEFAULT_LABELS) {
232
+ * i18n runtime. `harden` is an EXTRA pass over each formula's markup,
233
+ * run between two `sanitizeMathSvg` passes rather than instead of one,
234
+ * so it can only ever narrow what reaches the page (see the HOST note
235
+ * at the top of the file); it defaults to leaving the sanitised markup
236
+ * alone. */
237
+ export async function renderMathNodes(root, labels = DEFAULT_LABELS, harden = (markup) => markup) {
204
238
  if (!root)
205
239
  return;
206
240
  const nodes = pendingNodes(root);
@@ -220,5 +254,5 @@ export async function renderMathNodes(root, labels = DEFAULT_LABELS) {
220
254
  return;
221
255
  }
222
256
  for (const node of nodes)
223
- renderOne(node, typesetter, labels);
257
+ renderOne(node, typesetter, labels, harden);
224
258
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmoclaude/markdown-utils",
3
- "version": "1.4.0",
3
+ "version": "2.0.0",
4
4
  "description": "Browser-safe markdown / image rendering utilities shared by the MulmoClaude host and the markdown plugin",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",