normalize-data.js 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. // Originally normalize-package-data
  2. const url = require('node:url')
  3. const hostedGitInfo = require('hosted-git-info')
  4. const validateLicense = require('validate-npm-package-license')
  5. const typos = {
  6. dependancies: 'dependencies',
  7. dependecies: 'dependencies',
  8. depdenencies: 'dependencies',
  9. devEependencies: 'devDependencies',
  10. depends: 'dependencies',
  11. 'dev-dependencies': 'devDependencies',
  12. devDependences: 'devDependencies',
  13. devDepenencies: 'devDependencies',
  14. devdependencies: 'devDependencies',
  15. repostitory: 'repository',
  16. repo: 'repository',
  17. prefereGlobal: 'preferGlobal',
  18. hompage: 'homepage',
  19. hampage: 'homepage',
  20. autohr: 'author',
  21. autor: 'author',
  22. contributers: 'contributors',
  23. publicationConfig: 'publishConfig',
  24. script: 'scripts',
  25. }
  26. const isEmail = str => str.includes('@') && (str.indexOf('@') < str.lastIndexOf('.'))
  27. // Extracts description from contents of a readme file in markdown format
  28. function extractDescription (description) {
  29. // the first block of text before the first heading that isn't the first line heading
  30. const lines = description.trim().split('\n')
  31. let start = 0
  32. // skip initial empty lines and lines that start with #
  33. while (lines[start]?.trim().match(/^(#|$)/)) {
  34. start++
  35. }
  36. let end = start + 1
  37. // keep going till we get to the end or an empty line
  38. while (end < lines.length && lines[end].trim()) {
  39. end++
  40. }
  41. return lines.slice(start, end).join(' ').trim()
  42. }
  43. function stringifyPerson (person) {
  44. if (typeof person !== 'string') {
  45. const name = person.name || ''
  46. const u = person.url || person.web
  47. const wrappedUrl = u ? (' (' + u + ')') : ''
  48. const e = person.email || person.mail
  49. const wrappedEmail = e ? (' <' + e + '>') : ''
  50. person = name + wrappedEmail + wrappedUrl
  51. }
  52. const matchedName = person.match(/^([^(<]+)/)
  53. const matchedUrl = person.match(/\(([^()]+)\)/)
  54. const matchedEmail = person.match(/<([^<>]+)>/)
  55. const parsed = {}
  56. if (matchedName?.[0].trim()) {
  57. parsed.name = matchedName[0].trim()
  58. }
  59. if (matchedEmail) {
  60. parsed.email = matchedEmail[1]
  61. }
  62. if (matchedUrl) {
  63. parsed.url = matchedUrl[1]
  64. }
  65. return parsed
  66. }
  67. function normalizeData (data, changes) {
  68. // fixDescriptionField
  69. if (data.description && typeof data.description !== 'string') {
  70. changes?.push(`'description' field should be a string`)
  71. delete data.description
  72. }
  73. if (data.readme && !data.description && data.readme !== 'ERROR: No README data found!') {
  74. data.description = extractDescription(data.readme)
  75. }
  76. if (data.description === undefined) {
  77. delete data.description
  78. }
  79. if (!data.description) {
  80. changes?.push('No description')
  81. }
  82. // fixModulesField
  83. if (data.modules) {
  84. changes?.push(`modules field is deprecated`)
  85. delete data.modules
  86. }
  87. // fixFilesField
  88. const files = data.files
  89. if (files && !Array.isArray(files)) {
  90. changes?.push(`Invalid 'files' member`)
  91. delete data.files
  92. } else if (data.files) {
  93. data.files = data.files.filter(function (file) {
  94. if (!file || typeof file !== 'string') {
  95. changes?.push(`Invalid filename in 'files' list: ${file}`)
  96. return false
  97. } else {
  98. return true
  99. }
  100. })
  101. }
  102. // fixManField
  103. if (data.man && typeof data.man === 'string') {
  104. data.man = [data.man]
  105. }
  106. // fixBugsField
  107. if (!data.bugs && data.repository?.url) {
  108. const hosted = hostedGitInfo.fromUrl(data.repository.url)
  109. if (hosted && hosted.bugs()) {
  110. data.bugs = { url: hosted.bugs() }
  111. }
  112. } else if (data.bugs) {
  113. if (typeof data.bugs === 'string') {
  114. if (isEmail(data.bugs)) {
  115. data.bugs = { email: data.bugs }
  116. /* eslint-disable-next-line node/no-deprecated-api */
  117. } else if (url.parse(data.bugs).protocol) {
  118. data.bugs = { url: data.bugs }
  119. } else {
  120. changes?.push(`Bug string field must be url, email, or {email,url}`)
  121. }
  122. } else {
  123. for (const k in data.bugs) {
  124. if (['web', 'name'].includes(k)) {
  125. changes?.push(`bugs['${k}'] should probably be bugs['url'].`)
  126. data.bugs.url = data.bugs[k]
  127. delete data.bugs[k]
  128. }
  129. }
  130. const oldBugs = data.bugs
  131. data.bugs = {}
  132. if (oldBugs.url) {
  133. /* eslint-disable-next-line node/no-deprecated-api */
  134. if (typeof (oldBugs.url) === 'string' && url.parse(oldBugs.url).protocol) {
  135. data.bugs.url = oldBugs.url
  136. } else {
  137. changes?.push('bugs.url field must be a string url. Deleted.')
  138. }
  139. }
  140. if (oldBugs.email) {
  141. if (typeof (oldBugs.email) === 'string' && isEmail(oldBugs.email)) {
  142. data.bugs.email = oldBugs.email
  143. } else {
  144. changes?.push('bugs.email field must be a string email. Deleted.')
  145. }
  146. }
  147. }
  148. if (!data.bugs.email && !data.bugs.url) {
  149. delete data.bugs
  150. changes?.push('Normalized value of bugs field is an empty object. Deleted.')
  151. }
  152. }
  153. // fixKeywordsField
  154. if (typeof data.keywords === 'string') {
  155. data.keywords = data.keywords.split(/,\s+/)
  156. }
  157. if (data.keywords && !Array.isArray(data.keywords)) {
  158. delete data.keywords
  159. changes?.push(`keywords should be an array of strings`)
  160. } else if (data.keywords) {
  161. data.keywords = data.keywords.filter(function (kw) {
  162. if (typeof kw !== 'string' || !kw) {
  163. changes?.push(`keywords should be an array of strings`)
  164. return false
  165. } else {
  166. return true
  167. }
  168. })
  169. }
  170. // fixBundleDependenciesField
  171. const bdd = 'bundledDependencies'
  172. const bd = 'bundleDependencies'
  173. if (data[bdd] && !data[bd]) {
  174. data[bd] = data[bdd]
  175. delete data[bdd]
  176. }
  177. if (data[bd] && !Array.isArray(data[bd])) {
  178. changes?.push(`Invalid 'bundleDependencies' list. Must be array of package names`)
  179. delete data[bd]
  180. } else if (data[bd]) {
  181. data[bd] = data[bd].filter(function (filtered) {
  182. if (!filtered || typeof filtered !== 'string') {
  183. changes?.push(`Invalid bundleDependencies member: ${filtered}`)
  184. return false
  185. } else {
  186. if (!data.dependencies) {
  187. data.dependencies = {}
  188. }
  189. if (!Object.prototype.hasOwnProperty.call(data.dependencies, filtered)) {
  190. changes?.push(`Non-dependency in bundleDependencies: ${filtered}`)
  191. data.dependencies[filtered] = '*'
  192. }
  193. return true
  194. }
  195. })
  196. }
  197. // fixHomepageField
  198. if (!data.homepage && data.repository && data.repository.url) {
  199. const hosted = hostedGitInfo.fromUrl(data.repository.url)
  200. if (hosted) {
  201. data.homepage = hosted.docs()
  202. }
  203. }
  204. if (data.homepage) {
  205. if (typeof data.homepage !== 'string') {
  206. changes?.push('homepage field must be a string url. Deleted.')
  207. delete data.homepage
  208. } else {
  209. /* eslint-disable-next-line node/no-deprecated-api */
  210. if (!url.parse(data.homepage).protocol) {
  211. data.homepage = 'http://' + data.homepage
  212. }
  213. }
  214. }
  215. // fixReadmeField
  216. if (!data.readme) {
  217. changes?.push('No README data')
  218. data.readme = 'ERROR: No README data found!'
  219. }
  220. // fixLicenseField
  221. const license = data.license || data.licence
  222. if (!license) {
  223. changes?.push('No license field.')
  224. } else if (typeof (license) !== 'string' || license.length < 1 || license.trim() === '') {
  225. changes?.push('license should be a valid SPDX license expression')
  226. } else if (!validateLicense(license).validForNewPackages) {
  227. changes?.push('license should be a valid SPDX license expression')
  228. }
  229. // fixPeople
  230. if (data.author) {
  231. data.author = stringifyPerson(data.author)
  232. }
  233. ['maintainers', 'contributors'].forEach(function (set) {
  234. if (!Array.isArray(data[set])) {
  235. return
  236. }
  237. data[set] = data[set].map(stringifyPerson)
  238. })
  239. // fixTypos
  240. for (const d in typos) {
  241. if (Object.prototype.hasOwnProperty.call(data, d)) {
  242. changes?.push(`${d} should probably be ${typos[d]}.`)
  243. }
  244. }
  245. }
  246. module.exports = { normalizeData }