index.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. /**
  2. * @typedef {import('hast').Comment} Comment
  3. * @typedef {import('hast').Element} Element
  4. * @typedef {import('hast').Nodes} Nodes
  5. * @typedef {import('hast').Parents} Parents
  6. * @typedef {import('hast').Text} Text
  7. * @typedef {import('hast-util-is-element').TestFunction} TestFunction
  8. */
  9. /**
  10. * @typedef {'normal' | 'nowrap' | 'pre' | 'pre-wrap'} Whitespace
  11. * Valid and useful whitespace values (from CSS).
  12. *
  13. * @typedef {0 | 1 | 2} BreakNumber
  14. * Specific break:
  15. *
  16. * * `0` — space
  17. * * `1` — line ending
  18. * * `2` — blank line
  19. *
  20. * @typedef {'\n'} BreakForce
  21. * Forced break.
  22. *
  23. * @typedef {boolean} BreakValue
  24. * Whether there was a break.
  25. *
  26. * @typedef {BreakNumber | BreakValue | undefined} BreakBefore
  27. * Any value for a break before.
  28. *
  29. * @typedef {BreakForce | BreakNumber | BreakValue | undefined} BreakAfter
  30. * Any value for a break after.
  31. *
  32. * @typedef CollectionInfo
  33. * Info on current collection.
  34. * @property {BreakAfter} breakAfter
  35. * Whether there was a break after.
  36. * @property {BreakBefore} breakBefore
  37. * Whether there was a break before.
  38. * @property {Whitespace} whitespace
  39. * Current whitespace setting.
  40. *
  41. * @typedef Options
  42. * Configuration.
  43. * @property {Whitespace | null | undefined} [whitespace='normal']
  44. * Initial CSS whitespace setting to use (default: `'normal'`).
  45. */
  46. import {findAfter} from 'unist-util-find-after'
  47. import {convertElement} from 'hast-util-is-element'
  48. const searchLineFeeds = /\n/g
  49. const searchTabOrSpaces = /[\t ]+/g
  50. const br = convertElement('br')
  51. const cell = convertElement(isCell)
  52. const p = convertElement('p')
  53. const row = convertElement('tr')
  54. // Note that we don’t need to include void elements here as they don’t have text.
  55. // See: <https://github.com/wooorm/html-void-elements>
  56. const notRendered = convertElement([
  57. // List from: <https://html.spec.whatwg.org/multipage/rendering.html#hidden-elements>
  58. 'datalist',
  59. 'head',
  60. 'noembed',
  61. 'noframes',
  62. 'noscript', // Act as if we support scripting.
  63. 'rp',
  64. 'script',
  65. 'style',
  66. 'template',
  67. 'title',
  68. // Hidden attribute.
  69. hidden,
  70. // From: <https://html.spec.whatwg.org/multipage/rendering.html#flow-content-3>
  71. closedDialog
  72. ])
  73. // See: <https://html.spec.whatwg.org/multipage/rendering.html#the-css-user-agent-style-sheet-and-presentational-hints>
  74. const blockOrCaption = convertElement([
  75. 'address', // Flow content
  76. 'article', // Sections and headings
  77. 'aside', // Sections and headings
  78. 'blockquote', // Flow content
  79. 'body', // Page
  80. 'caption', // `table-caption`
  81. 'center', // Flow content (legacy)
  82. 'dd', // Lists
  83. 'dialog', // Flow content
  84. 'dir', // Lists (legacy)
  85. 'dl', // Lists
  86. 'dt', // Lists
  87. 'div', // Flow content
  88. 'figure', // Flow content
  89. 'figcaption', // Flow content
  90. 'footer', // Flow content
  91. 'form,', // Flow content
  92. 'h1', // Sections and headings
  93. 'h2', // Sections and headings
  94. 'h3', // Sections and headings
  95. 'h4', // Sections and headings
  96. 'h5', // Sections and headings
  97. 'h6', // Sections and headings
  98. 'header', // Flow content
  99. 'hgroup', // Sections and headings
  100. 'hr', // Flow content
  101. 'html', // Page
  102. 'legend', // Flow content
  103. 'li', // Lists (as `display: list-item`)
  104. 'listing', // Flow content (legacy)
  105. 'main', // Flow content
  106. 'menu', // Lists
  107. 'nav', // Sections and headings
  108. 'ol', // Lists
  109. 'p', // Flow content
  110. 'plaintext', // Flow content (legacy)
  111. 'pre', // Flow content
  112. 'section', // Sections and headings
  113. 'ul', // Lists
  114. 'xmp' // Flow content (legacy)
  115. ])
  116. /**
  117. * Get the plain-text value of a node.
  118. *
  119. * ###### Algorithm
  120. *
  121. * * if `tree` is a comment, returns its `value`
  122. * * if `tree` is a text, applies normal whitespace collapsing to its
  123. * `value`, as defined by the CSS Text spec
  124. * * if `tree` is a root or element, applies an algorithm similar to the
  125. * `innerText` getter as defined by HTML
  126. *
  127. * ###### Notes
  128. *
  129. * > 👉 **Note**: the algorithm acts as if `tree` is being rendered, and as if
  130. * > we’re a CSS-supporting user agent, with scripting enabled.
  131. *
  132. * * if `tree` is an element that is not displayed (such as a `head`), we’ll
  133. * still use the `innerText` algorithm instead of switching to `textContent`
  134. * * if descendants of `tree` are elements that are not displayed, they are
  135. * ignored
  136. * * CSS is not considered, except for the default user agent style sheet
  137. * * a line feed is collapsed instead of ignored in cases where Fullwidth, Wide,
  138. * or Halfwidth East Asian Width characters are used, the same goes for a case
  139. * with Chinese, Japanese, or Yi writing systems
  140. * * replaced elements (such as `audio`) are treated like non-replaced elements
  141. *
  142. * @param {Nodes} tree
  143. * Tree to turn into text.
  144. * @param {Readonly<Options> | null | undefined} [options]
  145. * Configuration (optional).
  146. * @returns {string}
  147. * Serialized `tree`.
  148. */
  149. export function toText(tree, options) {
  150. const options_ = options || {}
  151. const children = 'children' in tree ? tree.children : []
  152. const block = blockOrCaption(tree)
  153. const whitespace = inferWhitespace(tree, {
  154. whitespace: options_.whitespace || 'normal',
  155. breakBefore: false,
  156. breakAfter: false
  157. })
  158. /** @type {Array<BreakNumber | string>} */
  159. const results = []
  160. // Treat `text` and `comment` as having normal white-space.
  161. // This deviates from the spec as in the DOM the node’s `.data` has to be
  162. // returned.
  163. // If you want that behavior use `hast-util-to-string`.
  164. // All other nodes are later handled as if they are `element`s (so the
  165. // algorithm also works on a `root`).
  166. // Nodes without children are treated as a void element, so `doctype` is thus
  167. // ignored.
  168. if (tree.type === 'text' || tree.type === 'comment') {
  169. results.push(
  170. ...collectText(tree, {
  171. whitespace,
  172. breakBefore: true,
  173. breakAfter: true
  174. })
  175. )
  176. }
  177. // 1. If this element is not being rendered, or if the user agent is a
  178. // non-CSS user agent, then return the same value as the textContent IDL
  179. // attribute on this element.
  180. //
  181. // Note: we’re not supporting stylesheets so we’re acting as if the node
  182. // is rendered.
  183. //
  184. // If you want that behavior use `hast-util-to-string`.
  185. // Important: we’ll have to account for this later though.
  186. // 2. Let results be a new empty list.
  187. let index = -1
  188. // 3. For each child node node of this element:
  189. while (++index < children.length) {
  190. // 3.1. Let current be the list resulting in running the inner text
  191. // collection steps with node.
  192. // Each item in results will either be a JavaScript string or a
  193. // positive integer (a required line break count).
  194. // 3.2. For each item item in current, append item to results.
  195. results.push(
  196. ...renderedTextCollection(
  197. children[index],
  198. // @ts-expect-error: `tree` is a parent if we’re here.
  199. tree,
  200. {
  201. whitespace,
  202. breakBefore: index ? undefined : block,
  203. breakAfter:
  204. index < children.length - 1 ? br(children[index + 1]) : block
  205. }
  206. )
  207. )
  208. }
  209. // 4. Remove any items from results that are the empty string.
  210. // 5. Remove any runs of consecutive required line break count items at the
  211. // start or end of results.
  212. // 6. Replace each remaining run of consecutive required line break count
  213. // items with a string consisting of as many U+000A LINE FEED (LF)
  214. // characters as the maximum of the values in the required line break
  215. // count items.
  216. /** @type {Array<string>} */
  217. const result = []
  218. /** @type {number | undefined} */
  219. let count
  220. index = -1
  221. while (++index < results.length) {
  222. const value = results[index]
  223. if (typeof value === 'number') {
  224. if (count !== undefined && value > count) count = value
  225. } else if (value) {
  226. if (count !== undefined && count > -1) {
  227. result.push('\n'.repeat(count) || ' ')
  228. }
  229. count = -1
  230. result.push(value)
  231. }
  232. }
  233. // 7. Return the concatenation of the string items in results.
  234. return result.join('')
  235. }
  236. /**
  237. * <https://html.spec.whatwg.org/multipage/dom.html#rendered-text-collection-steps>
  238. *
  239. * @param {Nodes} node
  240. * @param {Parents} parent
  241. * @param {CollectionInfo} info
  242. * @returns {Array<BreakNumber | string>}
  243. */
  244. function renderedTextCollection(node, parent, info) {
  245. if (node.type === 'element') {
  246. return collectElement(node, parent, info)
  247. }
  248. if (node.type === 'text') {
  249. return info.whitespace === 'normal'
  250. ? collectText(node, info)
  251. : collectPreText(node)
  252. }
  253. return []
  254. }
  255. /**
  256. * Collect an element.
  257. *
  258. * @param {Element} node
  259. * Element node.
  260. * @param {Parents} parent
  261. * @param {CollectionInfo} info
  262. * Info on current collection.
  263. * @returns {Array<BreakNumber | string>}
  264. */
  265. function collectElement(node, parent, info) {
  266. // First we infer the `white-space` property.
  267. const whitespace = inferWhitespace(node, info)
  268. const children = node.children || []
  269. let index = -1
  270. /** @type {Array<BreakNumber | string>} */
  271. let items = []
  272. // We’re ignoring point 3, and exiting without any content here, because we
  273. // deviated from the spec in `toText` at step 3.
  274. if (notRendered(node)) {
  275. return items
  276. }
  277. /** @type {BreakNumber | undefined} */
  278. let prefix
  279. /** @type {BreakForce | BreakNumber | undefined} */
  280. let suffix
  281. // Note: we first detect if there is going to be a break before or after the
  282. // contents, as that changes the white-space handling.
  283. // 2. If node’s computed value of `visibility` is not `visible`, then return
  284. // items.
  285. //
  286. // Note: Ignored, as everything is visible by default user agent styles.
  287. // 3. If node is not being rendered, then return items. [...]
  288. //
  289. // Note: We already did this above.
  290. // See `collectText` for step 4.
  291. // 5. If node is a `<br>` element, then append a string containing a single
  292. // U+000A LINE FEED (LF) character to items.
  293. if (br(node)) {
  294. suffix = '\n'
  295. }
  296. // 7. If node’s computed value of `display` is `table-row`, and node’s CSS
  297. // box is not the last `table-row` box of the nearest ancestor `table`
  298. // box, then append a string containing a single U+000A LINE FEED (LF)
  299. // character to items.
  300. //
  301. // See: <https://html.spec.whatwg.org/multipage/rendering.html#tables-2>
  302. // Note: needs further investigation as this does not account for implicit
  303. // rows.
  304. else if (
  305. row(node) &&
  306. // @ts-expect-error: something up with types of parents.
  307. findAfter(parent, node, row)
  308. ) {
  309. suffix = '\n'
  310. }
  311. // 8. If node is a `<p>` element, then append 2 (a required line break count)
  312. // at the beginning and end of items.
  313. else if (p(node)) {
  314. prefix = 2
  315. suffix = 2
  316. }
  317. // 9. If node’s used value of `display` is block-level or `table-caption`,
  318. // then append 1 (a required line break count) at the beginning and end of
  319. // items.
  320. else if (blockOrCaption(node)) {
  321. prefix = 1
  322. suffix = 1
  323. }
  324. // 1. Let items be the result of running the inner text collection steps with
  325. // each child node of node in tree order, and then concatenating the
  326. // results to a single list.
  327. while (++index < children.length) {
  328. items = items.concat(
  329. renderedTextCollection(children[index], node, {
  330. whitespace,
  331. breakBefore: index ? undefined : prefix,
  332. breakAfter:
  333. index < children.length - 1 ? br(children[index + 1]) : suffix
  334. })
  335. )
  336. }
  337. // 6. If node’s computed value of `display` is `table-cell`, and node’s CSS
  338. // box is not the last `table-cell` box of its enclosing `table-row` box,
  339. // then append a string containing a single U+0009 CHARACTER TABULATION
  340. // (tab) character to items.
  341. //
  342. // See: <https://html.spec.whatwg.org/multipage/rendering.html#tables-2>
  343. if (
  344. cell(node) &&
  345. // @ts-expect-error: something up with types of parents.
  346. findAfter(parent, node, cell)
  347. ) {
  348. items.push('\t')
  349. }
  350. // Add the pre- and suffix.
  351. if (prefix) items.unshift(prefix)
  352. if (suffix) items.push(suffix)
  353. return items
  354. }
  355. /**
  356. * 4. If node is a Text node, then for each CSS text box produced by node,
  357. * in content order, compute the text of the box after application of the
  358. * CSS `white-space` processing rules and `text-transform` rules, set
  359. * items to the list of the resulting strings, and return items.
  360. * The CSS `white-space` processing rules are slightly modified:
  361. * collapsible spaces at the end of lines are always collapsed, but they
  362. * are only removed if the line is the last line of the block, or it ends
  363. * with a br element.
  364. * Soft hyphens should be preserved.
  365. *
  366. * Note: See `collectText` and `collectPreText`.
  367. * Note: we don’t deal with `text-transform`, no element has that by
  368. * default.
  369. *
  370. * See: <https://drafts.csswg.org/css-text/#white-space-phase-1>
  371. *
  372. * @param {Comment | Text} node
  373. * Text node.
  374. * @param {CollectionInfo} info
  375. * Info on current collection.
  376. * @returns {Array<BreakNumber | string>}
  377. * Result.
  378. */
  379. function collectText(node, info) {
  380. const value = String(node.value)
  381. /** @type {Array<string>} */
  382. const lines = []
  383. /** @type {Array<BreakNumber | string>} */
  384. const result = []
  385. let start = 0
  386. while (start <= value.length) {
  387. searchLineFeeds.lastIndex = start
  388. const match = searchLineFeeds.exec(value)
  389. const end = match && 'index' in match ? match.index : value.length
  390. lines.push(
  391. // Any sequence of collapsible spaces and tabs immediately preceding or
  392. // following a segment break is removed.
  393. trimAndCollapseSpacesAndTabs(
  394. // […] ignoring bidi formatting characters (characters with the
  395. // Bidi_Control property [UAX9]: ALM, LTR, RTL, LRE-RLO, LRI-PDI) as if
  396. // they were not there.
  397. value
  398. .slice(start, end)
  399. .replace(/[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, ''),
  400. start === 0 ? info.breakBefore : true,
  401. end === value.length ? info.breakAfter : true
  402. )
  403. )
  404. start = end + 1
  405. }
  406. // Collapsible segment breaks are transformed for rendering according to the
  407. // segment break transformation rules.
  408. // So here we jump to 4.1.2 of [CSSTEXT]:
  409. // Any collapsible segment break immediately following another collapsible
  410. // segment break is removed
  411. let index = -1
  412. /** @type {BreakNumber | undefined} */
  413. let join
  414. while (++index < lines.length) {
  415. // * If the character immediately before or immediately after the segment
  416. // break is the zero-width space character (U+200B), then the break is
  417. // removed, leaving behind the zero-width space.
  418. if (
  419. lines[index].charCodeAt(lines[index].length - 1) === 0x20_0b /* ZWSP */ ||
  420. (index < lines.length - 1 &&
  421. lines[index + 1].charCodeAt(0) === 0x20_0b) /* ZWSP */
  422. ) {
  423. result.push(lines[index])
  424. join = undefined
  425. }
  426. // * Otherwise, if the East Asian Width property [UAX11] of both the
  427. // character before and after the segment break is Fullwidth, Wide, or
  428. // Halfwidth (not Ambiguous), and neither side is Hangul, then the
  429. // segment break is removed.
  430. //
  431. // Note: ignored.
  432. // * Otherwise, if the writing system of the segment break is Chinese,
  433. // Japanese, or Yi, and the character before or after the segment break
  434. // is punctuation or a symbol (Unicode general category P* or S*) and
  435. // has an East Asian Width property of Ambiguous, and the character on
  436. // the other side of the segment break is Fullwidth, Wide, or Halfwidth,
  437. // and not Hangul, then the segment break is removed.
  438. //
  439. // Note: ignored.
  440. // * Otherwise, the segment break is converted to a space (U+0020).
  441. else if (lines[index]) {
  442. if (typeof join === 'number') result.push(join)
  443. result.push(lines[index])
  444. join = 0
  445. } else if (index === 0 || index === lines.length - 1) {
  446. // If this line is empty, and it’s the first or last, add a space.
  447. // Note that this function is only called in normal whitespace, so we
  448. // don’t worry about `pre`.
  449. result.push(0)
  450. }
  451. }
  452. return result
  453. }
  454. /**
  455. * Collect a text node as “pre” whitespace.
  456. *
  457. * @param {Text} node
  458. * Text node.
  459. * @returns {Array<BreakNumber | string>}
  460. * Result.
  461. */
  462. function collectPreText(node) {
  463. return [String(node.value)]
  464. }
  465. /**
  466. * 3. Every collapsible tab is converted to a collapsible space (U+0020).
  467. * 4. Any collapsible space immediately following another collapsible
  468. * space—even one outside the boundary of the inline containing that
  469. * space, provided both spaces are within the same inline formatting
  470. * context—is collapsed to have zero advance width. (It is invisible,
  471. * but retains its soft wrap opportunity, if any.)
  472. *
  473. * @param {string} value
  474. * Value to collapse.
  475. * @param {BreakBefore} breakBefore
  476. * Whether there was a break before.
  477. * @param {BreakAfter} breakAfter
  478. * Whether there was a break after.
  479. * @returns {string}
  480. * Result.
  481. */
  482. function trimAndCollapseSpacesAndTabs(value, breakBefore, breakAfter) {
  483. /** @type {Array<string>} */
  484. const result = []
  485. let start = 0
  486. /** @type {number | undefined} */
  487. let end
  488. while (start < value.length) {
  489. searchTabOrSpaces.lastIndex = start
  490. const match = searchTabOrSpaces.exec(value)
  491. end = match ? match.index : value.length
  492. // If we’re not directly after a segment break, but there was white space,
  493. // add an empty value that will be turned into a space.
  494. if (!start && !end && match && !breakBefore) {
  495. result.push('')
  496. }
  497. if (start !== end) {
  498. result.push(value.slice(start, end))
  499. }
  500. start = match ? end + match[0].length : end
  501. }
  502. // If we reached the end, there was trailing white space, and there’s no
  503. // segment break after this node, add an empty value that will be turned
  504. // into a space.
  505. if (start !== end && !breakAfter) {
  506. result.push('')
  507. }
  508. return result.join(' ')
  509. }
  510. /**
  511. * Figure out the whitespace of a node.
  512. *
  513. * We don’t support void elements here (so `nobr wbr` -> `normal` is ignored).
  514. *
  515. * @param {Nodes} node
  516. * Node (typically `Element`).
  517. * @param {CollectionInfo} info
  518. * Info on current collection.
  519. * @returns {Whitespace}
  520. * Applied whitespace.
  521. */
  522. function inferWhitespace(node, info) {
  523. if (node.type === 'element') {
  524. const properties = node.properties || {}
  525. switch (node.tagName) {
  526. case 'listing':
  527. case 'plaintext':
  528. case 'xmp': {
  529. return 'pre'
  530. }
  531. case 'nobr': {
  532. return 'nowrap'
  533. }
  534. case 'pre': {
  535. return properties.wrap ? 'pre-wrap' : 'pre'
  536. }
  537. case 'td':
  538. case 'th': {
  539. return properties.noWrap ? 'nowrap' : info.whitespace
  540. }
  541. case 'textarea': {
  542. return 'pre-wrap'
  543. }
  544. default:
  545. }
  546. }
  547. return info.whitespace
  548. }
  549. /**
  550. * @type {TestFunction}
  551. * @param {Element} node
  552. * @returns {node is {properties: {hidden: true}}}
  553. */
  554. function hidden(node) {
  555. return Boolean((node.properties || {}).hidden)
  556. }
  557. /**
  558. * @type {TestFunction}
  559. * @param {Element} node
  560. * @returns {node is {tagName: 'td' | 'th'}}
  561. */
  562. function isCell(node) {
  563. return node.tagName === 'td' || node.tagName === 'th'
  564. }
  565. /**
  566. * @type {TestFunction}
  567. */
  568. function closedDialog(node) {
  569. return node.tagName === 'dialog' && !(node.properties || {}).open
  570. }