index.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. /**
  2. * @typedef {import('hast').Element} HastElement
  3. * @typedef {import('hast').ElementContent} HastElementContent
  4. * @typedef {import('mdast-util-from-markdown').CompileContext} CompileContext
  5. * @typedef {import('mdast-util-from-markdown').Extension} FromMarkdownExtension
  6. * @typedef {import('mdast-util-from-markdown').Handle} FromMarkdownHandle
  7. * @typedef {import('mdast-util-to-markdown').Handle} ToMarkdownHandle
  8. * @typedef {import('mdast-util-to-markdown').Options} ToMarkdownExtension
  9. * @typedef {import('../index.js').InlineMath} InlineMath
  10. * @typedef {import('../index.js').Math} Math
  11. *
  12. * @typedef ToOptions
  13. * Configuration.
  14. * @property {boolean | null | undefined} [singleDollarTextMath=true]
  15. * Whether to support math (text) with a single dollar (default: `true`).
  16. *
  17. * Single dollars work in Pandoc and many other places, but often interfere
  18. * with “normal” dollars in text.
  19. * If you turn this off, you can still use two or more dollars for text math.
  20. */
  21. import {ok as assert} from 'devlop'
  22. import {longestStreak} from 'longest-streak'
  23. /**
  24. * Create an extension for `mdast-util-from-markdown`.
  25. *
  26. * @returns {FromMarkdownExtension}
  27. * Extension for `mdast-util-from-markdown`.
  28. */
  29. export function mathFromMarkdown() {
  30. return {
  31. enter: {
  32. mathFlow: enterMathFlow,
  33. mathFlowFenceMeta: enterMathFlowMeta,
  34. mathText: enterMathText
  35. },
  36. exit: {
  37. mathFlow: exitMathFlow,
  38. mathFlowFence: exitMathFlowFence,
  39. mathFlowFenceMeta: exitMathFlowMeta,
  40. mathFlowValue: exitMathData,
  41. mathText: exitMathText,
  42. mathTextData: exitMathData
  43. }
  44. }
  45. /**
  46. * @this {CompileContext}
  47. * @type {FromMarkdownHandle}
  48. */
  49. function enterMathFlow(token) {
  50. /** @type {HastElement} */
  51. const code = {
  52. type: 'element',
  53. tagName: 'code',
  54. properties: {className: ['language-math', 'math-display']},
  55. children: []
  56. }
  57. this.enter(
  58. {
  59. type: 'math',
  60. meta: null,
  61. value: '',
  62. data: {hName: 'pre', hChildren: [code]}
  63. },
  64. token
  65. )
  66. }
  67. /**
  68. * @this {CompileContext}
  69. * @type {FromMarkdownHandle}
  70. */
  71. function enterMathFlowMeta() {
  72. this.buffer()
  73. }
  74. /**
  75. * @this {CompileContext}
  76. * @type {FromMarkdownHandle}
  77. */
  78. function exitMathFlowMeta() {
  79. const data = this.resume()
  80. const node = this.stack[this.stack.length - 1]
  81. assert(node.type === 'math')
  82. node.meta = data
  83. }
  84. /**
  85. * @this {CompileContext}
  86. * @type {FromMarkdownHandle}
  87. */
  88. function exitMathFlowFence() {
  89. // Exit if this is the closing fence.
  90. if (this.data.mathFlowInside) return
  91. this.buffer()
  92. this.data.mathFlowInside = true
  93. }
  94. /**
  95. * @this {CompileContext}
  96. * @type {FromMarkdownHandle}
  97. */
  98. function exitMathFlow(token) {
  99. const data = this.resume().replace(/^(\r?\n|\r)|(\r?\n|\r)$/g, '')
  100. const node = this.stack[this.stack.length - 1]
  101. assert(node.type === 'math')
  102. this.exit(token)
  103. node.value = data
  104. // @ts-expect-error: we defined it in `enterMathFlow`.
  105. const code = /** @type {HastElement} */ (node.data.hChildren[0])
  106. assert(code.type === 'element')
  107. assert(code.tagName === 'code')
  108. code.children.push({type: 'text', value: data})
  109. this.data.mathFlowInside = undefined
  110. }
  111. /**
  112. * @this {CompileContext}
  113. * @type {FromMarkdownHandle}
  114. */
  115. function enterMathText(token) {
  116. this.enter(
  117. {
  118. type: 'inlineMath',
  119. value: '',
  120. data: {
  121. hName: 'code',
  122. hProperties: {className: ['language-math', 'math-inline']},
  123. hChildren: []
  124. }
  125. },
  126. token
  127. )
  128. this.buffer()
  129. }
  130. /**
  131. * @this {CompileContext}
  132. * @type {FromMarkdownHandle}
  133. */
  134. function exitMathText(token) {
  135. const data = this.resume()
  136. const node = this.stack[this.stack.length - 1]
  137. assert(node.type === 'inlineMath')
  138. this.exit(token)
  139. node.value = data
  140. const children = /** @type {Array<HastElementContent>} */ (
  141. // @ts-expect-error: we defined it in `enterMathFlow`.
  142. node.data.hChildren
  143. )
  144. children.push({type: 'text', value: data})
  145. }
  146. /**
  147. * @this {CompileContext}
  148. * @type {FromMarkdownHandle}
  149. */
  150. function exitMathData(token) {
  151. this.config.enter.data.call(this, token)
  152. this.config.exit.data.call(this, token)
  153. }
  154. }
  155. /**
  156. * Create an extension for `mdast-util-to-markdown`.
  157. *
  158. * @param {ToOptions | null | undefined} [options]
  159. * Configuration (optional).
  160. * @returns {ToMarkdownExtension}
  161. * Extension for `mdast-util-to-markdown`.
  162. */
  163. export function mathToMarkdown(options) {
  164. let single = (options || {}).singleDollarTextMath
  165. if (single === null || single === undefined) {
  166. single = true
  167. }
  168. inlineMath.peek = inlineMathPeek
  169. return {
  170. unsafe: [
  171. {character: '\r', inConstruct: 'mathFlowMeta'},
  172. {character: '\n', inConstruct: 'mathFlowMeta'},
  173. {
  174. character: '$',
  175. after: single ? undefined : '\\$',
  176. inConstruct: 'phrasing'
  177. },
  178. {character: '$', inConstruct: 'mathFlowMeta'},
  179. {atBreak: true, character: '$', after: '\\$'}
  180. ],
  181. handlers: {math, inlineMath}
  182. }
  183. /**
  184. * @type {ToMarkdownHandle}
  185. * @param {Math} node
  186. */
  187. // Note: fixing this code? Please also fix the similar code for code:
  188. // <https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/code.js>
  189. function math(node, _, state, info) {
  190. const raw = node.value || ''
  191. const tracker = state.createTracker(info)
  192. const sequence = '$'.repeat(Math.max(longestStreak(raw, '$') + 1, 2))
  193. const exit = state.enter('mathFlow')
  194. let value = tracker.move(sequence)
  195. if (node.meta) {
  196. const subexit = state.enter('mathFlowMeta')
  197. value += tracker.move(
  198. state.safe(node.meta, {
  199. after: '\n',
  200. before: value,
  201. encode: ['$'],
  202. ...tracker.current()
  203. })
  204. )
  205. subexit()
  206. }
  207. value += tracker.move('\n')
  208. if (raw) {
  209. value += tracker.move(raw + '\n')
  210. }
  211. value += tracker.move(sequence)
  212. exit()
  213. return value
  214. }
  215. /**
  216. * @type {ToMarkdownHandle}
  217. * @param {InlineMath} node
  218. */
  219. // Note: fixing this code? Please also fix the similar code for inline code:
  220. // <https://github.com/syntax-tree/mdast-util-to-markdown/blob/main/lib/handle/inline-code.js>
  221. function inlineMath(node, _, state) {
  222. let value = node.value || ''
  223. let size = 1
  224. if (!single) size++
  225. // If there is a single dollar sign on its own in the math, use a fence of
  226. // two.
  227. // If there are two in a row, use one.
  228. while (
  229. new RegExp('(^|[^$])' + '\\$'.repeat(size) + '([^$]|$)').test(value)
  230. ) {
  231. size++
  232. }
  233. const sequence = '$'.repeat(size)
  234. // If this is not just spaces or eols (tabs don’t count), and either the
  235. // first and last character are a space or eol, or the first or last
  236. // character are dollar signs, then pad with spaces.
  237. if (
  238. // Contains non-space.
  239. /[^ \r\n]/.test(value) &&
  240. // Starts with space and ends with space.
  241. ((/^[ \r\n]/.test(value) && /[ \r\n]$/.test(value)) ||
  242. // Starts or ends with dollar.
  243. /^\$|\$$/.test(value))
  244. ) {
  245. value = ' ' + value + ' '
  246. }
  247. let index = -1
  248. // We have a potential problem: certain characters after eols could result in
  249. // blocks being seen.
  250. // For example, if someone injected the string `'\n# b'`, then that would
  251. // result in an ATX heading.
  252. // We can’t escape characters in `inlineMath`, but because eols are
  253. // transformed to spaces when going from markdown to HTML anyway, we can swap
  254. // them out.
  255. while (++index < state.unsafe.length) {
  256. const pattern = state.unsafe[index]
  257. // Only look for `atBreak`s.
  258. // Btw: note that `atBreak` patterns will always start the regex at LF or
  259. // CR.
  260. if (!pattern.atBreak) continue
  261. const expression = state.compilePattern(pattern)
  262. /** @type {RegExpExecArray | null} */
  263. let match
  264. while ((match = expression.exec(value))) {
  265. let position = match.index
  266. // Support CRLF (patterns only look for one of the characters).
  267. if (
  268. value.codePointAt(position) === 10 /* `\n` */ &&
  269. value.codePointAt(position - 1) === 13 /* `\r` */
  270. ) {
  271. position--
  272. }
  273. value = value.slice(0, position) + ' ' + value.slice(match.index + 1)
  274. }
  275. }
  276. return sequence + value + sequence
  277. }
  278. /**
  279. * @returns {string}
  280. */
  281. function inlineMathPeek() {
  282. return '$'
  283. }
  284. }