Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fund: support multiple funding sources #731

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 10 additions & 2 deletions docs/content/cli-commands/npm-fund.md
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
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
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: {}
ljharb marked this conversation as resolved.
Show resolved Hide resolved
})

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]
ljharb marked this conversation as resolved.
Show resolved Hide resolved
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