index.js 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. /**
  2. * @import {Root} from 'hast'
  3. * @import {ParserError} from 'parse5'
  4. * @import {Value} from 'vfile'
  5. * @import {ErrorCode, Options} from './types.js'
  6. */
  7. import {ok as assert} from 'devlop'
  8. import {fromParse5} from 'hast-util-from-parse5'
  9. import {parse, parseFragment} from 'parse5'
  10. import {VFile} from 'vfile'
  11. import {VFileMessage} from 'vfile-message'
  12. import {errors} from './errors.js'
  13. const base = 'https://html.spec.whatwg.org/multipage/parsing.html#parse-error-'
  14. const dashToCamelRe = /-[a-z]/g
  15. const formatCRe = /%c(?:([-+])(\d+))?/g
  16. const formatXRe = /%x/g
  17. const fatalities = {2: true, 1: false, 0: null}
  18. /** @type {Readonly<Options>} */
  19. const emptyOptions = {}
  20. /**
  21. * Turn serialized HTML into a hast tree.
  22. *
  23. * @param {VFile | Value} value
  24. * Serialized HTML to parse.
  25. * @param {Readonly<Options> | null | undefined} [options]
  26. * Configuration (optional).
  27. * @returns {Root}
  28. * Tree.
  29. */
  30. export function fromHtml(value, options) {
  31. const settings = options || emptyOptions
  32. const onerror = settings.onerror
  33. const file = value instanceof VFile ? value : new VFile(value)
  34. const parseFunction = settings.fragment ? parseFragment : parse
  35. const document = String(file)
  36. const p5Document = parseFunction(document, {
  37. sourceCodeLocationInfo: true,
  38. // Note `parse5` types currently do not allow `undefined`.
  39. onParseError: settings.onerror ? internalOnerror : null,
  40. scriptingEnabled: false
  41. })
  42. // `parse5` returns document which are always mapped to roots.
  43. return /** @type {Root} */ (
  44. fromParse5(p5Document, {
  45. file,
  46. space: settings.space,
  47. verbose: settings.verbose
  48. })
  49. )
  50. /**
  51. * Handle a parse error.
  52. *
  53. * @param {ParserError} error
  54. * Parse5 error.
  55. * @returns {undefined}
  56. * Nothing.
  57. */
  58. function internalOnerror(error) {
  59. const code = error.code
  60. const name = camelcase(code)
  61. const setting = settings[name]
  62. const config = setting === null || setting === undefined ? true : setting
  63. const level = typeof config === 'number' ? config : config ? 1 : 0
  64. if (level) {
  65. const info = errors[name]
  66. assert(info, 'expected known error from `parse5`')
  67. const message = new VFileMessage(format(info.reason), {
  68. place: {
  69. start: {
  70. line: error.startLine,
  71. column: error.startCol,
  72. offset: error.startOffset
  73. },
  74. end: {
  75. line: error.endLine,
  76. column: error.endCol,
  77. offset: error.endOffset
  78. }
  79. },
  80. ruleId: code,
  81. source: 'hast-util-from-html'
  82. })
  83. if (file.path) {
  84. message.file = file.path
  85. message.name = file.path + ':' + message.name
  86. }
  87. message.fatal = fatalities[level]
  88. message.note = format(info.description)
  89. message.url = info.url === false ? undefined : base + code
  90. assert(onerror, '`internalOnerror` is not passed if `onerror` is not set')
  91. onerror(message)
  92. }
  93. /**
  94. * Format a human readable string about an error.
  95. *
  96. * @param {string} value
  97. * Value to format.
  98. * @returns {string}
  99. * Formatted.
  100. */
  101. function format(value) {
  102. return value.replace(formatCRe, formatC).replace(formatXRe, formatX)
  103. /**
  104. * Format the character.
  105. *
  106. * @param {string} _
  107. * Match.
  108. * @param {string} $1
  109. * Sign (`-` or `+`, optional).
  110. * @param {string} $2
  111. * Offset.
  112. * @returns {string}
  113. * Formatted.
  114. */
  115. function formatC(_, $1, $2) {
  116. const offset =
  117. ($2 ? Number.parseInt($2, 10) : 0) * ($1 === '-' ? -1 : 1)
  118. const char = document.charAt(error.startOffset + offset)
  119. return visualizeCharacter(char)
  120. }
  121. /**
  122. * Format the character code.
  123. *
  124. * @returns {string}
  125. * Formatted.
  126. */
  127. function formatX() {
  128. return visualizeCharacterCode(document.charCodeAt(error.startOffset))
  129. }
  130. }
  131. }
  132. }
  133. /**
  134. * @param {string} value
  135. * Error code in dash case.
  136. * @returns {ErrorCode}
  137. * Error code in camelcase.
  138. */
  139. function camelcase(value) {
  140. // This should match an error code.
  141. return /** @type {ErrorCode} */ (value.replace(dashToCamelRe, dashToCamel))
  142. }
  143. /**
  144. * @param {string} $0
  145. * Match.
  146. * @returns {string}
  147. * Camelcased.
  148. */
  149. function dashToCamel($0) {
  150. return $0.charAt(1).toUpperCase()
  151. }
  152. /**
  153. * @param {string} char
  154. * Character.
  155. * @returns {string}
  156. * Formatted.
  157. */
  158. function visualizeCharacter(char) {
  159. return char === '`' ? '` ` `' : char
  160. }
  161. /**
  162. * @param {number} charCode
  163. * Character code.
  164. * @returns {string}
  165. * Formatted.
  166. */
  167. function visualizeCharacterCode(charCode) {
  168. return '0x' + charCode.toString(16).toUpperCase()
  169. }