@mulmoclaude/markdown-utils 1.4.0 → 2.1.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.
|
@@ -32,8 +32,29 @@
|
|
|
32
32
|
// and must not be a backslash.
|
|
33
33
|
// 4. The character AFTER the closing `$` must not be an ASCII digit
|
|
34
34
|
// (`$5-$10`).
|
|
35
|
-
// 5. The body must be non-empty, single-line, and must
|
|
36
|
-
//
|
|
35
|
+
// 5. The body must be non-empty, single-line, and must be something a
|
|
36
|
+
// formula could be ABOUT: a number written the way money is
|
|
37
|
+
// (`$1,000$`, `$1.000,50$` — a price written twice) or separators
|
|
38
|
+
// with no digits at all (`$+$`) are not.
|
|
39
|
+
//
|
|
40
|
+
// Rule 5 used to reject EVERY digits-and-separators body, and that was
|
|
41
|
+
// too wide: `1秒を $10000$ 個のステップに割る` and `答えは $1$` are the
|
|
42
|
+
// ordinary way to write a number in a maths article, and both came out
|
|
43
|
+
// as a literal `$10000$` / `$1$` sitting in the prose. The signature of
|
|
44
|
+
// a price is its SHAPE — digits in threes, or more than one separator —
|
|
45
|
+
// and the two shapes that actually
|
|
46
|
+
// appear in currency prose are already dead: `$100 と $200` by rule 3
|
|
47
|
+
// (whitespace before the close) and `$5-$10` by rule 4 (a digit after
|
|
48
|
+
// it). What stays admitted is a body like `$5$`, which a person quoting
|
|
49
|
+
// a price does not write: they write `$5`, and it is the DOUBLED
|
|
50
|
+
// delimiter that makes it maths.
|
|
51
|
+
//
|
|
52
|
+
// The SHAPE is matched rather than a particular separator, because a
|
|
53
|
+
// comma groups thousands in English and marks the decimal in most of
|
|
54
|
+
// Europe, and a dot does the opposite (codex, #2985). `$1,5$` is one and
|
|
55
|
+
// a half and typesets; `$1.000,50$` is a price and does not. `$1,500$`
|
|
56
|
+
// is genuinely ambiguous and is read as the price — the reading rule 5
|
|
57
|
+
// already had, which this narrowing keeps rather than reverses.
|
|
37
58
|
//
|
|
38
59
|
// Rules 2-5 are enforced in the tokenizer, where the whole match is in
|
|
39
60
|
// hand. Rule 1 needs the character BEFORE the match, which a marked
|
|
@@ -49,9 +70,50 @@
|
|
|
49
70
|
import { escapeHtml } from "@mulmoclaude/common";
|
|
50
71
|
const ASCII_ALNUM = /[A-Za-z0-9]/;
|
|
51
72
|
const ASCII_DIGIT = /\d/;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
73
|
+
const DIGITS = /^\d+$/;
|
|
74
|
+
/** Every character that separates the digit runs of a written number,
|
|
75
|
+
* in any locale: `1,000.50`, `1.000,50`, `1 000,50`. Which one groups
|
|
76
|
+
* and which one marks the decimal is exactly what cannot be known, so
|
|
77
|
+
* none of them is read as one or the other. */
|
|
78
|
+
const NUMBER_SEPARATORS = /[.,\s]/;
|
|
79
|
+
const THOUSAND = 3;
|
|
80
|
+
/** A bare number written the way MONEY is written rather than the way a
|
|
81
|
+
* quantity is — `1,000`, `1.000`, `1 000`, `1.000,50`, `12,345,678`.
|
|
82
|
+
*
|
|
83
|
+
* Read from the SHAPE, not from which separator appeared: a comma
|
|
84
|
+
* groups thousands in English and marks the decimal in most of Europe,
|
|
85
|
+
* and a dot does the opposite, so a rule that names one of them fails
|
|
86
|
+
* half the world's authors either way (codex, #2985).
|
|
87
|
+
*
|
|
88
|
+
* Two separators or more is money — a quantity does not need them. One
|
|
89
|
+
* separator followed by exactly three digits is ambiguous, `$1,500$` as
|
|
90
|
+
* much as `$1.500$`, and is read as money: that is the reading rule 5
|
|
91
|
+
* already had, and the one this file is narrowing rather than
|
|
92
|
+
* reversing. Everything else — `10000`, `1`, `1,5`, `3.14159` — is a
|
|
93
|
+
* number, and a number in a maths article is maths.
|
|
94
|
+
*
|
|
95
|
+
* A leading zero is NOT an exception, though it looks like one: a
|
|
96
|
+
* three-decimal sub-unit price is how fuel is priced (`$0.100` a litre),
|
|
97
|
+
* so `$0.100$` is as ambiguous as `$1.500$` and is read the same way
|
|
98
|
+
* (codex, #2985). Three digits after the separator is the whole test. */
|
|
99
|
+
function isMoneyShaped(body) {
|
|
100
|
+
const runs = body.split(NUMBER_SEPARATORS);
|
|
101
|
+
// Anything that is not digits-and-separators is not a written number at
|
|
102
|
+
// all — `x=1` and `\pi` land here and are maths by this rule.
|
|
103
|
+
if (!runs.every((run) => DIGITS.test(run)))
|
|
104
|
+
return false;
|
|
105
|
+
if (runs.length === 1)
|
|
106
|
+
return false;
|
|
107
|
+
if (runs.length > 2)
|
|
108
|
+
return true;
|
|
109
|
+
const [, tail] = runs;
|
|
110
|
+
if (tail === undefined)
|
|
111
|
+
return false;
|
|
112
|
+
return tail.length === THOUSAND;
|
|
113
|
+
}
|
|
114
|
+
/** The same set with the digits removed: a body of punctuation has
|
|
115
|
+
* nothing to typeset. */
|
|
116
|
+
const SEPARATORS_ONLY = /^[\s.,:;%+-]*$/;
|
|
55
117
|
/** Index of the first `$` in `src` that could legally open math, or
|
|
56
118
|
* `undefined` when there is none. Marked uses this to cut the
|
|
57
119
|
* preceding text token, so returning a position is what gets the
|
|
@@ -86,8 +148,11 @@ export function isPlausibleInlineMath(body, after) {
|
|
|
86
148
|
// Rule 4: `$5-$10`.
|
|
87
149
|
if (ASCII_DIGIT.test(after))
|
|
88
150
|
return false;
|
|
89
|
-
// Rule 5.
|
|
90
|
-
|
|
151
|
+
// Rule 5. The shape of money is what says "price"; a plain number is
|
|
152
|
+
// just a number, and a number in a maths article is maths.
|
|
153
|
+
if (isMoneyShaped(body))
|
|
154
|
+
return false;
|
|
155
|
+
if (SEPARATORS_ONLY.test(body))
|
|
91
156
|
return false;
|
|
92
157
|
return true;
|
|
93
158
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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