diff --git a/README.md b/README.md index 13f8f64..3eebd3b 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ AsciiMath dialect. If you want to use LaTeX, follow the instructions below. +**Note** [mathup][mathup] or [temml][temml] are optional peer +dependencies, you must explicitly install either of them if you plan +to use the default renderer (see [installation][#Installation] below). + ```md Pythagorean theorem is $a^2 + b^2 = c^2$. @@ -53,9 +57,9 @@ location of your modules. ``` -**Note** Importing [mathup][mathup] or [temml][temml] are -optional. Only import mathup if you want to use it as the default -AsciiMath renderer. Import Temml if you want to use it as the LaTeX +**Note** Adding [mathup][mathup] or [temml][temml] to your import map +is optional. Only add mathup if you want to use it as the default +AsciiMath renderer. Add Temml if you want to use it as the LaTeX renderer. ## Usage @@ -71,7 +75,7 @@ const options = { inlineDelimiters: ["$", ["$`", "`$"]], inlineAllowWhiteSpacePadding: false, blockDelimiters: "$$", - defaultRendererOptions, + mathupOptions, inlineCustomElement, // see below inlineRenderer, // see below blockCustomElement, // see below @@ -101,26 +105,28 @@ default renderer. ### LaTeX (Temml) ```bash +# install temml as a peer dependency npm install --save temml ``` ```js import markdownIt from "markdown-it"; -import markdownItMath from "markdown-it-math"; -import temml from "temml"; +import markdownItMathTemml from "markdown-it-math/temml"; // Optional, if you want macros to persit across equations. const macros = {}; -const md = markdownIt().use(markdownItMath, { - inlineRenderer: (src) => temml.renderToString(src, { macros }), - blockRenderer: (src) => - temml.renderToString(src, { displayStyle: true, macros }), +const md = markdownIt().use(markdownItMathTemml, { + temmlOptions: { macros }, }); ``` +Note that the `markdown-it-math/temml` export supports the same +options as above, except `mathupOptions`, you can use `temmlOptions` +instead. + ```js -md.render(` +md.render(String.raw` A text $1+1=2$ with math. $$ @@ -138,6 +144,26 @@ You may also want to include the stylesheets and fonts from Temml. See [Temml][temml] for reference and usage instructions about the default renderer. +### No Default Renderer + +**`markdown-it-math/no-default-renderer`** is the minimal export. Use +this if you want to provide your own renderer. + +**Note:** The other two exports use top-level await to dynamically +import the respective peer dependency. If your environment does not +support that, this export is recommended, in which case you should +manually supply the renderers. + +```js +import markdownIt from "markdown-it"; +import markdownItMath from "markdown-it-math/no-default-renderer"; + +const md = markdownIt().use(markdownItMath, { + inlineRenderer: customInlineMathRenderer, + blockRenderer: customBlockMathRenderer, +}); +``` + ### Options - `inlineDelimiters`: A string, or an array of strings (or pairs of @@ -153,8 +179,11 @@ default renderer. `\(...\)` as delimiters where the risk of non-intended math expression is low. - `blockDelimiters`: Same as above, but for block expressions. Default `"$$"`. -- `defaultRendererOptions`: The options passed into the default - renderer. Ignored if you use a custom renderer. Default `{}` +- `mathupOptions`: The options passed to the default mathup renderer. Ignored + if you use a custom renderer. Default `{}`. +- `temmlOptions`: The options passed to the temml renderer. Only available if + you import from `markdown-it-math/temml` Ignored if you use a custom renderer. + Default `{}`. - `inlineCustomElement`: Specify `"tag-name"` or `["tag-name", { some: "attrs" }]` if you want to render inline expressions to a custom element. Ignored if you provide a @@ -167,7 +196,7 @@ default renderer. import mathup from "mathup"; function defaultInlineRenderer(src, token, md) { - return mathup(src, defaultRendererOptions).toString(); + return mathup(src, mathupOptions).toString(); } ``` @@ -184,7 +213,7 @@ default renderer. function defaultBlockRenderer(src, token, md) { return mathup(src, { - ...defaultRendererOptions, + ...mathupOptions, display: "block", }).toString(); } @@ -207,7 +236,7 @@ import markdownIt from "markdown-it"; import markdownItMath from "markdown-it-math"; const md = markdownIt().use(markdownItMath, { - defaultRendererOptions: { decimalMark: "," }, + mathupOptions: { decimalMark: "," }, }); md.render("$40,2$"); @@ -338,14 +367,14 @@ e = \sum_{n=0}^{\infty} \frac{1}{n!} ```js import markdownIt from "markdown-it"; -import markdownItMath from "markdown-it-math"; +import markdownItMathTemml from "markdown-it-math/temml"; import temml from "temml"; // An object to keep all the global macros. const macros = {}; -const md = markdownIt().use(markdownItMath, { - inlineRenderer: (src) => temml.renderToString(src, { macros }), +const md = markdownIt().use(markdownItMathTemml, { + temmlOptions: { macros }, blockDelimiters: ["$$", ["$$ preample", "$$"]], blockRenderer(src, token) { @@ -384,23 +413,39 @@ delimiter can be customized to look like an info string (see below). Consider [markdown-it-mathblock][markdown-it-mathblock] if you need commonmark compliant info strings. -## Upgrading From v4 - -Version 5 introduced some breaking changes, along with dropping legacy platforms. +## Deprecated Options -- The `inlineOpen`, `inlineClose`, `blockOpen`, and `blockClose` options have - been depricated in favor of `inlineDelimiters` and `blockDelimiters` - respectively. +- **`inlineOpen`** and **`inlineClose`** (since v5.0.0): Deprecated in favor + of `inlineDelimiters`: ```diff markdownIt().use(markdownItMath, { - inlineOpen: "$", - inlineClose: "$", + + inlineDelimiters: "$", + }); + ``` +- **`blockOpen`** and **`blockClose`** (since v5.0.0): Deprecated in favor + of `blockDelimiters`: + ```diff + markdownIt().use(markdownItMath, { - blockOpen: "$$", - blockClose: "$$", - + inlineDelimiters: "$", + blockDelimiters: "$$", }); ``` +- **`defaultRendererOptions`** (since v5.2.0): Deprecated in favor of + `mathupOptions`: + ```diff + markdownIt().use(markdownItMath, { + - defaultRendererOptions: { decimalMark: "," }, + + mathupOptions: { decimalMark: "," }, + }); + ``` + +## Upgrading From v4 + +Version 5 introduced some breaking changes, along with dropping legacy platforms. + - The default delimiters changed from `$$` and `$$$` for inline and block math respectively to `$` and `$$`. If you want to keep the thicker variants, you must set the relevant options: @@ -411,11 +456,11 @@ Version 5 introduced some breaking changes, along with dropping legacy platforms }); ``` - The options passed into the default mathup renderer has been renamed - from `renderingOptions` to `defaultRendererOptions`: + from `renderingOptions` to `mathupOptions`: ```diff markdownIt().use(markdownItMath, { - renderingOptions: { decimalMark: ",", }, - + defaultRendererOptions: { decimalMark: ",", }, + + mathupOptions: { decimalMark: ",", }, }); ``` - The default math renderer has been changed from Ascii2MathML to it’s @@ -441,7 +486,6 @@ Version 5 introduced some breaking changes, along with dropping legacy platforms [@mdit/plugin-katex]: https://mdit-plugins.github.io/katex.html [importmap]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap -[jsdelivr]: https://www.jsdelivr.com/ [markdown-it]: https://github.com/markdown-it/markdown-it [markdown-it-mathblock]: https://github.com/runarberg/markdown-it-mathblock [markdown-it-mathspan]: https://github.com/runarberg/markdown-it-mathspan diff --git a/index.js b/index.js index afec3f5..81a01dd 100644 --- a/index.js +++ b/index.js @@ -1,359 +1,46 @@ -/** - * @typedef {import("mathup").Options} MathupOptions - * @typedef {import("markdown-it").default} MarkdownIt - * @typedef {import("markdown-it/lib/parser_block.mjs").RuleBlock} RuleBlock - * @typedef {import("markdown-it/lib/parser_inline.mjs").RuleInline} RuleInline - * @typedef {import("markdown-it/lib/rules_block/state_block.mjs").default} StateBlock - * @typedef {import("markdown-it/lib/rules_inline/state_inline.mjs").default} StateInline - * @typedef {import("markdown-it/lib/token.mjs").default} Token - * @typedef {string | [string, string]} Delimiter - */ - -/** @type {import("mathup").default | undefined} */ -let mathup; -try { - mathup = (await import("mathup")).default; -} catch { - // pass -} - -/** - * @param {string | Delimiter[]} delimiters - * @returns {Array<[string, string]> | null} - */ -function fromDelimiterOption(delimiters) { - if (typeof delimiters === "string") { - if (delimiters.length === 0) { - return null; - } - - return [[delimiters, delimiters]]; - } - - /** @type {Array<[string, string]>} */ - const pairs = []; - for (const pair of delimiters) { - if (typeof pair === "string") { - if (pair.length === 0) { - continue; - } - - pairs.push([pair, pair]); - } else { - if (pair[0].length === 0 || pair[1].length === 0) { - continue; - } - - pairs.push(pair); - } - } - - if (pairs.length === 0) { - return null; - } - - // Make sure we match longer variants first. - return pairs.sort(([a], [b]) => b.length - a.length); -} - -/** - * @param {object} options - * @param {Array<[string, string]>} options.delimiters - * @param {boolean} options.allowWhiteSpacePadding - * @returns {RuleInline} - */ -function createInlineMathRule({ delimiters, allowWhiteSpacePadding }) { - return (state, silent) => { - const start = state.pos; - - const markers = delimiters.filter( - ([open]) => open === state.src.slice(start, start + open.length), - ); - - if (markers.length === 0) { - return false; - } - - // Scan until the end of the line (or until close marker is found). - for (const [open, close] of markers) { - const pos = start + open.length; - - if ( - state.md.utils.isWhiteSpace(state.src.charCodeAt(pos)) && - !allowWhiteSpacePadding - ) { - // Don’t allow whitespace immediately after open delimiter - continue; - } - - const matchStart = state.src.indexOf(close, pos); - - if (matchStart === -1 || pos === matchStart) { - // Don’t allow empty expressions. - continue; - } - - if ( - state.md.utils.isWhiteSpace(state.src.charCodeAt(matchStart - 1)) && - !allowWhiteSpacePadding - ) { - // Don’t allow whitespace immediately before close delimiter - continue; - } - - let content = state.src.slice(pos, matchStart).replaceAll("\n", " "); - - if (allowWhiteSpacePadding) { - content = content.replace(/^ (.+) $/, "$1"); - } - - if (!silent) { - const token = state.push("math_inline", "math", 0); - - token.markup = open; - token.content = content; - } - - state.pos = matchStart + close.length; - - return true; - } - - return false; - }; -} - -/** - * @param {Array<[string, string]>} delimiters - * @returns {RuleBlock} - */ -function createBlockMathRule(delimiters) { - return function math_block(state, startLine, endLine, silent) { - const start = state.bMarks[startLine] + state.tShift[startLine]; - - for (const [open, close] of delimiters) { - let pos = start; - let max = state.eMarks[startLine]; - - if (pos + open.length > max) { - continue; - } - - const openDelim = state.src.slice(pos, pos + open.length); - - if (openDelim !== open) { - continue; - } - - pos += open.length; - let firstLine = state.src.slice(pos, max); - - // Since start is found, we can report success here in validation mode - if (silent) { - return true; - } - - let haveEndMarker = false; - - if (firstLine.trim().slice(-close.length) === close) { - // Single line expression - firstLine = firstLine.trim().slice(0, -close.length); - haveEndMarker = true; - } - - // search end of block - let nextLine = startLine; - /** @type {string | undefined} */ - let lastLine; - - for (;;) { - if (haveEndMarker) { - break; - } +import plugin from "./src/plugin.js"; - nextLine += 1; - - if (nextLine >= endLine) { - // unclosed block should be autoclosed by end of document. - // also block seems to be autoclosed by end of parent - break; - } - - pos = state.bMarks[nextLine] + state.tShift[nextLine]; - max = state.eMarks[nextLine]; - - if (state.src.slice(pos, max).trim().slice(-close.length) !== close) { - continue; - } - - if (state.tShift[nextLine] - state.blkIndent >= 4) { - // closing block math should be indented less then 4 spaces - continue; - } - - const lastLinePos = state.src.slice(0, max).lastIndexOf(close); - lastLine = state.src.slice(pos, lastLinePos); - - pos += lastLine.length + close.length; - - // make sure tail has spaces only - pos = state.skipSpaces(pos); - - if (pos < max) { - continue; - } - - // found! - haveEndMarker = true; - } - - // If math block has heading spaces, they should be removed from its inner block - const len = state.tShift[startLine]; - - state.line = nextLine + (haveEndMarker ? 1 : 0); - - const token = state.push("math_block", "math", 0); - token.block = true; - - const firstLineContent = firstLine && firstLine.trim() ? firstLine : ""; - const contentLines = state.getLines(startLine + 1, nextLine, len, false); - const lastLineContent = lastLine && lastLine.trim() ? lastLine : ""; - - token.content = `${firstLineContent}${firstLineContent && (contentLines || lastLineContent) ? "\n" : ""}${contentLines}${contentLines && lastLineContent ? "\n" : ""}${lastLineContent}`; - token.map = [startLine, state.line]; - token.markup = open; - - return true; - } - - return false; - }; -} - -/** - * @typedef {string | [tag: string, attrs?: Record]} CustomElementOption - * @param {CustomElementOption} customElementOption - * @param {MarkdownIt} md - * @returns {(src: string) => string} - */ -function createCustomElementRenderer(customElementOption, md) { - const { escapeHtml } = md.utils; - - /** @type {string} */ - let tag; - let attrs = ""; - if (typeof customElementOption === "string") { - tag = customElementOption; - } else { - const [tagName, attrsObj = {}] = customElementOption; - tag = tagName; - - for (const [key, value] of Object.entries(attrsObj)) { - attrs += ` ${key}="${escapeHtml(value)}"`; - } - } - - return (src) => `<${tag}${attrs}>${escapeHtml(src)}`; -} +const mathup = await import("mathup").then( + (pkg) => pkg.default, + () => null, +); /** - * @param {MathupOptions} options - * @param {MarkdownIt} md - * @returns {(src: string) => string} - */ -function defaultInlineRenderer(options, md) { - if (!mathup) { - return createCustomElementRenderer(["span", { class: "math inline" }], md); - } - - return (src) => mathup(src, options).toString(); -} - -/** - * @param {MathupOptions} options - * @param {MarkdownIt} md - * @returns {(src: string) => string} - */ -function defaultBlockRenderer(options, md) { - if (!mathup) { - return createCustomElementRenderer(["div", { class: "math block" }], md); - } - - return (src) => mathup(src, { ...options, display: "block" }).toString(); -} - -/** - * @callback Renderer - * @param {string} src - The source content - * @param {Token} token - The parsed markdown-it token - * @param {MarkdownIt} md - The markdown-it instance - * @typedef {object} PluginOptions - * @property {string | Delimiter[]} [inlineDelimiters] - Inline math delimiters. - * @property {string} [inlineOpen] - Deprecated: Use inlineDelimiters - * @property {string} [inlineClose] - Deprecated: Use inlineDelimiters - * @property {CustomElementOption} [inlineCustomElement] - If you want to render to a custom element. - * @property {Renderer} [inlineRenderer] - Custom renderer for inline math. Default mathup. - * @property {boolean} [inlineAllowWhiteSpacePadding] - If you want allow inline math to start or end with whitespace. - * @property {string | Delimiter[]} [blockDelimiters] - Block math delimters. - * @property {string} [blockOpen] - Deprecated: Use blockDelimiters - * @property {string} [blockClose] - Deprecated: Use blockDelimiters - * @property {CustomElementOption} [blockCustomElement] - If you want to render to a custom element. - * @property {Renderer} [blockRenderer] - Custom renderer for block math. Default mathup with display = "block". - * @property {MathupOptions} [defaultRendererOptions] - The options passed into the default renderer. + * @typedef {import("./src/plugin.js").PluginOptions} PluginOptions + * @typedef {import("mathup").Options} MathupOptions + * @typedef {object} ExtraOptions + * @property {MathupOptions} [defaultRendererOptions] - DEPRICATED: use mathupOptions. + * @property {MathupOptions} [mathupOptions] - Options passed into the mathup default renderer. + * @typedef {PluginOptions & ExtraOptions} MarkdownItMathOptions */ -/** @type {import("markdown-it").PluginWithOptions} */ +/** @type {import("markdown-it").PluginWithOptions} */ export default function markdownItMath( md, { - defaultRendererOptions = {}, - - inlineAllowWhiteSpacePadding = false, - inlineOpen, - inlineClose, - inlineDelimiters = inlineOpen && inlineClose - ? /** @type {Delimiter[]} */ ([[inlineOpen, inlineClose]]) - : /** @type {Delimiter[]} */ (["$", ["$`", "`$"]]), - - blockOpen, - blockClose, - blockDelimiters = blockOpen && blockClose - ? /** @type {Delimiter[]} */ ([[blockOpen, blockClose]]) - : "$$", - - inlineCustomElement, - inlineRenderer = inlineCustomElement - ? createCustomElementRenderer(inlineCustomElement, md) - : defaultInlineRenderer(defaultRendererOptions, md), - - blockCustomElement, - blockRenderer = blockCustomElement - ? createCustomElementRenderer(blockCustomElement, md) - : defaultBlockRenderer(defaultRendererOptions, md), + defaultRendererOptions, + mathupOptions = defaultRendererOptions, + ...options } = {}, ) { - const inlineDelimitersArray = fromDelimiterOption(inlineDelimiters); - if (inlineDelimitersArray) { - const inlineMathRule = createInlineMathRule({ - delimiters: inlineDelimitersArray, - allowWhiteSpacePadding: inlineAllowWhiteSpacePadding, - }); - - md.inline.ruler.before("escape", "math_inline", inlineMathRule); - - md.renderer.rules.math_inline = (tokens, idx) => - inlineRenderer(tokens[idx].content, tokens[idx], md); + if (!mathup) { + return plugin(md, options); } - const blockDelitiersArray = fromDelimiterOption(blockDelimiters); - if (blockDelitiersArray) { - const blockMathRule = createBlockMathRule(blockDelitiersArray); + let { blockRenderer, inlineRenderer } = options; - md.block.ruler.after("blockquote", "math_block", blockMathRule, { - alt: ["paragraph", "reference", "blockquote", "list"], - }); + if (!inlineRenderer && !options.inlineCustomElement) { + inlineRenderer = (src) => mathup(src, mathupOptions).toString(); + } - md.renderer.rules.math_block = (tokens, idx) => - `${blockRenderer(tokens[idx].content, tokens[idx], md)}\n`; + if (!blockRenderer && !options.blockCustomElement) { + blockRenderer = (src) => + mathup(src, { ...mathupOptions, display: "block" }).toString(); } + + return plugin(md, { + ...options, + inlineRenderer, + blockRenderer, + }); } diff --git a/no-default-renderer.js b/no-default-renderer.js new file mode 100644 index 0000000..c014fe3 --- /dev/null +++ b/no-default-renderer.js @@ -0,0 +1 @@ +export { default } from "./src/plugin.js"; diff --git a/package-lock.json b/package-lock.json index f9fe1d9..75f06b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,15 +19,18 @@ "globals": "^16.0.0", "markdown-it": "^14.1.0", "prettier": "3.5.3", - "temml": "^0.11.2", "typescript": "^5.8.2" }, "peerDependencies": { - "mathup": "^1.0.0" + "mathup": "^1.0.0", + "temml": "^0.11.0" }, "peerDependenciesMeta": { "mathup": { "optional": true + }, + "temml": { + "optional": true } } }, @@ -267,9 +270,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", "dev": true, "license": "MIT" }, @@ -1450,8 +1453,9 @@ "version": "0.11.2", "resolved": "https://registry.npmjs.org/temml/-/temml-0.11.2.tgz", "integrity": "sha512-7YkDcDYE5UXV6yOGYgcT2A0J+IeXye2gXg77eH8WxLOcVssGMxPSxkh6GkyZ5owLM4C3u/d5oG/2gZrlQ+mAew==", - "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18.13.0" } diff --git a/package.json b/package.json index 00959b5..7aa676c 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,20 @@ "description": "Markdown-it plugin to include math in your document", "main": "index.js", "types": "types/index.js", + "exports": { + ".": { + "default": "./index.js", + "types": "./types/index.d.ts" + }, + "./no-default-renderer": { + "default": "./no-default-renderer.js", + "types": "./types/no-default-renderer.d.ts" + }, + "./temml": { + "default": "./temml.js", + "types": "./types/temml.d.ts" + } + }, "scripts": { "clean": "rm -fr coverage/ types/ pages/", "check": "tsc --noEmit", @@ -17,7 +31,7 @@ "test": "node --test --experimental-test-coverage", "test:coverage-badge": "mkdir -p coverage && node --test --experimental-test-coverage --test-reporter ./test/reporters/coverage-badge-reporter.js --test-reporter-destination coverage/badge.svg", "test:watch": "node --test --watch", - "types": "tsc index.js --allowJS --declaration --declarationMap --emitDeclarationOnly --esModuleInterop --outDir types" + "types": "tsc index.js no-default-renderer.js temml.js --allowJS --declaration --declarationMap --emitDeclarationOnly --esModuleInterop --outDir types" }, "repository": { "type": "git", @@ -48,15 +62,18 @@ "globals": "^16.0.0", "markdown-it": "^14.1.0", "prettier": "3.5.3", - "temml": "^0.11.2", "typescript": "^5.8.2" }, "peerDependencies": { - "mathup": "^1.0.0" + "mathup": "^1.0.0", + "temml": "^0.11.0" }, "peerDependenciesMeta": { "mathup": { "optional": true + }, + "temml": { + "optional": true } } } diff --git a/src/options.js b/src/options.js new file mode 100644 index 0000000..a1985f5 --- /dev/null +++ b/src/options.js @@ -0,0 +1,47 @@ +/** + * @typedef {string | [string, string]} Delimiter + * @typedef {string | [tag: string, attrs?: Record]} CustomElementOption + * @callback Renderer + * @param {string} src - The source content + * @param {Token} token - The parsed markdown-it token + * @param {MarkdownIt} md - The markdown-it instance + */ + +/** + * @param {string | Delimiter[]} delimiters + * @returns {Array<[string, string]> | null} + */ +export function fromDelimiterOption(delimiters) { + if (typeof delimiters === "string") { + if (delimiters.length === 0) { + return null; + } + + return [[delimiters, delimiters]]; + } + + /** @type {Array<[string, string]>} */ + const pairs = []; + for (const pair of delimiters) { + if (typeof pair === "string") { + if (pair.length === 0) { + continue; + } + + pairs.push([pair, pair]); + } else { + if (pair[0].length === 0 || pair[1].length === 0) { + continue; + } + + pairs.push(pair); + } + } + + if (pairs.length === 0) { + return null; + } + + // Make sure we match longer variants first. + return pairs.sort(([a], [b]) => b.length - a.length); +} diff --git a/src/plugin.js b/src/plugin.js new file mode 100644 index 0000000..3c4f396 --- /dev/null +++ b/src/plugin.js @@ -0,0 +1,76 @@ +/** + * @typedef {import("mathup").Options} MathupOptions + * @typedef {import("markdown-it").default} MarkdownIt + * @typedef {import("markdown-it/lib/token.mjs").default} Token + * + * @typedef {import("./options.js").CustomElementOption} CustomElementOption + * @typedef {import("./options.js").Delimiter} Delimiter + * @typedef {import("./options.js").Renderer} Renderer + * + * @typedef {object} PluginOptions + * @property {string | Delimiter[]} [inlineDelimiters] - Inline math delimiters. + * @property {string} [inlineOpen] - Deprecated: Use inlineDelimiters + * @property {string} [inlineClose] - Deprecated: Use inlineDelimiters + * @property {CustomElementOption} [inlineCustomElement] - If you want to render to a custom element. + * @property {Renderer} [inlineRenderer] - Custom renderer for inline math. Default mathup. + * @property {boolean} [inlineAllowWhiteSpacePadding] - If you want allow inline math to start or end with whitespace. + * @property {string | Delimiter[]} [blockDelimiters] - Block math delimters. + * @property {string} [blockOpen] - Deprecated: Use blockDelimiters + * @property {string} [blockClose] - Deprecated: Use blockDelimiters + * @property {CustomElementOption} [blockCustomElement] - If you want to render to a custom element. + * @property {Renderer} [blockRenderer] - Custom renderer for block math. Default mathup with display = "block". + */ + +import { fromDelimiterOption } from "./options.js"; +import { createBlockRenderer, createInlineRenderer } from "./renderers.js"; +import { createBlockMathRule, createInlineMathRule } from "./rulers.js"; + +/** @type {import("markdown-it").PluginWithOptions} */ +export default function plugin( + md, + { + inlineAllowWhiteSpacePadding = false, + inlineOpen, + inlineClose, + inlineDelimiters = inlineOpen && inlineClose + ? /** @type {Delimiter[]} */ ([[inlineOpen, inlineClose]]) + : /** @type {Delimiter[]} */ (["$", ["$`", "`$"]]), + + blockOpen, + blockClose, + blockDelimiters = blockOpen && blockClose + ? /** @type {Delimiter[]} */ ([[blockOpen, blockClose]]) + : "$$", + + inlineCustomElement, + inlineRenderer = createInlineRenderer(inlineCustomElement, md), + + blockCustomElement, + blockRenderer = createBlockRenderer(blockCustomElement, md), + } = {}, +) { + const inlineDelimitersArray = fromDelimiterOption(inlineDelimiters); + if (inlineDelimitersArray) { + const inlineMathRule = createInlineMathRule({ + delimiters: inlineDelimitersArray, + allowWhiteSpacePadding: inlineAllowWhiteSpacePadding, + }); + + md.inline.ruler.before("escape", "math_inline", inlineMathRule); + + md.renderer.rules.math_inline = (tokens, idx) => + inlineRenderer(tokens[idx].content, tokens[idx], md); + } + + const blockDelitiersArray = fromDelimiterOption(blockDelimiters); + if (blockDelitiersArray) { + const blockMathRule = createBlockMathRule(blockDelitiersArray); + + md.block.ruler.after("blockquote", "math_block", blockMathRule, { + alt: ["paragraph", "reference", "blockquote", "list"], + }); + + md.renderer.rules.math_block = (tokens, idx) => + `${blockRenderer(tokens[idx].content, tokens[idx], md)}\n`; + } +} diff --git a/src/renderers.js b/src/renderers.js new file mode 100644 index 0000000..a94dc1c --- /dev/null +++ b/src/renderers.js @@ -0,0 +1,56 @@ +/** + * @typedef {import("mathup").Options} MathupOptions + * @typedef {import("markdown-it").default} MarkdownIt + * @typedef {import("./options.js").CustomElementOption} CustomElementOption + */ + +/** + * @param {CustomElementOption} customElementOption + * @param {MarkdownIt} md + * @returns {(src: string) => string} + */ +export function createCustomElementRenderer(customElementOption, md) { + const { escapeHtml } = md.utils; + + /** @type {string} */ + let tag; + let attrs = ""; + if (typeof customElementOption === "string") { + tag = customElementOption; + } else { + const [tagName, attrsObj = {}] = customElementOption; + tag = tagName; + + for (const [key, value] of Object.entries(attrsObj)) { + attrs += ` ${key}="${escapeHtml(value)}"`; + } + } + + return (src) => `<${tag}${attrs}>${escapeHtml(src)}`; +} + +/** + * @param {CustomElementOption | undefined} customElement + * @param {MarkdownIt} md + * @returns {(src: string) => string} + */ +export function createInlineRenderer(customElement, md) { + if (customElement) { + return createCustomElementRenderer(customElement, md); + } + + return createCustomElementRenderer(["span", { class: "math inline" }], md); +} + +/** + * @param {CustomElementOption | undefined} customElement + * @param {MarkdownIt} md + * @returns {(src: string) => string} + */ +export function createBlockRenderer(customElement, md) { + if (customElement) { + return createCustomElementRenderer(customElement, md); + } + + return createCustomElementRenderer(["div", { class: "math block" }], md); +} diff --git a/src/rulers.js b/src/rulers.js new file mode 100644 index 0000000..aa1e22f --- /dev/null +++ b/src/rulers.js @@ -0,0 +1,178 @@ +/** + * @typedef {import("markdown-it/lib/parser_block.mjs").RuleBlock} RuleBlock + * @typedef {import("markdown-it/lib/parser_inline.mjs").RuleInline} RuleInline + */ + +/** + * @param {object} options + * @param {Array<[string, string]>} options.delimiters + * @param {boolean} options.allowWhiteSpacePadding + * @returns {RuleInline} + */ +export function createInlineMathRule({ delimiters, allowWhiteSpacePadding }) { + return (state, silent) => { + const start = state.pos; + + const markers = delimiters.filter( + ([open]) => open === state.src.slice(start, start + open.length), + ); + + if (markers.length === 0) { + return false; + } + + // Scan until the end of the line (or until close marker is found). + for (const [open, close] of markers) { + const pos = start + open.length; + + if ( + state.md.utils.isWhiteSpace(state.src.charCodeAt(pos)) && + !allowWhiteSpacePadding + ) { + // Don’t allow whitespace immediately after open delimiter + continue; + } + + const matchStart = state.src.indexOf(close, pos); + + if (matchStart === -1 || pos === matchStart) { + // Don’t allow empty expressions. + continue; + } + + if ( + state.md.utils.isWhiteSpace(state.src.charCodeAt(matchStart - 1)) && + !allowWhiteSpacePadding + ) { + // Don’t allow whitespace immediately before close delimiter + continue; + } + + let content = state.src.slice(pos, matchStart).replaceAll("\n", " "); + + if (allowWhiteSpacePadding) { + content = content.replace(/^ (.+) $/, "$1"); + } + + if (!silent) { + const token = state.push("math_inline", "math", 0); + + token.markup = open; + token.content = content; + } + + state.pos = matchStart + close.length; + + return true; + } + + return false; + }; +} + +/** + * @param {Array<[string, string]>} delimiters + * @returns {RuleBlock} + */ +export function createBlockMathRule(delimiters) { + return function math_block(state, startLine, endLine, silent) { + const start = state.bMarks[startLine] + state.tShift[startLine]; + + for (const [open, close] of delimiters) { + let pos = start; + let max = state.eMarks[startLine]; + + if (pos + open.length > max) { + continue; + } + + const openDelim = state.src.slice(pos, pos + open.length); + + if (openDelim !== open) { + continue; + } + + pos += open.length; + let firstLine = state.src.slice(pos, max); + + // Since start is found, we can report success here in validation mode + if (silent) { + return true; + } + + let haveEndMarker = false; + + if (firstLine.trim().slice(-close.length) === close) { + // Single line expression + firstLine = firstLine.trim().slice(0, -close.length); + haveEndMarker = true; + } + + // search end of block + let nextLine = startLine; + /** @type {string | undefined} */ + let lastLine; + + for (;;) { + if (haveEndMarker) { + break; + } + + nextLine += 1; + + if (nextLine >= endLine) { + // unclosed block should be autoclosed by end of document. + // also block seems to be autoclosed by end of parent + break; + } + + pos = state.bMarks[nextLine] + state.tShift[nextLine]; + max = state.eMarks[nextLine]; + + if (state.src.slice(pos, max).trim().slice(-close.length) !== close) { + continue; + } + + if (state.tShift[nextLine] - state.blkIndent >= 4) { + // closing block math should be indented less then 4 spaces + continue; + } + + const lastLinePos = state.src.slice(0, max).lastIndexOf(close); + lastLine = state.src.slice(pos, lastLinePos); + + pos += lastLine.length + close.length; + + // make sure tail has spaces only + pos = state.skipSpaces(pos); + + if (pos < max) { + continue; + } + + // found! + haveEndMarker = true; + } + + // If math block has heading spaces, they should be removed from its inner block + const len = state.tShift[startLine]; + + state.line = nextLine + (haveEndMarker ? 1 : 0); + + const token = state.push("math_block", "math", 0); + token.block = true; + + const firstLineContent = firstLine && firstLine.trim() ? firstLine : ""; + const contentLines = state.getLines(startLine + 1, nextLine, len, false); + const lastLineContent = lastLine && lastLine.trim() ? lastLine : ""; + + token.content = `${firstLineContent}${firstLineContent && (contentLines || lastLineContent) ? "\n" : ""}${contentLines}${contentLines && lastLineContent ? "\n" : ""}${lastLineContent}`; + token.map = [startLine, state.line]; + token.markup = open; + + return true; + } + + return false; + }; +} diff --git a/temml.js b/temml.js new file mode 100644 index 0000000..cff0867 --- /dev/null +++ b/temml.js @@ -0,0 +1,38 @@ +import plugin from "./src/plugin.js"; + +const temml = await import("temml").then( + (pkg) => pkg.default, + () => null, +); + +/** + * @typedef {import("./src/plugin.js").PluginOptions} PluginOptions + * @typedef {import("temml").Options} TemmlOptions + * @typedef {object} ExtraOptions + * @property {TemmlOptions} [temmlOptions] - Options passed into the mathup default renderer. + * @typedef {PluginOptions & ExtraOptions} MarkdownItMathOptions + */ + +/** @type {import("markdown-it").PluginWithOptions} */ +export default function markdownItMath(md, { temmlOptions, ...options } = {}) { + if (!temml) { + return plugin(md, options); + } + + let { blockRenderer, inlineRenderer } = options; + + if (!inlineRenderer && !options.inlineCustomElement) { + inlineRenderer = (src) => temml.renderToString(src, temmlOptions); + } + + if (!blockRenderer && !options.blockCustomElement) { + blockRenderer = (src) => + temml.renderToString(src, { ...temmlOptions, displayMode: true }); + } + + return plugin(md, { + ...options, + inlineRenderer, + blockRenderer, + }); +} diff --git a/test/test.js b/test/test.js index 46ca8e3..ba3e90c 100644 --- a/test/test.js +++ b/test/test.js @@ -5,10 +5,9 @@ import markdownIt from "markdown-it"; import Token from "markdown-it/lib/token.mjs"; import temml from "temml"; -import markdownItMath from "../index.js"; - -const inlineCustomElement = ["span", { class: "math inline" }]; -const blockCustomElement = ["div", { class: "math block" }]; +import markdownItMathMathup from "../index.js"; +import markdownItMath from "../no-default-renderer.js"; +import markdownItMathTemml from "../temml.js"; /** * @param {string} str @@ -52,10 +51,7 @@ function ul(strs) { } suite("Inline Math", () => { - const md = markdownIt().use(markdownItMath, { - inlineCustomElement, - blockCustomElement, - }); + const md = markdownIt().use(markdownItMath); test("Simple inline math", () => { const src = "$1+1 = 2$"; @@ -182,10 +178,7 @@ suite("Inline Math", () => { }); suite("Block Math", () => { - const md = markdownIt().use(markdownItMath, { - inlineCustomElement, - blockCustomElement, - }); + const md = markdownIt().use(markdownItMath); test("Simple block math", () => { const src = `$$ @@ -326,8 +319,6 @@ $$ test("Matches the longest possible delimiter", () => { const mdd = markdownIt().use(markdownItMath, { blockDelimiters: ["$$", "$$$"], - inlineCustomElement, - blockCustomElement, }); const src = "$$$ $$1+1$$ $$$"; @@ -343,8 +334,6 @@ $$ test("Allows close delimiters as long as end of line matches", () => { const mdd = markdownIt().use(markdownItMath, { blockDelimiters: ["$$", "$$$"], - inlineCustomElement, - blockCustomElement, }); const src = "$$ $$$1+1$$$ $$"; @@ -354,8 +343,6 @@ $$ test("But closes on the first match on multiline", () => { const mdd = markdownIt().use(markdownItMath, { blockDelimiters: ["$$", "$$$"], - inlineCustomElement, - blockCustomElement, }); const src = ` @@ -373,8 +360,6 @@ $$ suite("Options", () => { test("Thick dollar delims", () => { const md = markdownIt().use(markdownItMath, { - inlineCustomElement, - blockCustomElement, inlineDelimiters: "$$", blockDelimiters: "$$$", }); @@ -410,9 +395,7 @@ $$ test("Empty open or close dilimeters are filtered out", () => { const md = markdownIt().use(markdownItMath, { - blockCustomElement, blockDelimiters: [["$$", ""]], - inlineCustomElement, inlineDelimiters: ["", ["", "$"]], }); @@ -428,8 +411,6 @@ $$ test("Space dollar delims", () => { const md = markdownIt().use(markdownItMath, { - blockCustomElement, - inlineCustomElement, inlineDelimiters: [["$ ", " $"]], }); @@ -440,8 +421,6 @@ $$ test("Allow inline space padding", () => { const md = markdownIt().use(markdownItMath, { - blockCustomElement, - inlineCustomElement, inlineAllowWhiteSpacePadding: true, }); @@ -452,8 +431,6 @@ $$ test("LaTeX style delims", () => { const md = markdownIt().use(markdownItMath, { - inlineCustomElement, - blockCustomElement, inlineDelimiters: [["\\(", "\\)"]], blockDelimiters: [["\\[", "\\]"]], }); @@ -471,9 +448,9 @@ $$ ); }); - test("Different options for the default renderer", (t) => { - const md = markdownIt().use(markdownItMath, { - defaultRendererOptions: { + test("Different options for mathup", () => { + const md = markdownIt().use(markdownItMathMathup, { + mathupOptions: { decimalMark: ",", }, }); @@ -483,7 +460,10 @@ $$ 40,2 $$`; - t.assert.snapshot(md.render(src)); + assert.equal( + md.render(src), + '

40,2

\n40,2\n', + ); }); suite("Use Temml as renderer", () => { @@ -581,5 +561,69 @@ $$`; assert.equal(mdDepricated.render(src), mdRecommended.render(src)); }); + + test("defaultRendererOptions", () => { + const mdDepricated = markdownIt().use(markdownItMath, { + defaultRendererOptions: { + decimalMark: ",", + }, + }); + + const mdRecommended = markdownIt().use(markdownItMath, { + mathupOptions: { + decimalMark: ",", + }, + }); + + const src = `$40,2$ +$$ +40,2 +$$`; + + assert.equal(mdDepricated.render(src), mdRecommended.render(src)); + }); + }); +}); + +suite("temml", () => { + test("no options", () => { + const mdTemml = markdownIt().use(markdownItMathTemml); + const md = markdownIt().use(markdownItMath, { + inlineRenderer: (str) => temml.renderToString(str), + blockRenderer: (str) => temml.renderToString(str, { displayMode: true }), + }); + + const src = `$1+1 = 2$ +$$ +\\sin(2\\pi) +$$ +`; + + assert.equal(mdTemml.render(src), md.render(src)); + }); + + test("macros", () => { + const macros = temml.definePreamble(String.raw` +\def\E{\mathbb{E}} +\newcommand\d[0]{\operatorname{d}\!} +`); + + const mdTemml = markdownIt().use(markdownItMathTemml, { + temmlOptions: { macros }, + }); + + const md = markdownIt().use(markdownItMath, { + inlineRenderer: (str) => temml.renderToString(str, { macros }), + blockRenderer: (str) => + temml.renderToString(str, { macros, displayMode: true }), + }); + + const src = String.raw`$\E[X]$ +$$ +\E[X] = \int_{-\infty}^{\infty} xf(x) \d{x} +$$ +`; + + assert.equal(mdTemml.render(src), md.render(src)); }); }); diff --git a/test/test.js.snapshot b/test/test.js.snapshot index c1a06b9..488f5c4 100644 --- a/test/test.js.snapshot +++ b/test/test.js.snapshot @@ -1,7 +1,3 @@ -exports[`Options > Different options for the default renderer 1`] = ` -"

40,2

\\n40,2\\n" -`; - exports[`Options > Use Temml as renderer > block 1`] = ` "sin(2π)\\n" `; diff --git a/tsconfig.json b/tsconfig.json index 800b56c..24c6717 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,6 @@ "markdown-it-math": ["./index.js"] } }, - "include": ["**/*.js"], + "include": ["index.js", "no-default-renderer.js", "temml.js", "src/**/*.js"], "exclude": ["coverage/"] }