Skip to content

Commit

Permalink
fund: support multiple funding sources
Browse files Browse the repository at this point in the history
See npm/rfcs#68

PR-URL: #731
Credit: @
Close: #731
Reviewed-by: @darcy Clarke
  • Loading branch information
ljharb authored and darcyclarke committed Feb 25, 2020
1 parent 373224b commit 30f1708
Show file tree
Hide file tree
Showing 7 changed files with 300 additions and 129 deletions.
12 changes: 10 additions & 2 deletions docs/content/cli-commands/npm-fund.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ a given project. If no package name is provided, it will list all
dependencies that are looking for funding in a tree-structure in which
are listed the type of funding and the url to visit. If a package name
is provided then it tries to open its funding url using the `--browser`
config param.
config param; if there are multiple funding sources for the package, the
user will be instructed to pass the `--which` command to disambiguate.

The list will avoid duplicated entries and will stack all packages
that share the same type/url as a single entry. Given this nature the
Expand All @@ -38,8 +39,8 @@ The browser that is called by the `npm fund` command to open websites.

#### json

* Default: false
* Type: Boolean
* Default: false

Show information in JSON format.

Expand All @@ -51,6 +52,13 @@ Show information in JSON format.
Whether to represent the tree structure using unicode characters.
Set it to `false` in order to use all-ansi output.

#### which

* Type: Number
* Default: undefined

If there are multiple funding sources, which 1-indexed source URL to open.

## See Also

* [npm docs](/cli-commands/npm-docs)
Expand Down
21 changes: 19 additions & 2 deletions docs/content/configuring-npm/package-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,8 @@ npm also sets a top-level "maintainers" field with your npm user info.
### funding

You can specify an object containing an URL that provides up-to-date
information about ways to help fund development of your package:
information about ways to help fund development of your package, or
a string URL, or an array of these:

"funding": {
"type" : "individual",
Expand All @@ -209,10 +210,26 @@ information about ways to help fund development of your package:
"url" : "https://www.patreon.com/my-account"
}

"funding": "http://example.com/donate"

"funding": [
{
"type" : "individual",
"url" : "http://example.com/donate"
},
"http://example.com/donateAlso",
{
"type" : "patreon",
"url" : "https://www.patreon.com/my-account"
}
]


Users can use the `npm fund` subcommand to list the `funding` URLs of all
dependencies of their project, direct and indirect. A shortcut to visit each
funding url is also available when providing the project name such as:
`npm fund <projectname>`.
`npm fund <projectname>` (when there are multiple URLs, the first one will be
visited)

### files

Expand Down
128 changes: 47 additions & 81 deletions lib/fund.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ const readShrinkwrap = require('./install/read-shrinkwrap.js')
const mutateIntoLogicalTree = require('./install/mutate-into-logical-tree.js')
const output = require('./utils/output.js')
const openUrl = require('./utils/open-url.js')
const { getFundingInfo, retrieveFunding, validFundingUrl } = require('./utils/funding.js')
const { getFundingInfo, retrieveFunding, validFundingField, flatCacheSymbol } = require('./utils/funding.js')

const FundConfig = figgyPudding({
browser: {}, // used by ./utils/open-url
global: {},
json: {},
unicode: {}
unicode: {},
which: {}
})

module.exports = fundCmd
Expand All @@ -29,7 +30,7 @@ const usage = require('./utils/usage')
fundCmd.usage = usage(
'fund',
'npm fund [--json]',
'npm fund [--browser] [[<@scope>/]<pkg>'
'npm fund [--browser] [[<@scope>/]<pkg> [--which=<fundingSourceNumber>]'
)

fundCmd.completion = function (opts, cb) {
Expand All @@ -52,96 +53,52 @@ function printJSON (fundingInfo) {
// level possible, in that process they also carry their dependencies along
// with them, moving those up in the visual tree
function printHuman (fundingInfo, opts) {
// mapping logic that keeps track of seen items in order to be able
// to push all other items from the same type/url in the same place
const seen = new Map()
const flatCache = fundingInfo[flatCacheSymbol]

function seenKey ({ type, url } = {}) {
return url ? String(type) + String(url) : null
}

function setStackedItem (funding, result) {
const key = seenKey(funding)
if (key && !seen.has(key)) seen.set(key, result)
}
const { name, version } = fundingInfo
const printableVersion = version ? `@${version}` : ''

function retrieveStackedItem (funding) {
const key = seenKey(funding)
if (key && seen.has(key)) return seen.get(key)
}
const items = Object.keys(flatCache).map((url) => {
const deps = flatCache[url]

// ---

const getFundingItems = (fundingItems) =>
Object.keys(fundingItems || {}).map((fundingItemName) => {
// first-level loop, prepare the pretty-printed formatted data
const fundingItem = fundingItems[fundingItemName]
const { version, funding } = fundingItem
const { type, url } = funding || {}
const packages = deps.map((dep) => {
const { name, version } = dep

const printableVersion = version ? `@${version}` : ''
const printableType = type && { label: `type: ${funding.type}` }
const printableUrl = url && { label: `url: ${funding.url}` }
const result = {
fundingItem,
label: fundingItemName + printableVersion,
nodes: []
}

if (printableType) {
result.nodes.push(printableType)
}

if (printableUrl) {
result.nodes.push(printableUrl)
}

setStackedItem(funding, result)

return result
}).reduce((res, result) => {
// recurse and exclude nodes that are going to be stacked together
const { fundingItem } = result
const { dependencies, funding } = fundingItem
const items = getFundingItems(dependencies)
const stackedResult = retrieveStackedItem(funding)
items.forEach(i => result.nodes.push(i))

if (stackedResult && stackedResult !== result) {
stackedResult.label += `, ${result.label}`
items.forEach(i => stackedResult.nodes.push(i))
return res
}

res.push(result)

return res
}, [])

const [ result ] = getFundingItems({
[fundingInfo.name]: {
dependencies: fundingInfo.dependencies,
funding: fundingInfo.funding,
version: fundingInfo.version
return `${name}${printableVersion}`
})

return {
label: url,
nodes: [packages.join(', ')]
}
})

return archy(result, '', { unicode: opts.unicode })
return archy({ label: `${name}${printableVersion}`, nodes: items }, '', { unicode: opts.unicode })
}

function openFundingUrl (packageName, cb) {
function openFundingUrl (packageName, fundingSourceNumber, cb) {
function getUrlAndOpen (packageMetadata) {
const { funding } = packageMetadata
const { type, url } = retrieveFunding(funding) || {}
const noFundingError =
new Error(`No funding method available for: ${packageName}`)
noFundingError.code = 'ENOFUND'
const typePrefix = type ? `${type} funding` : 'Funding'
const msg = `${typePrefix} available at the following URL`

if (validFundingUrl(funding)) {
const validSources = [].concat(retrieveFunding(funding)).filter(validFundingField)

if (validSources.length === 1 || (fundingSourceNumber > 0 && fundingSourceNumber <= validSources.length)) {
const { type, url } = validSources[fundingSourceNumber ? fundingSourceNumber - 1 : 0]
const typePrefix = type ? `${type} funding` : 'Funding'
const msg = `${typePrefix} available at the following URL`
openUrl(url, msg, cb)
} else if (!(fundingSourceNumber >= 1)) {
validSources.forEach(({ type, url }, i) => {
const typePrefix = type ? `${type} funding` : 'Funding'
const msg = `${typePrefix} available at the following URL`
console.log(`${i + 1}: ${msg}: ${url}`)
})
console.log('Run `npm fund [<@scope>/]<pkg> --which=1`, for example, to open the first funding URL listed in that package')
cb()
} else {
const noFundingError = new Error(`No valid funding method available for: ${packageName}`)
noFundingError.code = 'ENOFUND'

throw noFundingError
}
}
Expand All @@ -161,15 +118,24 @@ function fundCmd (args, cb) {
const opts = FundConfig(npmConfig())
const dir = path.resolve(npm.dir, '..')
const packageName = args[0]
const numberArg = opts.which

const fundingSourceNumber = numberArg && parseInt(numberArg, 10)

if (numberArg !== undefined && (String(fundingSourceNumber) !== numberArg || fundingSourceNumber < 1)) {
const err = new Error('`npm fund [<@scope>/]<pkg> [--which=fundingSourceNumber]` must be given a positive integer')
err.code = 'EFUNDNUMBER'
throw err
}

if (opts.global) {
const err = new Error('`npm fund` does not support globals')
const err = new Error('`npm fund` does not support global packages')
err.code = 'EFUNDGLOBAL'
throw err
}

if (packageName) {
openFundingUrl(packageName, cb)
openFundingUrl(packageName, fundingSourceNumber, cb)
return
}

Expand Down
Loading

0 comments on commit 30f1708

Please sign in to comment.