evol-npm-pulse 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/FUNDING.yml +3 -0
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/chart.html +87 -0
- package/css/me.css +76 -0
- package/css/pulse.css +207 -0
- package/css/spirals.png +0 -0
- package/favicon.png +0 -0
- package/index.html +151 -0
- package/js/chart.js +134 -0
- package/js/npm-data.js +249 -0
- package/js/pulse.js +186 -0
- package/npm-pulse-charts.png +0 -0
- package/npm-pulse.png +0 -0
- package/package.json +30 -0
- package/robots.txt +4 -0
- package/sitemap.xml +9 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Olivier Giulieri
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# npm-Pulse
|
|
2
|
+
|
|
3
|
+
One page to see how all your npm packages are doing: downloads over the last week, month, or year, a weekly sparkline for each package, the trend against the previous month, and the GitHub stars that go with it.
|
|
4
|
+
|
|
5
|
+
- [See the packages](https://evoluteur.github.io/npm-pulse/)
|
|
6
|
+
- [See the charts](https://evoluteur.github.io/npm-pulse/chart.html)
|
|
7
|
+
|
|
8
|
+

|
|
9
|
+
|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
Everything is computed in the browser from three public APIs - no backend, no build step, no library:
|
|
13
|
+
|
|
14
|
+
- the [npm registry search API](https://github.com/npm/registry/blob/main/docs/REGISTRY-API.md) for the list of packages published by a maintainer,
|
|
15
|
+
- the [npm download counts API](https://github.com/npm/registry/blob/main/docs/download-counts.md) for a year of daily downloads (fetched in a single bulk call),
|
|
16
|
+
- the [GitHub API](https://docs.github.com/en/rest) for stars and forks.
|
|
17
|
+
|
|
18
|
+
The sparklines are 52 weekly buckets drawn as plain SVG, and the charts page shows total downloads month by month plus the ranking of the most downloaded packages.
|
|
19
|
+
|
|
20
|
+
Downloads are recomputed once a day by npm, so the answers are cached in `localStorage` for 6 hours. Use the "Refresh" link to fetch them again.
|
|
21
|
+
|
|
22
|
+
## Use it for your own packages
|
|
23
|
+
|
|
24
|
+
Change the `user` value (at the top of the [/js/npm-data.js](https://github.com/evoluteur/npm-pulse/blob/main/js/npm-data.js) file) to see your packages instead of mine:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
const user = "evoluteur"; // npm maintainer whose packages are shown
|
|
28
|
+
const ghUser = "evoluteur"; // GitHub user (for stars and forks)
|
|
29
|
+
const cacheMinutes = 360; // npm recomputes its stats once a day
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then open `index.html` - there is nothing to install and nothing to build.
|
|
33
|
+
|
|
34
|
+
GitHub allows 60 anonymous API calls per hour. When that limit is reached the stars simply do not show, and everything else keeps working.
|
|
35
|
+
|
|
36
|
+
npm-Pulse is open source at [GitHub](https://github.com/evoluteur/npm-pulse) with MIT license.
|
|
37
|
+
|
|
38
|
+
For more ways to look at your projects check out my other projects [GitHub-Projects-Cards](https://github.com/evoluteur/github-projects-cards) ([demo](https://evoluteur.github.io/github-projects-cards/)) to display your repositories as cards, and [Meet-the-Fans](https://github.com/evoluteur/meet-the-fans) ([demo](https://evoluteur.github.io/meet-the-fans/)) to visualize the network graph of your repositories, followers, stargazers, and forks.
|
|
39
|
+
|
|
40
|
+
Copyright (c) 2026 [Olivier Giulieri](https://evoluteur.github.io/).
|
package/chart.html
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<script
|
|
5
|
+
async
|
|
6
|
+
src="https://www.googletagmanager.com/gtag/js?id=G-063933E3C2"
|
|
7
|
+
></script>
|
|
8
|
+
<script>
|
|
9
|
+
window.dataLayer = window.dataLayer || [];
|
|
10
|
+
function gtag() {
|
|
11
|
+
dataLayer.push(arguments);
|
|
12
|
+
}
|
|
13
|
+
gtag("js", new Date());
|
|
14
|
+
gtag("config", "G-063933E3C2");
|
|
15
|
+
</script>
|
|
16
|
+
<title>npm Pulse Charts - Downloads month by month</title>
|
|
17
|
+
<link
|
|
18
|
+
rel="canonical"
|
|
19
|
+
href="https://evoluteur.github.io/npm-pulse/chart.html"
|
|
20
|
+
/>
|
|
21
|
+
<meta charset="utf-8" />
|
|
22
|
+
<meta http-equiv="content-language" content="en" />
|
|
23
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
24
|
+
<meta name="theme-color" content="#0288d1" />
|
|
25
|
+
<link rel="icon" type="image/png" href="favicon.png" />
|
|
26
|
+
<link
|
|
27
|
+
rel="stylesheet"
|
|
28
|
+
href="https://fonts.googleapis.com/css?family=Marcellus"
|
|
29
|
+
/>
|
|
30
|
+
<link href="css/me.css" rel="stylesheet" />
|
|
31
|
+
<link href="css/pulse.css" rel="stylesheet" />
|
|
32
|
+
<script src="js/npm-data.js"></script>
|
|
33
|
+
<script src="js/chart.js"></script>
|
|
34
|
+
<meta
|
|
35
|
+
name="keywords"
|
|
36
|
+
content="npm, downloads, chart, monthly, statistics, packages, ranking, trends, svg"
|
|
37
|
+
/>
|
|
38
|
+
<meta
|
|
39
|
+
name="description"
|
|
40
|
+
content="Total npm downloads month by month across all the packages of a maintainer, and the ranking of the most downloaded ones."
|
|
41
|
+
/>
|
|
42
|
+
<meta name="robots" content="index, follow" />
|
|
43
|
+
<meta name="author" content="Olivier Giulieri" />
|
|
44
|
+
<meta property="og:type" content="website" />
|
|
45
|
+
<meta property="og:site_name" content="Evoluteur" />
|
|
46
|
+
<meta property="og:title" content="npm Pulse Charts" />
|
|
47
|
+
<meta
|
|
48
|
+
property="og:description"
|
|
49
|
+
content="Total npm downloads month by month across all the packages of a maintainer, and the ranking of the most downloaded ones."
|
|
50
|
+
/>
|
|
51
|
+
<meta
|
|
52
|
+
property="og:url"
|
|
53
|
+
content="https://evoluteur.github.io/npm-pulse/chart.html"
|
|
54
|
+
/>
|
|
55
|
+
</head>
|
|
56
|
+
|
|
57
|
+
<body onload="setupChartPage()">
|
|
58
|
+
<div class="nav">
|
|
59
|
+
<a href="https://evoluteur.github.io/">Evoluteur</a> >
|
|
60
|
+
<a href="index.html">npm Pulse</a> > Charts
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<section class="gpc">
|
|
64
|
+
<h1 id="title">Loading...</h1>
|
|
65
|
+
<div id="charts"></div>
|
|
66
|
+
</section>
|
|
67
|
+
|
|
68
|
+
<footer>
|
|
69
|
+
<p>
|
|
70
|
+
Charts are drawn in plain SVG from the
|
|
71
|
+
<a
|
|
72
|
+
href="https://github.com/npm/registry/blob/main/docs/download-counts.md"
|
|
73
|
+
>npm registry API</a
|
|
74
|
+
>, no charting library.
|
|
75
|
+
</p>
|
|
76
|
+
<p>
|
|
77
|
+
<a href="index.html">Back to the packages</a>
|
|
78
|
+
</p>
|
|
79
|
+
<p>
|
|
80
|
+
© 2026
|
|
81
|
+
<a href="https://evoluteur.github.io/" target="_blank"
|
|
82
|
+
>Olivier Giulieri</a
|
|
83
|
+
>
|
|
84
|
+
</p>
|
|
85
|
+
</footer>
|
|
86
|
+
</body>
|
|
87
|
+
</html>
|
package/css/me.css
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/*
|
|
2
|
+
https://evoluteur.github.io/
|
|
3
|
+
(c) 2026 Olivier Giulieri
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
html {
|
|
7
|
+
margin: 0;
|
|
8
|
+
padding: 0;
|
|
9
|
+
scroll-behavior: smooth;
|
|
10
|
+
background: #0288d1 url("spirals.png");
|
|
11
|
+
}
|
|
12
|
+
body {
|
|
13
|
+
margin: 0;
|
|
14
|
+
padding: 0;
|
|
15
|
+
font-family: "Marcellus", serif;
|
|
16
|
+
font-size: 20px;
|
|
17
|
+
font-weight: 300;
|
|
18
|
+
line-height: 1.4em;
|
|
19
|
+
color: white;
|
|
20
|
+
overflow-x: hidden;
|
|
21
|
+
}
|
|
22
|
+
h1,
|
|
23
|
+
h2 {
|
|
24
|
+
color: #ffcc80;
|
|
25
|
+
}
|
|
26
|
+
h1 {
|
|
27
|
+
font-size: 2.5rem;
|
|
28
|
+
}
|
|
29
|
+
a,
|
|
30
|
+
a:visited {
|
|
31
|
+
color: #ffb74d;
|
|
32
|
+
text-decoration: none;
|
|
33
|
+
font-weight: 400;
|
|
34
|
+
/* background-color: #2b88d2; */
|
|
35
|
+
}
|
|
36
|
+
a:active,
|
|
37
|
+
a:hover {
|
|
38
|
+
color: #ffcc80;
|
|
39
|
+
text-decoration: underline;
|
|
40
|
+
font-weight: 400;
|
|
41
|
+
}
|
|
42
|
+
footer {
|
|
43
|
+
color: #bdcff1;
|
|
44
|
+
margin: 50px 20px 10px 20px;
|
|
45
|
+
> a {
|
|
46
|
+
white-space: nowrap;
|
|
47
|
+
}
|
|
48
|
+
&:hover {
|
|
49
|
+
color: white;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.nav {
|
|
54
|
+
color: white;
|
|
55
|
+
background-color: #1a1a1a;
|
|
56
|
+
padding: 8px 10px;
|
|
57
|
+
> a {
|
|
58
|
+
color: #92cddc;
|
|
59
|
+
background-color: #1a1a1a !important;
|
|
60
|
+
text-decoration: none;
|
|
61
|
+
&:hover {
|
|
62
|
+
color: #ff9900;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
.gpc {
|
|
67
|
+
padding: 0 20px;
|
|
68
|
+
}
|
|
69
|
+
@media print {
|
|
70
|
+
body {
|
|
71
|
+
color: #1a1a1a !important;
|
|
72
|
+
}
|
|
73
|
+
@page {
|
|
74
|
+
margin: 1cm 1cm;
|
|
75
|
+
}
|
|
76
|
+
}
|
package/css/pulse.css
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/*
|
|
2
|
+
npm-pulse
|
|
3
|
+
https://evoluteur.github.io/npm-pulse/
|
|
4
|
+
(c) 2026 Olivier Giulieri
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
#title > a {
|
|
8
|
+
text-transform: none;
|
|
9
|
+
}
|
|
10
|
+
.totals {
|
|
11
|
+
font-size: 1.2rem;
|
|
12
|
+
white-space: nowrap;
|
|
13
|
+
margin-left: 20px;
|
|
14
|
+
> span {
|
|
15
|
+
margin-right: 20px;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
.filters {
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-wrap: wrap;
|
|
21
|
+
gap: 20px;
|
|
22
|
+
align-items: baseline;
|
|
23
|
+
margin-bottom: 20px;
|
|
24
|
+
label::after {
|
|
25
|
+
content: ":";
|
|
26
|
+
}
|
|
27
|
+
input,
|
|
28
|
+
select {
|
|
29
|
+
font-size: 16px;
|
|
30
|
+
padding: 5px 10px;
|
|
31
|
+
margin-left: 5px;
|
|
32
|
+
}
|
|
33
|
+
.tools {
|
|
34
|
+
white-space: nowrap;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
#summary {
|
|
38
|
+
font-size: 0.9em;
|
|
39
|
+
color: #bbdefb;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.projects {
|
|
43
|
+
display: flex;
|
|
44
|
+
flex-wrap: wrap;
|
|
45
|
+
gap: 16px;
|
|
46
|
+
font-size: 0.9em;
|
|
47
|
+
> .project-card {
|
|
48
|
+
display: flex;
|
|
49
|
+
flex-direction: column;
|
|
50
|
+
justify-content: space-between;
|
|
51
|
+
flex-grow: 1;
|
|
52
|
+
background-color: #5594f4;
|
|
53
|
+
width: 300px;
|
|
54
|
+
max-width: 100%;
|
|
55
|
+
border: 3px solid #0288d1;
|
|
56
|
+
border-radius: 10px;
|
|
57
|
+
padding: 5px 20px 15px;
|
|
58
|
+
.desc {
|
|
59
|
+
padding: 10px 0;
|
|
60
|
+
}
|
|
61
|
+
.p-links > a {
|
|
62
|
+
display: inline-block;
|
|
63
|
+
margin-top: 5px;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
.pcard-title {
|
|
68
|
+
display: flex;
|
|
69
|
+
gap: 10px;
|
|
70
|
+
align-items: baseline;
|
|
71
|
+
min-height: 56px;
|
|
72
|
+
margin-bottom: 5px;
|
|
73
|
+
> span:nth-child(1) {
|
|
74
|
+
flex-grow: 1;
|
|
75
|
+
overflow-wrap: anywhere;
|
|
76
|
+
}
|
|
77
|
+
> .dls {
|
|
78
|
+
font-size: 1.1rem;
|
|
79
|
+
color: white;
|
|
80
|
+
white-space: nowrap;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
.pcard-foot {
|
|
84
|
+
display: flex;
|
|
85
|
+
flex-wrap: wrap;
|
|
86
|
+
gap: 10px;
|
|
87
|
+
justify-content: space-between;
|
|
88
|
+
align-items: baseline;
|
|
89
|
+
margin-top: 12px;
|
|
90
|
+
font-size: 0.85em;
|
|
91
|
+
color: #dbe9ff;
|
|
92
|
+
}
|
|
93
|
+
.lang {
|
|
94
|
+
margin-right: 10px;
|
|
95
|
+
padding: 3px 10px;
|
|
96
|
+
border-radius: 10px;
|
|
97
|
+
border: solid 1px white;
|
|
98
|
+
white-space: nowrap;
|
|
99
|
+
}
|
|
100
|
+
.trend {
|
|
101
|
+
white-space: nowrap;
|
|
102
|
+
&.up {
|
|
103
|
+
color: #b9f6ca;
|
|
104
|
+
}
|
|
105
|
+
&.down {
|
|
106
|
+
color: #ffcdd2;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
.crud-icon {
|
|
110
|
+
position: relative;
|
|
111
|
+
display: inline-block;
|
|
112
|
+
height: 16px;
|
|
113
|
+
width: 16px;
|
|
114
|
+
top: 2px;
|
|
115
|
+
svg {
|
|
116
|
+
fill: currentColor;
|
|
117
|
+
height: 16px;
|
|
118
|
+
width: 16px;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* sparklines: 52 weekly buckets, no axis, no library */
|
|
123
|
+
.spark {
|
|
124
|
+
display: block;
|
|
125
|
+
width: 100%;
|
|
126
|
+
height: 46px;
|
|
127
|
+
overflow: visible;
|
|
128
|
+
> polygon {
|
|
129
|
+
fill: rgba(255, 255, 255, 0.22);
|
|
130
|
+
}
|
|
131
|
+
> polyline {
|
|
132
|
+
fill: none;
|
|
133
|
+
stroke: #ffcc80;
|
|
134
|
+
stroke-width: 1.5;
|
|
135
|
+
stroke-linejoin: round;
|
|
136
|
+
vector-effect: non-scaling-stroke;
|
|
137
|
+
}
|
|
138
|
+
> circle {
|
|
139
|
+
fill: #ffcc80;
|
|
140
|
+
vector-effect: non-scaling-stroke;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
.spark-empty {
|
|
144
|
+
height: 46px;
|
|
145
|
+
display: flex;
|
|
146
|
+
align-items: center;
|
|
147
|
+
font-size: 0.85em;
|
|
148
|
+
color: #dbe9ff;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.noresults {
|
|
152
|
+
margin: 20px 0;
|
|
153
|
+
padding: 30px;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* charts page */
|
|
157
|
+
.chart-block {
|
|
158
|
+
margin: 0 0 50px 0;
|
|
159
|
+
> h2 {
|
|
160
|
+
margin-bottom: 5px;
|
|
161
|
+
}
|
|
162
|
+
> .chart-note {
|
|
163
|
+
font-size: 0.85em;
|
|
164
|
+
color: #bbdefb;
|
|
165
|
+
margin: 0 0 15px 0;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
.chart {
|
|
169
|
+
width: 100%;
|
|
170
|
+
height: auto;
|
|
171
|
+
overflow: visible;
|
|
172
|
+
.grid {
|
|
173
|
+
stroke: rgba(255, 255, 255, 0.25);
|
|
174
|
+
stroke-width: 1;
|
|
175
|
+
}
|
|
176
|
+
.bar {
|
|
177
|
+
fill: #ffcc80;
|
|
178
|
+
}
|
|
179
|
+
.bar.partial {
|
|
180
|
+
fill: rgba(255, 204, 128, 0.6);
|
|
181
|
+
stroke: #ffcc80;
|
|
182
|
+
stroke-dasharray: 3 3;
|
|
183
|
+
}
|
|
184
|
+
.axis {
|
|
185
|
+
fill: white;
|
|
186
|
+
font-size: 12px;
|
|
187
|
+
font-family: "Marcellus", serif;
|
|
188
|
+
}
|
|
189
|
+
.axis.value {
|
|
190
|
+
fill: #dbe9ff;
|
|
191
|
+
}
|
|
192
|
+
.bar-label {
|
|
193
|
+
fill: white;
|
|
194
|
+
font-size: 13px;
|
|
195
|
+
font-family: "Marcellus", serif;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
@media print {
|
|
200
|
+
.filters,
|
|
201
|
+
.nav {
|
|
202
|
+
display: none;
|
|
203
|
+
}
|
|
204
|
+
.projects > .project-card {
|
|
205
|
+
break-inside: avoid;
|
|
206
|
+
}
|
|
207
|
+
}
|
package/css/spirals.png
ADDED
|
Binary file
|
package/favicon.png
ADDED
|
Binary file
|
package/index.html
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<script
|
|
5
|
+
async
|
|
6
|
+
src="https://www.googletagmanager.com/gtag/js?id=G-063933E3C2"
|
|
7
|
+
></script>
|
|
8
|
+
<script>
|
|
9
|
+
window.dataLayer = window.dataLayer || [];
|
|
10
|
+
function gtag() {
|
|
11
|
+
dataLayer.push(arguments);
|
|
12
|
+
}
|
|
13
|
+
gtag("js", new Date());
|
|
14
|
+
gtag("config", "G-063933E3C2");
|
|
15
|
+
</script>
|
|
16
|
+
<title>npm Pulse - Downloads and trends for your npm packages</title>
|
|
17
|
+
<link rel="canonical" href="https://evoluteur.github.io/npm-pulse/" />
|
|
18
|
+
<meta charset="utf-8" />
|
|
19
|
+
<meta http-equiv="content-language" content="en" />
|
|
20
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
21
|
+
<meta name="theme-color" content="#0288d1" />
|
|
22
|
+
<link rel="icon" type="image/png" href="favicon.png" />
|
|
23
|
+
<link
|
|
24
|
+
rel="stylesheet"
|
|
25
|
+
href="https://fonts.googleapis.com/css?family=Marcellus"
|
|
26
|
+
/>
|
|
27
|
+
<link href="css/me.css" rel="stylesheet" />
|
|
28
|
+
<link href="css/pulse.css" rel="stylesheet" />
|
|
29
|
+
<script src="js/npm-data.js"></script>
|
|
30
|
+
<script src="js/pulse.js"></script>
|
|
31
|
+
<meta
|
|
32
|
+
name="keywords"
|
|
33
|
+
content="npm, downloads, packages, statistics, stats, dashboard, sparkline, trends, registry, open source, portfolio, github, stars"
|
|
34
|
+
/>
|
|
35
|
+
<meta
|
|
36
|
+
name="description"
|
|
37
|
+
content="One page dashboard for all the npm packages of a maintainer: downloads over the last week, month, and year, weekly sparklines, trends, and GitHub stars."
|
|
38
|
+
/>
|
|
39
|
+
<meta name="robots" content="index, follow" />
|
|
40
|
+
<meta name="author" content="Olivier Giulieri" />
|
|
41
|
+
|
|
42
|
+
<meta property="og:type" content="website" />
|
|
43
|
+
<meta property="og:site_name" content="Evoluteur" />
|
|
44
|
+
<meta property="og:title" content="npm Pulse" />
|
|
45
|
+
<meta
|
|
46
|
+
property="og:description"
|
|
47
|
+
content="One page dashboard for all the npm packages of a maintainer: downloads over the last week, month, and year, weekly sparklines, trends, and GitHub stars."
|
|
48
|
+
/>
|
|
49
|
+
<meta property="og:url" content="https://evoluteur.github.io/npm-pulse/" />
|
|
50
|
+
<meta
|
|
51
|
+
property="og:image"
|
|
52
|
+
content="https://raw.githubusercontent.com/evoluteur/npm-pulse/main/npm-pulse.png"
|
|
53
|
+
/>
|
|
54
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
55
|
+
<meta name="twitter:title" content="npm Pulse" />
|
|
56
|
+
<meta
|
|
57
|
+
name="twitter:description"
|
|
58
|
+
content="Downloads, sparklines, and trends for all your npm packages on a single page."
|
|
59
|
+
/>
|
|
60
|
+
<meta
|
|
61
|
+
name="twitter:image"
|
|
62
|
+
content="https://raw.githubusercontent.com/evoluteur/npm-pulse/main/npm-pulse.png"
|
|
63
|
+
/>
|
|
64
|
+
|
|
65
|
+
<script type="application/ld+json">
|
|
66
|
+
{
|
|
67
|
+
"@context": "https://schema.org",
|
|
68
|
+
"@type": "SoftwareSourceCode",
|
|
69
|
+
"name": "npm-pulse",
|
|
70
|
+
"description": "One page dashboard for all the npm packages of a maintainer: downloads, weekly sparklines, trends, and GitHub stars.",
|
|
71
|
+
"url": "https://evoluteur.github.io/npm-pulse/",
|
|
72
|
+
"codeRepository": "https://github.com/evoluteur/npm-pulse",
|
|
73
|
+
"license": "https://github.com/evoluteur/npm-pulse/blob/main/LICENSE",
|
|
74
|
+
"programmingLanguage": "JavaScript",
|
|
75
|
+
"author": {
|
|
76
|
+
"@type": "Person",
|
|
77
|
+
"name": "Olivier Giulieri",
|
|
78
|
+
"url": "https://evoluteur.github.io/"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
</script>
|
|
82
|
+
</head>
|
|
83
|
+
|
|
84
|
+
<body onload="setupPulsePage()">
|
|
85
|
+
<div class="nav">
|
|
86
|
+
<a href="https://evoluteur.github.io/">Evoluteur</a> > npm Pulse
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<section class="gpc">
|
|
90
|
+
<h1 id="title">Loading...</h1>
|
|
91
|
+
|
|
92
|
+
<div class="filters">
|
|
93
|
+
<div>
|
|
94
|
+
<label for="filter">Find</label>
|
|
95
|
+
<input type="text" onkeyup="filter(this.value)" id="filter" />
|
|
96
|
+
</div>
|
|
97
|
+
<div>
|
|
98
|
+
<label for="periodPicker">Period</label>
|
|
99
|
+
<select onchange="setPeriod(this.value)" id="periodPicker">
|
|
100
|
+
<option value="last7">Last 7 days</option>
|
|
101
|
+
<option value="last30" selected>Last 30 days</option>
|
|
102
|
+
<option value="last365">Last 12 months</option>
|
|
103
|
+
</select>
|
|
104
|
+
</div>
|
|
105
|
+
<div>
|
|
106
|
+
<label for="sortPicker">Sort by</label>
|
|
107
|
+
<select onchange="sort(this.value)" id="sortPicker">
|
|
108
|
+
<option value="downloads" selected>Downloads</option>
|
|
109
|
+
<option value="name">Name</option>
|
|
110
|
+
<option value="stars">Stars</option>
|
|
111
|
+
<option value="date">Published</option>
|
|
112
|
+
<option value="trend">Trend</option>
|
|
113
|
+
</select>
|
|
114
|
+
</div>
|
|
115
|
+
<span id="summary"></span>
|
|
116
|
+
<span class="tools">
|
|
117
|
+
<a href="chart.html">Charts</a>
|
|
118
|
+
<!-- <a href="#" onclick="clearCache(); return false;">Refresh</a> -->
|
|
119
|
+
</span>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div id="packages" class="projects"></div>
|
|
123
|
+
</section>
|
|
124
|
+
|
|
125
|
+
<footer>
|
|
126
|
+
<p>
|
|
127
|
+
<a href="https://github.com/evoluteur/npm-pulse">npm-Pulse</a> is open
|
|
128
|
+
source at GitHub with MIT license. Downloads come from the
|
|
129
|
+
<a
|
|
130
|
+
href="https://github.com/npm/registry/blob/main/docs/download-counts.md"
|
|
131
|
+
>npm registry API</a
|
|
132
|
+
>, stars from the
|
|
133
|
+
<a href="https://docs.github.com/en/rest">GitHub API</a>.
|
|
134
|
+
</p>
|
|
135
|
+
|
|
136
|
+
<p>
|
|
137
|
+
For more ways to look at your projects check out
|
|
138
|
+
<a href="https://evoluteur.github.io/github-projects-cards/"
|
|
139
|
+
>GitHub-Projects-Cards</a
|
|
140
|
+
>
|
|
141
|
+
and
|
|
142
|
+
<a href="https://evoluteur.github.io/meet-the-fans/">Meet-the-Fans</a>.
|
|
143
|
+
</p>
|
|
144
|
+
|
|
145
|
+
© 2026
|
|
146
|
+
<a href="https://evoluteur.github.io/" target="_blank"
|
|
147
|
+
>Olivier Giulieri</a
|
|
148
|
+
>
|
|
149
|
+
</footer>
|
|
150
|
+
</body>
|
|
151
|
+
</html>
|
package/js/chart.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/*
|
|
2
|
+
npm-pulse - charts
|
|
3
|
+
https://evoluteur.github.io/npm-pulse/
|
|
4
|
+
(c) 2026 Olivier Giulieri
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const topCount = 12; // number of packages in the ranking
|
|
8
|
+
|
|
9
|
+
// round a maximum up to a readable value (1200 -> 2000, 43 -> 50)
|
|
10
|
+
const niceMax = (value) => {
|
|
11
|
+
if (value <= 0) return 1;
|
|
12
|
+
const power = Math.pow(10, Math.floor(Math.log10(value)));
|
|
13
|
+
const steps = [1, 1.5, 2, 2.5, 3, 4, 5, 7.5, 10];
|
|
14
|
+
const step = steps.find((s) => value <= s * power) || 10;
|
|
15
|
+
return step * power;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const gridHTML = (max, x0, x1, y0, y1, ticks) => {
|
|
19
|
+
let html = "";
|
|
20
|
+
for (let i = 0; i <= ticks; i++) {
|
|
21
|
+
const y = y1 - ((y1 - y0) * i) / ticks;
|
|
22
|
+
html += `<line class="grid" x1="${x0}" y1="${y}" x2="${x1}" y2="${y}"></line>
|
|
23
|
+
<text class="axis value" x="${x0 - 10}" y="${
|
|
24
|
+
y + 4
|
|
25
|
+
}" text-anchor="end">${fNum(Math.round((max * i) / ticks))}</text>`;
|
|
26
|
+
}
|
|
27
|
+
return html;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// downloads per month, across every package
|
|
31
|
+
const monthsChart = (months) => {
|
|
32
|
+
const w = 900;
|
|
33
|
+
const h = 340;
|
|
34
|
+
const left = 90;
|
|
35
|
+
const right = 20;
|
|
36
|
+
const top = 20;
|
|
37
|
+
const bottom = 50;
|
|
38
|
+
const max = niceMax(Math.max(...months.map((m) => m.downloads), 1));
|
|
39
|
+
const iw = w - left - right;
|
|
40
|
+
const step = iw / months.length;
|
|
41
|
+
const barW = Math.min(step * 0.62, 60);
|
|
42
|
+
const bars = months
|
|
43
|
+
.map((m, i) => {
|
|
44
|
+
const height = ((h - top - bottom) * m.downloads) / max;
|
|
45
|
+
const x = left + i * step + (step - barW) / 2;
|
|
46
|
+
const y = h - bottom - height;
|
|
47
|
+
const partial = i === months.length - 1;
|
|
48
|
+
return `<rect class="bar${partial ? " partial" : ""}" x="${x.toFixed(
|
|
49
|
+
1
|
|
50
|
+
)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${height.toFixed(
|
|
51
|
+
1
|
|
52
|
+
)}"><title>${m.month}: ${fNum(m.downloads)} downloads${
|
|
53
|
+
partial ? " so far" : ""
|
|
54
|
+
}</title></rect>
|
|
55
|
+
<text class="axis" x="${(x + barW / 2).toFixed(1)}" y="${
|
|
56
|
+
h - bottom + 20
|
|
57
|
+
}" text-anchor="middle">${fMonth(m.month)}</text>`;
|
|
58
|
+
})
|
|
59
|
+
.join("");
|
|
60
|
+
return `<svg class="chart" viewBox="0 0 ${w} ${h}" role="img"
|
|
61
|
+
aria-label="Total npm downloads per month over the last year">
|
|
62
|
+
${gridHTML(max, left, w - right, top, h - bottom, 4)}
|
|
63
|
+
${bars}
|
|
64
|
+
</svg>`;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// the most downloaded packages of the last 12 months
|
|
68
|
+
const topChart = (top) => {
|
|
69
|
+
const rowH = 32;
|
|
70
|
+
const w = 900;
|
|
71
|
+
const left = 230;
|
|
72
|
+
const right = 90;
|
|
73
|
+
const h = top.length * rowH + 10;
|
|
74
|
+
const max = Math.max(...top.map((p) => p.last365), 1);
|
|
75
|
+
const iw = w - left - right;
|
|
76
|
+
const rows = top
|
|
77
|
+
.map((p, i) => {
|
|
78
|
+
const y = i * rowH + 5;
|
|
79
|
+
const barW = (iw * p.last365) / max;
|
|
80
|
+
return `<text class="bar-label" x="${left - 12}" y="${
|
|
81
|
+
y + rowH / 2
|
|
82
|
+
}" text-anchor="end" dominant-baseline="middle">${escapeHtml(
|
|
83
|
+
p.name
|
|
84
|
+
)}</text>
|
|
85
|
+
<rect class="bar" x="${left}" y="${y + 4}" width="${barW.toFixed(
|
|
86
|
+
1
|
|
87
|
+
)}" height="${rowH - 12}" rx="3"><title>${escapeHtml(p.name)}: ${fNum(
|
|
88
|
+
p.last365
|
|
89
|
+
)} downloads</title></rect>
|
|
90
|
+
<text class="axis value" x="${(left + barW + 10).toFixed(1)}" y="${
|
|
91
|
+
y + rowH / 2
|
|
92
|
+
}" dominant-baseline="middle">${fNum(p.last365)}</text>`;
|
|
93
|
+
})
|
|
94
|
+
.join("");
|
|
95
|
+
return `<svg class="chart" viewBox="0 0 ${w} ${h}" role="img"
|
|
96
|
+
aria-label="Most downloaded packages over the last 12 months">${rows}</svg>`;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const setupChartPage = async () => {
|
|
100
|
+
try {
|
|
101
|
+
await loadData();
|
|
102
|
+
} catch (e) {
|
|
103
|
+
document.getElementById("title").innerHTML = "npm";
|
|
104
|
+
document.getElementById("charts").innerHTML =
|
|
105
|
+
`<div class="noresults">Could not reach the npm registry: ${escapeHtml(
|
|
106
|
+
e.message
|
|
107
|
+
)}</div>`;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
document.getElementById("title").innerHTML = `<a href="https://www.npmjs.com/~${user}"
|
|
111
|
+
target="npm-user">${escapeHtml(user)}</a> downloads`;
|
|
112
|
+
|
|
113
|
+
const months = monthlyTotals();
|
|
114
|
+
const top = packages
|
|
115
|
+
.slice()
|
|
116
|
+
.sort((a, b) => b.last365 - a.last365)
|
|
117
|
+
.slice(0, topCount)
|
|
118
|
+
.filter((p) => p.last365);
|
|
119
|
+
|
|
120
|
+
document.getElementById("charts").innerHTML = `
|
|
121
|
+
<div class="chart-block">
|
|
122
|
+
<h2>${fNum(totals.last365)} downloads over the last 12 months</h2>
|
|
123
|
+
<p class="chart-note">
|
|
124
|
+
All ${totals.packages} packages together, month by month. The last bar
|
|
125
|
+
covers the current month only up to yesterday.
|
|
126
|
+
</p>
|
|
127
|
+
${monthsChart(months)}
|
|
128
|
+
</div>
|
|
129
|
+
<div class="chart-block">
|
|
130
|
+
<h2>Most downloaded packages</h2>
|
|
131
|
+
<p class="chart-note">Downloads over the last 12 months.</p>
|
|
132
|
+
${topChart(top)}
|
|
133
|
+
</div>`;
|
|
134
|
+
};
|
package/js/npm-data.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/*
|
|
2
|
+
npm-pulse - shared data layer
|
|
3
|
+
https://evoluteur.github.io/npm-pulse/
|
|
4
|
+
(c) 2026 Olivier Giulieri
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// --- config options ---------------------------------------------------
|
|
8
|
+
const user = "evoluteur"; // npm maintainer whose packages are shown
|
|
9
|
+
const ghUser = "evoluteur"; // GitHub user (for stars and forks)
|
|
10
|
+
const cacheMinutes = 360; // npm recomputes its stats once a day, no need to refetch often
|
|
11
|
+
// ----------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
const npmURL = "https://www.npmjs.com/package/";
|
|
14
|
+
const ghURL = `https://github.com/${ghUser}`;
|
|
15
|
+
const cacheKey = "npm-pulse-data";
|
|
16
|
+
|
|
17
|
+
let packages = []; // all packages, enriched with downloads and GitHub stats
|
|
18
|
+
let totals = {};
|
|
19
|
+
|
|
20
|
+
const periods = {
|
|
21
|
+
last7: "last 7 days",
|
|
22
|
+
last30: "last 30 days",
|
|
23
|
+
last365: "last 12 months",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// --- formatting -------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const numFormat = new Intl.NumberFormat();
|
|
29
|
+
const fNum = (n) => numFormat.format(n || 0);
|
|
30
|
+
|
|
31
|
+
const fDate = (d) => (d ? new Date(d).toDateString().substring(4) : "");
|
|
32
|
+
|
|
33
|
+
const fMonth = (ym) => {
|
|
34
|
+
const [y, m] = ym.split("-");
|
|
35
|
+
return new Date(y, m - 1, 1).toLocaleString(undefined, { month: "short" });
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const escapeHtml = (txt) =>
|
|
39
|
+
String(txt)
|
|
40
|
+
.replace(/&/g, "&")
|
|
41
|
+
.replace(/</g, "<")
|
|
42
|
+
.replace(/>/g, ">")
|
|
43
|
+
.replace(/"/g, """)
|
|
44
|
+
.replace(/'/g, "'");
|
|
45
|
+
|
|
46
|
+
// --- fetching ---------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
const fetchJSON = async (url) => {
|
|
49
|
+
const response = await fetch(url);
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
throw new Error(`${response.status} on ${url}`);
|
|
52
|
+
}
|
|
53
|
+
return response.json();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// all packages published by the maintainer
|
|
57
|
+
const fetchPackages = async () => {
|
|
58
|
+
const data = await fetchJSON(
|
|
59
|
+
`https://registry.npmjs.org/-/v1/search?text=maintainer:${user}&size=250`
|
|
60
|
+
);
|
|
61
|
+
return (data.objects || []).map((o) => o.package);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// a year of daily downloads, in bulk (the API takes up to 128 packages per call)
|
|
65
|
+
const fetchDownloads = async (names) => {
|
|
66
|
+
const chunks = [];
|
|
67
|
+
for (let i = 0; i < names.length; i += 100) {
|
|
68
|
+
chunks.push(names.slice(i, i + 100));
|
|
69
|
+
}
|
|
70
|
+
const answers = await Promise.all(
|
|
71
|
+
chunks.map((chunk) =>
|
|
72
|
+
fetchJSON(
|
|
73
|
+
`https://api.npmjs.org/downloads/range/last-year/${chunk.join(",")}`
|
|
74
|
+
).catch(() => null)
|
|
75
|
+
)
|
|
76
|
+
);
|
|
77
|
+
const all = {};
|
|
78
|
+
answers.forEach((answer) => {
|
|
79
|
+
if (!answer) return;
|
|
80
|
+
// asking for a single package answers with one object rather than a map
|
|
81
|
+
const map = answer.package ? { [answer.package]: answer } : answer;
|
|
82
|
+
Object.keys(map).forEach((name) => {
|
|
83
|
+
if (map[name]) {
|
|
84
|
+
all[name] = map[name].downloads || [];
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
return all;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// stars and forks are a bonus: GitHub throttles anonymous calls at 60/hour
|
|
92
|
+
const fetchRepos = async () => {
|
|
93
|
+
try {
|
|
94
|
+
const list = await fetchJSON(
|
|
95
|
+
`https://api.github.com/users/${ghUser}/repos?per_page=100`
|
|
96
|
+
);
|
|
97
|
+
const map = {};
|
|
98
|
+
list.forEach((r) => {
|
|
99
|
+
map[r.name.toLowerCase()] = {
|
|
100
|
+
name: r.name,
|
|
101
|
+
stars: r.stargazers_count,
|
|
102
|
+
forks: r.forks_count,
|
|
103
|
+
language: r.language,
|
|
104
|
+
homepage: r.homepage,
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
return map;
|
|
108
|
+
} catch (e) {
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// --- deriving ---------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
// "git+https://github.com/evoluteur/healing-frequencies.git" -> "healing-frequencies"
|
|
116
|
+
const repoOf = (pkg) => {
|
|
117
|
+
const url = (pkg.links || {}).repository;
|
|
118
|
+
if (!url) return "";
|
|
119
|
+
const match = url
|
|
120
|
+
.replace(/\.git$/, "")
|
|
121
|
+
.replace(/\/$/, "")
|
|
122
|
+
.match(/github\.com[/:]([^/]+)\/([^/]+)$/);
|
|
123
|
+
return match ? match[2] : "";
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const sumDays = (days, from, to) =>
|
|
127
|
+
days.slice(from, to).reduce((total, d) => total + (d.downloads || 0), 0);
|
|
128
|
+
|
|
129
|
+
// 52 weekly buckets, oldest first, for the sparklines
|
|
130
|
+
const weeklyBuckets = (days) => {
|
|
131
|
+
const weeks = [];
|
|
132
|
+
const year = days.slice(-364);
|
|
133
|
+
for (let i = 0; i < year.length; i += 7) {
|
|
134
|
+
weeks.push(sumDays(year, i, i + 7));
|
|
135
|
+
}
|
|
136
|
+
return weeks;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const build = (pkgs, downloads, repos) => {
|
|
140
|
+
packages = pkgs.map((pkg) => {
|
|
141
|
+
const days = downloads[pkg.name] || [];
|
|
142
|
+
const repoName = repoOf(pkg);
|
|
143
|
+
const repo = repos[repoName.toLowerCase()];
|
|
144
|
+
const last30 = sumDays(days, -30);
|
|
145
|
+
const previous30 = sumDays(days, -60, -30);
|
|
146
|
+
return {
|
|
147
|
+
name: pkg.name,
|
|
148
|
+
version: pkg.version || "",
|
|
149
|
+
description: pkg.description || "",
|
|
150
|
+
date: pkg.date,
|
|
151
|
+
repo: repoName,
|
|
152
|
+
homepage: (pkg.links || {}).homepage || (repo && repo.homepage) || "",
|
|
153
|
+
language: repo ? repo.language : "",
|
|
154
|
+
stars: repo ? repo.stars : 0,
|
|
155
|
+
forks: repo ? repo.forks : 0,
|
|
156
|
+
days,
|
|
157
|
+
weeks: weeklyBuckets(days),
|
|
158
|
+
last7: sumDays(days, -7),
|
|
159
|
+
last30,
|
|
160
|
+
last365: sumDays(days, -365),
|
|
161
|
+
// growth of the last 30 days over the 30 days before them
|
|
162
|
+
trend: previous30
|
|
163
|
+
? Math.round(((last30 - previous30) / previous30) * 100)
|
|
164
|
+
: null,
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
const add = (field) => packages.reduce((t, p) => t + p[field], 0);
|
|
168
|
+
totals = {
|
|
169
|
+
packages: packages.length,
|
|
170
|
+
last7: add("last7"),
|
|
171
|
+
last30: add("last30"),
|
|
172
|
+
last365: add("last365"),
|
|
173
|
+
stars: add("stars"),
|
|
174
|
+
forks: add("forks"),
|
|
175
|
+
};
|
|
176
|
+
return packages;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// total downloads per month, across all packages, oldest first
|
|
180
|
+
const monthlyTotals = () => {
|
|
181
|
+
const months = {};
|
|
182
|
+
packages.forEach((p) =>
|
|
183
|
+
p.days.forEach((d) => {
|
|
184
|
+
const month = d.day.substring(0, 7);
|
|
185
|
+
months[month] = (months[month] || 0) + (d.downloads || 0);
|
|
186
|
+
})
|
|
187
|
+
);
|
|
188
|
+
const keys = Object.keys(months).sort();
|
|
189
|
+
// the first month is only partially covered by a "last-year" range
|
|
190
|
+
keys.shift();
|
|
191
|
+
return keys.map((month) => ({ month, downloads: months[month] }));
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// --- caching ----------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
const readCache = () => {
|
|
197
|
+
try {
|
|
198
|
+
const cache = JSON.parse(localStorage.getItem(cacheKey));
|
|
199
|
+
if (!cache || cache.user !== user) return null;
|
|
200
|
+
if (Date.now() - cache.time > cacheMinutes * 60000) return null;
|
|
201
|
+
return cache.data;
|
|
202
|
+
} catch (e) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const writeCache = (data) => {
|
|
208
|
+
try {
|
|
209
|
+
localStorage.setItem(cacheKey, JSON.stringify({ user, time: Date.now(), data }));
|
|
210
|
+
} catch (e) {
|
|
211
|
+
// a year of daily downloads can outgrow the quota, the page works without the cache
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const clearCache = () => {
|
|
216
|
+
try {
|
|
217
|
+
localStorage.removeItem(cacheKey);
|
|
218
|
+
} catch (e) {}
|
|
219
|
+
location.reload();
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// --- entry point ------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
const loadData = async () => {
|
|
225
|
+
const cached = readCache();
|
|
226
|
+
if (cached) {
|
|
227
|
+
build(cached.pkgs, cached.downloads, cached.repos);
|
|
228
|
+
return { cached: true, time: cached.time };
|
|
229
|
+
}
|
|
230
|
+
const pkgs = await fetchPackages();
|
|
231
|
+
const names = pkgs.map((p) => p.name);
|
|
232
|
+
const [downloads, repos] = await Promise.all([
|
|
233
|
+
fetchDownloads(names),
|
|
234
|
+
fetchRepos(),
|
|
235
|
+
]);
|
|
236
|
+
build(pkgs, downloads, repos);
|
|
237
|
+
writeCache({ pkgs, downloads, repos, time: Date.now() });
|
|
238
|
+
return { cached: false, time: Date.now() };
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// --- icons ------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
const icoStar = `<span class="crud-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12,15.39L8.24,17.66L9.23,13.38L5.91,10.5L10.29,10.13L12,6.09L13.71,10.13L18.09,10.5L14.77,13.38L15.76,17.66M22,9.24L14.81,8.63L12,2L9.19,8.63L2,9.24L7.45,13.97L5.82,21L12,17.27L18.18,21L16.54,13.97L22,9.24Z"></path></svg></span>`;
|
|
244
|
+
|
|
245
|
+
const icoDownload = `<span class="crud-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z"></path></svg></span>`;
|
|
246
|
+
|
|
247
|
+
const icoBox = `<span class="crud-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12,2L2,7V17L12,22L22,17V7L12,2M12,4.2L19.2,7.8L12,11.4L4.8,7.8L12,4.2M4,9.4L11,12.9V19.6L4,16.1V9.4M13,19.6V12.9L20,9.4V16.1L13,19.6Z"></path></svg></span>`;
|
|
248
|
+
|
|
249
|
+
const icoChart = `<span class="crud-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M22,21H2V3H4V19H6V10H10V19H12V6H16V19H18V14H22V21Z"></path></svg></span>`;
|
package/js/pulse.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/*
|
|
2
|
+
npm-pulse - package cards
|
|
3
|
+
https://evoluteur.github.io/npm-pulse/
|
|
4
|
+
(c) 2026 Olivier Giulieri
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
let selection = [];
|
|
8
|
+
let period = localStorage.getItem("npm-pulse-period") || "last30";
|
|
9
|
+
let sortField = localStorage.getItem("npm-pulse-sort") || "downloads";
|
|
10
|
+
let searchString = "";
|
|
11
|
+
|
|
12
|
+
// --- sparkline --------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
// 52 weeks of downloads drawn as an area and a line, no library, no axis
|
|
15
|
+
const sparkHTML = (pkg) => {
|
|
16
|
+
const weeks = pkg.weeks;
|
|
17
|
+
if (weeks.length < 2 || !pkg.last365) {
|
|
18
|
+
return `<div class="spark-empty">no downloads reported yet</div>`;
|
|
19
|
+
}
|
|
20
|
+
const w = 300;
|
|
21
|
+
const h = 46;
|
|
22
|
+
const max = Math.max(...weeks, 1);
|
|
23
|
+
const points = weeks.map((v, i) => {
|
|
24
|
+
const x = (i / (weeks.length - 1)) * w;
|
|
25
|
+
const y = h - 3 - (v / max) * (h - 8);
|
|
26
|
+
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
27
|
+
});
|
|
28
|
+
const peak = weeks.indexOf(max);
|
|
29
|
+
const peakX = ((peak / (weeks.length - 1)) * w).toFixed(1);
|
|
30
|
+
const peakY = (h - 3 - (h - 8)).toFixed(1);
|
|
31
|
+
return `<svg class="spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none"
|
|
32
|
+
role="img" aria-label="Weekly downloads over the last 12 months, peak of ${fNum(
|
|
33
|
+
max
|
|
34
|
+
)}">
|
|
35
|
+
<polygon points="0,${h} ${points.join(" ")} ${w},${h}"></polygon>
|
|
36
|
+
<polyline points="${points.join(" ")}"></polyline>
|
|
37
|
+
<circle cx="${peakX}" cy="${peakY}" r="2.5"></circle>
|
|
38
|
+
</svg>`;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// --- cards ------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
const trendHTML = (pkg) => {
|
|
44
|
+
if (pkg.trend === null || !pkg.last30) return "";
|
|
45
|
+
const up = pkg.trend >= 0;
|
|
46
|
+
return `<span class="trend ${up ? "up" : "down"}" title="${
|
|
47
|
+
up ? "up" : "down"
|
|
48
|
+
} ${Math.abs(pkg.trend)}% over the previous 30 days">${up ? "▲" : "▼"} ${Math.abs(
|
|
49
|
+
pkg.trend
|
|
50
|
+
)}%</span>`;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const linksHTML = (pkg) => {
|
|
54
|
+
const n = pkg.name;
|
|
55
|
+
const links = [`<a href="${npmURL}${n}" target="npm-${n}">npm</a>`];
|
|
56
|
+
if (pkg.repo) {
|
|
57
|
+
links.push(`<a href="${ghURL}/${pkg.repo}" target="code-${n}">Code</a>`);
|
|
58
|
+
}
|
|
59
|
+
if (pkg.homepage) {
|
|
60
|
+
links.push(`<a href="${pkg.homepage}" target="demo-${n}">Demo</a>`);
|
|
61
|
+
}
|
|
62
|
+
links.push(
|
|
63
|
+
`<a href="https://npm-stat.com/charts.html?package=${n}" target="stat-${n}">Charts</a>`
|
|
64
|
+
);
|
|
65
|
+
const lang = pkg.language
|
|
66
|
+
? `<span class="lang">${escapeHtml(pkg.language)}</span>`
|
|
67
|
+
: "";
|
|
68
|
+
return lang + links.join(" - ");
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const statsHTML = (pkg) => {
|
|
72
|
+
const bits = [];
|
|
73
|
+
if (pkg.stars) {
|
|
74
|
+
bits.push(
|
|
75
|
+
`<a href="${ghURL}/${pkg.repo}/stargazers" target="stars-${pkg.name}" aria-label="stars">${icoStar} ${pkg.stars}</a>`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return bits.join(" ");
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const cardHTML = (pkg) => `<div class="project-card">
|
|
82
|
+
<div>
|
|
83
|
+
<h2 class="pcard-title">
|
|
84
|
+
<span><a href="${npmURL}${pkg.name}" target="npm-${pkg.name}">${escapeHtml(
|
|
85
|
+
pkg.name
|
|
86
|
+
)}</a></span>
|
|
87
|
+
<span class="dls" title="downloads ${periods[period]}">${icoDownload} ${fNum(
|
|
88
|
+
pkg[period]
|
|
89
|
+
)}</span>
|
|
90
|
+
</h2>
|
|
91
|
+
${sparkHTML(pkg)}
|
|
92
|
+
<div class="desc">${escapeHtml(pkg.description || pkg.name)}</div>
|
|
93
|
+
<div class="p-links">${linksHTML(pkg)}</div>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="pcard-foot">
|
|
96
|
+
<span>v${escapeHtml(pkg.version)} - ${fDate(pkg.date)}</span>
|
|
97
|
+
<span class="stats">${trendHTML(pkg)} ${statsHTML(pkg)}</span>
|
|
98
|
+
</div>
|
|
99
|
+
</div>`;
|
|
100
|
+
|
|
101
|
+
// --- filtering and sorting --------------------------------------------
|
|
102
|
+
|
|
103
|
+
const applySort = () => {
|
|
104
|
+
const field = sortField === "downloads" ? period : sortField;
|
|
105
|
+
const alpha = field === "name";
|
|
106
|
+
const way = alpha ? 1 : -1;
|
|
107
|
+
selection.sort((a, b) => {
|
|
108
|
+
const av = a[field] === null ? -Infinity : a[field];
|
|
109
|
+
const bv = b[field] === null ? -Infinity : b[field];
|
|
110
|
+
if (av === bv) return a.name > b.name ? 1 : -1;
|
|
111
|
+
return way * (av > bv ? 1 : -1);
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const applyFilter = () => {
|
|
116
|
+
const s = searchString.toLowerCase();
|
|
117
|
+
selection = s
|
|
118
|
+
? packages.filter(
|
|
119
|
+
(p) =>
|
|
120
|
+
p.name.toLowerCase().includes(s) ||
|
|
121
|
+
p.description.toLowerCase().includes(s)
|
|
122
|
+
)
|
|
123
|
+
: packages.slice();
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const refresh = () => {
|
|
127
|
+
applyFilter();
|
|
128
|
+
applySort();
|
|
129
|
+
const html = selection.map(cardHTML).join("");
|
|
130
|
+
document.getElementById("packages").innerHTML =
|
|
131
|
+
html ||
|
|
132
|
+
`<div class="noresults">No results. The search criteria is too restrictive.</div>`;
|
|
133
|
+
const shown = selection.length;
|
|
134
|
+
const downloads = selection.reduce((t, p) => t + p[period], 0);
|
|
135
|
+
document.getElementById("summary").innerHTML = `${shown} package${
|
|
136
|
+
shown === 1 ? "" : "s"
|
|
137
|
+
} - ${fNum(downloads)} downloads ${periods[period]}`;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const filter = (value) => {
|
|
141
|
+
searchString = value;
|
|
142
|
+
refresh();
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const sort = (value) => {
|
|
146
|
+
sortField = value;
|
|
147
|
+
localStorage.setItem("npm-pulse-sort", value);
|
|
148
|
+
refresh();
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const setPeriod = (value) => {
|
|
152
|
+
period = value;
|
|
153
|
+
localStorage.setItem("npm-pulse-period", value);
|
|
154
|
+
refresh();
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// --- page -------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
const titleHTML = () => `<a href="https://www.npmjs.com/~${user}" target="npm-user">${escapeHtml(
|
|
160
|
+
user
|
|
161
|
+
)}</a> on npm
|
|
162
|
+
<span class="totals">
|
|
163
|
+
<span title="packages">${icoBox} ${totals.packages}</span>
|
|
164
|
+
<span title="downloads in the last 12 months">${icoDownload} ${fNum(
|
|
165
|
+
totals.last365
|
|
166
|
+
)}</span>
|
|
167
|
+
${totals.stars ? `<span title="stars on GitHub">${icoStar} ${totals.stars}</span>` : ""}
|
|
168
|
+
</span>`;
|
|
169
|
+
|
|
170
|
+
const setupPulsePage = async () => {
|
|
171
|
+
document.getElementById("sortPicker").value = sortField;
|
|
172
|
+
document.getElementById("periodPicker").value = period;
|
|
173
|
+
try {
|
|
174
|
+
await loadData();
|
|
175
|
+
} catch (e) {
|
|
176
|
+
document.getElementById("title").innerHTML = "npm";
|
|
177
|
+
document.getElementById(
|
|
178
|
+
"packages"
|
|
179
|
+
).innerHTML = `<div class="noresults">Could not reach the npm registry: ${escapeHtml(
|
|
180
|
+
e.message
|
|
181
|
+
)}</div>`;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
document.getElementById("title").innerHTML = titleHTML();
|
|
185
|
+
refresh();
|
|
186
|
+
};
|
|
Binary file
|
package/npm-pulse.png
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "evol-npm-pulse",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "One page dashboard for all your npm packages: downloads over the last week, month, and year, weekly sparklines, trends, and GitHub stars.",
|
|
5
|
+
"repository": "github:evoluteur/npm-pulse",
|
|
6
|
+
"homepage": "https://evoluteur.github.io/npm-pulse/",
|
|
7
|
+
"copyright": "(c) 2026 Olivier Giulieri",
|
|
8
|
+
"author": "Olivier Giulieri (https://evoluteur.github.io/)",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"keywords": [
|
|
11
|
+
"npm",
|
|
12
|
+
"downloads",
|
|
13
|
+
"download-counts",
|
|
14
|
+
"packages",
|
|
15
|
+
"registry",
|
|
16
|
+
"stats",
|
|
17
|
+
"statistics",
|
|
18
|
+
"dashboard",
|
|
19
|
+
"sparkline",
|
|
20
|
+
"trends",
|
|
21
|
+
"chart",
|
|
22
|
+
"svg",
|
|
23
|
+
"portfolio",
|
|
24
|
+
"github",
|
|
25
|
+
"stars",
|
|
26
|
+
"open-source",
|
|
27
|
+
"javascript",
|
|
28
|
+
"vanilla-js"
|
|
29
|
+
]
|
|
30
|
+
}
|
package/robots.txt
ADDED
package/sitemap.xml
ADDED