mirror of https://github.com/feross/funding
Compare commits
50 Commits
Author | SHA1 | Date |
---|---|---|
![]() |
56f8887088 | |
![]() |
9460c64a43 | |
![]() |
58b090c51c | |
![]() |
b12b311554 | |
![]() |
4a04573bc8 | |
![]() |
7238cd97d6 | |
![]() |
d07b9af505 | |
![]() |
03937d3f11 | |
![]() |
2485ab553e | |
![]() |
8115da8aa9 | |
![]() |
427bb8ffb6 | |
![]() |
356655d985 | |
![]() |
705e7c9c23 | |
![]() |
67746f78f2 | |
![]() |
ebce9a2a28 | |
![]() |
4593e982d6 | |
![]() |
08601f8c52 | |
![]() |
b9a3b3802b | |
![]() |
3134b34041 | |
![]() |
4399a0950a | |
![]() |
237caca5b8 | |
![]() |
b24ccba2bb | |
![]() |
c5269f1fcb | |
![]() |
9627cd338b | |
![]() |
b2fbc11692 | |
![]() |
2820d65c4a | |
![]() |
7e286cbbed | |
![]() |
687ff1c077 | |
![]() |
748b703f62 | |
![]() |
7eea1cb993 | |
![]() |
86fbb555b2 | |
![]() |
fe7f33baaf | |
![]() |
fbb1fb862e | |
![]() |
da797835b8 | |
![]() |
657a4a4812 | |
![]() |
25d8b44305 | |
![]() |
d3f4570c80 | |
![]() |
0d16106b72 | |
![]() |
e7d1d03c33 | |
![]() |
91e2379d35 | |
![]() |
9517319abd | |
![]() |
c5898317ab | |
![]() |
86f7c96a74 | |
![]() |
c1b0e477ff | |
![]() |
96ee1764a3 | |
![]() |
f903921258 | |
![]() |
b86bd18438 | |
![]() |
3d5248a154 | |
![]() |
c56c6c99d2 | |
![]() |
fac43074fe |
|
@ -1,2 +1,3 @@
|
||||||
.travis.yml
|
.travis.yml
|
||||||
test/
|
test/
|
||||||
|
tools/
|
||||||
|
|
47
README.md
47
README.md
|
@ -1,26 +1,39 @@
|
||||||
# funding
|
# funding [![travis][travis-image]][travis-url] [![npm][npm-image]][npm-url] [![downloads][downloads-image]][downloads-url] [![javascript style guide][standard-image]][standard-url]
|
||||||
|
|
||||||
### Get open source maintainers paid
|
### Let's get open source maintainers paid ✨
|
||||||
|
|
||||||
This is an open source funding experiment! ✨ The current model of sustaining open source is not working. We desparately need more experimentation. This is one such experiment.
|
[travis-image]: https://img.shields.io/travis/feross/funding/master.svg
|
||||||
|
[travis-url]: https://travis-ci.org/feross/funding
|
||||||
|
[npm-image]: https://img.shields.io/npm/v/funding.svg
|
||||||
|
[npm-url]: https://npmjs.org/package/funding
|
||||||
|
[downloads-image]: https://img.shields.io/npm/dm/funding.svg
|
||||||
|
[downloads-url]: https://npmjs.org/package/funding
|
||||||
|
[standard-image]: https://img.shields.io/badge/code_style-standard-brightgreen.svg
|
||||||
|
[standard-url]: https://standardjs.com
|
||||||
|
|
||||||
## Usage
|
### UPDATE: The experiment is over – Feross posted [a recap](https://feross.org/funding-experiment-recap/) on his blog
|
||||||
|
|
||||||
|
This is an open source funding experiment! The current model of sustaining open source is not working. We desperately need more experimentation. This is one such experiment.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install funding
|
npm install funding
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### UPDATE: The experiment is over – Feross posted [a recap](https://feross.org/funding-experiment-recap/) on his blog
|
||||||
|
|
||||||
## What is this?
|
## What is this?
|
||||||
|
|
||||||
This is an open source funding experiment! ✨
|
This is an open source funding experiment! ✨
|
||||||
|
|
||||||
Whenever users install open source software, this package will display a message from a company that supports open source. Currently, these are [Linode](https://welcome.linode.com/standardjs) and [LogRocket](https://logrocket.com/term). The sponsorship pays directly for maintainer time. That is, writing new features, fixing bugs, answering user questions, and improving documentation.
|
Whenever users install open source software, this package will display a message from a company that supports open source. The sponsorship pays directly for maintainer time. That is, writing new features, fixing bugs, answering user questions, and improving documentation.
|
||||||
|
|
||||||
The goal is to make sure that packages are well-maintained now and for the foreseeable future, with regular releases, improved reliability, and timely security patches. Healthy open source packages benefit users and maintainers alike.
|
The goal is to make sure that packages are well-maintained now and for the foreseeable future, with regular releases, improved reliability, and timely security patches. Healthy open source packages benefit users and maintainers alike.
|
||||||
|
|
||||||
## What does this code do?
|
## What does this code do?
|
||||||
|
|
||||||
You can take a look! All the code is open source in this GitHub repository. Essentially, it calls `console.log()` on some text. There is no tracking, data collecting, or unexpected behavior. You can look at the code to verify – indeed, this is the beauty of open source!
|
You can take a look! All the code is open source in this GitHub repository. Essentially, it calls `console.log()` on some text. **There is no tracking or data collecting — and it will always stay this way.** You can look at the code to verify – indeed, this is the beauty of open source!
|
||||||
|
|
||||||
## Where is this experiment running?
|
## Where is this experiment running?
|
||||||
|
|
||||||
|
@ -28,26 +41,38 @@ This experiment is currently running on a few open source projects that [Feross]
|
||||||
|
|
||||||
- [`standard`](https://standardjs.com)
|
- [`standard`](https://standardjs.com)
|
||||||
|
|
||||||
|
### UPDATE: The experiment is over – Feross posted [a recap](https://feross.org/funding-experiment-recap/) on his blog
|
||||||
|
|
||||||
## Who is Feross?
|
## Who is Feross?
|
||||||
|
|
||||||
Hey there, I'm Feross!
|
Hey there, I'm Feross!
|
||||||
|
|
||||||
<img src="https://feross.org/images/feross-cat.jpg" width=400 />
|
<img src="https://feross.org/images/feross-cat.jpg" width=400 />
|
||||||
|
|
||||||
I'm an open source author, maintainer, and mad scientist. I maintain **[100+ packages on npm](https://www.npmjs.com/~feross)** which are downloaded 100+ million times per month 🤯It's quite mind-blowing, tbh! All my code is [freely accessible on GitHub](https://github.com/feross).
|
I'm an open source author, maintainer, and mad scientist. I maintain [100+ packages on npm](https://www.npmjs.com/~feross) which are downloaded 100+ million times per month. All my code is [freely accessible on GitHub](https://github.com/feross).
|
||||||
|
|
||||||
I work on innovative projects like [WebTorrent](https://github.com/webtorrent/webtorrent), a streaming torrent client for the web, [WebTorrent Desktop](https://github.com/webtorrent/webtorrent-desktop), a slick torrent app for Mac/Windows/Linux, and [StandardJS](https://github.com/standard/standard), a JavaScript style guide, linter, and automatic code fixer. I also work on fun projects like [BitMidi](https://bitmidi.com), a free MIDI database, and [Play](https://play.cash), a music video app.
|
I work on innovative projects like [WebTorrent](https://github.com/webtorrent/webtorrent), a streaming torrent client for the web, [WebTorrent Desktop](https://github.com/webtorrent/webtorrent-desktop), a slick torrent app for Mac/Windows/Linux, and [StandardJS](https://github.com/standard/standard), a JavaScript style guide, linter, and automatic code fixer. I also work on fun projects like [BitMidi](https://bitmidi.com), a free MIDI database, and [Play](https://play.cash), a music video app.
|
||||||
|
|
||||||
I wrote and maintain several popular browserify + webpack ecosystem packages like [buffer](https://github.com/feross/buffer) (38M downloads/month) and [safe-buffer](https://github.com/feross/safe-buffer) (64M downloads/month). Some of my favorite npm packages that I've written are [simple-get](https://github.com/feross/simple-get) (4M downloads/month), [run-parallel](https://github.com/feross/run-parallel) (1.6M downloads/month), and [simple-peer](https://github.com/feross/simple-peer) (32K downloads/month).
|
I wrote and maintain several popular browserify + webpack ecosystem packages like [buffer](https://github.com/feross/buffer) (38M downloads/month) and [safe-buffer](https://github.com/feross/safe-buffer) (64M downloads/month). Some of my favorite npm packages that I've written are [simple-get](https://github.com/feross/simple-get) (4M downloads/month), [run-parallel](https://github.com/feross/run-parallel) (1.6M downloads/month), and [simple-peer](https://github.com/feross/simple-peer) (32K downloads/month).
|
||||||
|
|
||||||
In the past, I was on the Node.js Board of Directors, representing individual Node.js users like you! It was an unpaid position, but I was happy to play some small part in making things better for everyone. Just for fun, a couple years ago I helped organize [ArcticJS](https://arcticjs.club/2017/), an impromptu JavaScript conference in Svalbard, the nothern-most human settlement on Earth, with some amazing friends.
|
In the past, I was on the Node.js Board of Directors, representing individual Node.js users like you! It was an unpaid position, but I was happy to play some small part in making things better for everyone. Just for fun, a couple years ago I helped organize [ArcticJS](https://arcticjs.club/2017/), an impromptu JavaScript conference in Svalbard, the northern-most human settlement on Earth, with some amazing friends.
|
||||||
|
|
||||||
|
## What is the long-term goal?
|
||||||
|
|
||||||
|
My goal with this experiment is to make StandardJS healthier. If we learn that the experiment works, perhaps we can help make all open source healthier, too. For complex reasons, companies are generally hesitant or unwilling to fund open source directly. When it does happen, it's never enough and it never reaches packages which are transitive dependencies (i.e. packages that no one installs explicitly and therefore no one knows exists). Essentially, we have a public good which is consumed by huge numbers of users, but which almost no one pays for. Fortunately, there exists a funding model that usually works for public goods like this – ads. The goal of this experiment is to answer the question: Can we use ethical ads – ads that don't track users or collect data – to fund open source software?
|
||||||
|
|
||||||
## What will the funds be used for?
|
## What will the funds be used for?
|
||||||
|
|
||||||
The funds raised so far ($2,000) have paid for Feross's time to [release Standard 14](https://standardjs.com/changelog.html#1400---2019-08-19) which has taken around five full days.
|
The funds raised so far ($2,000) have paid for Feross's time to [release Standard 14](https://standardjs.com/changelog.html#1400---2019-08-19) which has taken around five days. If we are able to raise additional funds, the next thing we'd like to focus on is out-of-the-box TypeScript support in StandardJS (one of the most common feature requests!) and modernizing the various text editor plugins (many of which are currently unmaintained).
|
||||||
|
|
||||||
If we are able to raise additional funds, the next thing we'd like to fund is development towards out-of-the-box TypeScript support in StandardJS (one of the most common feature requests!) as well as modernizing the various text editor plugins (many of which are currently unmaintained).
|
|
||||||
|
|
||||||
## Where can I provide feedback about this experiment?
|
## Where can I provide feedback about this experiment?
|
||||||
|
|
||||||
You can open an issue. But please be kind. I'm a human with feelings. ❤️
|
You can open an issue. But please be kind. I'm a human with feelings. ❤️
|
||||||
|
|
||||||
|
## How can I disable this?
|
||||||
|
|
||||||
|
Just to be super clear: **This package does no tracking or data collecting — and it will always stay this way.** It's just a fancy `console.log()`.
|
||||||
|
|
||||||
|
If you support open source through direct contributions, donations, or however else you see fit, you can permanently silence `funding` by adding an environment variable `OPEN_SOURCE_CONTRIBUTOR=true` to your terminal environment.
|
||||||
|
|
||||||
|
Note, `funding` also respects npm's `loglevel` setting, so e.g. `npm install --silent` and `npm install --quiet` will be respected.
|
||||||
|
|
|
@ -15,7 +15,7 @@ try {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (funding) {
|
if (funding) {
|
||||||
funding.printRandomMessage()
|
funding.printMessage()
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err.stack || err.message || err)
|
console.error(err.stack || err.message || err)
|
||||||
|
|
94
index.js
94
index.js
|
@ -1,31 +1,50 @@
|
||||||
const boxen = require('boxen')
|
const boxen = require('boxen')
|
||||||
const chalk = require('chalk')
|
const chalk = require('chalk')
|
||||||
|
|
||||||
const detect = require('./lib/detect')
|
const {
|
||||||
const wrap = require('./lib/wrap')
|
isHyper,
|
||||||
const check = require('./lib/check')
|
isITerm,
|
||||||
|
isCI,
|
||||||
|
isSilentMode
|
||||||
|
} = require('./lib/detect')
|
||||||
|
|
||||||
|
const { isShownRecently, markShown } = require('./lib/limit')
|
||||||
|
|
||||||
|
const { checkMessage } = require('./lib/check')
|
||||||
const messages = require('./messages.json')
|
const messages = require('./messages.json')
|
||||||
|
const wrap = require('./lib/wrap')
|
||||||
|
|
||||||
function formatTitle (title) {
|
function formatTitle (title) {
|
||||||
title = wrap(title)
|
title = wrap(title)
|
||||||
if (detect.isITerm() || detect.isHyper()) {
|
|
||||||
return chalk.black(title)
|
if (!isCI()) {
|
||||||
|
title = chalk.black(title)
|
||||||
}
|
}
|
||||||
return chalk.black.bold(title)
|
|
||||||
|
if (!isHyper() && !isITerm()) {
|
||||||
|
title = chalk.bold(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
return title
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatText (text) {
|
function formatText (text) {
|
||||||
text = wrap(text)
|
text = wrap(text)
|
||||||
return chalk.black(
|
|
||||||
text.replace(
|
text = text.replace(
|
||||||
/{{([^}]*?)}}/g,
|
/{{([^}]*?)}}/g,
|
||||||
(match, url) => chalk.blue.underline(url)
|
(match, url) => chalk.blue.underline(url)
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!isCI()) {
|
||||||
|
text = chalk.black(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatUrl (url) {
|
function formatUrl (url) {
|
||||||
|
url = wrap(url, { cut: true })
|
||||||
return chalk.blue.underline(url)
|
return chalk.blue.underline(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,6 +55,7 @@ function formatMessage (message) {
|
||||||
'\n\n' + formatUrl(url)
|
'\n\n' + formatUrl(url)
|
||||||
|
|
||||||
const opts = {
|
const opts = {
|
||||||
|
align: 'center',
|
||||||
borderStyle: {
|
borderStyle: {
|
||||||
topLeft: ' ',
|
topLeft: ' ',
|
||||||
topRight: ' ',
|
topRight: ' ',
|
||||||
|
@ -44,18 +64,38 @@ function formatMessage (message) {
|
||||||
horizontal: ' ',
|
horizontal: ' ',
|
||||||
vertical: ' '
|
vertical: ' '
|
||||||
},
|
},
|
||||||
backgroundColor: 'white',
|
|
||||||
padding: 2,
|
|
||||||
margin: 1,
|
|
||||||
float: 'center',
|
float: 'center',
|
||||||
align: 'center'
|
margin: 0,
|
||||||
|
padding: {
|
||||||
|
top: 1,
|
||||||
|
right: 4,
|
||||||
|
bottom: 1,
|
||||||
|
left: 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCI()) {
|
||||||
|
Object.assign(opts, {
|
||||||
|
backgroundColor: 'white'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return boxen(coloredMessage, opts)
|
return boxen(coloredMessage, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkMessage (message) {
|
function printMessage () {
|
||||||
const { title, text, url } = message
|
// Do not print message when npm is run in silent mode
|
||||||
|
if (isSilentMode()) return
|
||||||
|
|
||||||
|
// Do not print message when one has been shown recently
|
||||||
|
if (isShownRecently()) return
|
||||||
|
|
||||||
|
// Skip running if no messages are available
|
||||||
|
if (messages.length === 0) return
|
||||||
|
|
||||||
|
// Select a random message
|
||||||
|
const i = Math.floor(Math.random() * messages.length)
|
||||||
|
const message = messages[i]
|
||||||
|
|
||||||
// Check if the strings are safe to print to the terminal. Specifically, the
|
// Check if the strings are safe to print to the terminal. Specifically, the
|
||||||
// string should be plain ASCII, excluding control characters. This is
|
// string should be plain ASCII, excluding control characters. This is
|
||||||
|
@ -64,22 +104,16 @@ function checkMessage (message) {
|
||||||
// strings at package publish time (see test/messages.js). But it doesn't hurt
|
// strings at package publish time (see test/messages.js). But it doesn't hurt
|
||||||
// to check again in the client and assert that messages are plain ASCII. This
|
// to check again in the client and assert that messages are plain ASCII. This
|
||||||
// is the security principle of defense-in-depth.
|
// is the security principle of defense-in-depth.
|
||||||
check(title)
|
|
||||||
check(text)
|
|
||||||
check(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
function printRandomMessage () {
|
|
||||||
const i = Math.floor(Math.random() * messages.length)
|
|
||||||
const message = messages[i]
|
|
||||||
|
|
||||||
checkMessage(message)
|
checkMessage(message)
|
||||||
|
|
||||||
|
// Format the message and print it
|
||||||
const formattedMessage = formatMessage(message)
|
const formattedMessage = formatMessage(message)
|
||||||
console.log(formattedMessage)
|
console.log(formattedMessage + '\n')
|
||||||
|
|
||||||
|
// Limit the frequency that messages are shown
|
||||||
|
markShown()
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
formatMessage,
|
printMessage
|
||||||
checkMessage,
|
|
||||||
printRandomMessage
|
|
||||||
}
|
}
|
||||||
|
|
13
lib/check.js
13
lib/check.js
|
@ -18,4 +18,15 @@ function checkString (str) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = checkString
|
function checkMessage (message) {
|
||||||
|
const { title, text, url } = message
|
||||||
|
|
||||||
|
checkString(title)
|
||||||
|
checkString(text)
|
||||||
|
checkString(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
checkString,
|
||||||
|
checkMessage
|
||||||
|
}
|
||||||
|
|
|
@ -1,8 +1,18 @@
|
||||||
/**
|
/**
|
||||||
* Functions to detect which Terminal emulator is in use.
|
* Functions to detect information about the environment, e.g. which Terminal
|
||||||
|
* emulator is in use, or whether silent mode is enabled.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const TERM_PROGRAM = process.env.TERM_PROGRAM
|
const ciInfo = require('ci-info')
|
||||||
|
|
||||||
|
const {
|
||||||
|
TERM_PROGRAM,
|
||||||
|
npm_config_loglevel: NPM_CONFIG_LOGLEVEL,
|
||||||
|
OPEN_SOURCE_CONTRIBUTOR
|
||||||
|
} = process.env
|
||||||
|
|
||||||
|
// Is Hyper (Mac)?
|
||||||
|
const isHyper = () => TERM_PROGRAM === 'Hyper'
|
||||||
|
|
||||||
// Is iTerm.app (Mac)?
|
// Is iTerm.app (Mac)?
|
||||||
const isITerm = () => TERM_PROGRAM === 'iTerm.app'
|
const isITerm = () => TERM_PROGRAM === 'iTerm.app'
|
||||||
|
@ -10,11 +20,24 @@ const isITerm = () => TERM_PROGRAM === 'iTerm.app'
|
||||||
// Is Terminal.app (Mac)?
|
// Is Terminal.app (Mac)?
|
||||||
const isTerminalApp = () => TERM_PROGRAM === 'Apple_Terminal'
|
const isTerminalApp = () => TERM_PROGRAM === 'Apple_Terminal'
|
||||||
|
|
||||||
// Is Hyper (Mac)?
|
// Is CI?
|
||||||
const isHyper = () => TERM_PROGRAM === 'Hyper'
|
const isCI = () => ciInfo.isCI
|
||||||
|
|
||||||
|
// Is silent mode enabled?
|
||||||
|
const isSilentMode = () => (
|
||||||
|
['silent', 'error'].includes(NPM_CONFIG_LOGLEVEL) ||
|
||||||
|
(NPM_CONFIG_LOGLEVEL === 'warn' && !process.version.startsWith('v6.')) ||
|
||||||
|
isEnabled(OPEN_SOURCE_CONTRIBUTOR)
|
||||||
|
)
|
||||||
|
|
||||||
|
function isEnabled (value) {
|
||||||
|
return !!value && value !== '0' && value !== 'false'
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
isHyper,
|
||||||
isITerm,
|
isITerm,
|
||||||
isTerminalApp,
|
isTerminalApp,
|
||||||
isHyper
|
isCI,
|
||||||
|
isSilentMode
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
/**
|
||||||
|
* Functions to limit the frequency that messages are shown.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { tmpdir } = require('os')
|
||||||
|
const { statSync, unlinkSync, writeFileSync } = require('fs')
|
||||||
|
const { join } = require('path')
|
||||||
|
|
||||||
|
const LIMIT_FILE_PATH = join(tmpdir(), 'funding-message-shown')
|
||||||
|
const LIMIT_TIMEOUT = 60 * 1000 // 1 minute
|
||||||
|
|
||||||
|
function isShownRecently () {
|
||||||
|
try {
|
||||||
|
const { mtime: lastShown } = statSync(LIMIT_FILE_PATH)
|
||||||
|
return Date.now() - lastShown < LIMIT_TIMEOUT
|
||||||
|
} catch (e) {}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function markShown () {
|
||||||
|
try {
|
||||||
|
writeFileSync(LIMIT_FILE_PATH, '')
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only used in tests
|
||||||
|
function clearShown () {
|
||||||
|
try {
|
||||||
|
unlinkSync(LIMIT_FILE_PATH)
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isShownRecently,
|
||||||
|
markShown,
|
||||||
|
clearShown
|
||||||
|
}
|
15
lib/wrap.js
15
lib/wrap.js
|
@ -1,17 +1,18 @@
|
||||||
const wordWrap = require('word-wrap')
|
const wordWrap = require('word-wrap')
|
||||||
const termSize = require('term-size')
|
const termSize = require('term-size')
|
||||||
|
|
||||||
|
const MAX_WIDTH = 100
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrap text so it fits within the terminal window width while respecting word
|
* Wrap text so it fits within the terminal window width while respecting word
|
||||||
* boundaries. Optionally, ensure that width is within a range even when
|
* boundaries.
|
||||||
* terminal is very small or large.
|
|
||||||
*/
|
*/
|
||||||
function wrap (str, minWidth = 60, maxWidth = 120) {
|
function wrap (str, opts) {
|
||||||
const columns = Math.max(minWidth, Math.min(maxWidth, termSize().columns))
|
const columns = Math.min(MAX_WIDTH, termSize().columns)
|
||||||
const opts = {
|
opts = Object.assign({
|
||||||
width: columns - 20, // Leave room for padding and margin
|
width: columns - 15, // Leave room for padding and margin
|
||||||
indent: ''
|
indent: ''
|
||||||
}
|
}, opts)
|
||||||
return wordWrap(str, opts)
|
return wordWrap(str, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,7 @@
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"title": "Linode cloud computing",
|
"title": "npm install funding",
|
||||||
"text": "Deploy a server in seconds with your choice of Linux distro, resources, and host location. For a $20 credit, enter promo code STANDARDJS19 at sign up.",
|
"text": "I appreciate the thoughtful discussion and feedback from the community. I shared some thoughts about how this experiment went from my perspective.",
|
||||||
"url": "https://welcome.linode.com/standardjs"
|
"url": "https://feross.org/funding-experiment-recap/"
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "LogRocket",
|
|
||||||
"text": "Stop guessing why bugs happen. LogRocket lets you replay what users do on your web app or website, help you reproduce bugs and fix issues faster.",
|
|
||||||
"url": "https://logrocket.com/term"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
11
package.json
11
package.json
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "funding",
|
"name": "funding",
|
||||||
"description": "Get open source maintainers paid",
|
"description": "Get open source maintainers paid",
|
||||||
"version": "1.0.0",
|
"version": "1.0.9",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Feross Aboukhadijeh",
|
"name": "Feross Aboukhadijeh",
|
||||||
"email": "feross@feross.org",
|
"email": "feross@feross.org",
|
||||||
|
@ -16,19 +16,19 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"boxen": "^3.2.0",
|
"boxen": "^3.2.0",
|
||||||
"chalk": "^2.4.2",
|
"chalk": "^2.4.2",
|
||||||
|
"ci-info": "^2.0.0",
|
||||||
"term-size": "^2.1.0",
|
"term-size": "^2.1.0",
|
||||||
"word-wrap": "^1.2.3"
|
"word-wrap": "^1.2.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"standard": "*",
|
"standard": "*",
|
||||||
"tape": "^4.11.0"
|
"tape": "^5.0.0"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/feross/funding",
|
"homepage": "https://github.com/feross/funding",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"funding",
|
"funding",
|
||||||
"maintainers",
|
"maintainers",
|
||||||
"open source",
|
"open source",
|
||||||
"get paid",
|
|
||||||
"sustainability"
|
"sustainability"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
@ -38,7 +38,8 @@
|
||||||
"url": "git://github.com/feross/funding.git"
|
"url": "git://github.com/feross/funding.git"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"install": "node bin/funding.js",
|
"postinstall": "node bin/funding.js",
|
||||||
"test": "standard && tape test/*.js && node bin/funding.js"
|
"start": "node tools/clear.js && node bin/funding.js",
|
||||||
|
"test": "standard && npm start && tape test/*.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
132
test/check.js
132
test/check.js
|
@ -1,146 +1,146 @@
|
||||||
const test = require('tape')
|
const test = require('tape')
|
||||||
const check = require('../lib/check')
|
const { checkString } = require('../lib/check')
|
||||||
|
|
||||||
test('check() accepts valid strings', t => {
|
test('checkString() accepts valid strings', t => {
|
||||||
t.doesNotThrow(() => {
|
t.doesNotThrow(() => {
|
||||||
check('')
|
checkString('')
|
||||||
})
|
})
|
||||||
t.doesNotThrow(() => {
|
t.doesNotThrow(() => {
|
||||||
check('support open source')
|
checkString('support open source')
|
||||||
})
|
})
|
||||||
t.doesNotThrow(() => {
|
t.doesNotThrow(() => {
|
||||||
check('support open source\nbe a part of history')
|
checkString('support open source\nbe a part of history')
|
||||||
})
|
})
|
||||||
t.doesNotThrow(() => {
|
t.doesNotThrow(() => {
|
||||||
check('support open source\nbe a part of history\nmaintainers unite')
|
checkString('support open source\nbe a part of history\nmaintainers unite')
|
||||||
})
|
})
|
||||||
t.doesNotThrow(() => {
|
t.doesNotThrow(() => {
|
||||||
check('!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~')
|
checkString('!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~')
|
||||||
})
|
})
|
||||||
t.end()
|
t.end()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('check() behaves as expected on first 127 characters', t => {
|
test('checkString() behaves as expected on first 127 characters', t => {
|
||||||
// control characters not allowed
|
// control characters not allowed
|
||||||
t.throws(() => { check('\u0000') })
|
t.throws(() => { checkString('\u0000') })
|
||||||
t.throws(() => { check('\u0001') })
|
t.throws(() => { checkString('\u0001') })
|
||||||
t.throws(() => { check('\u0002') })
|
t.throws(() => { checkString('\u0002') })
|
||||||
t.throws(() => { check('\u0003') })
|
t.throws(() => { checkString('\u0003') })
|
||||||
t.throws(() => { check('\u0004') })
|
t.throws(() => { checkString('\u0004') })
|
||||||
t.throws(() => { check('\u0005') })
|
t.throws(() => { checkString('\u0005') })
|
||||||
t.throws(() => { check('\u0006') })
|
t.throws(() => { checkString('\u0006') })
|
||||||
t.throws(() => { check('\u0007') })
|
t.throws(() => { checkString('\u0007') })
|
||||||
t.throws(() => { check('\u0008') })
|
t.throws(() => { checkString('\u0008') })
|
||||||
t.throws(() => { check('\u0009') })
|
t.throws(() => { checkString('\u0009') })
|
||||||
|
|
||||||
// newline is allowed
|
// newline is allowed
|
||||||
t.doesNotThrow(() => { check('\u000a') })
|
t.doesNotThrow(() => { checkString('\u000a') })
|
||||||
|
|
||||||
// control characters not allowed
|
// control characters not allowed
|
||||||
t.throws(() => { check('\u000b') })
|
t.throws(() => { checkString('\u000b') })
|
||||||
t.throws(() => { check('\u000c') })
|
t.throws(() => { checkString('\u000c') })
|
||||||
t.throws(() => { check('\u000d') })
|
t.throws(() => { checkString('\u000d') })
|
||||||
t.throws(() => { check('\u000e') })
|
t.throws(() => { checkString('\u000e') })
|
||||||
t.throws(() => { check('\u000f') })
|
t.throws(() => { checkString('\u000f') })
|
||||||
t.throws(() => { check('\u0010') })
|
t.throws(() => { checkString('\u0010') })
|
||||||
t.throws(() => { check('\u0011') })
|
t.throws(() => { checkString('\u0011') })
|
||||||
t.throws(() => { check('\u0012') })
|
t.throws(() => { checkString('\u0012') })
|
||||||
t.throws(() => { check('\u0013') })
|
t.throws(() => { checkString('\u0013') })
|
||||||
t.throws(() => { check('\u0014') })
|
t.throws(() => { checkString('\u0014') })
|
||||||
t.throws(() => { check('\u0015') })
|
t.throws(() => { checkString('\u0015') })
|
||||||
t.throws(() => { check('\u0016') })
|
t.throws(() => { checkString('\u0016') })
|
||||||
t.throws(() => { check('\u0017') })
|
t.throws(() => { checkString('\u0017') })
|
||||||
t.throws(() => { check('\u0018') })
|
t.throws(() => { checkString('\u0018') })
|
||||||
t.throws(() => { check('\u0019') })
|
t.throws(() => { checkString('\u0019') })
|
||||||
t.throws(() => { check('\u001a') })
|
t.throws(() => { checkString('\u001a') })
|
||||||
t.throws(() => { check('\u001b') })
|
t.throws(() => { checkString('\u001b') })
|
||||||
t.throws(() => { check('\u001c') })
|
t.throws(() => { checkString('\u001c') })
|
||||||
t.throws(() => { check('\u001d') })
|
t.throws(() => { checkString('\u001d') })
|
||||||
t.throws(() => { check('\u001e') })
|
t.throws(() => { checkString('\u001e') })
|
||||||
t.throws(() => { check('\u001f') })
|
t.throws(() => { checkString('\u001f') })
|
||||||
|
|
||||||
// normal characters are allowed
|
// normal characters are allowed
|
||||||
for (let i = 0x20; i < 0x7f; i++) {
|
for (let i = 0x20; i < 0x7f; i++) {
|
||||||
t.doesNotThrow(() => { check(Buffer.from([i]).toString()) })
|
t.doesNotThrow(() => { checkString(Buffer.from([i]).toString()) })
|
||||||
}
|
}
|
||||||
|
|
||||||
// del is not allowed
|
// del is not allowed
|
||||||
t.throws(() => { check('\u007f') })
|
t.throws(() => { checkString('\u007f') })
|
||||||
|
|
||||||
t.end()
|
t.end()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('check() rejects high code points', t => {
|
test('checkString() rejects high code points', t => {
|
||||||
// char codes 128-255 are not allowed
|
// char codes 128-255 are not allowed
|
||||||
for (let i = 0x80; i <= 0xff; i++) {
|
for (let i = 0x80; i <= 0xff; i++) {
|
||||||
t.throws(() => { check(Buffer.from([i]).toString()) })
|
t.throws(() => { checkString(Buffer.from([i]).toString()) })
|
||||||
}
|
}
|
||||||
|
|
||||||
// emojis are not allowed
|
// emojis are not allowed
|
||||||
t.throws(() => { check('💩') })
|
t.throws(() => { checkString('💩') })
|
||||||
t.throws(() => { check('❤️') })
|
t.throws(() => { checkString('❤️') })
|
||||||
t.throws(() => { check('✨') })
|
t.throws(() => { checkString('✨') })
|
||||||
|
|
||||||
// ansi escape sequences are not allowed
|
// ansi escape sequences are not allowed
|
||||||
t.throws(() => { check('\u001B') })
|
t.throws(() => { checkString('\u001B') })
|
||||||
t.throws(() => { check('\u001B[4mfoo\u001B[24m') })
|
t.throws(() => { checkString('\u001B[4mfoo\u001B[24m') })
|
||||||
t.throws(() => { check('\u001B[31mfoo\u001B[39m') })
|
t.throws(() => { checkString('\u001B[31mfoo\u001B[39m') })
|
||||||
t.throws(() => { check('\u001B[41mfoo\u001B[49m') })
|
t.throws(() => { checkString('\u001B[41mfoo\u001B[49m') })
|
||||||
t.throws(() => { check('\u001B[31m\u001B[42m\u001B[4mfoo\u001B[24m\u001B[49m\u001B[39m') })
|
t.throws(() => { checkString('\u001B[31m\u001B[42m\u001B[4mfoo\u001B[24m\u001B[49m\u001B[39m') })
|
||||||
t.throws(() => { check('\u001B[31mfoo\u001B[4m\u001B[44mbar\u001B[49m\u001B[24m!\u001B[39m') })
|
t.throws(() => { checkString('\u001B[31mfoo\u001B[4m\u001B[44mbar\u001B[49m\u001B[24m!\u001B[39m') })
|
||||||
t.throws(() => { check('\u001B[31ma\u001B[33mb\u001B[32mc\u001B[33mb\u001B[31mc\u001B[39m') })
|
t.throws(() => { checkString('\u001B[31ma\u001B[33mb\u001B[32mc\u001B[33mb\u001B[31mc\u001B[39m') })
|
||||||
t.throws(() => { check('\u001B[90mhello\u001B[39m\n\u001B[90mworld\u001B[39m') })
|
t.throws(() => { checkString('\u001B[90mhello\u001B[39m\n\u001B[90mworld\u001B[39m') })
|
||||||
|
|
||||||
t.end()
|
t.end()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('check() accepts valid strings', t => {
|
test('checkString() accepts valid strings', t => {
|
||||||
// 20 lines, with 20 line max
|
// 20 lines, with 20 line max
|
||||||
t.doesNotThrow(() => {
|
t.doesNotThrow(() => {
|
||||||
check('a\nb\nc\nd\ne\nf\ng\nh\ni\nj\na\nb\nc\nd\ne\nf\ng\nh\ni\nj', 20)
|
checkString('a\nb\nc\nd\ne\nf\ng\nh\ni\nj\na\nb\nc\nd\ne\nf\ng\nh\ni\nj', 20)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.end()
|
t.end()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('check() rejects invalid strings', t => {
|
test('checkString() rejects invalid strings', t => {
|
||||||
// 3 character line, followed by line with unsafe characters
|
// 3 character line, followed by line with unsafe characters
|
||||||
t.throws(() => {
|
t.throws(() => {
|
||||||
check('abc\ndef💩gih')
|
checkString('abc\ndef💩gih')
|
||||||
})
|
})
|
||||||
|
|
||||||
// two lines with invalid characters
|
// two lines with invalid characters
|
||||||
t.throws(() => {
|
t.throws(() => {
|
||||||
check('🌟\ndef💩gih')
|
checkString('🌟\ndef💩gih')
|
||||||
})
|
})
|
||||||
|
|
||||||
t.end()
|
t.end()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('check() rejects non-strings', t => {
|
test('checkString() rejects non-strings', t => {
|
||||||
// function argument
|
// function argument
|
||||||
t.throws(() => {
|
t.throws(() => {
|
||||||
check(() => {})
|
checkString(() => {})
|
||||||
})
|
})
|
||||||
|
|
||||||
// object argument
|
// object argument
|
||||||
t.throws(() => {
|
t.throws(() => {
|
||||||
check({})
|
checkString({})
|
||||||
})
|
})
|
||||||
|
|
||||||
// number argument
|
// number argument
|
||||||
t.throws(() => {
|
t.throws(() => {
|
||||||
check(42)
|
checkString(42)
|
||||||
})
|
})
|
||||||
|
|
||||||
// null argument
|
// null argument
|
||||||
t.throws(() => {
|
t.throws(() => {
|
||||||
check(null)
|
checkString(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
// undefined argument
|
// undefined argument
|
||||||
t.throws(() => {
|
t.throws(() => {
|
||||||
check(undefined)
|
checkString(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.end()
|
t.end()
|
||||||
|
|
113
test/funding.js
113
test/funding.js
|
@ -1,18 +1,115 @@
|
||||||
const test = require('tape')
|
const test = require('tape')
|
||||||
const cp = require('child_process')
|
const cp = require('child_process')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const util = require('util')
|
|
||||||
|
|
||||||
const execFile = util.promisify(cp.execFile)
|
const { clearShown } = require('../lib/limit')
|
||||||
|
|
||||||
const FUNDING_BIN_PATH = path.join(__dirname, '..', 'bin', 'funding.js')
|
const FUNDING_BIN_PATH = path.join(__dirname, '..', 'bin', 'funding.js')
|
||||||
|
|
||||||
test('Santiy check bin/funding.js output', async t => {
|
test('Sanity check bin/funding.js output', t => {
|
||||||
const { stdout, stderr } = await execFile(FUNDING_BIN_PATH)
|
t.plan(4)
|
||||||
|
|
||||||
t.ok(stdout.length > 0, 'there exists some stdout ouput')
|
clearShown()
|
||||||
t.ok(!stdout.match(/error/gi), 'stdout output is not an error')
|
|
||||||
t.equal(stderr.length, 0, 'no stderr output')
|
|
||||||
|
|
||||||
t.end()
|
cp.execFile(FUNDING_BIN_PATH, (err, stdout, stderr) => {
|
||||||
|
t.error(err)
|
||||||
|
t.ok(stdout.length > 0, 'there exists some stdout ouput')
|
||||||
|
t.ok(!stdout.match(/error/gi), 'stdout output is not an error')
|
||||||
|
t.equal(stderr, '', 'no stderr output')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('`npm --silent` or `npm --loglevel silent` prevents output', t => {
|
||||||
|
t.plan(3)
|
||||||
|
|
||||||
|
clearShown()
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
env: Object.assign({}, process.env, {
|
||||||
|
npm_config_loglevel: 'silent'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
cp.execFile(FUNDING_BIN_PATH, [], opts, (err, stdout, stderr) => {
|
||||||
|
t.error(err)
|
||||||
|
t.equal(stdout, '', 'no stdout ouput')
|
||||||
|
t.equal(stderr, '', 'no stderr output')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('`npm --quiet` or `npm --loglevel warn` prevents output', t => {
|
||||||
|
if (process.version.startsWith('v6.')) {
|
||||||
|
t.pass('Ignore `--loglevel warn` on Node 6 (it is the default)')
|
||||||
|
return t.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.plan(3)
|
||||||
|
|
||||||
|
clearShown()
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
env: Object.assign({}, process.env, {
|
||||||
|
npm_config_loglevel: 'warn'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
cp.execFile(FUNDING_BIN_PATH, [], opts, (err, stdout, stderr) => {
|
||||||
|
t.error(err)
|
||||||
|
t.equal(stdout, '', 'no stdout ouput')
|
||||||
|
t.equal(stderr, '', 'no stderr output')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('`npm --loglevel error` prevents output', t => {
|
||||||
|
t.plan(3)
|
||||||
|
|
||||||
|
clearShown()
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
env: Object.assign({}, process.env, {
|
||||||
|
npm_config_loglevel: 'error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
cp.execFile(FUNDING_BIN_PATH, [], opts, (err, stdout, stderr) => {
|
||||||
|
t.error(err)
|
||||||
|
t.equal(stdout, '', 'no stdout ouput')
|
||||||
|
t.equal(stderr, '', 'no stderr output')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deduplication / rate-limiting', t => {
|
||||||
|
t.plan(7)
|
||||||
|
|
||||||
|
clearShown()
|
||||||
|
|
||||||
|
cp.execFile(FUNDING_BIN_PATH, (err, stdout, stderr) => {
|
||||||
|
t.error(err)
|
||||||
|
t.ok(stdout.length > 0, 'there exists some stdout ouput')
|
||||||
|
t.ok(!stdout.match(/error/gi), 'stdout output is not an error')
|
||||||
|
t.equal(stderr, '', 'no stderr output')
|
||||||
|
|
||||||
|
// Second run should print nothing, since it was recently shown
|
||||||
|
cp.execFile(FUNDING_BIN_PATH, (err, stdout, stderr) => {
|
||||||
|
t.error(err)
|
||||||
|
t.equal(stdout, '', 'no stdout ouput')
|
||||||
|
t.equal(stderr, '', 'no stderr output')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('OPEN_SOURCE_CONTRIBUTOR=true prevents output', t => {
|
||||||
|
t.plan(3)
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
env: Object.assign({}, process.env, {
|
||||||
|
OPEN_SOURCE_CONTRIBUTOR: 'true'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
cp.execFile(FUNDING_BIN_PATH, [], opts, (err, stdout, stderr) => {
|
||||||
|
t.error(err)
|
||||||
|
t.equal(stdout.length, 0, 'no stdout ouput')
|
||||||
|
t.equal(stderr.length, 0, 'no stderr output')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
const test = require('tape')
|
||||||
|
|
||||||
|
const { isShownRecently, markShown, clearShown } = require('../lib/limit')
|
||||||
|
|
||||||
|
test('shown file works', t => {
|
||||||
|
clearShown()
|
||||||
|
t.ok(!isShownRecently(), 'initially, not shown recently')
|
||||||
|
|
||||||
|
markShown()
|
||||||
|
t.ok(isShownRecently(), 'after markShown(), is shown recently')
|
||||||
|
|
||||||
|
clearShown()
|
||||||
|
t.ok(!isShownRecently(), 'after clearShown(), not shown recently')
|
||||||
|
|
||||||
|
t.end()
|
||||||
|
})
|
|
@ -1,6 +1,6 @@
|
||||||
const test = require('tape')
|
const test = require('tape')
|
||||||
const check = require('../lib/check')
|
|
||||||
const funding = require('../')
|
const { checkString, checkMessage } = require('../lib/check')
|
||||||
const messages = require('../messages.json')
|
const messages = require('../messages.json')
|
||||||
|
|
||||||
test('Messages is in the expected shape', t => {
|
test('Messages is in the expected shape', t => {
|
||||||
|
@ -17,26 +17,20 @@ test('Check all messages with check()', t => {
|
||||||
t.equal(typeof message.url, 'string')
|
t.equal(typeof message.url, 'string')
|
||||||
|
|
||||||
t.doesNotThrow(() => {
|
t.doesNotThrow(() => {
|
||||||
check(message.title)
|
checkString(message.title)
|
||||||
})
|
}, 'checkString(message.title)')
|
||||||
|
|
||||||
t.doesNotThrow(() => {
|
t.doesNotThrow(() => {
|
||||||
check(message.text)
|
checkString(message.text)
|
||||||
})
|
}, 'checkString(message.text)')
|
||||||
|
|
||||||
t.doesNotThrow(() => {
|
t.doesNotThrow(() => {
|
||||||
check(message.url)
|
checkString(message.url)
|
||||||
})
|
}, 'checkString(message.url)')
|
||||||
})
|
|
||||||
|
t.doesNotThrow(() => {
|
||||||
t.end()
|
checkMessage(message)
|
||||||
})
|
}, 'checkMessage(message)')
|
||||||
|
|
||||||
test('Check all messages with checkMessage()', t => {
|
|
||||||
messages.forEach(message => {
|
|
||||||
t.doesNotThrow(() => {
|
|
||||||
funding.checkMessage(message)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.end()
|
t.end()
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const { clearShown } = require('../lib/limit')
|
||||||
|
|
||||||
|
clearShown()
|
Loading…
Reference in New Issue