@mathzetta/inlinemath 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +326 -2
- package/dist/cas.external.js +2 -0
- package/dist/cas.js +2 -0
- package/dist/chunks/calculate-Cz87Lt0N.js +2 -0
- package/dist/chunks/draw-Bev17BJJ.js +2 -0
- package/dist/chunks/edit-C65xXzo8.js +2 -0
- package/dist/chunks/edit-M68aRA5o.js +2 -0
- package/dist/chunks/html-BimAqn7x.js +2 -0
- package/dist/chunks/html-CupluJSh.js +2 -0
- package/dist/chunks/lite-3Fb-OHbt.js +2 -0
- package/dist/fonts/GUST-FONT-LICENSE.txt +30 -0
- package/dist/fonts/fonts.css +17 -0
- package/dist/fonts/lmroman12-italic.woff2 +0 -0
- package/dist/fonts/lmroman12-regular.woff2 +0 -0
- package/dist/inlinemath-cas.external.global.min.js +2 -0
- package/dist/inlinemath-cas.global.min.js +2 -0
- package/dist/inlinemath-lite-calc.global.min.js +2 -0
- package/dist/inlinemath-lite.external.global.min.js +2 -0
- package/dist/inlinemath-lite.global.min.js +2 -0
- package/dist/inlinemath.css +158 -0
- package/dist/inlinemath.external.global.min.js +2 -0
- package/dist/inlinemath.external.js +2 -0
- package/dist/inlinemath.global.min.js +2 -0
- package/dist/inlinemath.js +2 -0
- package/dist/lite-calc.js +2 -0
- package/dist/lite.external.js +2 -0
- package/dist/lite.js +2 -0
- package/dist/markdown.external.js +2 -0
- package/dist/markdown.js +2 -0
- package/fonts/GUST-FONT-LICENSE.txt +30 -0
- package/fonts/fonts.css +17 -0
- package/fonts/lmroman12-italic.woff2 +0 -0
- package/fonts/lmroman12-regular.woff2 +0 -0
- package/inlinemath.css +158 -0
- package/package.json +95 -4
- package/src/calculator.js +6 -0
- package/src/cas.d.ts +3 -0
- package/src/cas.js +149 -0
- package/src/draw.js +72 -0
- package/src/edit.js +96 -0
- package/src/glyphs.js +40 -0
- package/src/html.js +32 -0
- package/src/index.d.ts +3 -0
- package/src/index.js +17 -0
- package/src/layout.js +649 -0
- package/src/lite-calc.d.ts +3 -0
- package/src/lite-calc.js +15 -0
- package/src/lite.d.ts +3 -0
- package/src/lite.js +18 -0
- package/src/markdown.d.ts +3 -0
- package/src/markdown.js +676 -0
- package/src/mathzetta.js +18 -0
- package/src/select.js +108 -0
- package/src/types.d.ts +204 -0
- package/src/variables.js +228 -0
- package/src/view.js +382 -0
package/src/markdown.js
ADDED
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* '@mathzetta/inlinemath/markdown' — Markdown with maths in it.
|
|
3
|
+
*
|
|
4
|
+
* import { markdownToHTML } from '@mathzetta/inlinemath/markdown';
|
|
5
|
+
* el.innerHTML = markdownToHTML('The area is $\\pi r^2$, so for $r = 3$ it is ${{pi r^2}}$.');
|
|
6
|
+
*
|
|
7
|
+
* Maths: $…$ inline, $$…$$ (or a ```math fence) on its own, \(…\) and \[…\] too.
|
|
8
|
+
* Each is read by MathZetta (LaTeX, plain text or code all work) and drawn by
|
|
9
|
+
* Inline Math; {{…}} inside is worked out first (MathZetta's calculator, or the CAS:
|
|
10
|
+
* { blocks: casBlocks() } from '@mathzetta/inlinemath/cas'). A definition such as $r = 3$ is
|
|
11
|
+
* remembered, as a number, for the {{…}} after it ({ define: false } turns that off).
|
|
12
|
+
*
|
|
13
|
+
* Saved variables: $A := 2+2$ saves A (and draws A ≔ 2 + 2); $_A := 2+2$ saves it without drawing.
|
|
14
|
+
* After it, A in any maths is drawn as its expression and {{A}} is its value: $A = {{A}}$ draws
|
|
15
|
+
* 2 + 2 = 4. $A := [[2+2]]$ is a textbox that sets A, [[2+2 | x^2]] a dropdown, [[1..10]] a slider
|
|
16
|
+
* ([[0..1 step 0.1 = 0.5]]). renderMarkdownInto keeps every part of a page in one store
|
|
17
|
+
* (`variables`), so a variable saved in one part is used in the others, and changing an input
|
|
18
|
+
* redraws everything that uses it.
|
|
19
|
+
*
|
|
20
|
+
* A $ is maths only when it looks like it (Pandoc's rule): the opening $ is not
|
|
21
|
+
* followed by a space, the closing $ is not after a space or before a digit, so
|
|
22
|
+
* "$5 and $10" stays text. \$ is a dollar sign. Code spans and fences are left alone.
|
|
23
|
+
* Maths that doesn't read is shown as written, marked, with the reason as its title.
|
|
24
|
+
*
|
|
25
|
+
* The Markdown is a small built-in renderer (headings, emphasis, links, images,
|
|
26
|
+
* lists, quotes, code, tables, rules). Raw HTML is shown as text unless { html: true }.
|
|
27
|
+
* { render: (markdown) => html } uses another renderer (marked, markdown-it …):
|
|
28
|
+
* the maths is taken out first and put back into its output.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { toHTML, treeOf } from './html.js';
|
|
32
|
+
import { calculate } from './calculator.js';
|
|
33
|
+
import { nameOf, bare, substitute, unexpand, inputOf, EXPRESSIONS, variables as pageVariables } from './variables.js';
|
|
34
|
+
import { toPostfix } from './mathzetta.js';
|
|
35
|
+
import { numberTree, select, pathOf, nodeAt } from './select.js';
|
|
36
|
+
import { openEditor, replaceAt } from './edit.js';
|
|
37
|
+
|
|
38
|
+
export { createVariables, variables, Variables } from './variables.js';
|
|
39
|
+
|
|
40
|
+
// ---- finding the maths --------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const OPEN = '';
|
|
43
|
+
const CLOSE = '';
|
|
44
|
+
const placeholder = (n) => `${OPEN}${n}${CLOSE}`;
|
|
45
|
+
const PLACEHOLDER = new RegExp(`${OPEN}(\\d+)${CLOSE}`, 'g');
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The maths in a Markdown text, and the text with each piece replaced by a
|
|
49
|
+
* placeholder (private-use characters, which no Markdown renderer touches).
|
|
50
|
+
* @returns {{ text: string, math: Array<{ source, display, raw, start, end }> }} (start, end: where the raw text is)
|
|
51
|
+
*/
|
|
52
|
+
export function extractMath(markdown, { delimiters = ['$$', '$', '\\[', '\\('] } = {}) {
|
|
53
|
+
const math = [];
|
|
54
|
+
let out = '';
|
|
55
|
+
let i = 0;
|
|
56
|
+
const s = String(markdown);
|
|
57
|
+
const atLineStart = (k) => k === 0 || s[k - 1] === '\n';
|
|
58
|
+
const keep = (source, display, raw) => {
|
|
59
|
+
math.push({ source, display, raw, start: i, end: i + raw.length });
|
|
60
|
+
return placeholder(math.length - 1);
|
|
61
|
+
};
|
|
62
|
+
while (i < s.length) {
|
|
63
|
+
// a fenced code block: copied as is, except ```math, which is display maths
|
|
64
|
+
if (atLineStart(i)) {
|
|
65
|
+
const fence = /^( {0,3})(`{3,}|~{3,})([^\n]*)\n?/.exec(s.slice(i));
|
|
66
|
+
if (fence) {
|
|
67
|
+
const marker = fence[2];
|
|
68
|
+
const closeRe = new RegExp(`\\n {0,3}${marker[0] === '`' ? '`' : '~'}{${marker.length},}[ \\t]*(?=\\n|$)`);
|
|
69
|
+
const rest = s.slice(i + fence[0].length);
|
|
70
|
+
const end = closeRe.exec(`\n${rest}`);
|
|
71
|
+
const body = end ? rest.slice(0, Math.max(0, end.index - 1)) : rest;
|
|
72
|
+
const length = fence[0].length + (end ? end.index - 1 + end[0].length : rest.length);
|
|
73
|
+
const raw = s.slice(i, i + length);
|
|
74
|
+
if (fence[3].trim() === 'math') out += keep(body, true, raw);
|
|
75
|
+
else out += raw;
|
|
76
|
+
i += length;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const c = s[i];
|
|
81
|
+
// a code span: as many backticks to close it as opened it
|
|
82
|
+
if (c === '`') {
|
|
83
|
+
const run = /^`+/.exec(s.slice(i))[0];
|
|
84
|
+
const close = s.indexOf(run, i + run.length);
|
|
85
|
+
if (close > -1) {
|
|
86
|
+
out += s.slice(i, close + run.length);
|
|
87
|
+
i = close + run.length;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
out += run;
|
|
91
|
+
i += run.length;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (c === '\\') {
|
|
95
|
+
const two = s.slice(i, i + 2);
|
|
96
|
+
const closer = two === '\\[' ? '\\]' : two === '\\(' ? '\\)' : null;
|
|
97
|
+
if (closer && delimiters.includes(two)) {
|
|
98
|
+
const end = s.indexOf(closer, i + 2);
|
|
99
|
+
if (end > -1) {
|
|
100
|
+
out += keep(s.slice(i + 2, end), two === '\\[', s.slice(i, end + 2));
|
|
101
|
+
i = end + 2;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
out += two; // \$ and other escapes stay for the Markdown renderer
|
|
106
|
+
i += 2;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (c === '$') {
|
|
110
|
+
if (s[i + 1] === '$' && delimiters.includes('$$')) {
|
|
111
|
+
const end = s.indexOf('$$', i + 2);
|
|
112
|
+
if (end > -1) {
|
|
113
|
+
out += keep(s.slice(i + 2, end), true, s.slice(i, end + 2));
|
|
114
|
+
i = end + 2;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
} else if (delimiters.includes('$') && s[i + 1] && !/\s/.test(s[i + 1])) {
|
|
118
|
+
const end = closingDollar(s, i + 1);
|
|
119
|
+
if (end > -1) {
|
|
120
|
+
out += keep(s.slice(i + 1, end), false, s.slice(i, end + 1));
|
|
121
|
+
i = end + 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
out += c;
|
|
127
|
+
i++;
|
|
128
|
+
}
|
|
129
|
+
return { text: out, math };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The $ that closes inline maths opened before `from`: not after a space, not before a digit, within the paragraph. */
|
|
133
|
+
function closingDollar(s, from) {
|
|
134
|
+
for (let j = from; j < s.length; j++) {
|
|
135
|
+
const c = s[j];
|
|
136
|
+
if (c === '\\') { j++; continue; }
|
|
137
|
+
if (c === '`') return -1; // maths doesn't run into code
|
|
138
|
+
if (c === '\n' && /^\n[ \t]*\n/.test(s.slice(j))) return -1;
|
|
139
|
+
if (c === '$' && !/\s/.test(s[j - 1]) && !/\d/.test(s[j + 1] ?? '') && s[j + 1] !== '$') return j;
|
|
140
|
+
}
|
|
141
|
+
return -1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- drawing the maths --------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
const escape = (t) => String(t).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
147
|
+
|
|
148
|
+
/** A definition: r = 3, g = 9.81 (a single name on the left) — remembered as a number for {{…}}. */
|
|
149
|
+
const DEFINITION = /^\s*([A-Za-z\u0370-\u03ff](?:_\{?\w+\}?)?)\s*=\s*([^=<>≤≥]+)$/;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Maths that saves a variable: `A := 2+2` (drawn), `_A := 2+2` or `_A = 2+2` (not drawn),
|
|
153
|
+
* `A := [[…]]` (an input). Else null; `{ error }` for a `$_…$` that saves nothing.
|
|
154
|
+
*/
|
|
155
|
+
function savingOf(source) {
|
|
156
|
+
let s = source.trim();
|
|
157
|
+
const hidden = s.startsWith('_');
|
|
158
|
+
if (hidden) s = s.slice(1).trimStart();
|
|
159
|
+
let at = s.indexOf(':=');
|
|
160
|
+
let width = 2;
|
|
161
|
+
if (at < 0 && hidden) {
|
|
162
|
+
at = s.search(/(?<![<>!≤≥=])=(?!=)/);
|
|
163
|
+
width = 1;
|
|
164
|
+
}
|
|
165
|
+
const nothing = hidden ? { error: 'a hidden $_…$ saves a variable: $_A := 2+2$' } : null;
|
|
166
|
+
if (at <= 0) return nothing;
|
|
167
|
+
const lhs = s.slice(0, at).trim();
|
|
168
|
+
const rhs = s.slice(at + width).trim();
|
|
169
|
+
let name = null;
|
|
170
|
+
try {
|
|
171
|
+
name = /\{\{/.test(lhs) ? null : nameOf(treeOf(lhs));
|
|
172
|
+
} catch {
|
|
173
|
+
// not a name
|
|
174
|
+
}
|
|
175
|
+
if (name === null || !rhs) return nothing;
|
|
176
|
+
return { hidden, lhs, name, rhs, input: inputOf(rhs) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** `A = …` drawn as `A ≔ …`: the first relation is the definition's. */
|
|
180
|
+
function asDefinition(html) {
|
|
181
|
+
return html
|
|
182
|
+
.replace('<span class="mv-rel">=</span>', '<span class="mv-rel mv-rel-def">≔</span>')
|
|
183
|
+
.replace(/(aria-label="[^"]*?) equals /, '$1 is defined as ')
|
|
184
|
+
.replace('<mo>=</mo>', '<mo>≔</mo>');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The input a definition's [[…]] asks for, holding `text`. */
|
|
188
|
+
function controlHTML(def, text) {
|
|
189
|
+
const { input, name } = def;
|
|
190
|
+
const attrs = `class="mv-md-input mv-md-${input.kind}" data-mv-var="${escape(name)}" aria-label="${escape(name)}"`;
|
|
191
|
+
if (input.kind === 'dropdown') {
|
|
192
|
+
const options = input.options.map((o) => `<option value="${escape(o)}"${o === text ? ' selected' : ''}>${escape(o)}</option>`);
|
|
193
|
+
return `<select ${attrs}>${options.join('')}</select>`;
|
|
194
|
+
}
|
|
195
|
+
if (input.kind === 'slider') {
|
|
196
|
+
return `<input type="range" ${attrs} min="${input.min}" max="${input.max}" step="${input.step}" value="${escape(text)}">`
|
|
197
|
+
+ `<output class="mv-md-output" data-mv-out="${escape(name)}">${escape(text)}</output>`;
|
|
198
|
+
}
|
|
199
|
+
return `<input type="text" ${attrs} value="${escape(text)}" size="${Math.max(4, text.length + 1)}" spellcheck="false" autocomplete="off">`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Every piece of maths drawn, in reading order: a variable is known to the maths after it, and
|
|
204
|
+
* the variables already in `store` (the other parts of the page) to all of it.
|
|
205
|
+
*/
|
|
206
|
+
function drawAll(markdown, options, store, owner) {
|
|
207
|
+
const { text, math } = extractMath(markdown, options);
|
|
208
|
+
const defs = store ? store.definitions(owner) : new Map();
|
|
209
|
+
const textOf = (def) => (store ? store.textOf(def) : def.text);
|
|
210
|
+
const numbers = { ...(options.scope ?? {}) }; // options.scope and $r = 3$
|
|
211
|
+
const scope = {}; // what {{…}} sees: those and the saved variables' values
|
|
212
|
+
const mine = new Map();
|
|
213
|
+
const inputs = new Set();
|
|
214
|
+
const trees = []; // each piece's drawn tree, for finding what a double-click landed on
|
|
215
|
+
// the saved variables' values, for {{…}}: worked out until nothing more can be (B := 2A before A := 3)
|
|
216
|
+
const refresh = () => {
|
|
217
|
+
for (const k of Object.keys(scope)) delete scope[k];
|
|
218
|
+
Object.assign(scope, numbers);
|
|
219
|
+
for (const name of defs.keys()) delete scope[name]; // x^2 has no value: {{A}} draws A, drawn as x^2
|
|
220
|
+
let pending = [...defs.values()];
|
|
221
|
+
while (pending.length) {
|
|
222
|
+
const left = pending.filter((def) => {
|
|
223
|
+
try {
|
|
224
|
+
const r = calculate(textOf(def), scope);
|
|
225
|
+
// a number, or a matrix (or list): {{M^2}} works it out
|
|
226
|
+
if (typeof r.value === 'number' || typeof r.value === 'bigint' || Array.isArray(r.value)) {
|
|
227
|
+
scope[def.name] = r.exact ? r.text : r.value;
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
// not (yet) a number
|
|
232
|
+
}
|
|
233
|
+
return true;
|
|
234
|
+
});
|
|
235
|
+
if (left.length === pending.length) break;
|
|
236
|
+
pending = left;
|
|
237
|
+
}
|
|
238
|
+
// and their expressions, for a CAS block: {{diff(f, x)}} differentiates what f stands for
|
|
239
|
+
const expressions = {};
|
|
240
|
+
const plain = lookupFor({});
|
|
241
|
+
for (const name of defs.keys()) {
|
|
242
|
+
const tree = plain(name);
|
|
243
|
+
if (!tree) continue;
|
|
244
|
+
try {
|
|
245
|
+
expressions[name] = toPostfix(substitute(tree, (n) => (n === name ? null : plain(n))).tree);
|
|
246
|
+
} catch {
|
|
247
|
+
// an expression that can't be written as postfix: the CAS sees the name
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
scope[EXPRESSIONS] = expressions;
|
|
251
|
+
};
|
|
252
|
+
const lookupFor = (drawOpts) => {
|
|
253
|
+
const trees = new Map();
|
|
254
|
+
return (name) => {
|
|
255
|
+
const def = defs.get(name);
|
|
256
|
+
if (!def) return null;
|
|
257
|
+
if (!trees.has(name)) {
|
|
258
|
+
let tree = null;
|
|
259
|
+
try {
|
|
260
|
+
tree = treeOf(textOf(def), drawOpts);
|
|
261
|
+
} catch {
|
|
262
|
+
// an input holding something that doesn't read: A is drawn as A
|
|
263
|
+
}
|
|
264
|
+
trees.set(name, tree);
|
|
265
|
+
}
|
|
266
|
+
return trees.get(name);
|
|
267
|
+
};
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
refresh();
|
|
271
|
+
|
|
272
|
+
const drawItem = (item, i) => {
|
|
273
|
+
const { source, display } = item;
|
|
274
|
+
const cls = display ? 'mv-md-display' : 'mv-md-math';
|
|
275
|
+
const wrap = (html, extra = '') => `<span class="${cls}${extra}" data-mv-md="${i}">${html}</span>`;
|
|
276
|
+
try {
|
|
277
|
+
const drawOpts = { ...options.math, display, blocks: options.blocks, scope };
|
|
278
|
+
const save = savingOf(source);
|
|
279
|
+
if (save && save.error) throw new Error(save.error);
|
|
280
|
+
if (save) {
|
|
281
|
+
const { name, input, hidden } = save;
|
|
282
|
+
const def = input
|
|
283
|
+
? { name, text: input.default, input, key: `input:${JSON.stringify(input)}` }
|
|
284
|
+
: { name, text: save.rhs, input: null, key: `expr:${save.rhs}`, start: item.start, end: item.end };
|
|
285
|
+
let html = '';
|
|
286
|
+
if (input) {
|
|
287
|
+
const control = controlHTML(def, textOf(def));
|
|
288
|
+
html = hidden ? control : `${toHTML(save.lhs, drawOpts)}<span class="mv-md-assign" aria-hidden="true">≔</span>${control}`;
|
|
289
|
+
inputs.add(i);
|
|
290
|
+
} else {
|
|
291
|
+
const rhs = substitute(treeOf(save.rhs, drawOpts), lookupFor(drawOpts)).tree;
|
|
292
|
+
if (!hidden) {
|
|
293
|
+
const tree = numberTree({ type: 'op', op: '=', args: [bare(treeOf(save.lhs)), rhs.id === undefined ? rhs : bare(rhs)] });
|
|
294
|
+
trees[i] = tree;
|
|
295
|
+
html = asDefinition(toHTML(tree, drawOpts));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
defs.delete(name);
|
|
299
|
+
defs.set(name, def);
|
|
300
|
+
mine.set(name, def);
|
|
301
|
+
refresh();
|
|
302
|
+
if (hidden && !input) return '';
|
|
303
|
+
return wrap(html, input ? ' mv-md-input-def' : '');
|
|
304
|
+
}
|
|
305
|
+
const { tree, changed } = substitute(treeOf(source, drawOpts), lookupFor(drawOpts));
|
|
306
|
+
if (changed) numberTree(tree);
|
|
307
|
+
trees[i] = tree;
|
|
308
|
+
const html = toHTML(tree, drawOpts);
|
|
309
|
+
if (!changed && options.define !== false && define(source, numbers, defs)) refresh();
|
|
310
|
+
return wrap(html);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
return `<span class="mv-md-error" data-mv-md="${i}" title="${escape(error.message)}"><code>${escape(item.raw)}</code></span>`;
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
const drawn = math.map(drawItem);
|
|
316
|
+
return { text, math, drawn, defs: mine, inputs, trees };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** $r = 3$ (or $A = \\begin{pmatrix}…$): remember r (exactly, as the calculator's text) for later {{…}} — unless r is a saved variable. */
|
|
320
|
+
function define(source, numbers, saved) {
|
|
321
|
+
const m = DEFINITION.exec(source);
|
|
322
|
+
if (!m || /\{\{/.test(m[2])) return false;
|
|
323
|
+
const name = m[1].replace(/[{}]/g, '');
|
|
324
|
+
if (saved.has(name)) return false;
|
|
325
|
+
try {
|
|
326
|
+
const r = calculate(m[2], numbers);
|
|
327
|
+
// a number, or a matrix (or list), as for saved variables
|
|
328
|
+
if (typeof r.value === 'number' || typeof r.value === 'bigint' || Array.isArray(r.value)) {
|
|
329
|
+
numbers[name] = r.exact ? r.text : r.value;
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
} catch {
|
|
333
|
+
// not a number (x = y + 1): nothing to remember
|
|
334
|
+
}
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---- the Markdown renderer ----------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
const SAFE_URL = /^(https?:|mailto:|#|\/|\.{0,2}\/|[^:]*$)/i;
|
|
341
|
+
const safeUrl = (u) => (SAFE_URL.test(u.trim()) ? escape(u.trim()) : '#');
|
|
342
|
+
|
|
343
|
+
/** Inline Markdown: code, links, images, emphasis, breaks. Placeholders pass through. */
|
|
344
|
+
function inline(text, options, sourceOf) {
|
|
345
|
+
const parts = [];
|
|
346
|
+
const hold = (html) => {
|
|
347
|
+
parts.push(html);
|
|
348
|
+
return `${parts.length - 1}`;
|
|
349
|
+
};
|
|
350
|
+
let t = text;
|
|
351
|
+
// code spans (maths placeholders inside are written back as their source)
|
|
352
|
+
t = t.replace(/(`+)([\s\S]*?[^`])\1(?!`)/g, (_, run, code) => hold(`<code>${escape(sourceOf(code.trim()))}</code>`));
|
|
353
|
+
// backslash escapes
|
|
354
|
+
t = t.replace(/\\([\\`*_{}[\]()#+\-.!|~$<>"'])/g, (_, ch) => hold(escape(ch)));
|
|
355
|
+
// autolinks
|
|
356
|
+
t = t.replace(/<((?:https?:\/\/|mailto:)[^\s>]+)>/g, (_, url) => hold(`<a href="${safeUrl(url)}">${escape(url)}</a>`));
|
|
357
|
+
// raw HTML (only with { html: true })
|
|
358
|
+
if (options.html) t = t.replace(/<\/?[A-Za-z][^>]*>/g, (tag) => hold(tag));
|
|
359
|
+
// images and links
|
|
360
|
+
// an address may hold balanced brackets: wiki/Function_(mathematics)
|
|
361
|
+
t = t.replace(/!\[([^\]]*)\]\(\s*((?:[^\s()]|\([^\s()]*\))+)(?:\s+"([^"]*)")?\s*\)/g, (_, alt, url, title) =>
|
|
362
|
+
hold(`<img src="${safeUrl(url)}" alt="${escape(alt)}"${title ? ` title="${escape(title)}"` : ''}>`));
|
|
363
|
+
t = t.replace(/\[([^\]]+)\]\(\s*((?:[^\s()]|\([^\s()]*\))+)(?:\s+"([^"]*)")?\s*\)/g, (_, label, url, title) =>
|
|
364
|
+
hold(`<a href="${safeUrl(url)}"${title ? ` title="${escape(title)}"` : ''}>${inline(label, options, sourceOf)}</a>`));
|
|
365
|
+
t = escape(t);
|
|
366
|
+
t = t.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, '<strong>$2</strong>');
|
|
367
|
+
t = t.replace(/(^|[^*\w])\*(?=\S)([\s\S]*?\S)\*(?!\*)/g, '$1<em>$2</em>');
|
|
368
|
+
t = t.replace(/(^|[^_\w])_(?=\S)([\s\S]*?\S)_(?![_\w])/g, '$1<em>$2</em>');
|
|
369
|
+
t = t.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
|
|
370
|
+
t = t.replace(/( {2,}|\\)\n/g, '<br>\n');
|
|
371
|
+
return t.replace(/(\d+)/g, (_, n) => parts[Number(n)]);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const BLOCK_START = /^( {0,3})(#{1,6}\s|>|```|~~~|([-*+]|\d{1,9}[.)])\s|(\*[ \t]*){3,}$|(-[ \t]*){3,}$|(_[ \t]*){3,}$)/;
|
|
375
|
+
const LIST_ITEM = /^( {0,3})([-*+]|\d{1,9}[.)])(\s+|$)/;
|
|
376
|
+
const TABLE_RULE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
|
|
377
|
+
|
|
378
|
+
/** Block Markdown → HTML. */
|
|
379
|
+
function blocks(lines, options, sourceOf) {
|
|
380
|
+
const out = [];
|
|
381
|
+
let i = 0;
|
|
382
|
+
const para = (text) => inline(text, options, sourceOf);
|
|
383
|
+
while (i < lines.length) {
|
|
384
|
+
const line = lines[i];
|
|
385
|
+
if (!line.trim()) { i++; continue; }
|
|
386
|
+
// fenced code
|
|
387
|
+
const fence = /^ {0,3}(`{3,}|~{3,})\s*([^\s`]*)/.exec(line);
|
|
388
|
+
if (fence) {
|
|
389
|
+
const body = [];
|
|
390
|
+
i++;
|
|
391
|
+
while (i < lines.length && !new RegExp(`^ {0,3}${fence[1][0] === '`' ? '`' : '~'}{${fence[1].length},}\\s*$`).test(lines[i])) body.push(lines[i++]);
|
|
392
|
+
i++;
|
|
393
|
+
const lang = fence[2] ? ` class="language-${escape(fence[2])}"` : '';
|
|
394
|
+
out.push(`<pre><code${lang}>${escape(sourceOf(body.join('\n')))}</code></pre>`);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
// ATX heading
|
|
398
|
+
const h = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
|
|
399
|
+
if (h) {
|
|
400
|
+
out.push(`<h${h[1].length}>${para(h[2])}</h${h[1].length}>`);
|
|
401
|
+
i++;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
// thematic break
|
|
405
|
+
if (/^ {0,3}((\*[ \t]*){3,}|(-[ \t]*){3,}|(_[ \t]*){3,})$/.test(line)) {
|
|
406
|
+
out.push('<hr>');
|
|
407
|
+
i++;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
// block quote
|
|
411
|
+
if (/^ {0,3}>/.test(line)) {
|
|
412
|
+
const body = [];
|
|
413
|
+
while (i < lines.length && lines[i].trim() && (/^ {0,3}>/.test(lines[i]) || body.length)) {
|
|
414
|
+
body.push(lines[i].replace(/^ {0,3}> ?/, ''));
|
|
415
|
+
i++;
|
|
416
|
+
}
|
|
417
|
+
out.push(`<blockquote>\n${blocks(body, options, sourceOf)}\n</blockquote>`);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
// list
|
|
421
|
+
const item = LIST_ITEM.exec(line);
|
|
422
|
+
if (item) {
|
|
423
|
+
const ordered = /\d/.test(item[2]);
|
|
424
|
+
const start = ordered ? parseInt(item[2], 10) : 1;
|
|
425
|
+
const items = [];
|
|
426
|
+
let loose = false;
|
|
427
|
+
while (i < lines.length) {
|
|
428
|
+
const m = LIST_ITEM.exec(lines[i]);
|
|
429
|
+
if (!m || /\d/.test(m[2]) !== ordered) break;
|
|
430
|
+
const indent = m[0].length;
|
|
431
|
+
const body = [lines[i].slice(indent)];
|
|
432
|
+
i++;
|
|
433
|
+
while (i < lines.length) {
|
|
434
|
+
const next = lines[i];
|
|
435
|
+
if (!next.trim()) {
|
|
436
|
+
// a blank line inside an item, or between items (then the list is loose)
|
|
437
|
+
const following = lines[i + 1];
|
|
438
|
+
if (following !== undefined && (following.startsWith(' '.repeat(Math.min(indent, 4))) && following.trim())) {
|
|
439
|
+
body.push('');
|
|
440
|
+
loose = true;
|
|
441
|
+
i++;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (following !== undefined && LIST_ITEM.test(following)) loose = true;
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
if (next.startsWith(' '.repeat(Math.min(indent, 4)))) body.push(next.slice(Math.min(indent, 4)));
|
|
448
|
+
else if (LIST_ITEM.test(next) || BLOCK_START.test(next)) break;
|
|
449
|
+
else body.push(next); // a lazy continuation line
|
|
450
|
+
i++;
|
|
451
|
+
}
|
|
452
|
+
items.push(body);
|
|
453
|
+
while (i < lines.length && !lines[i].trim() && lines[i + 1] !== undefined && LIST_ITEM.test(lines[i + 1])) i++;
|
|
454
|
+
}
|
|
455
|
+
const tag = ordered ? 'ol' : 'ul';
|
|
456
|
+
const html = items.map((body) => {
|
|
457
|
+
let inner = blocks(body, options, sourceOf);
|
|
458
|
+
if (!loose) inner = inner.replace(/^<p>([\s\S]*?)<\/p>/, '$1').replace(/\n<p>([\s\S]*?)<\/p>/g, '\n$1');
|
|
459
|
+
return `<li>${inner}</li>`;
|
|
460
|
+
});
|
|
461
|
+
out.push(`<${tag}${ordered && start !== 1 ? ` start="${start}"` : ''}>\n${html.join('\n')}\n</${tag}>`);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
// table
|
|
465
|
+
if (line.includes('|') && lines[i + 1] !== undefined && TABLE_RULE.test(lines[i + 1]) && lines[i + 1].includes('-')) {
|
|
466
|
+
const cells = (row) => row.trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map((c) => c.trim());
|
|
467
|
+
const align = cells(lines[i + 1]).map((c) => (c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : c.startsWith(':') ? 'left' : ''));
|
|
468
|
+
const cell = (tag, c, k) => `<${tag}${align[k] ? ` style="text-align:${align[k]}"` : ''}>${para(c)}</${tag}>`;
|
|
469
|
+
const head = cells(line);
|
|
470
|
+
i += 2;
|
|
471
|
+
const rows = [];
|
|
472
|
+
while (i < lines.length && lines[i].trim() && lines[i].includes('|')) rows.push(cells(lines[i++]));
|
|
473
|
+
out.push(`<table>\n<thead><tr>${head.map((c, k) => cell('th', c, k)).join('')}</tr></thead>\n<tbody>\n${rows.map((r) => `<tr>${head.map((_, k) => cell('td', r[k] ?? '', k)).join('')}</tr>`).join('\n')}\n</tbody>\n</table>`);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
// indented code
|
|
477
|
+
if (/^( {4}|\t)/.test(line)) {
|
|
478
|
+
const body = [];
|
|
479
|
+
while (i < lines.length && (/^( {4}|\t)/.test(lines[i]) || !lines[i].trim())) body.push(lines[i++].replace(/^( {4}|\t)/, ''));
|
|
480
|
+
while (body.length && !body[body.length - 1].trim()) body.pop();
|
|
481
|
+
out.push(`<pre><code>${escape(sourceOf(body.join('\n')))}</code></pre>`);
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
// paragraph (or a setext heading)
|
|
485
|
+
const text = [];
|
|
486
|
+
while (i < lines.length && lines[i].trim() && !(text.length && BLOCK_START.test(lines[i]) && !/^ {0,3}[-=]+\s*$/.test(lines[i]))) {
|
|
487
|
+
if (text.length && /^ {0,3}(=+|-+)\s*$/.test(lines[i])) {
|
|
488
|
+
const level = lines[i].trim()[0] === '=' ? 1 : 2;
|
|
489
|
+
out.push(`<h${level}>${para(text.join('\n'))}</h${level}>`);
|
|
490
|
+
text.length = 0;
|
|
491
|
+
i++;
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
text.push(lines[i].replace(/^ {0,3}/, ''));
|
|
495
|
+
i++;
|
|
496
|
+
}
|
|
497
|
+
if (text.length) out.push(`<p>${para(text.join('\n'))}</p>`);
|
|
498
|
+
}
|
|
499
|
+
return out.join('\n');
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** The built-in renderer: Markdown → HTML, raw HTML escaped unless options.html. */
|
|
503
|
+
export function renderMarkdown(markdown, options = {}, sourceOf = (t) => t) {
|
|
504
|
+
return blocks(String(markdown).replace(/\r\n?/g, '\n').split('\n'), options, sourceOf);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ---- together ---------------------------------------------------------------------------
|
|
508
|
+
|
|
509
|
+
/** The Markdown around the drawn maths. */
|
|
510
|
+
function assemble({ text, math, drawn }, options) {
|
|
511
|
+
const sourceOf = (t) => t.replace(PLACEHOLDER, (_, n) => math[Number(n)].raw);
|
|
512
|
+
const html = options.render ? options.render(text) : renderMarkdown(text, options, sourceOf);
|
|
513
|
+
return html
|
|
514
|
+
// display maths alone in a paragraph is a block of its own
|
|
515
|
+
.replace(new RegExp(`<p>\\s*${OPEN}(\\d+)${CLOSE}\\s*</p>`, 'g'), (whole, n) => (math[Number(n)].display && drawn[Number(n)] ? `<div class="mv-md-block">${drawn[Number(n)]}</div>` : whole))
|
|
516
|
+
.replace(PLACEHOLDER, (_, n) => drawn[Number(n)])
|
|
517
|
+
// a paragraph that only saved variables ($_A := 2+2$) leaves nothing behind
|
|
518
|
+
.replace(/<p>\s*<\/p>\n?/g, '');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Markdown with maths → HTML.
|
|
523
|
+
* @param {string} markdown
|
|
524
|
+
* @param {object} [options]
|
|
525
|
+
* @param {object} [options.math] options for each drawing (toHTML's: fnParens, colors …)
|
|
526
|
+
* @param {Function} [options.blocks] the {{…}} handler (default the calculator; casBlocks() for the CAS)
|
|
527
|
+
* @param {object} [options.scope] values for {{…}}: { r: 3 }
|
|
528
|
+
* @param {boolean} [options.define] remember $r = 3$ for later {{…}} (default true)
|
|
529
|
+
* @param {object} [options.variables] a store (createVariables()) whose variables the maths may use
|
|
530
|
+
* @param {boolean} [options.html] let raw HTML through (default false: it is shown as text)
|
|
531
|
+
* @param {(markdown: string) => string} [options.render] another Markdown renderer
|
|
532
|
+
* @param {string[]} [options.delimiters] which of '$$', '$', '\\[', '\\(' mark maths (default all)
|
|
533
|
+
*/
|
|
534
|
+
export function markdownToHTML(markdown, options = {}) {
|
|
535
|
+
return assemble(drawAll(markdown, options, options.variables || null), options);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const bindings = new WeakMap();
|
|
539
|
+
const live = new Set(); // every bound element's binding, to notice the ones taken off the page
|
|
540
|
+
|
|
541
|
+
/** Parts taken off the page take their variables with them. */
|
|
542
|
+
function sweep() {
|
|
543
|
+
for (const b of live) if (b.connected() && !b.element.isConnected) b.stop(true);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Draw Markdown with maths into an element, live: its variables join the page's store
|
|
548
|
+
* (options.variables, default `variables`; false for none), its inputs set them, and its maths is
|
|
549
|
+
* drawn again whenever a variable it can see changes. Drawing into the element again replaces it all.
|
|
550
|
+
* Double-clicking a part drawn from a textbox variable edits it ({ editable: { toText } }, toText
|
|
551
|
+
* from 'mathzetta/text'); any other part fires 'inlinemath:select' with where its maths is in the
|
|
552
|
+
* Markdown ({ index, start, end, raw }).
|
|
553
|
+
*/
|
|
554
|
+
export function renderMarkdownInto(element, markdown, options = {}) {
|
|
555
|
+
const store = options.variables === undefined ? pageVariables : options.variables || null;
|
|
556
|
+
sweep();
|
|
557
|
+
const old = bindings.get(element);
|
|
558
|
+
if (old) old.stop(old.store !== store);
|
|
559
|
+
let result = drawAll(markdown, options, store, element);
|
|
560
|
+
element.innerHTML = assemble(result, options);
|
|
561
|
+
if (!store) {
|
|
562
|
+
bindings.delete(element);
|
|
563
|
+
return element;
|
|
564
|
+
}
|
|
565
|
+
store.own(element, result.defs);
|
|
566
|
+
|
|
567
|
+
let connected = element.isConnected;
|
|
568
|
+
const redraw = () => {
|
|
569
|
+
const next = drawAll(markdown, options, store, element);
|
|
570
|
+
next.drawn.forEach((html, i) => {
|
|
571
|
+
if (html === result.drawn[i] || next.inputs.has(i)) return;
|
|
572
|
+
const at = element.querySelector(`[data-mv-md="${i}"]`);
|
|
573
|
+
if (at) at.outerHTML = html;
|
|
574
|
+
});
|
|
575
|
+
// inputs for the same variable agree; a slider shows its value
|
|
576
|
+
for (const el of element.querySelectorAll('[data-mv-var], [data-mv-out]')) {
|
|
577
|
+
const def = next.defs.get(el.dataset.mvVar ?? el.dataset.mvOut);
|
|
578
|
+
if (!def) continue;
|
|
579
|
+
const text = store.textOf(def);
|
|
580
|
+
if (el.dataset.mvOut !== undefined) el.textContent = text;
|
|
581
|
+
else if (el.value !== text && el !== el.ownerDocument?.activeElement) el.value = text;
|
|
582
|
+
}
|
|
583
|
+
result = next;
|
|
584
|
+
};
|
|
585
|
+
const onInput = (event) => {
|
|
586
|
+
const el = event.target;
|
|
587
|
+
const name = el && el.dataset && el.dataset.mvVar;
|
|
588
|
+
if (name === undefined || !element.contains(el)) return;
|
|
589
|
+
if (el.type === 'text') {
|
|
590
|
+
try {
|
|
591
|
+
treeOf(el.value, { blocks: options.blocks, scope: {} });
|
|
592
|
+
el.removeAttribute('aria-invalid');
|
|
593
|
+
} catch {
|
|
594
|
+
el.setAttribute('aria-invalid', 'true'); // keeps what it held last
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
store.set(name, el.value);
|
|
599
|
+
};
|
|
600
|
+
// double-click a part: a part that came from a textbox variable is edited there (in place,
|
|
601
|
+
// with options.editable = { toText }; else the textbox is focused); anything else tells the
|
|
602
|
+
// page where that maths is in the Markdown, as an 'inlinemath:select' event
|
|
603
|
+
const onDoubleClick = (event) => {
|
|
604
|
+
const part = event.target.closest?.('[data-node]');
|
|
605
|
+
const piece = part && part.closest('[data-mv-md]');
|
|
606
|
+
if (!piece || !element.contains(piece)) return;
|
|
607
|
+
const index = Number(piece.dataset.mvMd);
|
|
608
|
+
const tree = result.trees[index];
|
|
609
|
+
if (!tree) return;
|
|
610
|
+
const id = Number(part.dataset.node);
|
|
611
|
+
const path = pathOf(tree, id) || [];
|
|
612
|
+
// the innermost variable the part sits in
|
|
613
|
+
let at = null;
|
|
614
|
+
for (let k = path.length; k >= 0 && !at; k--) {
|
|
615
|
+
const n = nodeAt(tree, path.slice(0, k));
|
|
616
|
+
if (n && n.from !== undefined) at = { node: n, depth: k };
|
|
617
|
+
}
|
|
618
|
+
const def = at && store.definitions().get(at.node.from);
|
|
619
|
+
if (def && def.input && def.input.kind === 'textbox') {
|
|
620
|
+
event.preventDefault();
|
|
621
|
+
const name = def.name;
|
|
622
|
+
const box = element.ownerDocument.querySelector(`[data-mv-var="${CSS.escape(name)}"]`);
|
|
623
|
+
const toText = options.editable && options.editable.toText;
|
|
624
|
+
if (!toText) {
|
|
625
|
+
if (box) { box.focus(); box.select(); }
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const named = (n) => treeOf(n);
|
|
629
|
+
const node = nodeAt(tree, path);
|
|
630
|
+
openEditor(part, toText(unexpand(node, named)), {
|
|
631
|
+
label: `Edit ${name}`,
|
|
632
|
+
parse: (text) => treeOf(text, { blocks: options.blocks }),
|
|
633
|
+
commit: (typed) => {
|
|
634
|
+
const whole = replaceAt(unexpand(at.node, named), path.slice(at.depth), typed);
|
|
635
|
+
store.set(name, toText(whole));
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
// a saved variable defined here: point at its definition rather than where it is used
|
|
641
|
+
const here = def && result.defs.get(def.name);
|
|
642
|
+
const item = here && here.key === def.key && here.start !== undefined
|
|
643
|
+
? { ...result.math[index], start: here.start, end: here.end, raw: markdown.slice(here.start, here.end) }
|
|
644
|
+
: result.math[index];
|
|
645
|
+
element.dispatchEvent(new CustomEvent('inlinemath:select', {
|
|
646
|
+
bubbles: true,
|
|
647
|
+
detail: { index, start: item.start, end: item.end, raw: item.raw, source: item.source, id, path },
|
|
648
|
+
}));
|
|
649
|
+
};
|
|
650
|
+
element.addEventListener('input', onInput);
|
|
651
|
+
element.addEventListener('change', onInput);
|
|
652
|
+
element.addEventListener('dblclick', onDoubleClick);
|
|
653
|
+
const unsubscribe = store.subscribe((from) => {
|
|
654
|
+
if (from === element) return;
|
|
655
|
+
if (!element.isConnected) {
|
|
656
|
+
if (connected) stop(true); // taken off the page: its variables go with it
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
connected = true;
|
|
660
|
+
sweep();
|
|
661
|
+
redraw();
|
|
662
|
+
});
|
|
663
|
+
function stop(drop) {
|
|
664
|
+
unsubscribe();
|
|
665
|
+
element.removeEventListener('input', onInput);
|
|
666
|
+
element.removeEventListener('change', onInput);
|
|
667
|
+
element.removeEventListener('dblclick', onDoubleClick);
|
|
668
|
+
live.delete(binding);
|
|
669
|
+
bindings.delete(element);
|
|
670
|
+
if (drop) store.own(element, null);
|
|
671
|
+
}
|
|
672
|
+
const binding = { element, store, stop, connected: () => connected };
|
|
673
|
+
live.add(binding);
|
|
674
|
+
bindings.set(element, binding);
|
|
675
|
+
return element;
|
|
676
|
+
}
|