index.js 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. /**
  2. * @typedef {import('hast').Element} Element
  3. * @typedef {import('hast').Parents} Parents
  4. */
  5. /**
  6. * @template Fn
  7. * @template Fallback
  8. * @typedef {Fn extends (value: any) => value is infer Thing ? Thing : Fallback} Predicate
  9. */
  10. /**
  11. * @callback Check
  12. * Check that an arbitrary value is an element.
  13. * @param {unknown} this
  14. * Context object (`this`) to call `test` with
  15. * @param {unknown} [element]
  16. * Anything (typically a node).
  17. * @param {number | null | undefined} [index]
  18. * Position of `element` in its parent.
  19. * @param {Parents | null | undefined} [parent]
  20. * Parent of `element`.
  21. * @returns {boolean}
  22. * Whether this is an element and passes a test.
  23. *
  24. * @typedef {Array<TestFunction | string> | TestFunction | string | null | undefined} Test
  25. * Check for an arbitrary element.
  26. *
  27. * * when `string`, checks that the element has that tag name
  28. * * when `function`, see `TestFunction`
  29. * * when `Array`, checks if one of the subtests pass
  30. *
  31. * @callback TestFunction
  32. * Check if an element passes a test.
  33. * @param {unknown} this
  34. * The given context.
  35. * @param {Element} element
  36. * An element.
  37. * @param {number | undefined} [index]
  38. * Position of `element` in its parent.
  39. * @param {Parents | undefined} [parent]
  40. * Parent of `element`.
  41. * @returns {boolean | undefined | void}
  42. * Whether this element passes the test.
  43. *
  44. * Note: `void` is included until TS sees no return as `undefined`.
  45. */
  46. /**
  47. * Check if `element` is an `Element` and whether it passes the given test.
  48. *
  49. * @param element
  50. * Thing to check, typically `element`.
  51. * @param test
  52. * Check for a specific element.
  53. * @param index
  54. * Position of `element` in its parent.
  55. * @param parent
  56. * Parent of `element`.
  57. * @param context
  58. * Context object (`this`) to call `test` with.
  59. * @returns
  60. * Whether `element` is an `Element` and passes a test.
  61. * @throws
  62. * When an incorrect `test`, `index`, or `parent` is given; there is no error
  63. * thrown when `element` is not a node or not an element.
  64. */
  65. export const isElement =
  66. // Note: overloads in JSDoc can’t yet use different `@template`s.
  67. /**
  68. * @type {(
  69. * (<Condition extends TestFunction>(element: unknown, test: Condition, index?: number | null | undefined, parent?: Parents | null | undefined, context?: unknown) => element is Element & Predicate<Condition, Element>) &
  70. * (<Condition extends string>(element: unknown, test: Condition, index?: number | null | undefined, parent?: Parents | null | undefined, context?: unknown) => element is Element & {tagName: Condition}) &
  71. * ((element?: null | undefined) => false) &
  72. * ((element: unknown, test?: null | undefined, index?: number | null | undefined, parent?: Parents | null | undefined, context?: unknown) => element is Element) &
  73. * ((element: unknown, test?: Test, index?: number | null | undefined, parent?: Parents | null | undefined, context?: unknown) => boolean)
  74. * )}
  75. */
  76. (
  77. /**
  78. * @param {unknown} [element]
  79. * @param {Test | undefined} [test]
  80. * @param {number | null | undefined} [index]
  81. * @param {Parents | null | undefined} [parent]
  82. * @param {unknown} [context]
  83. * @returns {boolean}
  84. */
  85. // eslint-disable-next-line max-params
  86. function (element, test, index, parent, context) {
  87. const check = convertElement(test)
  88. if (
  89. index !== null &&
  90. index !== undefined &&
  91. (typeof index !== 'number' ||
  92. index < 0 ||
  93. index === Number.POSITIVE_INFINITY)
  94. ) {
  95. throw new Error('Expected positive finite `index`')
  96. }
  97. if (
  98. parent !== null &&
  99. parent !== undefined &&
  100. (!parent.type || !parent.children)
  101. ) {
  102. throw new Error('Expected valid `parent`')
  103. }
  104. if (
  105. (index === null || index === undefined) !==
  106. (parent === null || parent === undefined)
  107. ) {
  108. throw new Error('Expected both `index` and `parent`')
  109. }
  110. return looksLikeAnElement(element)
  111. ? check.call(context, element, index, parent)
  112. : false
  113. }
  114. )
  115. /**
  116. * Generate a check from a test.
  117. *
  118. * Useful if you’re going to test many nodes, for example when creating a
  119. * utility where something else passes a compatible test.
  120. *
  121. * The created function is a bit faster because it expects valid input only:
  122. * an `element`, `index`, and `parent`.
  123. *
  124. * @param test
  125. * A test for a specific element.
  126. * @returns
  127. * A check.
  128. */
  129. export const convertElement =
  130. // Note: overloads in JSDoc can’t yet use different `@template`s.
  131. /**
  132. * @type {(
  133. * (<Condition extends TestFunction>(test: Condition) => (element: unknown, index?: number | null | undefined, parent?: Parents | null | undefined, context?: unknown) => element is Element & Predicate<Condition, Element>) &
  134. * (<Condition extends string>(test: Condition) => (element: unknown, index?: number | null | undefined, parent?: Parents | null | undefined, context?: unknown) => element is Element & {tagName: Condition}) &
  135. * ((test?: null | undefined) => (element?: unknown, index?: number | null | undefined, parent?: Parents | null | undefined, context?: unknown) => element is Element) &
  136. * ((test?: Test) => Check)
  137. * )}
  138. */
  139. (
  140. /**
  141. * @param {Test | null | undefined} [test]
  142. * @returns {Check}
  143. */
  144. function (test) {
  145. if (test === null || test === undefined) {
  146. return element
  147. }
  148. if (typeof test === 'string') {
  149. return tagNameFactory(test)
  150. }
  151. // Assume array.
  152. if (typeof test === 'object') {
  153. return anyFactory(test)
  154. }
  155. if (typeof test === 'function') {
  156. return castFactory(test)
  157. }
  158. throw new Error('Expected function, string, or array as `test`')
  159. }
  160. )
  161. /**
  162. * Handle multiple tests.
  163. *
  164. * @param {Array<TestFunction | string>} tests
  165. * @returns {Check}
  166. */
  167. function anyFactory(tests) {
  168. /** @type {Array<Check>} */
  169. const checks = []
  170. let index = -1
  171. while (++index < tests.length) {
  172. checks[index] = convertElement(tests[index])
  173. }
  174. return castFactory(any)
  175. /**
  176. * @this {unknown}
  177. * @type {TestFunction}
  178. */
  179. function any(...parameters) {
  180. let index = -1
  181. while (++index < checks.length) {
  182. if (checks[index].apply(this, parameters)) return true
  183. }
  184. return false
  185. }
  186. }
  187. /**
  188. * Turn a string into a test for an element with a certain type.
  189. *
  190. * @param {string} check
  191. * @returns {Check}
  192. */
  193. function tagNameFactory(check) {
  194. return castFactory(tagName)
  195. /**
  196. * @param {Element} element
  197. * @returns {boolean}
  198. */
  199. function tagName(element) {
  200. return element.tagName === check
  201. }
  202. }
  203. /**
  204. * Turn a custom test into a test for an element that passes that test.
  205. *
  206. * @param {TestFunction} testFunction
  207. * @returns {Check}
  208. */
  209. function castFactory(testFunction) {
  210. return check
  211. /**
  212. * @this {unknown}
  213. * @type {Check}
  214. */
  215. function check(value, index, parent) {
  216. return Boolean(
  217. looksLikeAnElement(value) &&
  218. testFunction.call(
  219. this,
  220. value,
  221. typeof index === 'number' ? index : undefined,
  222. parent || undefined
  223. )
  224. )
  225. }
  226. }
  227. /**
  228. * Make sure something is an element.
  229. *
  230. * @param {unknown} element
  231. * @returns {element is Element}
  232. */
  233. function element(element) {
  234. return Boolean(
  235. element &&
  236. typeof element === 'object' &&
  237. 'type' in element &&
  238. element.type === 'element' &&
  239. 'tagName' in element &&
  240. typeof element.tagName === 'string'
  241. )
  242. }
  243. /**
  244. * @param {unknown} value
  245. * @returns {value is Element}
  246. */
  247. function looksLikeAnElement(value) {
  248. return (
  249. value !== null &&
  250. typeof value === 'object' &&
  251. 'type' in value &&
  252. 'tagName' in value
  253. )
  254. }