format-search-stream.js 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. const { stripVTControlCharacters: strip } = require('node:util')
  2. const { Minipass } = require('minipass')
  3. // This module consumes package data in the following format:
  4. //
  5. // {
  6. // name: String,
  7. // description: String,
  8. // maintainers: [{ username: String, email: String }],
  9. // keywords: String | [String],
  10. // version: String,
  11. // date: Date // can be null,
  12. // }
  13. //
  14. // The returned stream will format this package data into a byte stream of formatted, displayable output.
  15. function filter (data, exclude) {
  16. const words = [data.name]
  17. .concat(data.maintainers.map(m => m.username))
  18. .concat(data.keywords || [])
  19. .map(f => f?.trim?.())
  20. .filter(Boolean)
  21. .join(' ')
  22. .toLowerCase()
  23. if (exclude.find(pattern => {
  24. // Treats both /foo and /foo/ as regex searches
  25. if (pattern.startsWith('/')) {
  26. if (pattern.endsWith('/')) {
  27. pattern = pattern.slice(0, -1)
  28. }
  29. return words.match(new RegExp(pattern.slice(1)))
  30. }
  31. return words.includes(pattern)
  32. })) {
  33. return false
  34. }
  35. return true
  36. }
  37. module.exports = (opts) => {
  38. return opts.json ? new JSONOutputStream(opts) : new TextOutputStream(opts)
  39. }
  40. class JSONOutputStream extends Minipass {
  41. #didFirst = false
  42. #exclude
  43. constructor (opts) {
  44. super()
  45. this.#exclude = opts.exclude
  46. }
  47. write (obj) {
  48. if (!filter(obj, this.#exclude)) {
  49. return
  50. }
  51. if (!this.#didFirst) {
  52. super.write('[\n')
  53. this.#didFirst = true
  54. } else {
  55. super.write('\n,\n')
  56. }
  57. return super.write(JSON.stringify(obj))
  58. }
  59. end () {
  60. super.write(this.#didFirst ? ']\n' : '\n[]\n')
  61. super.end()
  62. }
  63. }
  64. class TextOutputStream extends Minipass {
  65. #args
  66. #chalk
  67. #exclude
  68. #parseable
  69. constructor (opts) {
  70. super()
  71. // Consider a search for "cowboys" and "boy".
  72. // If we highlight "boys" first the "cowboys" string will no longer string match because of the ansi highlighting added to "boys".
  73. // If we highlight "boy" second then the ansi reset at the end will make the highlighting only on "cowboy" with a normal "s".
  74. // Neither is perfect but at least the first option doesn't do partial highlighting. So, we sort strings smaller to larger
  75. this.#args = opts.args
  76. .map(s => s.toLowerCase())
  77. .filter(Boolean)
  78. .sort((a, b) => a.length - b.length)
  79. this.#chalk = opts.npm.chalk
  80. this.#exclude = opts.exclude
  81. this.#parseable = opts.parseable
  82. }
  83. write (data) {
  84. if (!filter(data, this.#exclude)) {
  85. return
  86. }
  87. // Normalize
  88. const pkg = {
  89. authors: data.maintainers.map((m) => `${strip(m.username)}`).join(' '),
  90. publisher: strip(data.publisher?.username || ''),
  91. date: data.date ? data.date.toISOString().slice(0, 10) : 'prehistoric',
  92. description: strip(data.description ?? ''),
  93. keywords: [],
  94. name: strip(data.name),
  95. version: data.version,
  96. }
  97. if (Array.isArray(data.keywords)) {
  98. pkg.keywords = data.keywords.map(strip)
  99. } else if (typeof data.keywords === 'string') {
  100. pkg.keywords = strip(data.keywords.replace(/[,\s]+/, ' ')).split(' ')
  101. }
  102. let output
  103. if (this.#parseable) {
  104. output = [pkg.name, pkg.description, pkg.author, pkg.date, pkg.version, pkg.keywords]
  105. .filter(Boolean)
  106. .map(col => ('' + col).replace(/\t/g, ' ')).join('\t')
  107. return super.write(output)
  108. }
  109. const keywords = pkg.keywords.map(k => {
  110. if (this.#args.includes(k)) {
  111. return this.#chalk.cyan(k)
  112. } else {
  113. return k
  114. }
  115. }).join(' ')
  116. const description = this.#highlight(pkg.description)
  117. let name
  118. if (this.#args.includes(pkg.name)) {
  119. name = this.#chalk.cyan(pkg.name)
  120. } else {
  121. name = this.#highlight(pkg.name)
  122. name = this.#chalk.blue(name)
  123. }
  124. if (description.length) {
  125. output = `${name}\n${description}\n`
  126. } else {
  127. output = `${name}\n`
  128. }
  129. if (pkg.publisher) {
  130. output += `Version ${this.#chalk.blue(pkg.version)} published ${this.#chalk.blue(pkg.date)} by ${this.#chalk.blue(pkg.publisher)}\n`
  131. } else {
  132. output += `Version ${this.#chalk.blue(pkg.version)} published ${this.#chalk.blue(pkg.date)} by ${this.#chalk.yellow('???')}\n`
  133. }
  134. output += `Maintainers: ${pkg.authors}\n`
  135. if (keywords) {
  136. output += `Keywords: ${keywords}\n`
  137. }
  138. output += `${this.#chalk.blue(`https://npm.im/${pkg.name}`)}\n`
  139. return super.write(output)
  140. }
  141. #highlight (input) {
  142. let output = input
  143. for (const arg of this.#args) {
  144. let i = output.toLowerCase().indexOf(arg)
  145. while (i > -1) {
  146. const highlit = this.#chalk.cyan(output.slice(i, i + arg.length))
  147. output = [
  148. output.slice(0, i),
  149. highlit,
  150. output.slice(i + arg.length),
  151. ].join('')
  152. i = output.toLowerCase().indexOf(arg, i + highlit.length)
  153. }
  154. }
  155. return output
  156. }
  157. }