index.js 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. const localeCompare = require('@isaacs/string-locale-compare')('en')
  2. const { join, basename, resolve } = require('path')
  3. const transformHTML = require('./transform-html.js')
  4. const { version } = require('../../lib/npm.js')
  5. const { aliases } = require('../../lib/utils/cmd-list')
  6. const { shorthands, definitions } = require('@npmcli/config/lib/definitions')
  7. const DOC_EXT = '.md'
  8. const TAGS = {
  9. CONFIG: '<!-- AUTOGENERATED CONFIG DESCRIPTIONS -->',
  10. USAGE: '<!-- AUTOGENERATED USAGE DESCRIPTIONS -->',
  11. SHORTHANDS: '<!-- AUTOGENERATED CONFIG SHORTHANDS -->',
  12. }
  13. const assertPlaceholder = (src, path, placeholder) => {
  14. if (!src.includes(placeholder)) {
  15. throw new Error(
  16. `Cannot replace ${placeholder} in ${path} due to missing placeholder`
  17. )
  18. }
  19. return placeholder
  20. }
  21. // Default command loader - loads commands from lib/commands
  22. const defaultCommandLoader = (name) => {
  23. return require(`../../lib/commands/${name}`)
  24. }
  25. // Load a command using the provided loader or default
  26. const getCommand = (name, commandLoader = defaultCommandLoader) => {
  27. return commandLoader(name)
  28. }
  29. // Resolve definitions for a command - use definitions if present, otherwise build from params
  30. const resolveDefinitions = (command) => {
  31. // If command has definitions, use them directly (ignore params)
  32. if (command.definitions && Object.keys(command.definitions).length > 0) {
  33. return command.definitions
  34. }
  35. // Otherwise build from params using global definitions
  36. if (command.params) {
  37. const resolved = {}
  38. for (const param of command.params) {
  39. if (definitions[param]) {
  40. resolved[param] = definitions[param]
  41. }
  42. }
  43. return resolved
  44. }
  45. return {}
  46. }
  47. const getCommandByDoc = (docFile, docExt, commandLoader = defaultCommandLoader) => {
  48. // Grab the command name from the *.md filename
  49. // NOTE: We cannot use the name property command file because in the case of
  50. // `npx` the file being used is `lib/commands/exec.js`
  51. const name = basename(docFile, docExt).replace('npm-', '')
  52. if (name === 'npm') {
  53. return {
  54. name,
  55. definitions: [],
  56. usage: 'npm',
  57. }
  58. }
  59. // special case for `npx`:
  60. // `npx` is not technically a command in and of itself,
  61. // so it just needs the usage of npm exec
  62. const srcName = name === 'npx' ? 'exec' : name
  63. const command = getCommand(srcName, commandLoader)
  64. const { usage = [''], workspaces } = command
  65. const usagePrefix = name === 'npx' ? 'npx' : `npm ${name}`
  66. // Resolve definitions - handles exclusive params expansion
  67. const commandDefs = resolveDefinitions(command)
  68. const resolvedDefs = {}
  69. for (const [key, def] of Object.entries(commandDefs)) {
  70. resolvedDefs[key] = def
  71. // Handle exclusive params
  72. if (def.exclusive) {
  73. for (const e of def.exclusive) {
  74. if (!resolvedDefs[e] && definitions[e]) {
  75. resolvedDefs[e] = definitions[e]
  76. }
  77. }
  78. }
  79. }
  80. return {
  81. name,
  82. workspaces,
  83. definitions: name === 'npx' ? {} : resolvedDefs,
  84. usage: usage?.map(u => `${usagePrefix} ${u}`.trim()).join('\n'),
  85. }
  86. }
  87. const replaceVersion = (src) => src.replace(/@VERSION@/g, version)
  88. const replaceUsage = (src, { path, commandLoader }) => {
  89. const replacer = assertPlaceholder(src, path, TAGS.USAGE)
  90. const { usage, name, workspaces } = getCommandByDoc(path, DOC_EXT, commandLoader)
  91. const synopsis = []
  92. if (usage) {
  93. synopsis.push('```bash', usage)
  94. const cmdAliases = Object.keys(aliases).reduce((p, c) => {
  95. if (aliases[c] === name) {
  96. p.push(c)
  97. }
  98. return p
  99. }, [])
  100. if (cmdAliases.length === 1) {
  101. synopsis.push('', `alias: ${cmdAliases[0]}`)
  102. } else if (cmdAliases.length > 1) {
  103. synopsis.push('', `aliases: ${cmdAliases.join(', ')}`)
  104. }
  105. synopsis.push('```')
  106. }
  107. if (!workspaces) {
  108. if (synopsis.length) {
  109. synopsis.push('')
  110. }
  111. synopsis.push('Note: This command is unaware of workspaces.')
  112. }
  113. return src.replace(replacer, synopsis.join('\n'))
  114. }
  115. // Helper to generate a markdown table from definitions
  116. const generateFlagsTable = (definitionPool) => {
  117. const rows = Object.keys(definitionPool).map((n) => {
  118. const def = definitionPool[n]
  119. const flags = [`\`--${def.key}\``]
  120. if (def.alias) {
  121. flags.push(...def.alias.map(a => `\`--${a}\``))
  122. }
  123. if (def.short) {
  124. flags.push(`\`-${def.short}\``)
  125. }
  126. const flagsStr = flags.join(', ')
  127. let defaultVal = def.defaultDescription
  128. if (!defaultVal) {
  129. defaultVal = String(def.default)
  130. }
  131. let typeVal = def.typeDescription || String(def.type)
  132. if (def.required) {
  133. typeVal = `${typeVal} (required)`
  134. }
  135. const desc = (def.description || '').replace(/\n/g, ' ').trim()
  136. return `| ${flagsStr} | ${defaultVal} | ${typeVal} | ${desc} |`
  137. })
  138. return [
  139. '| Flag | Default | Type | Description |',
  140. '| --- | --- | --- | --- |',
  141. ...rows,
  142. ].join('\n')
  143. }
  144. const replaceDefinitions = (src, { path, commandLoader }) => {
  145. const { definitions: commandDefs, name } = getCommandByDoc(path, DOC_EXT, commandLoader)
  146. let subcommands = {}
  147. try {
  148. const command = getCommand(name, commandLoader)
  149. subcommands = command.subcommands || {}
  150. } catch {
  151. // Command doesn't exist
  152. }
  153. // If no definitions and no subcommands, nothing to replace
  154. if (Object.keys(commandDefs).length === 0 && Object.keys(subcommands).length === 0) {
  155. return src
  156. }
  157. // Assert placeholder is present
  158. const replacer = assertPlaceholder(src, path, TAGS.CONFIG)
  159. // If command has subcommands, generate sections for each subcommand
  160. if (Object.keys(subcommands).length > 0) {
  161. const subcommandSections = Object.entries(subcommands).map(([subName, SubCommand]) => {
  162. const subUsage = SubCommand.usage || []
  163. const subDefs = resolveDefinitions(SubCommand)
  164. const parts = [`### \`npm ${name} ${subName}\``, '']
  165. if (SubCommand.description) {
  166. parts.push(SubCommand.description, '')
  167. }
  168. // Add usage/synopsis
  169. if (subUsage.length > 0) {
  170. parts.push('#### Synopsis', '', '```bash')
  171. subUsage.forEach(u => {
  172. parts.push(`npm ${name} ${subName} ${u}`.trim())
  173. })
  174. parts.push('```', '')
  175. }
  176. // Add flags section if definitions exist
  177. if (Object.keys(subDefs).length > 0) {
  178. parts.push('#### Flags', '')
  179. parts.push(generateFlagsTable(subDefs), '')
  180. }
  181. return parts.join('\n')
  182. })
  183. return src.replace(replacer, subcommandSections.join('\n'))
  184. }
  185. // For commands without subcommands - commandDefs must be non-empty here
  186. // (we would have returned early at line 175 if both were empty)
  187. const paramDescriptions = Object.values(commandDefs)
  188. .map(def => def.describe())
  189. return src.replace(replacer, paramDescriptions.join('\n\n'))
  190. }
  191. const replaceConfig = (src, { path }) => {
  192. const replacer = assertPlaceholder(src, path, TAGS.CONFIG)
  193. // sort not-deprecated ones to the top
  194. /* istanbul ignore next - typically already sorted in the definitions file,
  195. * but this is here so that our help doc will stay consistent if we decide
  196. * to move them around. */
  197. const sort = ([keya, { deprecated: depa }], [keyb, { deprecated: depb }]) => {
  198. return depa && !depb ? 1
  199. : !depa && depb ? -1
  200. : localeCompare(keya, keyb)
  201. }
  202. const allConfig = Object.entries(definitions).sort(sort)
  203. .map(([, def]) => def.describe())
  204. .join('\n\n')
  205. return src.replace(replacer, allConfig)
  206. }
  207. const replaceShorthands = (src, { path }) => {
  208. const replacer = assertPlaceholder(src, path, TAGS.SHORTHANDS)
  209. const sh = Object.entries(shorthands)
  210. .sort(([shorta, expansiona], [shortb, expansionb]) =>
  211. // sort by what they're short FOR
  212. localeCompare(expansiona.join(' '), expansionb.join(' ')) || localeCompare(shorta, shortb)
  213. )
  214. .map(([short, expansion]) => {
  215. // XXX: this is incorrect. we have multicharacter flags like `-iwr` that
  216. // can only be set with a single dash
  217. const dash = short.length === 1 ? '-' : '--'
  218. return `* \`${dash}${short}\`: \`${expansion.join(' ')}\``
  219. })
  220. return src.replace(replacer, sh.join('\n'))
  221. }
  222. const replaceHelpLinks = (src) => {
  223. // replaces markdown links with equivalent-ish npm help commands
  224. return src.replace(
  225. /\[`?([\w\s-]+)`?\]\(\/(?:commands|configuring-npm|using-npm)\/(?:[\w\s-]+)\)/g,
  226. (_, p1) => {
  227. const term = p1.replace(/npm\s/g, '').replace(/\s+/g, ' ').trim()
  228. const help = `npm help ${term.includes(' ') ? `"${term}"` : term}`
  229. return help
  230. }
  231. )
  232. }
  233. const transformMan = (src, { data, unified, remarkParse, remarkMan }) => unified()
  234. .use(remarkParse)
  235. .use(remarkMan, { version: `NPM@${version}` })
  236. .processSync(`# ${data.title}(${data.section}) - ${data.description}\n\n${src}`)
  237. .toString()
  238. const manPath = (name, { data }) => join(`man${data.section}`, `${name}.${data.section}`)
  239. const transformMd = (src, { frontmatter }) => ['---', frontmatter, '---', '', src].join('\n')
  240. module.exports = {
  241. DOC_EXT,
  242. TAGS,
  243. paths: {
  244. content: resolve(__dirname, 'content'),
  245. nav: resolve(__dirname, 'content', 'nav.yml'),
  246. template: resolve(__dirname, 'template.html'),
  247. man: resolve(__dirname, '..', '..', 'man'),
  248. html: resolve(__dirname, '..', 'output'),
  249. md: resolve(__dirname, '..', 'content'),
  250. },
  251. usage: replaceUsage,
  252. definitions: replaceDefinitions,
  253. config: replaceConfig,
  254. shorthands: replaceShorthands,
  255. version: replaceVersion,
  256. helpLinks: replaceHelpLinks,
  257. man: transformMan,
  258. manPath: manPath,
  259. md: transformMd,
  260. html: transformHTML,
  261. }