state.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. /**
  2. * @import {
  3. * ElementContent as HastElementContent,
  4. * Element as HastElement,
  5. * Nodes as HastNodes,
  6. * Properties as HastProperties,
  7. * RootContent as HastRootContent,
  8. * Text as HastText
  9. * } from 'hast'
  10. * @import {
  11. * Definition as MdastDefinition,
  12. * FootnoteDefinition as MdastFootnoteDefinition,
  13. * Nodes as MdastNodes,
  14. * Parents as MdastParents
  15. * } from 'mdast'
  16. * @import {VFile} from 'vfile'
  17. * @import {
  18. * FootnoteBackContentTemplate,
  19. * FootnoteBackLabelTemplate
  20. * } from './footer.js'
  21. */
  22. /**
  23. * @callback Handler
  24. * Handle a node.
  25. * @param {State} state
  26. * Info passed around.
  27. * @param {any} node
  28. * mdast node to handle.
  29. * @param {MdastParents | undefined} parent
  30. * Parent of `node`.
  31. * @returns {Array<HastElementContent> | HastElementContent | undefined}
  32. * hast node.
  33. *
  34. * @typedef {Partial<Record<MdastNodes['type'], Handler>>} Handlers
  35. * Handle nodes.
  36. *
  37. * @typedef Options
  38. * Configuration (optional).
  39. * @property {boolean | null | undefined} [allowDangerousHtml=false]
  40. * Whether to persist raw HTML in markdown in the hast tree (default:
  41. * `false`).
  42. * @property {string | null | undefined} [clobberPrefix='user-content-']
  43. * Prefix to use before the `id` property on footnotes to prevent them from
  44. * *clobbering* (default: `'user-content-'`).
  45. *
  46. * Pass `''` for trusted markdown and when you are careful with
  47. * polyfilling.
  48. * You could pass a different prefix.
  49. *
  50. * DOM clobbering is this:
  51. *
  52. * ```html
  53. * <p id="x"></p>
  54. * <script>alert(x) // `x` now refers to the `p#x` DOM element</script>
  55. * ```
  56. *
  57. * The above example shows that elements are made available by browsers, by
  58. * their ID, on the `window` object.
  59. * This is a security risk because you might be expecting some other variable
  60. * at that place.
  61. * It can also break polyfills.
  62. * Using a prefix solves these problems.
  63. * @property {VFile | null | undefined} [file]
  64. * Corresponding virtual file representing the input document (optional).
  65. * @property {FootnoteBackContentTemplate | string | null | undefined} [footnoteBackContent]
  66. * Content of the backreference back to references (default: `defaultFootnoteBackContent`).
  67. *
  68. * The default value is:
  69. *
  70. * ```js
  71. * function defaultFootnoteBackContent(_, rereferenceIndex) {
  72. * const result = [{type: 'text', value: '↩'}]
  73. *
  74. * if (rereferenceIndex > 1) {
  75. * result.push({
  76. * type: 'element',
  77. * tagName: 'sup',
  78. * properties: {},
  79. * children: [{type: 'text', value: String(rereferenceIndex)}]
  80. * })
  81. * }
  82. *
  83. * return result
  84. * }
  85. * ```
  86. *
  87. * This content is used in the `a` element of each backreference (the `↩`
  88. * links).
  89. * @property {FootnoteBackLabelTemplate | string | null | undefined} [footnoteBackLabel]
  90. * Label to describe the backreference back to references (default:
  91. * `defaultFootnoteBackLabel`).
  92. *
  93. * The default value is:
  94. *
  95. * ```js
  96. * function defaultFootnoteBackLabel(referenceIndex, rereferenceIndex) {
  97. * return (
  98. * 'Back to reference ' +
  99. * (referenceIndex + 1) +
  100. * (rereferenceIndex > 1 ? '-' + rereferenceIndex : '')
  101. * )
  102. * }
  103. * ```
  104. *
  105. * Change it when the markdown is not in English.
  106. *
  107. * This label is used in the `ariaLabel` property on each backreference
  108. * (the `↩` links).
  109. * It affects users of assistive technology.
  110. * @property {string | null | undefined} [footnoteLabel='Footnotes']
  111. * Textual label to use for the footnotes section (default: `'Footnotes'`).
  112. *
  113. * Change it when the markdown is not in English.
  114. *
  115. * This label is typically hidden visually (assuming a `sr-only` CSS class
  116. * is defined that does that) and so affects screen readers only.
  117. * If you do have such a class, but want to show this section to everyone,
  118. * pass different properties with the `footnoteLabelProperties` option.
  119. * @property {HastProperties | null | undefined} [footnoteLabelProperties={className: ['sr-only']}]
  120. * Properties to use on the footnote label (default: `{className:
  121. * ['sr-only']}`).
  122. *
  123. * Change it to show the label and add other properties.
  124. *
  125. * This label is typically hidden visually (assuming an `sr-only` CSS class
  126. * is defined that does that) and so affects screen readers only.
  127. * If you do have such a class, but want to show this section to everyone,
  128. * pass an empty string.
  129. * You can also add different properties.
  130. *
  131. * > **Note**: `id: 'footnote-label'` is always added, because footnote
  132. * > calls use it with `aria-describedby` to provide an accessible label.
  133. * @property {string | null | undefined} [footnoteLabelTagName='h2']
  134. * HTML tag name to use for the footnote label element (default: `'h2'`).
  135. *
  136. * Change it to match your document structure.
  137. *
  138. * This label is typically hidden visually (assuming a `sr-only` CSS class
  139. * is defined that does that) and so affects screen readers only.
  140. * If you do have such a class, but want to show this section to everyone,
  141. * pass different properties with the `footnoteLabelProperties` option.
  142. * @property {Handlers | null | undefined} [handlers]
  143. * Extra handlers for nodes (optional).
  144. * @property {Array<MdastNodes['type']> | null | undefined} [passThrough]
  145. * List of custom mdast node types to pass through (keep) in hast (note that
  146. * the node itself is passed, but eventual children are transformed)
  147. * (optional).
  148. * @property {Handler | null | undefined} [unknownHandler]
  149. * Handler for all unknown nodes (optional).
  150. *
  151. * @typedef State
  152. * Info passed around.
  153. * @property {(node: MdastNodes) => Array<HastElementContent>} all
  154. * Transform the children of an mdast parent to hast.
  155. * @property {<Type extends HastNodes>(from: MdastNodes, to: Type) => HastElement | Type} applyData
  156. * Honor the `data` of `from`, and generate an element instead of `node`.
  157. * @property {Map<string, MdastDefinition>} definitionById
  158. * Definitions by their identifier.
  159. * @property {Map<string, MdastFootnoteDefinition>} footnoteById
  160. * Footnote definitions by their identifier.
  161. * @property {Map<string, number>} footnoteCounts
  162. * Counts for how often the same footnote was called.
  163. * @property {Array<string>} footnoteOrder
  164. * Identifiers of order when footnote calls first appear in tree order.
  165. * @property {Handlers} handlers
  166. * Applied handlers.
  167. * @property {(node: MdastNodes, parent: MdastParents | undefined) => Array<HastElementContent> | HastElementContent | undefined} one
  168. * Transform an mdast node to hast.
  169. * @property {Options} options
  170. * Configuration.
  171. * @property {(from: MdastNodes, node: HastNodes) => undefined} patch
  172. * Copy a node’s positional info.
  173. * @property {<Type extends HastRootContent>(nodes: Array<Type>, loose?: boolean | undefined) => Array<HastText | Type>} wrap
  174. * Wrap `nodes` with line endings between each node, adds initial/final line endings when `loose`.
  175. */
  176. import structuredClone from '@ungap/structured-clone'
  177. import {visit} from 'unist-util-visit'
  178. import {position} from 'unist-util-position'
  179. import {handlers as defaultHandlers} from './handlers/index.js'
  180. const own = {}.hasOwnProperty
  181. /** @type {Options} */
  182. const emptyOptions = {}
  183. /**
  184. * Create `state` from an mdast tree.
  185. *
  186. * @param {MdastNodes} tree
  187. * mdast node to transform.
  188. * @param {Options | null | undefined} [options]
  189. * Configuration (optional).
  190. * @returns {State}
  191. * `state` function.
  192. */
  193. export function createState(tree, options) {
  194. const settings = options || emptyOptions
  195. /** @type {Map<string, MdastDefinition>} */
  196. const definitionById = new Map()
  197. /** @type {Map<string, MdastFootnoteDefinition>} */
  198. const footnoteById = new Map()
  199. /** @type {Map<string, number>} */
  200. const footnoteCounts = new Map()
  201. /** @type {Handlers} */
  202. // @ts-expect-error: the root handler returns a root.
  203. // Hard to type.
  204. const handlers = {...defaultHandlers, ...settings.handlers}
  205. /** @type {State} */
  206. const state = {
  207. all,
  208. applyData,
  209. definitionById,
  210. footnoteById,
  211. footnoteCounts,
  212. footnoteOrder: [],
  213. handlers,
  214. one,
  215. options: settings,
  216. patch,
  217. wrap
  218. }
  219. visit(tree, function (node) {
  220. if (node.type === 'definition' || node.type === 'footnoteDefinition') {
  221. const map = node.type === 'definition' ? definitionById : footnoteById
  222. const id = String(node.identifier).toUpperCase()
  223. // Mimick CM behavior of link definitions.
  224. // See: <https://github.com/syntax-tree/mdast-util-definitions/blob/9032189/lib/index.js#L20-L21>.
  225. if (!map.has(id)) {
  226. // @ts-expect-error: node type matches map.
  227. map.set(id, node)
  228. }
  229. }
  230. })
  231. return state
  232. /**
  233. * Transform an mdast node into a hast node.
  234. *
  235. * @param {MdastNodes} node
  236. * mdast node.
  237. * @param {MdastParents | undefined} [parent]
  238. * Parent of `node`.
  239. * @returns {Array<HastElementContent> | HastElementContent | undefined}
  240. * Resulting hast node.
  241. */
  242. function one(node, parent) {
  243. const type = node.type
  244. const handle = state.handlers[type]
  245. if (own.call(state.handlers, type) && handle) {
  246. return handle(state, node, parent)
  247. }
  248. if (state.options.passThrough && state.options.passThrough.includes(type)) {
  249. if ('children' in node) {
  250. const {children, ...shallow} = node
  251. const result = structuredClone(shallow)
  252. // @ts-expect-error: TS doesn’t understand…
  253. result.children = state.all(node)
  254. // @ts-expect-error: TS doesn’t understand…
  255. return result
  256. }
  257. // @ts-expect-error: it’s custom.
  258. return structuredClone(node)
  259. }
  260. const unknown = state.options.unknownHandler || defaultUnknownHandler
  261. return unknown(state, node, parent)
  262. }
  263. /**
  264. * Transform the children of an mdast node into hast nodes.
  265. *
  266. * @param {MdastNodes} parent
  267. * mdast node to compile
  268. * @returns {Array<HastElementContent>}
  269. * Resulting hast nodes.
  270. */
  271. function all(parent) {
  272. /** @type {Array<HastElementContent>} */
  273. const values = []
  274. if ('children' in parent) {
  275. const nodes = parent.children
  276. let index = -1
  277. while (++index < nodes.length) {
  278. const result = state.one(nodes[index], parent)
  279. // To do: see if we van clean this? Can we merge texts?
  280. if (result) {
  281. if (index && nodes[index - 1].type === 'break') {
  282. if (!Array.isArray(result) && result.type === 'text') {
  283. result.value = trimMarkdownSpaceStart(result.value)
  284. }
  285. if (!Array.isArray(result) && result.type === 'element') {
  286. const head = result.children[0]
  287. if (head && head.type === 'text') {
  288. head.value = trimMarkdownSpaceStart(head.value)
  289. }
  290. }
  291. }
  292. if (Array.isArray(result)) {
  293. values.push(...result)
  294. } else {
  295. values.push(result)
  296. }
  297. }
  298. }
  299. }
  300. return values
  301. }
  302. }
  303. /**
  304. * Copy a node’s positional info.
  305. *
  306. * @param {MdastNodes} from
  307. * mdast node to copy from.
  308. * @param {HastNodes} to
  309. * hast node to copy into.
  310. * @returns {undefined}
  311. * Nothing.
  312. */
  313. function patch(from, to) {
  314. if (from.position) to.position = position(from)
  315. }
  316. /**
  317. * Honor the `data` of `from` and maybe generate an element instead of `to`.
  318. *
  319. * @template {HastNodes} Type
  320. * Node type.
  321. * @param {MdastNodes} from
  322. * mdast node to use data from.
  323. * @param {Type} to
  324. * hast node to change.
  325. * @returns {HastElement | Type}
  326. * Nothing.
  327. */
  328. function applyData(from, to) {
  329. /** @type {HastElement | Type} */
  330. let result = to
  331. // Handle `data.hName`, `data.hProperties, `data.hChildren`.
  332. if (from && from.data) {
  333. const hName = from.data.hName
  334. const hChildren = from.data.hChildren
  335. const hProperties = from.data.hProperties
  336. if (typeof hName === 'string') {
  337. // Transforming the node resulted in an element with a different name
  338. // than wanted:
  339. if (result.type === 'element') {
  340. result.tagName = hName
  341. }
  342. // Transforming the node resulted in a non-element, which happens for
  343. // raw, text, and root nodes (unless custom handlers are passed).
  344. // The intent of `hName` is to create an element, but likely also to keep
  345. // the content around (otherwise: pass `hChildren`).
  346. else {
  347. /** @type {Array<HastElementContent>} */
  348. // @ts-expect-error: assume no doctypes in `root`.
  349. const children = 'children' in result ? result.children : [result]
  350. result = {type: 'element', tagName: hName, properties: {}, children}
  351. }
  352. }
  353. if (result.type === 'element' && hProperties) {
  354. Object.assign(result.properties, structuredClone(hProperties))
  355. }
  356. if (
  357. 'children' in result &&
  358. result.children &&
  359. hChildren !== null &&
  360. hChildren !== undefined
  361. ) {
  362. result.children = hChildren
  363. }
  364. }
  365. return result
  366. }
  367. /**
  368. * Transform an unknown node.
  369. *
  370. * @param {State} state
  371. * Info passed around.
  372. * @param {MdastNodes} node
  373. * Unknown mdast node.
  374. * @returns {HastElement | HastText}
  375. * Resulting hast node.
  376. */
  377. function defaultUnknownHandler(state, node) {
  378. const data = node.data || {}
  379. /** @type {HastElement | HastText} */
  380. const result =
  381. 'value' in node &&
  382. !(own.call(data, 'hProperties') || own.call(data, 'hChildren'))
  383. ? {type: 'text', value: node.value}
  384. : {
  385. type: 'element',
  386. tagName: 'div',
  387. properties: {},
  388. children: state.all(node)
  389. }
  390. state.patch(node, result)
  391. return state.applyData(node, result)
  392. }
  393. /**
  394. * Wrap `nodes` with line endings between each node.
  395. *
  396. * @template {HastRootContent} Type
  397. * Node type.
  398. * @param {Array<Type>} nodes
  399. * List of nodes to wrap.
  400. * @param {boolean | undefined} [loose=false]
  401. * Whether to add line endings at start and end (default: `false`).
  402. * @returns {Array<HastText | Type>}
  403. * Wrapped nodes.
  404. */
  405. export function wrap(nodes, loose) {
  406. /** @type {Array<HastText | Type>} */
  407. const result = []
  408. let index = -1
  409. if (loose) {
  410. result.push({type: 'text', value: '\n'})
  411. }
  412. while (++index < nodes.length) {
  413. if (index) result.push({type: 'text', value: '\n'})
  414. result.push(nodes[index])
  415. }
  416. if (loose && nodes.length > 0) {
  417. result.push({type: 'text', value: '\n'})
  418. }
  419. return result
  420. }
  421. /**
  422. * Trim spaces and tabs at the start of `value`.
  423. *
  424. * @param {string} value
  425. * Value to trim.
  426. * @returns {string}
  427. * Result.
  428. */
  429. function trimMarkdownSpaceStart(value) {
  430. let index = 0
  431. let code = value.charCodeAt(index)
  432. while (code === 9 || code === 32) {
  433. index++
  434. code = value.charCodeAt(index)
  435. }
  436. return value.slice(index)
  437. }