index.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787
  1. /**
  2. * @import {Identifier, Literal, MemberExpression} from 'estree'
  3. * @import {Jsx, JsxDev, Options, Props} from 'hast-util-to-jsx-runtime'
  4. * @import {Element, Nodes, Parents, Root, Text} from 'hast'
  5. * @import {MdxFlowExpressionHast, MdxTextExpressionHast} from 'mdast-util-mdx-expression'
  6. * @import {MdxJsxFlowElementHast, MdxJsxTextElementHast} from 'mdast-util-mdx-jsx'
  7. * @import {MdxjsEsmHast} from 'mdast-util-mdxjs-esm'
  8. * @import {Position} from 'unist'
  9. * @import {Child, Create, Field, JsxElement, State, Style} from './types.js'
  10. */
  11. import {stringify as commas} from 'comma-separated-tokens'
  12. import {ok as assert} from 'devlop'
  13. import {name as isIdentifierName} from 'estree-util-is-identifier-name'
  14. import {whitespace} from 'hast-util-whitespace'
  15. import {find, hastToReact, html, svg} from 'property-information'
  16. import {stringify as spaces} from 'space-separated-tokens'
  17. import styleToJs from 'style-to-js'
  18. import {pointStart} from 'unist-util-position'
  19. import {VFileMessage} from 'vfile-message'
  20. // To do: next major: `Object.hasOwn`.
  21. const own = {}.hasOwnProperty
  22. /** @type {Map<string, number>} */
  23. const emptyMap = new Map()
  24. const cap = /[A-Z]/g
  25. // `react-dom` triggers a warning for *any* white space in tables.
  26. // To follow GFM, `mdast-util-to-hast` injects line endings between elements.
  27. // Other tools might do so too, but they don’t do here, so we remove all of
  28. // that.
  29. // See: <https://github.com/facebook/react/pull/7081>.
  30. // See: <https://github.com/facebook/react/pull/7515>.
  31. // See: <https://github.com/remarkjs/remark-react/issues/64>.
  32. // See: <https://github.com/rehypejs/rehype-react/pull/29>.
  33. // See: <https://github.com/rehypejs/rehype-react/pull/32>.
  34. // See: <https://github.com/rehypejs/rehype-react/pull/45>.
  35. const tableElements = new Set(['table', 'tbody', 'thead', 'tfoot', 'tr'])
  36. const tableCellElement = new Set(['td', 'th'])
  37. const docs = 'https://github.com/syntax-tree/hast-util-to-jsx-runtime'
  38. /**
  39. * Transform a hast tree to preact, react, solid, svelte, vue, etc.,
  40. * with an automatic JSX runtime.
  41. *
  42. * @param {Nodes} tree
  43. * Tree to transform.
  44. * @param {Options} options
  45. * Configuration (required).
  46. * @returns {JsxElement}
  47. * JSX element.
  48. */
  49. export function toJsxRuntime(tree, options) {
  50. if (!options || options.Fragment === undefined) {
  51. throw new TypeError('Expected `Fragment` in options')
  52. }
  53. const filePath = options.filePath || undefined
  54. /** @type {Create} */
  55. let create
  56. if (options.development) {
  57. if (typeof options.jsxDEV !== 'function') {
  58. throw new TypeError(
  59. 'Expected `jsxDEV` in options when `development: true`'
  60. )
  61. }
  62. create = developmentCreate(filePath, options.jsxDEV)
  63. } else {
  64. if (typeof options.jsx !== 'function') {
  65. throw new TypeError('Expected `jsx` in production options')
  66. }
  67. if (typeof options.jsxs !== 'function') {
  68. throw new TypeError('Expected `jsxs` in production options')
  69. }
  70. create = productionCreate(filePath, options.jsx, options.jsxs)
  71. }
  72. /** @type {State} */
  73. const state = {
  74. Fragment: options.Fragment,
  75. ancestors: [],
  76. components: options.components || {},
  77. create,
  78. elementAttributeNameCase: options.elementAttributeNameCase || 'react',
  79. evaluater: options.createEvaluater ? options.createEvaluater() : undefined,
  80. filePath,
  81. ignoreInvalidStyle: options.ignoreInvalidStyle || false,
  82. passKeys: options.passKeys !== false,
  83. passNode: options.passNode || false,
  84. schema: options.space === 'svg' ? svg : html,
  85. stylePropertyNameCase: options.stylePropertyNameCase || 'dom',
  86. tableCellAlignToStyle: options.tableCellAlignToStyle !== false
  87. }
  88. const result = one(state, tree, undefined)
  89. // JSX element.
  90. if (result && typeof result !== 'string') {
  91. return result
  92. }
  93. // Text node or something that turned into nothing.
  94. return state.create(
  95. tree,
  96. state.Fragment,
  97. {children: result || undefined},
  98. undefined
  99. )
  100. }
  101. /**
  102. * Transform a node.
  103. *
  104. * @param {State} state
  105. * Info passed around.
  106. * @param {Nodes} node
  107. * Current node.
  108. * @param {string | undefined} key
  109. * Key.
  110. * @returns {Child | undefined}
  111. * Child, optional.
  112. */
  113. function one(state, node, key) {
  114. if (node.type === 'element') {
  115. return element(state, node, key)
  116. }
  117. if (node.type === 'mdxFlowExpression' || node.type === 'mdxTextExpression') {
  118. return mdxExpression(state, node)
  119. }
  120. if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') {
  121. return mdxJsxElement(state, node, key)
  122. }
  123. if (node.type === 'mdxjsEsm') {
  124. return mdxEsm(state, node)
  125. }
  126. if (node.type === 'root') {
  127. return root(state, node, key)
  128. }
  129. if (node.type === 'text') {
  130. return text(state, node)
  131. }
  132. }
  133. /**
  134. * Handle element.
  135. *
  136. * @param {State} state
  137. * Info passed around.
  138. * @param {Element} node
  139. * Current node.
  140. * @param {string | undefined} key
  141. * Key.
  142. * @returns {Child | undefined}
  143. * Child, optional.
  144. */
  145. function element(state, node, key) {
  146. const parentSchema = state.schema
  147. let schema = parentSchema
  148. if (node.tagName.toLowerCase() === 'svg' && parentSchema.space === 'html') {
  149. schema = svg
  150. state.schema = schema
  151. }
  152. state.ancestors.push(node)
  153. const type = findComponentFromName(state, node.tagName, false)
  154. const props = createElementProps(state, node)
  155. let children = createChildren(state, node)
  156. if (tableElements.has(node.tagName)) {
  157. children = children.filter(function (child) {
  158. return typeof child === 'string' ? !whitespace(child) : true
  159. })
  160. }
  161. addNode(state, props, type, node)
  162. addChildren(props, children)
  163. // Restore.
  164. state.ancestors.pop()
  165. state.schema = parentSchema
  166. return state.create(node, type, props, key)
  167. }
  168. /**
  169. * Handle MDX expression.
  170. *
  171. * @param {State} state
  172. * Info passed around.
  173. * @param {MdxFlowExpressionHast | MdxTextExpressionHast} node
  174. * Current node.
  175. * @returns {Child | undefined}
  176. * Child, optional.
  177. */
  178. function mdxExpression(state, node) {
  179. if (node.data && node.data.estree && state.evaluater) {
  180. const program = node.data.estree
  181. const expression = program.body[0]
  182. assert(expression.type === 'ExpressionStatement')
  183. // Assume result is a child.
  184. return /** @type {Child | undefined} */ (
  185. state.evaluater.evaluateExpression(expression.expression)
  186. )
  187. }
  188. crashEstree(state, node.position)
  189. }
  190. /**
  191. * Handle MDX ESM.
  192. *
  193. * @param {State} state
  194. * Info passed around.
  195. * @param {MdxjsEsmHast} node
  196. * Current node.
  197. * @returns {Child | undefined}
  198. * Child, optional.
  199. */
  200. function mdxEsm(state, node) {
  201. if (node.data && node.data.estree && state.evaluater) {
  202. // Assume result is a child.
  203. return /** @type {Child | undefined} */ (
  204. state.evaluater.evaluateProgram(node.data.estree)
  205. )
  206. }
  207. crashEstree(state, node.position)
  208. }
  209. /**
  210. * Handle MDX JSX.
  211. *
  212. * @param {State} state
  213. * Info passed around.
  214. * @param {MdxJsxFlowElementHast | MdxJsxTextElementHast} node
  215. * Current node.
  216. * @param {string | undefined} key
  217. * Key.
  218. * @returns {Child | undefined}
  219. * Child, optional.
  220. */
  221. function mdxJsxElement(state, node, key) {
  222. const parentSchema = state.schema
  223. let schema = parentSchema
  224. if (node.name === 'svg' && parentSchema.space === 'html') {
  225. schema = svg
  226. state.schema = schema
  227. }
  228. state.ancestors.push(node)
  229. const type =
  230. node.name === null
  231. ? state.Fragment
  232. : findComponentFromName(state, node.name, true)
  233. const props = createJsxElementProps(state, node)
  234. const children = createChildren(state, node)
  235. addNode(state, props, type, node)
  236. addChildren(props, children)
  237. // Restore.
  238. state.ancestors.pop()
  239. state.schema = parentSchema
  240. return state.create(node, type, props, key)
  241. }
  242. /**
  243. * Handle root.
  244. *
  245. * @param {State} state
  246. * Info passed around.
  247. * @param {Root} node
  248. * Current node.
  249. * @param {string | undefined} key
  250. * Key.
  251. * @returns {Child | undefined}
  252. * Child, optional.
  253. */
  254. function root(state, node, key) {
  255. /** @type {Props} */
  256. const props = {}
  257. addChildren(props, createChildren(state, node))
  258. return state.create(node, state.Fragment, props, key)
  259. }
  260. /**
  261. * Handle text.
  262. *
  263. * @param {State} _
  264. * Info passed around.
  265. * @param {Text} node
  266. * Current node.
  267. * @returns {Child | undefined}
  268. * Child, optional.
  269. */
  270. function text(_, node) {
  271. return node.value
  272. }
  273. /**
  274. * Add `node` to props.
  275. *
  276. * @param {State} state
  277. * Info passed around.
  278. * @param {Props} props
  279. * Props.
  280. * @param {unknown} type
  281. * Type.
  282. * @param {Element | MdxJsxFlowElementHast | MdxJsxTextElementHast} node
  283. * Node.
  284. * @returns {undefined}
  285. * Nothing.
  286. */
  287. function addNode(state, props, type, node) {
  288. // If this is swapped out for a component:
  289. if (typeof type !== 'string' && type !== state.Fragment && state.passNode) {
  290. props.node = node
  291. }
  292. }
  293. /**
  294. * Add children to props.
  295. *
  296. * @param {Props} props
  297. * Props.
  298. * @param {Array<Child>} children
  299. * Children.
  300. * @returns {undefined}
  301. * Nothing.
  302. */
  303. function addChildren(props, children) {
  304. if (children.length > 0) {
  305. const value = children.length > 1 ? children : children[0]
  306. if (value) {
  307. props.children = value
  308. }
  309. }
  310. }
  311. /**
  312. * @param {string | undefined} _
  313. * Path to file.
  314. * @param {Jsx} jsx
  315. * Dynamic.
  316. * @param {Jsx} jsxs
  317. * Static.
  318. * @returns {Create}
  319. * Create a production element.
  320. */
  321. function productionCreate(_, jsx, jsxs) {
  322. return create
  323. /** @type {Create} */
  324. function create(_, type, props, key) {
  325. // Only an array when there are 2 or more children.
  326. const isStaticChildren = Array.isArray(props.children)
  327. const fn = isStaticChildren ? jsxs : jsx
  328. return key ? fn(type, props, key) : fn(type, props)
  329. }
  330. }
  331. /**
  332. * @param {string | undefined} filePath
  333. * Path to file.
  334. * @param {JsxDev} jsxDEV
  335. * Development.
  336. * @returns {Create}
  337. * Create a development element.
  338. */
  339. function developmentCreate(filePath, jsxDEV) {
  340. return create
  341. /** @type {Create} */
  342. function create(node, type, props, key) {
  343. // Only an array when there are 2 or more children.
  344. const isStaticChildren = Array.isArray(props.children)
  345. const point = pointStart(node)
  346. return jsxDEV(
  347. type,
  348. props,
  349. key,
  350. isStaticChildren,
  351. {
  352. columnNumber: point ? point.column - 1 : undefined,
  353. fileName: filePath,
  354. lineNumber: point ? point.line : undefined
  355. },
  356. undefined
  357. )
  358. }
  359. }
  360. /**
  361. * Create props from an element.
  362. *
  363. * @param {State} state
  364. * Info passed around.
  365. * @param {Element} node
  366. * Current element.
  367. * @returns {Props}
  368. * Props.
  369. */
  370. function createElementProps(state, node) {
  371. /** @type {Props} */
  372. const props = {}
  373. /** @type {string | undefined} */
  374. let alignValue
  375. /** @type {string} */
  376. let prop
  377. for (prop in node.properties) {
  378. if (prop !== 'children' && own.call(node.properties, prop)) {
  379. const result = createProperty(state, prop, node.properties[prop])
  380. if (result) {
  381. const [key, value] = result
  382. if (
  383. state.tableCellAlignToStyle &&
  384. key === 'align' &&
  385. typeof value === 'string' &&
  386. tableCellElement.has(node.tagName)
  387. ) {
  388. alignValue = value
  389. } else {
  390. props[key] = value
  391. }
  392. }
  393. }
  394. }
  395. if (alignValue) {
  396. // Assume style is an object.
  397. const style = /** @type {Style} */ (props.style || (props.style = {}))
  398. style[state.stylePropertyNameCase === 'css' ? 'text-align' : 'textAlign'] =
  399. alignValue
  400. }
  401. return props
  402. }
  403. /**
  404. * Create props from a JSX element.
  405. *
  406. * @param {State} state
  407. * Info passed around.
  408. * @param {MdxJsxFlowElementHast | MdxJsxTextElementHast} node
  409. * Current JSX element.
  410. * @returns {Props}
  411. * Props.
  412. */
  413. function createJsxElementProps(state, node) {
  414. /** @type {Props} */
  415. const props = {}
  416. for (const attribute of node.attributes) {
  417. if (attribute.type === 'mdxJsxExpressionAttribute') {
  418. if (attribute.data && attribute.data.estree && state.evaluater) {
  419. const program = attribute.data.estree
  420. const expression = program.body[0]
  421. assert(expression.type === 'ExpressionStatement')
  422. const objectExpression = expression.expression
  423. assert(objectExpression.type === 'ObjectExpression')
  424. const property = objectExpression.properties[0]
  425. assert(property.type === 'SpreadElement')
  426. Object.assign(
  427. props,
  428. state.evaluater.evaluateExpression(property.argument)
  429. )
  430. } else {
  431. crashEstree(state, node.position)
  432. }
  433. } else {
  434. // For JSX, the author is responsible of passing in the correct values.
  435. const name = attribute.name
  436. /** @type {unknown} */
  437. let value
  438. if (attribute.value && typeof attribute.value === 'object') {
  439. if (
  440. attribute.value.data &&
  441. attribute.value.data.estree &&
  442. state.evaluater
  443. ) {
  444. const program = attribute.value.data.estree
  445. const expression = program.body[0]
  446. assert(expression.type === 'ExpressionStatement')
  447. value = state.evaluater.evaluateExpression(expression.expression)
  448. } else {
  449. crashEstree(state, node.position)
  450. }
  451. } else {
  452. value = attribute.value === null ? true : attribute.value
  453. }
  454. // Assume a prop.
  455. props[name] = /** @type {Props[keyof Props]} */ (value)
  456. }
  457. }
  458. return props
  459. }
  460. /**
  461. * Create children.
  462. *
  463. * @param {State} state
  464. * Info passed around.
  465. * @param {Parents} node
  466. * Current element.
  467. * @returns {Array<Child>}
  468. * Children.
  469. */
  470. function createChildren(state, node) {
  471. /** @type {Array<Child>} */
  472. const children = []
  473. let index = -1
  474. /** @type {Map<string, number>} */
  475. // Note: test this when Solid doesn’t want to merge my upcoming PR.
  476. /* c8 ignore next */
  477. const countsByName = state.passKeys ? new Map() : emptyMap
  478. while (++index < node.children.length) {
  479. const child = node.children[index]
  480. /** @type {string | undefined} */
  481. let key
  482. if (state.passKeys) {
  483. const name =
  484. child.type === 'element'
  485. ? child.tagName
  486. : child.type === 'mdxJsxFlowElement' ||
  487. child.type === 'mdxJsxTextElement'
  488. ? child.name
  489. : undefined
  490. if (name) {
  491. const count = countsByName.get(name) || 0
  492. key = name + '-' + count
  493. countsByName.set(name, count + 1)
  494. }
  495. }
  496. const result = one(state, child, key)
  497. if (result !== undefined) children.push(result)
  498. }
  499. return children
  500. }
  501. /**
  502. * Handle a property.
  503. *
  504. * @param {State} state
  505. * Info passed around.
  506. * @param {string} prop
  507. * Key.
  508. * @param {Array<number | string> | boolean | number | string | null | undefined} value
  509. * hast property value.
  510. * @returns {Field | undefined}
  511. * Field for runtime, optional.
  512. */
  513. function createProperty(state, prop, value) {
  514. const info = find(state.schema, prop)
  515. // Ignore nullish and `NaN` values.
  516. if (
  517. value === null ||
  518. value === undefined ||
  519. (typeof value === 'number' && Number.isNaN(value))
  520. ) {
  521. return
  522. }
  523. if (Array.isArray(value)) {
  524. // Accept `array`.
  525. // Most props are space-separated.
  526. value = info.commaSeparated ? commas(value) : spaces(value)
  527. }
  528. // React only accepts `style` as object.
  529. if (info.property === 'style') {
  530. let styleObject =
  531. typeof value === 'object' ? value : parseStyle(state, String(value))
  532. if (state.stylePropertyNameCase === 'css') {
  533. styleObject = transformStylesToCssCasing(styleObject)
  534. }
  535. return ['style', styleObject]
  536. }
  537. return [
  538. state.elementAttributeNameCase === 'react' && info.space
  539. ? hastToReact[info.property] || info.property
  540. : info.attribute,
  541. value
  542. ]
  543. }
  544. /**
  545. * Parse a CSS declaration to an object.
  546. *
  547. * @param {State} state
  548. * Info passed around.
  549. * @param {string} value
  550. * CSS declarations.
  551. * @returns {Style}
  552. * Properties.
  553. * @throws
  554. * Throws `VFileMessage` when CSS cannot be parsed.
  555. */
  556. function parseStyle(state, value) {
  557. try {
  558. return styleToJs(value, {reactCompat: true})
  559. } catch (error) {
  560. if (state.ignoreInvalidStyle) {
  561. return {}
  562. }
  563. const cause = /** @type {Error} */ (error)
  564. const message = new VFileMessage('Cannot parse `style` attribute', {
  565. ancestors: state.ancestors,
  566. cause,
  567. ruleId: 'style',
  568. source: 'hast-util-to-jsx-runtime'
  569. })
  570. message.file = state.filePath || undefined
  571. message.url = docs + '#cannot-parse-style-attribute'
  572. throw message
  573. }
  574. }
  575. /**
  576. * Create a JSX name from a string.
  577. *
  578. * @param {State} state
  579. * To do.
  580. * @param {string} name
  581. * Name.
  582. * @param {boolean} allowExpression
  583. * Allow member expressions and identifiers.
  584. * @returns {unknown}
  585. * To do.
  586. */
  587. function findComponentFromName(state, name, allowExpression) {
  588. /** @type {Identifier | Literal | MemberExpression} */
  589. let result
  590. if (!allowExpression) {
  591. result = {type: 'Literal', value: name}
  592. } else if (name.includes('.')) {
  593. const identifiers = name.split('.')
  594. let index = -1
  595. /** @type {Identifier | Literal | MemberExpression | undefined} */
  596. let node
  597. while (++index < identifiers.length) {
  598. /** @type {Identifier | Literal} */
  599. const prop = isIdentifierName(identifiers[index])
  600. ? {type: 'Identifier', name: identifiers[index]}
  601. : {type: 'Literal', value: identifiers[index]}
  602. node = node
  603. ? {
  604. type: 'MemberExpression',
  605. object: node,
  606. property: prop,
  607. computed: Boolean(index && prop.type === 'Literal'),
  608. optional: false
  609. }
  610. : prop
  611. }
  612. assert(node, 'always a result')
  613. result = node
  614. } else {
  615. result =
  616. isIdentifierName(name) && !/^[a-z]/.test(name)
  617. ? {type: 'Identifier', name}
  618. : {type: 'Literal', value: name}
  619. }
  620. // Only literals can be passed in `components` currently.
  621. // No identifiers / member expressions.
  622. if (result.type === 'Literal') {
  623. const name = /** @type {string | number} */ (result.value)
  624. return own.call(state.components, name) ? state.components[name] : name
  625. }
  626. // Assume component.
  627. if (state.evaluater) {
  628. return state.evaluater.evaluateExpression(result)
  629. }
  630. crashEstree(state)
  631. }
  632. /**
  633. * @param {State} state
  634. * @param {Position | undefined} [place]
  635. * @returns {never}
  636. */
  637. function crashEstree(state, place) {
  638. const message = new VFileMessage(
  639. 'Cannot handle MDX estrees without `createEvaluater`',
  640. {
  641. ancestors: state.ancestors,
  642. place,
  643. ruleId: 'mdx-estree',
  644. source: 'hast-util-to-jsx-runtime'
  645. }
  646. )
  647. message.file = state.filePath || undefined
  648. message.url = docs + '#cannot-handle-mdx-estrees-without-createevaluater'
  649. throw message
  650. }
  651. /**
  652. * Transform a DOM casing style object to a CSS casing style object.
  653. *
  654. * @param {Style} domCasing
  655. * @returns {Style}
  656. */
  657. function transformStylesToCssCasing(domCasing) {
  658. /** @type {Style} */
  659. const cssCasing = {}
  660. /** @type {string} */
  661. let from
  662. for (from in domCasing) {
  663. if (own.call(domCasing, from)) {
  664. cssCasing[transformStyleToCssCasing(from)] = domCasing[from]
  665. }
  666. }
  667. return cssCasing
  668. }
  669. /**
  670. * Transform a DOM casing style field to a CSS casing style field.
  671. *
  672. * @param {string} from
  673. * @returns {string}
  674. */
  675. function transformStyleToCssCasing(from) {
  676. let to = from.replace(cap, toDash)
  677. // Handle `ms-xxx` -> `-ms-xxx`.
  678. if (to.slice(0, 3) === 'ms-') to = '-' + to
  679. return to
  680. }
  681. /**
  682. * Make `$0` dash cased.
  683. *
  684. * @param {string} $0
  685. * Capitalized ASCII leter.
  686. * @returns {string}
  687. * Dash and lower letter.
  688. */
  689. function toDash($0) {
  690. return '-' + $0.toLowerCase()
  691. }