Skip to content

Commit e3eb244

Browse files
committed
✨ NEW: Add block math parsing/rendering
1 parent 89bbdc6 commit e3eb244

File tree

7 files changed

+665
-26
lines changed

7 files changed

+665
-26
lines changed

README.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ it is more performant, handles backslash ``\\`` escaping properly, and allows fo
1313

1414
## Usage
1515

16+
Options:
17+
18+
- `allow_space`: Parse inline math when there is space after/before the opening/closing `$`, e.g. `$ a $`
19+
- `allow_digits`: Parse inline math when there is a digit before/after the opening/closing `$`, e.g. `1$` or `$2`
20+
- `double_inline`: Search for double-dollar math within inline contexts
21+
- `allow_labels`: Capture math blocks with label suffix, e.g. `$$a=1$$ (eq1)`
22+
- `labelNormalizer`: Function to normalize the label, by default replaces whitespace with `-` (to align with [HTML5 ids](https://html.spec.whatwg.org/multipage/dom.html#global-attributes:the-id-attribute-2))
23+
1624
You should "bring your own" math render, provided as an option to the plugin.
1725
This function should take the string plus (optional) options, and return a string.
1826
For example, below the [KaTeX](https://github.com/Khan/KaTeX) render is used.
@@ -25,13 +33,16 @@ import MarkdownIt from "markdown-it"
2533
import dollarmathPlugin from "markdown-it-dollarmath"
2634

2735
const mdit = MarkdownIt().use(dollarmathPlugin, {
28-
allow_space: true,
29-
allow_digits: true,
30-
double_inline: true,
31-
allow_labels: true,
32-
renderer: renderToString,
33-
optionsInline: { throwOnError: false, displayMode: false },
34-
optionsBlock: { throwOnError: false, displayMode: true }
36+
allow_space: true,
37+
allow_digits: true,
38+
double_inline: true,
39+
allow_labels: true,
40+
labelNormalizer: (label: string) => {
41+
return label.replace(/[\s]+/g, "-")
42+
}
43+
renderer: renderToString,
44+
optionsInline: { throwOnError: false, displayMode: false },
45+
optionsBlock: { throwOnError: false, displayMode: true }
3546
})
3647
const text = mdit.render("$a = 1$")
3748
```

docs/index.html

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,27 @@ <h1>markdown-it-dollarmath</h1>
3232
Simply write in the text box below, then click away, and the text will be
3333
rendered.
3434
</p>
35-
<textarea id="inputtext" class="inputtext" rows="6">
36-
# Title
37-
35+
<textarea id="inputtext" class="inputtext" rows="15">
3836
Inline math: $a=1$
3937

40-
Inline display math: $$b=2$$</textarea
38+
Inline display math: $$b=2$$
39+
40+
Block math:
41+
42+
$$
43+
c=3
44+
$$
45+
46+
Block math with label:
47+
48+
$$
49+
d=4
50+
$$ (label)</textarea
4151
>
4252
<div id="renderer" class="rendered"></div>
4353
</div>
4454
</div>
55+
<noscript>You need to enable JavaScript to run this app.</noscript>
4556
<script>
4657
// get elements
4758
var inputText = document.getElementById("inputtext")

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@
4141
"node": ">=12",
4242
"npm": ">=6"
4343
},
44+
"peerDependencies": {
45+
"markdown-it": "12.x"
46+
},
4447
"devDependencies": {
4548
"@rollup/plugin-babel": "^5.3.0",
4649
"@rollup/plugin-commonjs": "^19.0.0",

src/index.ts

Lines changed: 173 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
import type MarkdownIt from "markdown-it/lib"
3-
// import type StateBlock from "markdown-it/lib/rules_block/state_block"
3+
import type StateBlock from "markdown-it/lib/rules_block/state_block"
44
import type StateInline from "markdown-it/lib/rules_inline/state_inline"
55

66
export interface IOptions {
7-
// Capture math blocks with label suffix, e.g. `$$a=1$$ (eq1)`
8-
allow_labels?: boolean
97
// Parse inline math when there is space after/before the opening/closing `$`, e.g. `$ a $`
108
allow_space?: boolean
119
// Parse inline math when there is a digit before/after the opening/closing `$`, e.g. `1$` or `$2`
1210
allow_digits?: boolean
1311
// Search for double-dollar math within inline contexts
1412
double_inline?: boolean
13+
// Capture math blocks with label suffix, e.g. `$$a=1$$ (eq1)`
14+
allow_labels?: boolean
15+
// function to normalize the label, by default replaces whitespace with `-`
16+
labelNormalizer?: (label: string) => string
1517
// The render function for math content
1618
renderer?: (content: string, options?: { [key: string]: any }) => string
1719
// options to parse to the render function, for inline math
@@ -21,10 +23,13 @@ export interface IOptions {
2123
}
2224

2325
const OptionDefaults: IOptions = {
24-
allow_labels: true,
2526
allow_space: true,
2627
allow_digits: true,
27-
double_inline: true
28+
double_inline: true,
29+
allow_labels: true,
30+
labelNormalizer: (label: string) => {
31+
return label.replace(/[\s]+/g, "-")
32+
}
2833
}
2934

3035
/**
@@ -34,31 +39,70 @@ const OptionDefaults: IOptions = {
3439
export default function dollarmath_plugin(md: MarkdownIt, options?: IOptions): void {
3540
const fullOptions = { ...OptionDefaults, ...options }
3641
md.inline.ruler.before("escape", "math_inline", math_inline_dollar(fullOptions))
37-
// md.block.ruler.before("fence", "math_block", math_block_dollar(fullOptions))
42+
md.block.ruler.before("fence", "math_block", math_block_dollar(fullOptions))
3843

3944
const renderer = fullOptions?.renderer
4045

4146
if (renderer) {
4247
md.renderer.rules["math_inline"] = (tokens, idx) => {
4348
const content = tokens[idx].content
44-
const renderOptions =
45-
tokens[idx].markup === "$$"
46-
? fullOptions?.optionsBlock
47-
: fullOptions?.optionsInline
4849
let res: string
4950
try {
50-
res = renderer(content, renderOptions)
51+
res = renderer(content, fullOptions?.optionsInline)
52+
} catch (err) {
53+
res = md.utils.escapeHtml(`${content}:${err.message}`)
54+
}
55+
return res
56+
}
57+
md.renderer.rules["math_inline_double"] = (tokens, idx) => {
58+
const content = tokens[idx].content
59+
let res: string
60+
try {
61+
res = renderer(content, fullOptions?.optionsBlock)
5162
} catch (err) {
5263
res = md.utils.escapeHtml(`${content}:${err.message}`)
5364
}
5465
return res
5566
}
67+
md.renderer.rules["math_block"] = (tokens, idx) => {
68+
const content = tokens[idx].content
69+
let res: string
70+
try {
71+
res = renderer(content, fullOptions?.optionsBlock)
72+
} catch (err) {
73+
res = md.utils.escapeHtml(`${content}:${err.message}`)
74+
}
75+
return res
76+
}
77+
md.renderer.rules["math_block_label"] = (tokens, idx) => {
78+
const content = tokens[idx].content
79+
const label = tokens[idx].info
80+
let res: string
81+
try {
82+
res = renderer(content, fullOptions?.optionsBlock)
83+
} catch (err) {
84+
res = md.utils.escapeHtml(`${content}:${err.message}`)
85+
}
86+
return (
87+
res +
88+
`\n<a href="#${label}" class="mathlabel" title="Permalink to this equation">¶</a>\n`
89+
)
90+
}
5691
} else {
57-
// basic renderer for testing
92+
// basic renderers for testing
5893
md.renderer.rules["math_inline"] = (tokens, idx) => {
94+
return `<eq>${tokens[idx].content}</eq>`
95+
}
96+
md.renderer.rules["math_inline_double"] = (tokens, idx) => {
97+
return `<eqn>${tokens[idx].content}</eqn>`
98+
}
99+
md.renderer.rules["math_block"] = (tokens, idx) => {
100+
return `<section>\n<eqn>${tokens[idx].content}</eqn>\n</section>\n`
101+
}
102+
md.renderer.rules["math_block_label"] = (tokens, idx) => {
59103
const content = tokens[idx].content
60-
const tag = tokens[idx].markup === "$$" ? "eqn" : "eq"
61-
return `<${tag}>${content}</${tag}>`
104+
const label = tokens[idx].info
105+
return `<section>\n<eqn>${content}</eqn>\n<span class="eqno">(${label})</span>\n</section>\n`
62106
}
63107
}
64108
}
@@ -181,7 +225,11 @@ function math_inline_dollar(
181225
return false
182226
}
183227
if (!silent) {
184-
const token = state.push("math_inline", "math", 0)
228+
const token = state.push(
229+
is_double ? "math_inline_double" : "math_inline",
230+
"math",
231+
0
232+
)
185233
token.content = text
186234
token.markup = is_double ? "$$" : "$"
187235
}
@@ -190,3 +238,113 @@ function math_inline_dollar(
190238
}
191239
return math_inline_dollar_rule
192240
}
241+
242+
/** Match a trailing label for a math block */
243+
function matchLabel(lineText: string, end: number): { label?: string; end: number } {
244+
// reverse the line and match
245+
const eqnoMatch = lineText
246+
.split("")
247+
.reverse()
248+
.join("")
249+
.match(/^\s*\)(?<label>[^)$\r\n]+?)\(\s*\${2}/)
250+
if (eqnoMatch && eqnoMatch.groups) {
251+
const label = eqnoMatch.groups["label"].split("").reverse().join("")
252+
end = end - ((eqnoMatch.index || 0) + eqnoMatch[0].length)
253+
return { label, end }
254+
}
255+
return { end }
256+
}
257+
258+
/** Generate inline dollar rule */
259+
function math_block_dollar(
260+
options: IOptions
261+
): (state: StateBlock, startLine: number, endLine: number, silent: boolean) => boolean {
262+
/** Block dollar rule */
263+
function math_block_dollar_rule(
264+
state: StateBlock,
265+
startLine: number,
266+
endLine: number,
267+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
268+
silent: boolean
269+
): boolean {
270+
let haveEndMarker = false
271+
const startPos = state.bMarks[startLine] + state.tShift[startLine]
272+
let end = state.eMarks[startLine]
273+
274+
// if it's indented more than 3 spaces, it should be a code block
275+
if (state.sCount[startLine] - state.blkIndent >= 4) {
276+
return false
277+
}
278+
if (startPos + 2 > end) {
279+
return false
280+
}
281+
if (
282+
state.src.charCodeAt(startPos) != 0x24 ||
283+
state.src.charCodeAt(startPos + 1) != 0x24
284+
) {
285+
return false
286+
}
287+
// search for end of block
288+
let nextLine = startLine
289+
let label: undefined | string = undefined
290+
// search for end of block on same line
291+
let lineText = state.src.slice(startPos, end)
292+
if (lineText.trim().length > 3) {
293+
if (lineText.trim().endsWith("$$")) {
294+
haveEndMarker = true
295+
end = end - 2 - (lineText.length - lineText.trim().length)
296+
} else if (options.allow_labels) {
297+
const output = matchLabel(lineText, end)
298+
if (output.label !== undefined) {
299+
haveEndMarker = true
300+
label = output.label
301+
end = output.end
302+
}
303+
}
304+
}
305+
306+
// search for end of block on subsequent line
307+
let start: number
308+
if (!haveEndMarker) {
309+
while (nextLine + 1 < endLine) {
310+
nextLine += 1
311+
start = state.bMarks[nextLine] + state.tShift[nextLine]
312+
end = state.eMarks[nextLine]
313+
if (end - start < 2) {
314+
continue
315+
}
316+
lineText = state.src.slice(start, end)
317+
if (lineText.trim().endsWith("$$")) {
318+
haveEndMarker = true
319+
end = end - 2 - (lineText.length - lineText.trim().length)
320+
break
321+
}
322+
if (options.allow_labels) {
323+
const output = matchLabel(lineText, end)
324+
if (output.label !== undefined) {
325+
haveEndMarker = true
326+
label = output.label
327+
end = output.end
328+
break
329+
}
330+
}
331+
}
332+
}
333+
if (!haveEndMarker) {
334+
return false
335+
}
336+
337+
state.line = nextLine + (haveEndMarker ? 1 : 0)
338+
339+
const token = state.push(label ? "math_block_label" : "math_block", "math", 0)
340+
token.block = true
341+
token.content = state.src.slice(startPos + 2, end)
342+
token.markup = "$$"
343+
token.map = [startLine, state.line]
344+
if (label) {
345+
token.info = options.labelNormalizer ? options.labelNormalizer(label) : label
346+
}
347+
return true
348+
}
349+
return math_block_dollar_rule
350+
}

tests/fixtures.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ describe("Parses basic", () => {
2828
allow_labels: true
2929
})
3030
const rendered = mdit.render(text)
31+
// console.log(rendered)
3132
it(name, () => expect(rendered.trim()).toEqual(expected.trim()))
3233
})
3334

0 commit comments

Comments
 (0)