Skip to content

Commit

Permalink
fund: support multiple funding sources
Browse files Browse the repository at this point in the history
  • Loading branch information
ljharb committed Jan 27, 2020
1 parent f533d61 commit 06c5cfb
Show file tree
Hide file tree
Showing 6 changed files with 290 additions and 127 deletions.
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
94 changes: 62 additions & 32 deletions lib/utils/funding.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,31 @@ const URL = require('url').URL

exports.getFundingInfo = getFundingInfo
exports.retrieveFunding = retrieveFunding
exports.validFundingUrl = validFundingUrl
exports.validFundingField = validFundingField

// supports both object funding and string shorthand
const flatCacheSymbol = Symbol('npm flat cache')
exports.flatCacheSymbol = flatCacheSymbol

// supports object funding and string shorthand, or an array of these
// if original was an array, returns an array; else returns the lone item
function retrieveFunding (funding) {
return typeof funding === 'string'
? {
url: funding
}
: funding
const sources = [].concat(funding || []).map(item => (
typeof item === 'string'
? { url: item }
: item
))
return Array.isArray(funding) ? sources : sources[0]
}

// Is the value of a `funding` property of a `package.json`
// a valid type+url for `npm fund` to display?
function validFundingUrl (funding) {
function validFundingField (funding) {
if (!funding) return false

if (Array.isArray(funding)) {
return funding.every(f => !Array.isArray(f) && validFundingField(f))
}

try {
var parsed = new URL(funding.url || funding)
} catch (error) {
Expand All @@ -34,11 +43,13 @@ function validFundingUrl (funding) {
return Boolean(parsed.host)
}

const empty = () => Object.create(null)

function getFundingInfo (idealTree, opts) {
let length = 0
let packageWithFundingCount = 0
const flat = empty()
const seen = new Set()
const { countOnly } = opts || {}
const empty = () => Object.create(null)
const _trailingDependencies = Symbol('trailingDependencies')

function tracked (name, version) {
Expand Down Expand Up @@ -70,52 +81,70 @@ function getFundingInfo (idealTree, opts) {
)
}

function addToFlatCache (funding, dep) {
[].concat(funding || []).forEach((f) => {
const key = f.url
if (!Array.isArray(flat[key])) {
flat[key] = []
}
flat[key].push(dep)
})
}

function attachFundingInfo (target, funding, dep) {
if (funding && validFundingField(funding)) {
target.funding = retrieveFunding(funding)
if (!countOnly) {
addToFlatCache(target.funding, dep)
}

packageWithFundingCount++
}
}

function getFundingDependencies (tree) {
const deps = tree && tree.dependencies
if (!deps) return empty()

// broken into two steps to make sure items appearance
// within top levels takes precedence over nested ones
return (Object.keys(deps)).map((key) => {
const directDepsWithFunding = Object.keys(deps).map((key) => {
const dep = deps[key]
const { name, funding, version } = dep

const fundingItem = {}

// avoids duplicated items within the funding tree
if (tracked(name, version)) return empty()

const fundingItem = {}

if (version) {
fundingItem.version = version
}

if (funding && validFundingUrl(funding)) {
fundingItem.funding = retrieveFunding(funding)
length++
}
attachFundingInfo(fundingItem, funding, dep)

return {
dep,
fundingItem
}
}).reduce((res, { dep, fundingItem }, i) => {
if (!fundingItem) return res
})

return directDepsWithFunding.reduce((res, { dep: directDep, fundingItem }, i) => {
if (!fundingItem || fundingItem.length === 0) return res

// recurse
const dependencies = dep.dependencies &&
Object.keys(dep.dependencies).length > 0 &&
getFundingDependencies(dep)
const transitiveDependencies = directDep.dependencies &&
Object.keys(directDep.dependencies).length > 0 &&
getFundingDependencies(directDep)

// if we're only counting items there's no need
// to add all the data to the resulting object
if (countOnly) return null

if (hasDependencies(dependencies)) {
fundingItem.dependencies = retrieveDependencies(dependencies)
if (hasDependencies(transitiveDependencies)) {
fundingItem.dependencies = retrieveDependencies(transitiveDependencies)
}

if (fundingItem.funding) {
res[dep.name] = fundingItem
if (fundingItem.funding && fundingItem.funding.length !== 0) {
res[directDep.name] = fundingItem
} else if (fundingItem.dependencies) {
res[_trailingDependencies] =
Object.assign(
Expand All @@ -126,12 +155,12 @@ function getFundingInfo (idealTree, opts) {
}

return res
}, empty())
}, countOnly ? null : empty())
}

const idealTreeDependencies = getFundingDependencies(idealTree)
const result = {
length
length: packageWithFundingCount
}

if (!countOnly) {
Expand All @@ -145,8 +174,9 @@ function getFundingInfo (idealTree, opts) {
result.funding = retrieveFunding(idealTree.funding)
}

result.dependencies =
retrieveDependencies(idealTreeDependencies)
result.dependencies = retrieveDependencies(idealTreeDependencies)

result[flatCacheSymbol] = flat
}

return result
Expand Down
Loading

0 comments on commit 06c5cfb

Please sign in to comment.