create-h.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. /**
  2. * @import {Element, Nodes, RootContent, Root} from 'hast'
  3. * @import {Info, Schema} from 'property-information'
  4. */
  5. /**
  6. * @typedef {Array<Nodes | PrimitiveChild>} ArrayChildNested
  7. * List of children (deep).
  8. */
  9. /**
  10. * @typedef {Array<ArrayChildNested | Nodes | PrimitiveChild>} ArrayChild
  11. * List of children.
  12. */
  13. /**
  14. * @typedef {Array<number | string>} ArrayValue
  15. * List of property values for space- or comma separated values (such as `className`).
  16. */
  17. /**
  18. * @typedef {ArrayChild | Nodes | PrimitiveChild} Child
  19. * Acceptable child value.
  20. */
  21. /**
  22. * @typedef {number | string | null | undefined} PrimitiveChild
  23. * Primitive children, either ignored (nullish), or turned into text nodes.
  24. */
  25. /**
  26. * @typedef {boolean | number | string | null | undefined} PrimitiveValue
  27. * Primitive property value.
  28. */
  29. /**
  30. * @typedef {Record<string, PropertyValue | Style>} Properties
  31. * Acceptable value for element properties.
  32. */
  33. /**
  34. * @typedef {ArrayValue | PrimitiveValue} PropertyValue
  35. * Primitive value or list value.
  36. */
  37. /**
  38. * @typedef {Element | Root} Result
  39. * Result from a `h` (or `s`) call.
  40. */
  41. /**
  42. * @typedef {number | string} StyleValue
  43. * Value for a CSS style field.
  44. */
  45. /**
  46. * @typedef {Record<string, StyleValue>} Style
  47. * Supported value of a `style` prop.
  48. */
  49. import {parse as parseCommas} from 'comma-separated-tokens'
  50. import {parseSelector} from 'hast-util-parse-selector'
  51. import {find, normalize} from 'property-information'
  52. import {parse as parseSpaces} from 'space-separated-tokens'
  53. /**
  54. * @param {Schema} schema
  55. * Schema to use.
  56. * @param {string} defaultTagName
  57. * Default tag name.
  58. * @param {ReadonlyArray<string> | undefined} [caseSensitive]
  59. * Case-sensitive tag names (default: `undefined`).
  60. * @returns
  61. * `h`.
  62. */
  63. export function createH(schema, defaultTagName, caseSensitive) {
  64. const adjust = caseSensitive ? createAdjustMap(caseSensitive) : undefined
  65. /**
  66. * Hyperscript compatible DSL for creating virtual hast trees.
  67. *
  68. * @overload
  69. * @param {null | undefined} [selector]
  70. * @param {...Child} children
  71. * @returns {Root}
  72. *
  73. * @overload
  74. * @param {string} selector
  75. * @param {Properties} properties
  76. * @param {...Child} children
  77. * @returns {Element}
  78. *
  79. * @overload
  80. * @param {string} selector
  81. * @param {...Child} children
  82. * @returns {Element}
  83. *
  84. * @param {string | null | undefined} [selector]
  85. * Selector.
  86. * @param {Child | Properties | null | undefined} [properties]
  87. * Properties (or first child) (default: `undefined`).
  88. * @param {...Child} children
  89. * Children.
  90. * @returns {Result}
  91. * Result.
  92. */
  93. function h(selector, properties, ...children) {
  94. /** @type {Result} */
  95. let node
  96. if (selector === null || selector === undefined) {
  97. node = {type: 'root', children: []}
  98. // Properties are not supported for roots.
  99. const child = /** @type {Child} */ (properties)
  100. children.unshift(child)
  101. } else {
  102. node = parseSelector(selector, defaultTagName)
  103. // Normalize the name.
  104. const lower = node.tagName.toLowerCase()
  105. const adjusted = adjust ? adjust.get(lower) : undefined
  106. node.tagName = adjusted || lower
  107. // Handle properties.
  108. if (isChild(properties)) {
  109. children.unshift(properties)
  110. } else {
  111. for (const [key, value] of Object.entries(properties)) {
  112. addProperty(schema, node.properties, key, value)
  113. }
  114. }
  115. }
  116. // Handle children.
  117. for (const child of children) {
  118. addChild(node.children, child)
  119. }
  120. if (node.type === 'element' && node.tagName === 'template') {
  121. node.content = {type: 'root', children: node.children}
  122. node.children = []
  123. }
  124. return node
  125. }
  126. return h
  127. }
  128. /**
  129. * Check if something is properties or a child.
  130. *
  131. * @param {Child | Properties} value
  132. * Value to check.
  133. * @returns {value is Child}
  134. * Whether `value` is definitely a child.
  135. */
  136. function isChild(value) {
  137. // Never properties if not an object.
  138. if (value === null || typeof value !== 'object' || Array.isArray(value)) {
  139. return true
  140. }
  141. // Never node without `type`; that’s the main discriminator.
  142. if (typeof value.type !== 'string') return false
  143. // Slower check: never property value if object or array with
  144. // non-number/strings.
  145. const record = /** @type {Record<string, unknown>} */ (value)
  146. const keys = Object.keys(value)
  147. for (const key of keys) {
  148. const value = record[key]
  149. if (value && typeof value === 'object') {
  150. if (!Array.isArray(value)) return true
  151. const list = /** @type {ReadonlyArray<unknown>} */ (value)
  152. for (const item of list) {
  153. if (typeof item !== 'number' && typeof item !== 'string') {
  154. return true
  155. }
  156. }
  157. }
  158. }
  159. // Also see empty `children` as a node.
  160. if ('children' in value && Array.isArray(value.children)) {
  161. return true
  162. }
  163. // Default to properties, someone can always pass an empty object,
  164. // put `data: {}` in a node,
  165. // or wrap it in an array.
  166. return false
  167. }
  168. /**
  169. * @param {Schema} schema
  170. * Schema.
  171. * @param {Properties} properties
  172. * Properties object.
  173. * @param {string} key
  174. * Property name.
  175. * @param {PropertyValue | Style} value
  176. * Property value.
  177. * @returns {undefined}
  178. * Nothing.
  179. */
  180. function addProperty(schema, properties, key, value) {
  181. const info = find(schema, key)
  182. /** @type {PropertyValue} */
  183. let result
  184. // Ignore nullish and NaN values.
  185. if (value === null || value === undefined) return
  186. if (typeof value === 'number') {
  187. // Ignore NaN.
  188. if (Number.isNaN(value)) return
  189. result = value
  190. }
  191. // Booleans.
  192. else if (typeof value === 'boolean') {
  193. result = value
  194. }
  195. // Handle list values.
  196. else if (typeof value === 'string') {
  197. if (info.spaceSeparated) {
  198. result = parseSpaces(value)
  199. } else if (info.commaSeparated) {
  200. result = parseCommas(value)
  201. } else if (info.commaOrSpaceSeparated) {
  202. result = parseSpaces(parseCommas(value).join(' '))
  203. } else {
  204. result = parsePrimitive(info, info.property, value)
  205. }
  206. } else if (Array.isArray(value)) {
  207. result = [...value]
  208. } else {
  209. result = info.property === 'style' ? style(value) : String(value)
  210. }
  211. if (Array.isArray(result)) {
  212. /** @type {Array<number | string>} */
  213. const finalResult = []
  214. for (const item of result) {
  215. // Assume no booleans in array.
  216. finalResult.push(
  217. /** @type {number | string} */ (
  218. parsePrimitive(info, info.property, item)
  219. )
  220. )
  221. }
  222. result = finalResult
  223. }
  224. // Class names (which can be added both on the `selector` and here).
  225. if (info.property === 'className' && Array.isArray(properties.className)) {
  226. // Assume no booleans in `className`.
  227. result = properties.className.concat(
  228. /** @type {Array<number | string> | number | string} */ (result)
  229. )
  230. }
  231. properties[info.property] = result
  232. }
  233. /**
  234. * @param {Array<RootContent>} nodes
  235. * Children.
  236. * @param {Child} value
  237. * Child.
  238. * @returns {undefined}
  239. * Nothing.
  240. */
  241. function addChild(nodes, value) {
  242. if (value === null || value === undefined) {
  243. // Empty.
  244. } else if (typeof value === 'number' || typeof value === 'string') {
  245. nodes.push({type: 'text', value: String(value)})
  246. } else if (Array.isArray(value)) {
  247. for (const child of value) {
  248. addChild(nodes, child)
  249. }
  250. } else if (typeof value === 'object' && 'type' in value) {
  251. if (value.type === 'root') {
  252. addChild(nodes, value.children)
  253. } else {
  254. nodes.push(value)
  255. }
  256. } else {
  257. throw new Error('Expected node, nodes, or string, got `' + value + '`')
  258. }
  259. }
  260. /**
  261. * Parse a single primitives.
  262. *
  263. * @param {Info} info
  264. * Property information.
  265. * @param {string} name
  266. * Property name.
  267. * @param {PrimitiveValue} value
  268. * Property value.
  269. * @returns {PrimitiveValue}
  270. * Property value.
  271. */
  272. function parsePrimitive(info, name, value) {
  273. if (typeof value === 'string') {
  274. if (info.number && value && !Number.isNaN(Number(value))) {
  275. return Number(value)
  276. }
  277. if (
  278. (info.boolean || info.overloadedBoolean) &&
  279. (value === '' || normalize(value) === normalize(name))
  280. ) {
  281. return true
  282. }
  283. }
  284. return value
  285. }
  286. /**
  287. * Serialize a `style` object as a string.
  288. *
  289. * @param {Style} styles
  290. * Style object.
  291. * @returns {string}
  292. * CSS string.
  293. */
  294. function style(styles) {
  295. /** @type {Array<string>} */
  296. const result = []
  297. for (const [key, value] of Object.entries(styles)) {
  298. result.push([key, value].join(': '))
  299. }
  300. return result.join('; ')
  301. }
  302. /**
  303. * Create a map to adjust casing.
  304. *
  305. * @param {ReadonlyArray<string>} values
  306. * List of properly cased keys.
  307. * @returns {Map<string, string>}
  308. * Map of lowercase keys to uppercase keys.
  309. */
  310. function createAdjustMap(values) {
  311. /** @type {Map<string, string>} */
  312. const result = new Map()
  313. for (const value of values) {
  314. result.set(value.toLowerCase(), value)
  315. }
  316. return result
  317. }