Server render auto-index pages
Also, load the autoIndex bundle on the client so we can use React instead of using an inline script.
This commit is contained in:
8
server/client/.babelrc
Normal file
8
server/client/.babelrc
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"presets": ["env", "stage-2", "react"],
|
||||
"env": {
|
||||
"production": {
|
||||
"plugins": ["transform-react-remove-prop-types"]
|
||||
}
|
||||
}
|
||||
}
|
||||
14
server/client/.eslintrc
Normal file
14
server/client/.eslintrc
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"parser": "babel-eslint",
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:import/errors",
|
||||
"plugin:react/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"react/no-children-prop": 0
|
||||
}
|
||||
}
|
||||
56
server/client/MainPage.js
Normal file
56
server/client/MainPage.js
Normal file
@ -0,0 +1,56 @@
|
||||
const React = require("react");
|
||||
const PropTypes = require("prop-types");
|
||||
|
||||
const h = require("./utils/createHTML");
|
||||
const x = require("./utils/execScript");
|
||||
|
||||
function MainPage({ title, description, scripts, styles, data, content }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="description" content={description} />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,maximum-scale=1"
|
||||
/>
|
||||
<meta name="timestamp" content={new Date().toISOString()} />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
{styles.map(s => <link key={s} rel="stylesheet" href={s} />)}
|
||||
{x(
|
||||
"window.Promise || document.write('\\x3Cscript src=\"/_polyfills/es6-promise.min.js\">\\x3C/script>\\x3Cscript>ES6Promise.polyfill()\\x3C/script>')"
|
||||
)}
|
||||
{x(
|
||||
"window.fetch || document.write('\\x3Cscript src=\"/_polyfills/fetch.min.js\">\\x3C/script>')"
|
||||
)}
|
||||
{x(`window.__DATA__ = ${JSON.stringify(data)}`)}
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" dangerouslySetInnerHTML={h(content)} />
|
||||
{scripts.map(s => <script key={s} src={s} />)}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
MainPage.propTypes = {
|
||||
title: PropTypes.string,
|
||||
description: PropTypes.string,
|
||||
scripts: PropTypes.arrayOf(PropTypes.string),
|
||||
styles: PropTypes.arrayOf(PropTypes.string),
|
||||
data: PropTypes.any,
|
||||
content: PropTypes.string
|
||||
};
|
||||
|
||||
MainPage.defaultProps = {
|
||||
title: "UNPKG",
|
||||
description: "The CDN for everything on npm",
|
||||
scripts: [],
|
||||
styles: [],
|
||||
data: {},
|
||||
content: ""
|
||||
};
|
||||
|
||||
module.exports = MainPage;
|
||||
8
server/client/autoIndex.css
Normal file
8
server/client/autoIndex.css
Normal file
@ -0,0 +1,8 @@
|
||||
body {
|
||||
font-size: 14px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
|
||||
Arial, sans-serif;
|
||||
line-height: 1.7;
|
||||
padding: 0px 10px 5px;
|
||||
color: #000000;
|
||||
}
|
||||
10
server/client/autoIndex.js
Normal file
10
server/client/autoIndex.js
Normal file
@ -0,0 +1,10 @@
|
||||
require("./autoIndex.css");
|
||||
|
||||
const React = require("react");
|
||||
const ReactDOM = require("react-dom");
|
||||
|
||||
const App = require("./autoIndex/App");
|
||||
|
||||
const props = window.__DATA__ || {};
|
||||
|
||||
ReactDOM.hydrate(<App {...props} />, document.getElementById("root"));
|
||||
23
server/client/autoIndex/App.css
Normal file
23
server/client/autoIndex/App.css
Normal file
@ -0,0 +1,23 @@
|
||||
.app {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.app-version-selector {
|
||||
line-height: 2.25em;
|
||||
float: right;
|
||||
}
|
||||
.app-version-selector select {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.app-address {
|
||||
text-align: right;
|
||||
}
|
||||
77
server/client/autoIndex/App.js
Normal file
77
server/client/autoIndex/App.js
Normal file
@ -0,0 +1,77 @@
|
||||
require("./App.css");
|
||||
|
||||
const React = require("react");
|
||||
|
||||
const DirectoryListing = require("./DirectoryListing");
|
||||
|
||||
class App extends React.Component {
|
||||
static defaultProps = {
|
||||
availableVersions: []
|
||||
};
|
||||
|
||||
handleChange = event => {
|
||||
window.location.href = window.location.href.replace(
|
||||
"@" + this.props.packageVersion,
|
||||
"@" + event.target.value
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>
|
||||
Index of /{this.props.packageName}@{this.props.packageVersion}
|
||||
{this.props.filename}
|
||||
</h1>
|
||||
|
||||
<div className="app-version-selector">
|
||||
Version:{" "}
|
||||
<select
|
||||
id="version"
|
||||
defaultValue={this.props.packageVersion}
|
||||
onChange={this.handleChange}
|
||||
>
|
||||
{this.props.availableVersions.map(v => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<hr />
|
||||
|
||||
<DirectoryListing
|
||||
filename={this.props.filename}
|
||||
entry={this.props.entry}
|
||||
entries={this.props.entries}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<address className="app-address">
|
||||
{this.props.packageName}@{this.props.packageVersion}
|
||||
</address>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
const PropTypes = require("prop-types");
|
||||
|
||||
const entryType = PropTypes.object;
|
||||
|
||||
App.propTypes = {
|
||||
packageName: PropTypes.string.isRequired,
|
||||
packageVersion: PropTypes.string.isRequired,
|
||||
availableVersions: PropTypes.arrayOf(PropTypes.string),
|
||||
filename: PropTypes.string.isRequired,
|
||||
entry: entryType.isRequired,
|
||||
entries: PropTypes.objectOf(entryType).isRequired
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = App;
|
||||
17
server/client/autoIndex/DirectoryListing.css
Normal file
17
server/client/autoIndex/DirectoryListing.css
Normal file
@ -0,0 +1,17 @@
|
||||
.directory-listing table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font: 0.85em Monaco, monospace;
|
||||
}
|
||||
|
||||
.directory-listing tr.even {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.directory-listing th {
|
||||
text-align: left;
|
||||
}
|
||||
.directory-listing th,
|
||||
.directory-listing td {
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
126
server/client/autoIndex/DirectoryListing.js
Normal file
126
server/client/autoIndex/DirectoryListing.js
Normal file
@ -0,0 +1,126 @@
|
||||
require("./DirectoryListing.css");
|
||||
|
||||
const React = require("react");
|
||||
const formatBytes = require("pretty-bytes");
|
||||
const sortBy = require("sort-by");
|
||||
|
||||
function getDirname(name) {
|
||||
return (
|
||||
name
|
||||
.split("/")
|
||||
.slice(0, -1)
|
||||
.join("/") || "."
|
||||
);
|
||||
}
|
||||
|
||||
function getMatchingEntries(entry, entries) {
|
||||
const dirname = entry.name || ".";
|
||||
|
||||
return Object.keys(entries)
|
||||
.filter(name => entry.name !== name && getDirname(name) === dirname)
|
||||
.map(name => entries[name]);
|
||||
}
|
||||
|
||||
function getRelativeName(base, name) {
|
||||
return base.length ? name.substr(base.length + 1) : name;
|
||||
}
|
||||
|
||||
function DirectoryListing({ filename, entry, entries }) {
|
||||
const rows = [];
|
||||
|
||||
if (filename !== "/") {
|
||||
rows.push(
|
||||
<tr key="..">
|
||||
<td>
|
||||
<a title="Parent directory" href="../">
|
||||
..
|
||||
</a>
|
||||
</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
const matchingEntries = getMatchingEntries(entry, entries);
|
||||
|
||||
matchingEntries
|
||||
.filter(({ type }) => type === "directory")
|
||||
.sort(sortBy("name"))
|
||||
.forEach(({ name }) => {
|
||||
const relName = getRelativeName(entry.name, name);
|
||||
const href = relName + "/";
|
||||
|
||||
rows.push(
|
||||
<tr key={name}>
|
||||
<td>
|
||||
<a title={relName} href={href}>
|
||||
{href}
|
||||
</a>
|
||||
</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
matchingEntries
|
||||
.filter(({ type }) => type === "file")
|
||||
.sort(sortBy("name"))
|
||||
.forEach(({ name, size, contentType, lastModified }) => {
|
||||
const relName = getRelativeName(entry.name, name);
|
||||
|
||||
rows.push(
|
||||
<tr key={name}>
|
||||
<td>
|
||||
<a title={relName} href={relName}>
|
||||
{relName}
|
||||
</a>
|
||||
</td>
|
||||
<td>{contentType}</td>
|
||||
<td>{formatBytes(size)}</td>
|
||||
<td>{lastModified}</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="directory-listing">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Size</th>
|
||||
<th>Last Modified</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) =>
|
||||
React.cloneElement(row, {
|
||||
className: index % 2 ? "odd" : "even"
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
const PropTypes = require("prop-types");
|
||||
|
||||
const entryType = PropTypes.shape({
|
||||
name: PropTypes.string.isRequired
|
||||
});
|
||||
|
||||
DirectoryListing.propTypes = {
|
||||
filename: PropTypes.string.isRequired,
|
||||
entry: entryType.isRequired,
|
||||
entries: PropTypes.objectOf(entryType).isRequired
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = DirectoryListing;
|
||||
63
server/client/main.css
Normal file
63
server/client/main.css
Normal file
@ -0,0 +1,63 @@
|
||||
body {
|
||||
font-size: 14px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
|
||||
Arial, sans-serif;
|
||||
line-height: 1.7;
|
||||
padding: 5px 20px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
body {
|
||||
padding: 40px 20px 120px;
|
||||
}
|
||||
}
|
||||
|
||||
a:link {
|
||||
color: blue;
|
||||
}
|
||||
a:visited {
|
||||
color: rebeccapurple;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.8em;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.6em;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 25px;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-left: 25px;
|
||||
}
|
||||
|
||||
table {
|
||||
border: 1px solid black;
|
||||
border: 0;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
background-color: #eee;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 5px;
|
||||
}
|
||||
th {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
td {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
8
server/client/main.js
Normal file
8
server/client/main.js
Normal file
@ -0,0 +1,8 @@
|
||||
require("./main.css");
|
||||
|
||||
const React = require("react");
|
||||
const ReactDOM = require("react-dom");
|
||||
|
||||
const App = require("./main/App");
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById("root"));
|
||||
13
server/client/main/About.css
Normal file
13
server/client/main/About.css
Normal file
@ -0,0 +1,13 @@
|
||||
.about-logos {
|
||||
margin: 2em 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.about-logo {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
max-width: 80%;
|
||||
}
|
||||
.about-logo img {
|
||||
max-width: 60%;
|
||||
}
|
||||
12
server/client/main/About.js
Normal file
12
server/client/main/About.js
Normal file
@ -0,0 +1,12 @@
|
||||
require("./About.css");
|
||||
|
||||
const React = require("react");
|
||||
|
||||
const h = require("../utils/createHTML");
|
||||
const markup = require("./About.md");
|
||||
|
||||
function About() {
|
||||
return <div className="wrapper" dangerouslySetInnerHTML={h(markup)} />;
|
||||
}
|
||||
|
||||
module.exports = About;
|
||||
40
server/client/main/About.md
Normal file
40
server/client/main/About.md
Normal file
@ -0,0 +1,40 @@
|
||||
unpkg is an [open source](https://github.com/unpkg) project built and maintained by [Michael Jackson](https://twitter.com/mjackson).
|
||||
|
||||
### Sponsors
|
||||
|
||||
The fast, global infrastructure that powers unpkg is generously donated by [Cloudflare](https://www.cloudflare.com) and [Heroku](https://www.heroku.com).
|
||||
|
||||
<div class="about-logos">
|
||||
<div class="about-logo">
|
||||
<a href="https://www.cloudflare.com"><img src="CloudflareLogo.png"></a>
|
||||
</div>
|
||||
<div class="about-logo">
|
||||
<a href="https://www.heroku.com"><img src="HerokuLogo.png"></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### Cache Behavior
|
||||
|
||||
The CDN caches files based on their permanent URL, which includes the npm package version. This works because npm does not allow package authors to overwrite a package that has already been published with a different one at the same version number.
|
||||
|
||||
URLs that do not specify a package version number redirect to one that does. This is the `latest` version when no version is specified, or the `maxSatisfying` version when a [semver version](https://github.com/npm/node-semver) is given. Redirects are cached for 5 minutes.
|
||||
|
||||
Browsers are instructed (via the `Cache-Control` header) to cache assets for 4 hours.
|
||||
|
||||
### Support
|
||||
|
||||
unpkg is a free, best-effort service and cannot provide any uptime or support guarantees.
|
||||
|
||||
I do my best to keep it running, but sometimes things go wrong. Sometimes there are network or provider issues outside my control. Sometimes abusive traffic temporarily affects response times. Sometimes I break things by doing something dumb, but I try not to.
|
||||
|
||||
The goal of unpkg is to provide a hassle-free CDN for npm package authors. It's also a great resource for people creating demos and instructional material. However, if you rely on it to serve files that are crucial to your business, you should probably pay for a host with well-supported infrastructure and uptime guarantees.
|
||||
|
||||
unpkg is not affiliated with or supported by npm, Inc. in any way. Please do not contact npm for help with unpkg.
|
||||
|
||||
### Abuse
|
||||
|
||||
unpkg maintains a list of packages that are known to be malicious. If you find such a package on npm, please let us know!
|
||||
|
||||
### Feedback
|
||||
|
||||
If you think this is useful, we'd love to hear from you. Please reach out to [@unpkg](https://twitter.com/unpkg) with any questions or concerns.
|
||||
14
server/client/main/App.js
Normal file
14
server/client/main/App.js
Normal file
@ -0,0 +1,14 @@
|
||||
const React = require("react");
|
||||
const { HashRouter } = require("react-router-dom");
|
||||
|
||||
const Layout = require("./Layout");
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<Layout />
|
||||
</HashRouter>
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = App;
|
||||
BIN
server/client/main/CloudflareLogo.png
Normal file
BIN
server/client/main/CloudflareLogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
BIN
server/client/main/HerokuLogo.png
Normal file
BIN
server/client/main/HerokuLogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
6
server/client/main/Home.css
Normal file
6
server/client/main/Home.css
Normal file
@ -0,0 +1,6 @@
|
||||
.home-example {
|
||||
text-align: center;
|
||||
background-color: #eee;
|
||||
margin: 2em 0;
|
||||
padding: 5px 0;
|
||||
}
|
||||
12
server/client/main/Home.js
Normal file
12
server/client/main/Home.js
Normal file
@ -0,0 +1,12 @@
|
||||
require("./Home.css");
|
||||
|
||||
const React = require("react");
|
||||
|
||||
const h = require("../utils/createHTML");
|
||||
const markup = require("./Home.md");
|
||||
|
||||
function Home() {
|
||||
return <div className="wrapper" dangerouslySetInnerHTML={h(markup)} />;
|
||||
}
|
||||
|
||||
module.exports = Home;
|
||||
48
server/client/main/Home.md
Normal file
48
server/client/main/Home.md
Normal file
@ -0,0 +1,48 @@
|
||||
unpkg is a fast, global [content delivery network](https://en.wikipedia.org/wiki/Content_delivery_network) for everything on [npm](https://www.npmjs.com/). Use it to quickly and easily load any file from any package using a URL like:
|
||||
|
||||
<div class="home-example">unpkg.com/:package@:version/:file</div>
|
||||
|
||||
### Examples
|
||||
|
||||
Using a fixed version:
|
||||
|
||||
* [unpkg.com/react@16.0.0/umd/react.production.min.js](/react@16.0.0/umd/react.production.min.js)
|
||||
* [unpkg.com/react-dom@16.0.0/umd/react-dom.production.min.js](/react-dom@16.0.0/umd/react-dom.production.min.js)
|
||||
|
||||
You may also use a [semver range](https://docs.npmjs.com/misc/semver) or a [tag](https://docs.npmjs.com/cli/dist-tag) instead of a fixed version number, or omit the version/tag entirely to use the `latest` tag.
|
||||
|
||||
* [unpkg.com/react@^16/umd/react.production.min.js](/react@^16/umd/react.production.min.js)
|
||||
* [unpkg.com/react/umd/react.production.min.js](/react/umd/react.production.min.js)
|
||||
|
||||
If you omit the file path (i.e. use a "bare" URL), unpkg will serve the file specified by the `unpkg` field in `package.json`, or fall back to `main`.
|
||||
|
||||
* [unpkg.com/d3](/d3)
|
||||
* [unpkg.com/jquery](/jquery)
|
||||
* [unpkg.com/three](/three)
|
||||
|
||||
Append a `/` at the end of a URL to view a listing of all the files in a package.
|
||||
|
||||
* [unpkg.com/react/](/react/)
|
||||
* [unpkg.com/lodash/](/lodash/)
|
||||
|
||||
### Query Parameters
|
||||
|
||||
<dl>
|
||||
<dt>`?meta`</dt>
|
||||
<dd>Return metadata about any file in a package as JSON (e.g. `/any/file?meta`)</dd>
|
||||
|
||||
<dt>`?module`</dt>
|
||||
<dd>Expands all ["bare" `import` specifiers](https://html.spec.whatwg.org/multipage/webappapis.html#resolve-a-module-specifier) in JavaScript modules to unpkg URLs. This feature is *very experimental*</dd>
|
||||
</dl>
|
||||
|
||||
### Workflow
|
||||
|
||||
For npm package authors, unpkg relieves the burden of publishing your code to a CDN in addition to the npm registry. All you need to do is include your [UMD](https://github.com/umdjs/umd) build in your npm package (not your repo, that's different!).
|
||||
|
||||
You can do this easily using the following setup:
|
||||
|
||||
* Add the `umd` (or `dist`) directory to your `.gitignore` file
|
||||
* Add the `umd` directory to your [files array](https://docs.npmjs.com/files/package.json#files) in `package.json`
|
||||
* Use a build script to generate your UMD build in the `umd` directory when you publish
|
||||
|
||||
That's it! Now when you `npm publish` you'll have a version available on unpkg as well.
|
||||
38
server/client/main/Layout.css
Normal file
38
server/client/main/Layout.css
Normal file
@ -0,0 +1,38 @@
|
||||
.layout-title {
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
font-size: 5em;
|
||||
}
|
||||
|
||||
.layout-nav {
|
||||
margin: 0 0 3em;
|
||||
}
|
||||
|
||||
.layout-navList {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.layout-navList li {
|
||||
flex-basis: auto;
|
||||
list-style-type: none;
|
||||
display: inline-block;
|
||||
font-size: 1.1em;
|
||||
margin: 0 10px;
|
||||
}
|
||||
.layout-navList li a:link {
|
||||
text-decoration: none;
|
||||
}
|
||||
.layout-navList li a:link,
|
||||
.layout-navList li a:visited {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.layout-navUnderline {
|
||||
height: 4px;
|
||||
background-color: black;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
135
server/client/main/Layout.js
Normal file
135
server/client/main/Layout.js
Normal file
@ -0,0 +1,135 @@
|
||||
require("./Layout.css");
|
||||
|
||||
const React = require("react");
|
||||
const PropTypes = require("prop-types");
|
||||
const { Switch, Route, Link, withRouter } = require("react-router-dom");
|
||||
const { Motion, spring } = require("react-motion");
|
||||
|
||||
const WindowSize = require("./WindowSize");
|
||||
const About = require("./About");
|
||||
const Stats = require("./Stats");
|
||||
const Home = require("./Home");
|
||||
|
||||
class Layout extends React.Component {
|
||||
static propTypes = {
|
||||
location: PropTypes.object,
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
state = {
|
||||
underlineLeft: 0,
|
||||
underlineWidth: 0,
|
||||
useSpring: false,
|
||||
stats: null
|
||||
};
|
||||
|
||||
adjustUnderline = (useSpring = false) => {
|
||||
let itemIndex;
|
||||
switch (this.props.location.pathname) {
|
||||
case "/stats":
|
||||
itemIndex = 1;
|
||||
break;
|
||||
case "/about":
|
||||
itemIndex = 2;
|
||||
break;
|
||||
case "/":
|
||||
default:
|
||||
itemIndex = 0;
|
||||
}
|
||||
|
||||
const itemNodes = this.listNode.querySelectorAll("li");
|
||||
const currentNode = itemNodes[itemIndex];
|
||||
|
||||
this.setState({
|
||||
underlineLeft: currentNode.offsetLeft,
|
||||
underlineWidth: currentNode.offsetWidth,
|
||||
useSpring
|
||||
});
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.adjustUnderline();
|
||||
|
||||
fetch("/_stats?period=last-month")
|
||||
.then(res => res.json())
|
||||
.then(stats => this.setState({ stats }));
|
||||
|
||||
if (window.localStorage) {
|
||||
const savedStats = window.localStorage.savedStats;
|
||||
|
||||
if (savedStats) this.setState({ stats: JSON.parse(savedStats) });
|
||||
|
||||
window.onbeforeunload = () => {
|
||||
localStorage.savedStats = JSON.stringify(this.state.stats);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.location.pathname !== this.props.location.pathname)
|
||||
this.adjustUnderline(true);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { underlineLeft, underlineWidth, useSpring } = this.state;
|
||||
|
||||
const style = {
|
||||
left: useSpring
|
||||
? spring(underlineLeft, { stiffness: 220 })
|
||||
: underlineLeft,
|
||||
width: useSpring ? spring(underlineWidth) : underlineWidth
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<WindowSize onChange={this.adjustUnderline} />
|
||||
<div className="wrapper">
|
||||
<header>
|
||||
<h1 className="layout-title">unpkg</h1>
|
||||
<nav className="layout-nav">
|
||||
<ol
|
||||
className="layout-navList"
|
||||
ref={node => (this.listNode = node)}
|
||||
>
|
||||
<li>
|
||||
<Link to="/">Home</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/stats">Stats</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/about">About</Link>
|
||||
</li>
|
||||
</ol>
|
||||
<Motion
|
||||
defaultStyle={{ left: underlineLeft, width: underlineWidth }}
|
||||
style={style}
|
||||
children={style => (
|
||||
<div
|
||||
className="layout-navUnderline"
|
||||
style={{
|
||||
WebkitTransform: `translate3d(${style.left}px,0,0)`,
|
||||
transform: `translate3d(${style.left}px,0,0)`,
|
||||
width: style.width
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</nav>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<Switch>
|
||||
<Route
|
||||
path="/stats"
|
||||
render={() => <Stats data={this.state.stats} />}
|
||||
/>
|
||||
<Route path="/about" component={About} />
|
||||
<Route path="/" component={Home} />
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = withRouter(Layout);
|
||||
8
server/client/main/Stats.css
Normal file
8
server/client/main/Stats.css
Normal file
@ -0,0 +1,8 @@
|
||||
.table-filter {
|
||||
font-size: 0.8em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.regions-table .country-row td.country-name {
|
||||
padding-left: 20px;
|
||||
}
|
||||
323
server/client/main/Stats.js
Normal file
323
server/client/main/Stats.js
Normal file
@ -0,0 +1,323 @@
|
||||
require("./Stats.css");
|
||||
|
||||
const React = require("react");
|
||||
const PropTypes = require("prop-types");
|
||||
const formatBytes = require("pretty-bytes");
|
||||
const formatDate = require("date-fns/format");
|
||||
const parseDate = require("date-fns/parse");
|
||||
const { continents, countries } = require("countries-list");
|
||||
|
||||
const formatNumber = require("../utils/formatNumber");
|
||||
const formatPercent = require("../utils/formatPercent");
|
||||
|
||||
function getCountriesByContinent(continent) {
|
||||
return Object.keys(countries).filter(
|
||||
country => countries[country].continent === continent
|
||||
);
|
||||
}
|
||||
|
||||
function sumKeyValues(hash, keys) {
|
||||
return keys.reduce((n, key) => n + (hash[key] || 0), 0);
|
||||
}
|
||||
|
||||
function sumValues(hash) {
|
||||
return Object.keys(hash).reduce((memo, key) => memo + hash[key], 0);
|
||||
}
|
||||
|
||||
class Stats extends React.Component {
|
||||
static propTypes = {
|
||||
data: PropTypes.object
|
||||
};
|
||||
|
||||
state = {
|
||||
minPackageRequests: 1000000,
|
||||
minCountryRequests: 1000000
|
||||
};
|
||||
|
||||
render() {
|
||||
const { data } = this.props;
|
||||
|
||||
if (data == null) return null;
|
||||
|
||||
const totals = data.totals;
|
||||
|
||||
// Summary data
|
||||
const since = parseDate(totals.since);
|
||||
const until = parseDate(totals.until);
|
||||
|
||||
// Packages
|
||||
const packageRows = [];
|
||||
|
||||
Object.keys(totals.requests.package)
|
||||
.sort((a, b) => {
|
||||
return totals.requests.package[b] - totals.requests.package[a];
|
||||
})
|
||||
.forEach(packageName => {
|
||||
const requests = totals.requests.package[packageName];
|
||||
const bandwidth = totals.bandwidth.package[packageName];
|
||||
|
||||
if (requests >= this.state.minPackageRequests) {
|
||||
packageRows.push(
|
||||
<tr key={packageName}>
|
||||
<td>
|
||||
<a
|
||||
href={`https://npmjs.org/package/${packageName}`}
|
||||
title={`${packageName} on npm`}
|
||||
>
|
||||
{packageName}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{formatNumber(requests)} ({formatPercent(
|
||||
requests / totals.requests.all
|
||||
)}%)
|
||||
</td>
|
||||
{bandwidth ? (
|
||||
<td>
|
||||
{formatBytes(bandwidth)} ({formatPercent(
|
||||
bandwidth / totals.bandwidth.all
|
||||
)}%)
|
||||
</td>
|
||||
) : (
|
||||
<td>-</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Regions
|
||||
const regionRows = [];
|
||||
|
||||
const continentsData = Object.keys(continents).reduce((memo, continent) => {
|
||||
const localCountries = getCountriesByContinent(continent);
|
||||
|
||||
memo[continent] = {
|
||||
countries: localCountries,
|
||||
requests: sumKeyValues(totals.requests.country, localCountries),
|
||||
bandwidth: sumKeyValues(totals.bandwidth.country, localCountries)
|
||||
};
|
||||
|
||||
return memo;
|
||||
}, {});
|
||||
|
||||
const topContinents = Object.keys(continentsData).sort((a, b) => {
|
||||
return continentsData[b].requests - continentsData[a].requests;
|
||||
});
|
||||
|
||||
topContinents.forEach(continent => {
|
||||
const continentName = continents[continent];
|
||||
const continentData = continentsData[continent];
|
||||
|
||||
if (
|
||||
continentData.requests > this.state.minCountryRequests &&
|
||||
continentData.bandwidth !== 0
|
||||
) {
|
||||
regionRows.push(
|
||||
<tr key={continent} className="continent-row">
|
||||
<td>
|
||||
<strong>{continentName}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<strong>
|
||||
{formatNumber(continentData.requests)} ({formatPercent(
|
||||
continentData.requests / totals.requests.all
|
||||
)}%)
|
||||
</strong>
|
||||
</td>
|
||||
<td>
|
||||
<strong>
|
||||
{formatBytes(continentData.bandwidth)} ({formatPercent(
|
||||
continentData.bandwidth / totals.bandwidth.all
|
||||
)}%)
|
||||
</strong>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const topCountries = continentData.countries.sort((a, b) => {
|
||||
return totals.requests.country[b] - totals.requests.country[a];
|
||||
});
|
||||
|
||||
topCountries.forEach(country => {
|
||||
const countryRequests = totals.requests.country[country];
|
||||
const countryBandwidth = totals.bandwidth.country[country];
|
||||
|
||||
if (countryRequests > this.state.minCountryRequests) {
|
||||
regionRows.push(
|
||||
<tr key={continent + country} className="country-row">
|
||||
<td className="country-name">{countries[country].name}</td>
|
||||
<td>
|
||||
{formatNumber(countryRequests)} ({formatPercent(
|
||||
countryRequests / totals.requests.all
|
||||
)}%)
|
||||
</td>
|
||||
<td>
|
||||
{formatBytes(countryBandwidth)} ({formatPercent(
|
||||
countryBandwidth / totals.bandwidth.all
|
||||
)}%)
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Protocols
|
||||
const protocolRows = Object.keys(totals.requests.protocol)
|
||||
.sort((a, b) => {
|
||||
return totals.requests.protocol[b] - totals.requests.protocol[a];
|
||||
})
|
||||
.map(protocol => {
|
||||
const requests = totals.requests.protocol[protocol];
|
||||
|
||||
return (
|
||||
<tr key={protocol}>
|
||||
<td>{protocol}</td>
|
||||
<td>
|
||||
{formatNumber(requests)} ({formatPercent(
|
||||
requests / sumValues(totals.requests.protocol)
|
||||
)}%)
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="wrapper">
|
||||
<p>
|
||||
From <strong>{formatDate(since, "MMM D")}</strong> to{" "}
|
||||
<strong>{formatDate(until, "MMM D")}</strong> unpkg served{" "}
|
||||
<strong>{formatNumber(totals.requests.all)}</strong> requests and a
|
||||
total of <strong>{formatBytes(totals.bandwidth.all)}</strong> of data
|
||||
to <strong>{formatNumber(totals.uniques.all)}</strong> unique
|
||||
visitors,{" "}
|
||||
<strong>
|
||||
{formatPercent(totals.requests.cached / totals.requests.all, 0)}%
|
||||
</strong>{" "}
|
||||
of which were served from the cache.
|
||||
</p>
|
||||
|
||||
<h3>Packages</h3>
|
||||
|
||||
<p>
|
||||
The table below shows the most popular packages served by unpkg from{" "}
|
||||
<strong>{formatDate(since, "MMM D")}</strong> to{" "}
|
||||
<strong>{formatDate(until, "MMM D")}</strong>. Only the top{" "}
|
||||
{Object.keys(totals.requests.package).length} packages are shown.
|
||||
</p>
|
||||
|
||||
<p className="table-filter">
|
||||
Include only packages that received at least{" "}
|
||||
<select
|
||||
value={this.state.minPackageRequests}
|
||||
onChange={event =>
|
||||
this.setState({
|
||||
minPackageRequests: parseInt(event.target.value, 10)
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="0">0</option>
|
||||
<option value="1000">1,000</option>
|
||||
<option value="10000">10,000</option>
|
||||
<option value="100000">100,000</option>
|
||||
<option value="1000000">1,000,000</option>
|
||||
<option value="10000000">10,000,000</option>
|
||||
</select>{" "}
|
||||
requests.
|
||||
</p>
|
||||
|
||||
<table cellSpacing="0" cellPadding="0" style={{ width: "100%" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<strong>Package</strong>
|
||||
</th>
|
||||
<th>
|
||||
<strong>Requests (% of total)</strong>
|
||||
</th>
|
||||
<th>
|
||||
<strong>Bandwidth (% of total)</strong>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{packageRows}</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Regions</h3>
|
||||
|
||||
<p>
|
||||
The table below breaks down requests to unpkg from{" "}
|
||||
<strong>{formatDate(since, "MMM D")}</strong> to{" "}
|
||||
<strong>{formatDate(until, "MMM D")}</strong> by geographic region.
|
||||
</p>
|
||||
|
||||
<p className="table-filter">
|
||||
Include only countries that made at least{" "}
|
||||
<select
|
||||
value={this.state.minCountryRequests}
|
||||
onChange={event =>
|
||||
this.setState({
|
||||
minCountryRequests: parseInt(event.target.value, 10)
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="0">0</option>
|
||||
<option value="100000">100,000</option>
|
||||
<option value="1000000">1,000,000</option>
|
||||
<option value="10000000">10,000,000</option>
|
||||
<option value="100000000">100,000,000</option>
|
||||
</select>{" "}
|
||||
requests.
|
||||
</p>
|
||||
|
||||
<table
|
||||
cellSpacing="0"
|
||||
cellPadding="0"
|
||||
style={{ width: "100%" }}
|
||||
className="regions-table"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<strong>Region</strong>
|
||||
</th>
|
||||
<th>
|
||||
<strong>Requests (% of total)</strong>
|
||||
</th>
|
||||
<th>
|
||||
<strong>Bandwidth (% of total)</strong>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{regionRows}</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Protocols</h3>
|
||||
|
||||
<p>
|
||||
The table below breaks down requests to unpkg from{" "}
|
||||
<strong>{formatDate(since, "MMM D")}</strong> to{" "}
|
||||
<strong>{formatDate(until, "MMM D")}</strong> by HTTP protocol.
|
||||
</p>
|
||||
|
||||
<table cellSpacing="0" cellPadding="0" style={{ width: "100%" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<strong>Protocol</strong>
|
||||
</th>
|
||||
<th>
|
||||
<strong>Requests (% of total)</strong>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{protocolRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Stats;
|
||||
35
server/client/main/WindowSize.js
Normal file
35
server/client/main/WindowSize.js
Normal file
@ -0,0 +1,35 @@
|
||||
const React = require("react");
|
||||
const PropTypes = require("prop-types");
|
||||
|
||||
const addEvent = require("../utils/addEvent");
|
||||
const removeEvent = require("../utils/removeEvent");
|
||||
|
||||
const resizeEvent = "resize";
|
||||
|
||||
class WindowSize extends React.Component {
|
||||
static propTypes = {
|
||||
onChange: PropTypes.func
|
||||
};
|
||||
|
||||
handleWindowResize = () => {
|
||||
if (this.props.onChange)
|
||||
this.props.onChange({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
});
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
addEvent(window, resizeEvent, this.handleWindowResize);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
removeEvent(window, resizeEvent, this.handleWindowResize);
|
||||
}
|
||||
|
||||
render() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WindowSize;
|
||||
9
server/client/utils/addEvent.js
Normal file
9
server/client/utils/addEvent.js
Normal file
@ -0,0 +1,9 @@
|
||||
function addEvent(node, type, handler) {
|
||||
if (node.addEventListener) {
|
||||
node.addEventListener(type, handler, false);
|
||||
} else if (node.attachEvent) {
|
||||
node.attachEvent("on" + type, handler);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = addEvent;
|
||||
5
server/client/utils/createHTML.js
Normal file
5
server/client/utils/createHTML.js
Normal file
@ -0,0 +1,5 @@
|
||||
function createHTML(code) {
|
||||
return { __html: code };
|
||||
}
|
||||
|
||||
module.exports = createHTML;
|
||||
9
server/client/utils/execScript.js
Normal file
9
server/client/utils/execScript.js
Normal file
@ -0,0 +1,9 @@
|
||||
const React = require("react");
|
||||
|
||||
const h = require("./createHTML");
|
||||
|
||||
function execScript(code) {
|
||||
return <script dangerouslySetInnerHTML={h(code)} />;
|
||||
}
|
||||
|
||||
module.exports = execScript;
|
||||
12
server/client/utils/formatNumber.js
Normal file
12
server/client/utils/formatNumber.js
Normal file
@ -0,0 +1,12 @@
|
||||
function formatNumber(n) {
|
||||
const digits = String(n).split("");
|
||||
const groups = [];
|
||||
|
||||
while (digits.length) {
|
||||
groups.unshift(digits.splice(-3).join(""));
|
||||
}
|
||||
|
||||
return groups.join(",");
|
||||
}
|
||||
|
||||
module.exports = formatNumber;
|
||||
5
server/client/utils/formatPercent.js
Normal file
5
server/client/utils/formatPercent.js
Normal file
@ -0,0 +1,5 @@
|
||||
function formatPercent(n, fixed = 1) {
|
||||
return String((n.toPrecision(2) * 100).toFixed(fixed));
|
||||
}
|
||||
|
||||
module.exports = formatPercent;
|
||||
5
server/client/utils/parseNumber.js
Normal file
5
server/client/utils/parseNumber.js
Normal file
@ -0,0 +1,5 @@
|
||||
function parseNumber(s) {
|
||||
return parseInt(s.replace(/,/g, ""), 10) || 0;
|
||||
}
|
||||
|
||||
module.exports = parseNumber;
|
||||
9
server/client/utils/removeEvent.js
Normal file
9
server/client/utils/removeEvent.js
Normal file
@ -0,0 +1,9 @@
|
||||
function removeEvent(node, type, handler) {
|
||||
if (node.removeEventListener) {
|
||||
node.removeEventListener(type, handler, false);
|
||||
} else if (node.detachEvent) {
|
||||
node.detachEvent("on" + type, handler);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = removeEvent;
|
||||
Reference in New Issue
Block a user