Browse Source

New "browse" UI

Also, separated out browse, ?meta, and ?module request handlers.

Fixes #82
master
Michael Jackson 3 years ago
parent
commit
34baab07ab
  1. 10
      modules/__tests__/browseDirectory-test.js
  2. 34
      modules/__tests__/browseFile-test.js
  3. 10
      modules/__tests__/legacyURLs-test.js
  4. 28
      modules/actions/serveBrowsePage.js
  5. 81
      modules/actions/serveDirectoryBrowser.js
  6. 97
      modules/actions/serveDirectoryMetadata.js
  7. 35
      modules/actions/serveFile.js
  8. 84
      modules/actions/serveFileBrowser.js
  9. 61
      modules/actions/serveFileMetadata.js
  10. 15
      modules/actions/serveMainPage.js
  11. 42
      modules/actions/serveMetadata.js
  12. 2
      modules/actions/serveModule.js
  13. 24
      modules/actions/serveStaticFile.js
  14. 2
      modules/actions/serveStats.js
  15. 8
      modules/client/.eslintrc
  16. 91
      modules/client/autoIndex/App.js
  17. 142
      modules/client/autoIndex/DirectoryListing.js
  18. 2
      modules/client/browse.js
  19. 356
      modules/client/browse/App.js
  20. 182
      modules/client/browse/DirectoryViewer.js
  21. 212
      modules/client/browse/FileViewer.js
  22. 24
      modules/client/browse/Icons.js
  23. 11
      modules/client/browse/PackageInfo.js
  24. BIN
      modules/client/browse/images/DownArrow.png
  25. BIN
      modules/client/browse/images/SelectDownArrow.png
  26. 404
      modules/client/main/App.js
  27. BIN
      modules/client/main/GoogleCloudLogo.png
  28. 15
      modules/client/main/Icons.js
  29. 0
      modules/client/main/images/AngularLogo.png
  30. 0
      modules/client/main/images/CloudflareLogo.png
  31. 18
      modules/client/utils/format.js
  32. 10
      modules/client/utils/formatNumber.js
  33. 3
      modules/client/utils/formatPercent.js
  34. 3
      modules/client/utils/markup.js
  35. 24
      modules/client/utils/style.js
  36. 151
      modules/createServer.js
  37. 97
      modules/middleware/findEntry.js
  38. 45
      modules/middleware/validateFilename.js
  39. 49
      modules/middleware/validateVersion.js
  40. 5
      modules/templates/MainTemplate.js
  41. 68
      modules/utils/__tests__/getContentType-test.js
  42. 84
      modules/utils/__tests__/getLanguageName-test.js
  43. 0
      modules/utils/cloudflare.js
  44. 3
      modules/utils/createDataURI.js
  45. 8
      modules/utils/encodeJSONForScript.js
  46. 7
      modules/utils/getContentType.js
  47. 82
      modules/utils/getHighlights.js
  48. 36
      modules/utils/getLanguageName.js
  49. 2
      modules/utils/getScripts.js
  50. 0
      modules/utils/getStats.js
  51. 0
      modules/utils/markup.js
  52. 2
      modules/utils/parsePackageURL.js
  53. 11
      package.json
  54. 2
      plugins/entryManifest.js
  55. 4
      rollup.config.js
  56. BIN
      unpkg.sketch
  57. 415
      yarn.lock

10
modules/__tests__/directoryIndex-test.js → modules/__tests__/browseDirectory-test.js

@ -2,7 +2,7 @@ import request from 'supertest';
import createServer from '../createServer.js';
describe('A request that targets a directory with a trailing slash', () => {
describe('A request to browse a directory', () => {
let server;
beforeEach(() => {
server = createServer();
@ -11,7 +11,7 @@ describe('A request that targets a directory with a trailing slash', () => {
describe('when the directory exists', () => {
it('returns an HTML page', done => {
request(server)
.get('/[email protected]/umd/')
.get('/browse/[email protected]/umd/')
.end((err, res) => {
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toMatch(/\btext\/html\b/);
@ -21,12 +21,12 @@ describe('A request that targets a directory with a trailing slash', () => {
});
describe('when the directory does not exist', () => {
it('returns a 404 text error', done => {
it('returns a 404 HTML page', done => {
request(server)
.get('/[email protected]/not-here/')
.get('/browse/[email protected]/not-here/')
.end((err, res) => {
expect(res.statusCode).toBe(404);
expect(res.headers['content-type']).toMatch(/\btext\/plain\b/);
expect(res.headers['content-type']).toMatch(/\btext\/html\b/);
done();
});
});

34
modules/__tests__/browseFile-test.js

@ -0,0 +1,34 @@
import request from 'supertest';
import createServer from '../createServer.js';
describe('A request to browse a file', () => {
let server;
beforeEach(() => {
server = createServer();
});
describe('when the file exists', () => {
it('returns an HTML page', done => {
request(server)
.get('/browse/[email protected]/umd/react.production.min.js')
.end((err, res) => {
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toMatch(/\btext\/html\b/);
done();
});
});
});
describe('when the file does not exist', () => {
it('returns a 404 HTML page', done => {
request(server)
.get('/browse/[email protected]/not-here.js')
.end((err, res) => {
expect(res.statusCode).toBe(404);
expect(res.headers['content-type']).toMatch(/\btext\/html\b/);
done();
});
});
});
});

10
modules/__tests__/legacyURLs-test.js

@ -27,4 +27,14 @@ describe('Legacy URLs', () => {
done();
});
});
it('redirect */ to /browse/*/', done => {
request(server)
.get('/[email protected]/umd/')
.end((err, res) => {
expect(res.statusCode).toBe(302);
expect(res.headers.location).toEqual('/browse/[email protected]/umd/');
done();
});
});
});

28
modules/actions/serveAutoIndexPage.js → modules/actions/serveBrowsePage.js

@ -1,38 +1,36 @@
import { renderToString, renderToStaticMarkup } from 'react-dom/server';
import AutoIndexApp from '../client/autoIndex/App.js';
import MainTemplate from './utils/MainTemplate.js';
import getScripts from './utils/getScripts.js';
import { createElement, createHTML } from './utils/markupHelpers.js';
import BrowseApp from '../client/browse/App.js';
import MainTemplate from '../templates/MainTemplate.js';
import { getAvailableVersions } from '../utils/npm.js';
import getScripts from '../utils/getScripts.js';
import { createElement, createHTML } from '../utils/markup.js';
const doctype = '<!DOCTYPE html>';
const globalURLs =
process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'staging'
? {
'@emotion/core': '/@emotion/[email protected]/dist/core.umd.min.js',
react: '/[email protected]7.0/umd/react.production.min.js',
'react-dom': '/[email protected]7.0/umd/react-dom.production.min.js'
react: '/[email protected]8.6/umd/react.production.min.js',
'react-dom': '/[email protected]8.6/umd/react-dom.production.min.js'
}
: {
'@emotion/core': '/@emotion/[email protected]/dist/core.umd.min.js',
react: '/[email protected]7.0/umd/react.development.js',
'react-dom': '/[email protected]7.0/umd/react-dom.development.js'
react: '/[email protected]8.6/umd/react.development.js',
'react-dom': '/[email protected]8.6/umd/react-dom.development.js'
};
export default async function serveAutoIndexPage(req, res) {
export default async function serveBrowsePage(req, res) {
const availableVersions = await getAvailableVersions(req.packageName);
const data = {
packageName: req.packageName,
packageVersion: req.packageVersion,
availableVersions: availableVersions,
filename: req.filename,
entry: req.entry,
entries: req.entries
target: req.browseTarget
};
const content = createHTML(renderToString(createElement(AutoIndexApp, data)));
const elements = getScripts('autoIndex', 'iife', globalURLs);
const content = createHTML(renderToString(createElement(BrowseApp, data)));
const elements = getScripts('browse', 'iife', globalURLs);
const html =
doctype +
@ -49,7 +47,7 @@ export default async function serveAutoIndexPage(req, res) {
res
.set({
'Cache-Control': 'public, max-age=14400', // 4 hours
'Cache-Tag': 'auto-index'
'Cache-Tag': 'browse'
})
.send(html);
}

81
modules/actions/serveDirectoryBrowser.js

@ -0,0 +1,81 @@
import path from 'path';
import gunzip from 'gunzip-maybe';
import tar from 'tar-stream';
import bufferStream from '../utils/bufferStream.js';
import getContentType from '../utils/getContentType.js';
import getIntegrity from '../utils/getIntegrity.js';
import { getPackage } from '../utils/npm.js';
import serveBrowsePage from './serveBrowsePage.js';
async function findMatchingEntries(stream, filename) {
// filename = /some/dir/name
return new Promise((accept, reject) => {
const entries = {};
stream
.pipe(gunzip())
.pipe(tar.extract())
.on('error', reject)
.on('entry', async (header, stream, next) => {
const entry = {
// Most packages have header names that look like `package/index.js`
// so we shorten that to just `/index.js` here. A few packages use a
// prefix other than `package/`. e.g. the firebase package uses the
// `firebase_npm/` prefix. So we just strip the first dir name.
path: header.name.replace(/^[^/]+/, ''),
type: header.type
};
// Dynamically create "directory" entries for all subdirectories
// in this entry's path. Some tarballs omit directory entries for
// some reason, so this is the "brute force" method.
let dir = path.dirname(entry.path);
while (dir !== '/') {
if (!entries[dir] && path.dirname(dir) === filename) {
entries[dir] = { path: dir, type: 'directory' };
}
dir = path.dirname(dir);
}
// Ignore non-files and files that aren't in this directory.
if (entry.type !== 'file' || path.dirname(entry.path) !== filename) {
stream.resume();
stream.on('end', next);
return;
}
const content = await bufferStream(stream);
entry.contentType = getContentType(entry.path);
entry.integrity = getIntegrity(content);
entry.size = content.length;
entries[entry.path] = entry;
next();
})
.on('finish', () => {
accept(entries);
});
});
}
export default async function serveDirectoryBrowser(req, res) {
const stream = await getPackage(req.packageName, req.packageVersion);
const filename = req.filename.slice(0, -1) || '/';
const entries = await findMatchingEntries(stream, filename);
if (Object.keys(entries).length === 0) {
return res.status(404).send(`Not found: ${req.packageSpec}${req.filename}`);
}
req.browseTarget = {
path: filename,
type: 'directory',
details: entries
};
serveBrowsePage(req, res);
}

97
modules/actions/serveDirectoryMetadata.js

@ -0,0 +1,97 @@
import path from 'path';
import gunzip from 'gunzip-maybe';
import tar from 'tar-stream';
import bufferStream from '../utils/bufferStream.js';
import getContentType from '../utils/getContentType.js';
import getIntegrity from '../utils/getIntegrity.js';
import { getPackage } from '../utils/npm.js';
async function findMatchingEntries(stream, filename) {
// filename = /some/dir/name
return new Promise((accept, reject) => {
const entries = {};
entries[filename] = { path: filename, type: 'directory' };
stream
.pipe(gunzip())
.pipe(tar.extract())
.on('error', reject)
.on('entry', async (header, stream, next) => {
const entry = {
// Most packages have header names that look like `package/index.js`
// so we shorten that to just `/index.js` here. A few packages use a
// prefix other than `package/`. e.g. the firebase package uses the
// `firebase_npm/` prefix. So we just strip the first dir name.
path: header.name.replace(/^[^/]+/, ''),
type: header.type
};
// Dynamically create "directory" entries for all subdirectories
// in this entry's path. Some tarballs omit directory entries for
// some reason, so this is the "brute force" method.
let dir = path.dirname(entry.path);
while (dir !== '/') {
if (!entries[dir] && dir.startsWith(filename)) {
entries[dir] = { path: dir, type: 'directory' };
}
dir = path.dirname(dir);
}
// Ignore non-files and files that don't match the prefix.
if (entry.type !== 'file' || !entry.path.startsWith(filename)) {
stream.resume();
stream.on('end', next);
return;
}
const content = await bufferStream(stream);
entry.contentType = getContentType(entry.path);
entry.integrity = getIntegrity(content);
entry.lastModified = header.mtime.toUTCString();
entry.size = content.length;
entries[entry.path] = entry;
next();
})
.on('finish', () => {
accept(entries);
});
});
}
function getMatchingEntries(entry, entries) {
return Object.keys(entries)
.filter(key => entry.path !== key && path.dirname(key) === entry.path)
.map(key => entries[key]);
}
function getMetadata(entry, entries) {
const metadata = { path: entry.path, type: entry.type };
if (entry.type === 'file') {
metadata.contentType = entry.contentType;
metadata.integrity = entry.integrity;
metadata.lastModified = entry.lastModified;
metadata.size = entry.size;
} else if (entry.type === 'directory') {
metadata.files = getMatchingEntries(entry, entries).map(e =>
getMetadata(e, entries)
);
}
return metadata;
}
export default async function serveDirectoryMetadata(req, res) {
const stream = await getPackage(req.packageName, req.packageVersion);
const filename = req.filename.slice(0, -1) || '/';
const entries = await findMatchingEntries(stream, filename);
const metadata = getMetadata(entries[filename], entries);
res.send(metadata);
}

35
modules/actions/serveFile.js

@ -1,23 +1,24 @@
import serveAutoIndexPage from './serveAutoIndexPage.js';
import serveMetadata from './serveMetadata.js';
import serveModule from './serveModule.js';
import serveStaticFile from './serveStaticFile.js';
import path from 'path';
import etag from 'etag';
/**
* Send the file, JSON metadata, or HTML directory listing.
*/
export default function serveFile(req, res) {
if (req.query.meta != null) {
return serveMetadata(req, res);
}
import getContentTypeHeader from '../utils/getContentTypeHeader.js';
if (req.entry.type === 'directory') {
return serveAutoIndexPage(req, res);
}
export default function serveFile(req, res) {
const tags = ['file'];
if (req.query.module != null) {
return serveModule(req, res);
const ext = path.extname(req.entry.path).substr(1);
if (ext) {
tags.push(`${ext}-file`);
}
serveStaticFile(req, res);
res
.set({
'Content-Type': getContentTypeHeader(req.entry.contentType),
'Content-Length': req.entry.size,
'Cache-Control': 'public, max-age=31536000', // 1 year
'Last-Modified': req.entry.lastModified,
ETag: etag(req.entry.content),
'Cache-Tag': tags.join(', ')
})
.send(req.entry.content);
}

84
modules/actions/serveFileBrowser.js

@ -0,0 +1,84 @@
import gunzip from 'gunzip-maybe';
import tar from 'tar-stream';
import bufferStream from '../utils/bufferStream.js';
import createDataURI from '../utils/createDataURI.js';
import getContentType from '../utils/getContentType.js';
import getIntegrity from '../utils/getIntegrity.js';
import { getPackage } from '../utils/npm.js';
import getHighlights from '../utils/getHighlights.js';
import getLanguageName from '../utils/getLanguageName.js';
import serveBrowsePage from './serveBrowsePage.js';
async function findEntry(stream, filename) {
// filename = /some/file/name.js
return new Promise((accept, reject) => {
let foundEntry = null;
stream
.pipe(gunzip())
.pipe(tar.extract())
.on('error', reject)
.on('entry', async (header, stream, next) => {
const entry = {
// Most packages have header names that look like `package/index.js`
// so we shorten that to just `/index.js` here. A few packages use a
// prefix other than `package/`. e.g. the firebase package uses the
// `firebase_npm/` prefix. So we just strip the first dir name.
path: header.name.replace(/^[^/]+/, ''),
type: header.type
};
// Ignore non-files and files that don't match the name.
if (entry.type !== 'file' || entry.path !== filename) {
stream.resume();
stream.on('end', next);
return;
}
entry.content = await bufferStream(stream);
foundEntry = entry;
next();
})
.on('finish', () => {
accept(foundEntry);
});
});
}
export default async function serveFileBrowser(req, res) {
const stream = await getPackage(req.packageName, req.packageVersion);
const entry = await findEntry(stream, req.filename);
if (!entry) {
return res.status(404).send(`Not found: ${req.packageSpec}${req.filename}`);
}
const details = {
contentType: getContentType(entry.path),
integrity: getIntegrity(entry.content),
language: getLanguageName(entry.path),
size: entry.content.length
};
if (/^image\//.test(details.contentType)) {
details.uri = createDataURI(details.contentType, entry.content);
details.highlights = null;
} else {
details.uri = null;
details.highlights = getHighlights(
entry.content.toString('utf8'),
entry.path
);
}
req.browseTarget = {
path: req.filename,
type: 'file',
details
};
serveBrowsePage(req, res);
}

61
modules/actions/serveFileMetadata.js

@ -0,0 +1,61 @@
import gunzip from 'gunzip-maybe';
import tar from 'tar-stream';
import bufferStream from '../utils/bufferStream.js';
import getContentType from '../utils/getContentType.js';
import getIntegrity from '../utils/getIntegrity.js';
import { getPackage } from '../utils/npm.js';
async function findEntry(stream, filename) {
// filename = /some/file/name.js
return new Promise((accept, reject) => {
let foundEntry = null;
stream
.pipe(gunzip())
.pipe(tar.extract())
.on('error', reject)
.on('entry', async (header, stream, next) => {
const entry = {
// Most packages have header names that look like `package/index.js`
// so we shorten that to just `/index.js` here. A few packages use a
// prefix other than `package/`. e.g. the firebase package uses the
// `firebase_npm/` prefix. So we just strip the first dir name.
path: header.name.replace(/^[^/]+/, ''),
type: header.type
};
// Ignore non-files and files that don't match the name.
if (entry.type !== 'file' || entry.path !== filename) {
stream.resume();
stream.on('end', next);
return;
}
const content = await bufferStream(stream);
entry.contentType = getContentType(entry.path);
entry.integrity = getIntegrity(content);
entry.lastModified = header.mtime.toUTCString();
entry.size = content.length;
foundEntry = entry;
next();
})
.on('finish', () => {
accept(foundEntry);
});
});
}
export default async function serveFileMetadata(req, res) {
const stream = await getPackage(req.packageName, req.packageVersion);
const entry = await findEntry(stream, req.filename);
if (!entry) {
// TODO: 404
}
res.send(entry);
}

15
modules/actions/serveMainPage.js

@ -1,23 +1,22 @@
import { renderToString, renderToStaticMarkup } from 'react-dom/server';
import MainApp from '../client/main/App.js';
import MainTemplate from './utils/MainTemplate.js';
import getScripts from './utils/getScripts.js';
import { createElement, createHTML } from './utils/markupHelpers.js';
import MainTemplate from '../templates/MainTemplate.js';
import getScripts from '../utils/getScripts.js';
import { createElement, createHTML } from '../utils/markup.js';
const doctype = '<!DOCTYPE html>';
const globalURLs =
process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'staging'
? {
'@emotion/core': '/@emotion/[email protected]/dist/core.umd.min.js',
react: '/[email protected]7.0/umd/react.production.min.js',
'react-dom': '/[email protected]7.0/umd/react-dom.production.min.js'
react: '/[email protected]8.6/umd/react.production.min.js',
'react-dom': '/[email protected]8.6/umd/react-dom.production.min.js'
}
: {
'@emotion/core': '/@emotion/[email protected]/dist/core.umd.min.js',
react: '/[email protected]7.0/umd/react.development.js',
'react-dom': '/[email protected]7.0/umd/react-dom.development.js'
react: '/[email protected]8.6/umd/react.development.js',
'react-dom': '/[email protected]8.6/umd/react-dom.development.js'
};
export default function serveMainPage(req, res) {

42
modules/actions/serveMetadata.js

@ -1,42 +0,0 @@
import path from 'path';
function getMatchingEntries(entry, entries) {
const dirname = entry.name || '.';
return Object.keys(entries)
.filter(name => entry.name !== name && path.dirname(name) === dirname)
.map(name => entries[name]);
}
const leadingSlashes = /^\/*/;
function getMetadata(entry, entries) {
const metadata = {
path: entry.name.replace(leadingSlashes, '/'),
type: entry.type
};
if (entry.type === 'file') {
metadata.contentType = entry.contentType;
metadata.integrity = entry.integrity;
metadata.lastModified = entry.lastModified;
metadata.size = entry.size;
} else if (entry.type === 'directory') {
metadata.files = getMatchingEntries(entry, entries).map(e =>
getMetadata(e, entries)
);
}
return metadata;
}
export default function serveMetadata(req, res) {
const metadata = getMetadata(req.entry, req.entries);
res
.set({
'Cache-Control': 'public, max-age=31536000', // 1 year
'Cache-Tag': 'meta'
})
.send(metadata);
}

2
modules/actions/serveModule.js

@ -1,7 +1,7 @@
import serveHTMLModule from './serveHTMLModule.js';
import serveJavaScriptModule from './serveJavaScriptModule.js';
export default function serveModule(req, res) {
export default async function serveModule(req, res) {
if (req.entry.contentType === 'application/javascript') {
return serveJavaScriptModule(req, res);
}

24
modules/actions/serveStaticFile.js

@ -1,24 +0,0 @@
import path from 'path';
import etag from 'etag';
import getContentTypeHeader from '../utils/getContentTypeHeader.js';
export default function serveStaticFile(req, res) {
const tags = ['file'];
const ext = path.extname(req.entry.name).substr(1);
if (ext) {
tags.push(`${ext}-file`);
}
res
.set({
'Content-Length': req.entry.size,
'Content-Type': getContentTypeHeader(req.entry.contentType),
'Cache-Control': 'public, max-age=31536000', // 1 year
'Last-Modified': req.entry.lastModified,
ETag: etag(req.entry.content),
'Cache-Tag': tags.join(', ')
})
.send(req.entry.content);
}

2
modules/actions/serveStats.js

@ -1,6 +1,6 @@
import { subDays, startOfDay } from 'date-fns';
import getStats from './utils/getStats.js';
import getStats from '../utils/getStats.js';
export default function serveStats(req, res) {
let since, until;

8
modules/client/.eslintrc

@ -2,12 +2,12 @@
"env": {
"browser": true
},
"plugins": [
"react"
],
"plugins": ["react", "react-hooks"],
"rules": {
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error"
"react/jsx-uses-vars": "error",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
},
"settings": {
"react": {

91
modules/client/autoIndex/App.js

@ -1,91 +0,0 @@
/** @jsx jsx */
import PropTypes from 'prop-types';
import { Global, css, jsx } from '@emotion/core';
import DirectoryListing from './DirectoryListing.js';
const globalStyles = css`
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;
}
`;
export default function App({
packageName,
packageVersion,
availableVersions = [],
filename,
entry,
entries
}) {
function handleChange(event) {
window.location.href = window.location.href.replace(
'@' + packageVersion,
'@' + event.target.value
);
}
return (
<div css={{ maxWidth: 900, margin: '0 auto' }}>
<Global styles={globalStyles} />
<header
css={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between'
}}
>
<h1>
Index of /{packageName}@{packageVersion}
{filename}
</h1>
<div css={{ float: 'right', lineHeight: '2.25em' }}>
Version:{' '}
<select
id="version"
defaultValue={packageVersion}
onChange={handleChange}
css={{ fontSize: '1em' }}
>
{availableVersions.map(v => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div>
</header>
<hr />
<DirectoryListing filename={filename} entry={entry} entries={entries} />
<hr />
<address css={{ textAlign: 'right' }}>
{packageName}@{packageVersion}
</address>
</div>
);
}
if (process.env.NODE_ENV !== 'production') {
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
};
}

142
modules/client/autoIndex/DirectoryListing.js

@ -1,142 +0,0 @@
/** @jsx jsx */
import React from 'react';
import PropTypes from 'prop-types';
import { jsx } from '@emotion/core';
import formatBytes from 'pretty-bytes';
import sortBy from '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;
}
const styles = {
tableHead: {
textAlign: 'left',
padding: '0.5em 1em'
},
tableCell: {
padding: '0.5em 1em'
},
evenRow: {
backgroundColor: '#eee'
}
};
export default function DirectoryListing({ filename, entry, entries }) {
const rows = [];
if (filename !== '/') {
rows.push(
<tr key="..">
<td css={styles.tableCell}>
<a title="Parent directory" href="../">
..
</a>
</td>
<td css={styles.tableCell}>-</td>
<td css={styles.tableCell}>-</td>
<td css={styles.tableCell}>-</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 css={styles.tableCell}>
<a title={relName} href={href}>
{href}
</a>
</td>
<td css={styles.tableCell}>-</td>
<td css={styles.tableCell}>-</td>
<td css={styles.tableCell}>-</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 css={styles.tableCell}>
<a title={relName} href={relName}>
{relName}
</a>
</td>
<td css={styles.tableCell}>{contentType}</td>
<td css={styles.tableCell}>{formatBytes(size)}</td>
<td css={styles.tableCell}>{lastModified}</td>
</tr>
);
});
return (
<div>
<table
css={{
width: '100%',
borderCollapse: 'collapse',
font: '0.85em Monaco, monospace'
}}
>
<thead>
<tr>
<th css={styles.tableHead}>Name</th>
<th css={styles.tableHead}>Type</th>
<th css={styles.tableHead}>Size</th>
<th css={styles.tableHead}>Last Modified</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) =>
React.cloneElement(row, {
style: index % 2 ? undefined : styles.evenRow
})
)}
</tbody>
</table>
</div>
);
}
if (process.env.NODE_ENV !== 'production') {
const entryType = PropTypes.shape({
name: PropTypes.string.isRequired
});
DirectoryListing.propTypes = {
filename: PropTypes.string.isRequired,
entry: entryType.isRequired,
entries: PropTypes.objectOf(entryType).isRequired
};
}

2
modules/client/autoIndex.js → modules/client/browse.js

@ -1,7 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './autoIndex/App.js';
import App from './browse/App.js';
const props = window.__DATA__ || {};

356
modules/client/browse/App.js

@ -0,0 +1,356 @@
/** @jsx jsx */
import { Global, css, jsx } from '@emotion/core';
import { Fragment } from 'react';
import PropTypes from 'prop-types';
import { fontSans, fontMono } from '../utils/style.js';
import { PackageInfoProvider } from './PackageInfo.js';
import DirectoryViewer from './DirectoryViewer.js';
import FileViewer from './FileViewer.js';
import { TwitterIcon, GitHubIcon } from './Icons.js';
import SelectDownArrow from './images/SelectDownArrow.png';
const globalStyles = css`
html {
box-sizing: border-box;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
body {
${fontSans}
font-size: 16px;
line-height: 1.5;
background: white;
color: black;
}
code {
${fontMono}
}
th,
td {
padding: 0;
}
select {
font-size: inherit;
}
#root {
display: flex;
flex-direction: column;
}
`;
// Adapted from https://github.com/highlightjs/highlight.js/blob/master/src/styles/atom-one-light.css
const lightCodeStyles = css`
.code-listing {
background: #fbfdff;
color: #383a42;
}
.code-comment,
.code-quote {
color: #a0a1a7;
font-style: italic;
}
.code-doctag,
.code-keyword,
.code-link,
.code-formula {
color: #a626a4;
}
.code-section,
.code-name,
.code-selector-tag,
.code-deletion,
.code-subst {
color: #e45649;
}
.code-literal {
color: #0184bb;
}
.code-string,
.code-regexp,
.code-addition,
.code-attribute,
.code-meta-string {
color: #50a14f;
}
.code-built_in,
.code-class .code-title {
color: #c18401;
}
.code-attr,
.code-variable,
.code-template-variable,
.code-type,
.code-selector-class,
.code-selector-attr,
.code-selector-pseudo,
.code-number {
color: #986801;
}
.code-symbol,
.code-bullet,
.code-meta,
.code-selector-id,
.code-title {
color: #4078f2;
}
.code-emphasis {
font-style: italic;
}
.code-strong {
font-weight: bold;
}
`;
const linkStyle = {
color: '#0076ff',
textDecoration: 'none',
':hover': {
textDecoration: 'underline'
}
};
export default function App({
packageName,
packageVersion,
availableVersions = [],
filename,
target
}) {
function handleChange(event) {
window.location.href = window.location.href.replace(
'@' + packageVersion,
'@' + event.target.value
);
}
const breadcrumbs = [];
if (filename === '/') {
breadcrumbs.push(packageName);
} else {
let url = `/browse/${packageName}@${packageVersion}`;
breadcrumbs.push(
<a href={`${url}/`} css={linkStyle}>
{packageName}
</a>
);
const segments = filename
.replace(/^\/+/, '')
.replace(/\/+$/, '')
.split('/');
const lastSegment = segments.pop();
segments.forEach(segment => {
url += `/${segment}`;
breadcrumbs.push(
<a href={`${url}/`} css={linkStyle}>
{segment}
</a>
);
});
breadcrumbs.push(lastSegment);
}
// TODO: Provide a user pref to go full width?
const maxContentWidth = 940;
return (
<PackageInfoProvider
packageName={packageName}
packageVersion={packageVersion}
>
<Fragment>
<Global styles={globalStyles} />
<Global styles={lightCodeStyles} />
<div css={{ flex: '1 0 auto' }}>
<div
css={{
maxWidth: maxContentWidth,
padding: '0 20px',
margin: '0 auto'
}}
>
<header css={{ textAlign: 'center' }}>
<h1 css={{ fontSize: '3rem', marginTop: '2rem' }}>
<a href="/" css={{ color: '#000', textDecoration: 'none' }}>
UNPKG
</a>
</h1>
{/*
<nav>
<a href="#" css={{ ...linkStyle, color: '#c400ff' }}>
Become a Sponsor
</a>
</nav>
*/}
</header>
<header
css={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between'
}}
>
<h1 css={{ fontSize: '1.5rem' }}>
<nav>
{breadcrumbs.map((link, index) => (
<span key={index}>
{index !== 0 && (
<span css={{ paddingLeft: 5, paddingRight: 5 }}>/</span>
)}
{link}
</span>
))}
</nav>
</h1>
<div>
<label htmlFor="version">Version:</label>{' '}
<select
name="version"
defaultValue={packageVersion}
onChange={handleChange}
css={{
appearance: 'none',
cursor: 'pointer',
padding: '4px 24px 4px 8px',
fontWeight: 600,
fontSize: '0.9em',
color: '#24292e',
border: '1px solid rgba(27,31,35,.2)',
borderRadius: 3,
backgroundColor: '#eff3f6',
backgroundImage: `url(${SelectDownArrow})`,
backgroundPosition: 'right 8px center',
backgroundRepeat: 'no-repeat',
backgroundSize: 'auto 25%',
':hover': {
backgroundColor: '#e6ebf1',
borderColor: 'rgba(27,31,35,.35)'
},
':active': {
backgroundColor: '#e9ecef',
borderColor: 'rgba(27,31,35,.35)',
boxShadow: 'inset 0 0.15em 0.3em rgba(27,31,35,.15)'
}
}}
>
{availableVersions.map(v => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div>
</header>
</div>
<div
css={{
maxWidth: maxContentWidth,
padding: '0 20px',
margin: '0 auto',
'@media (max-width: 700px)': {
padding: 0,
margin: 0
}
}}
>
{target.type === 'directory' ? (
<DirectoryViewer path={target.path} details={target.details} />
) : target.type === 'file' ? (
<FileViewer path={target.path} details={target.details} />
) : null}
</div>
</div>
<footer
css={{
marginTop: '5rem',
background: 'black',
color: '#aaa'
}}
>
<div
css={{
maxWidth: maxContentWidth,
padding: '10px 20px',
margin: '0 auto',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between'
}}
>
<p>&copy; {new Date().getFullYear()} UNPKG</p>
<p css={{ fontSize: '1.5rem' }}>
<a
title="Twitter"
href="https://twitter.com/unpkg"
css={{
color: '#aaa',
display: 'inline-block',
':hover': { color: 'white' }
}}
>
<TwitterIcon />
</a>
<a
title="GitHub"
href="https://github.com/mjackson/unpkg"
css={{
color: '#aaa',
display: 'inline-block',
marginLeft: '1rem',
':hover': { color: 'white' }
}}
>
<GitHubIcon />
</a>
</p>
</div>
</footer>
</Fragment>
</PackageInfoProvider>
);
}
if (process.env.NODE_ENV !== 'production') {
const targetType = PropTypes.shape({
path: PropTypes.string.isRequired,
type: PropTypes.oneOf(['directory', 'file']).isRequired,
details: PropTypes.object.isRequired
});
App.propTypes = {
packageName: PropTypes.string.isRequired,
packageVersion: PropTypes.string.isRequired,
availableVersions: PropTypes.arrayOf(PropTypes.string),
filename: PropTypes.string.isRequired,
target: targetType.isRequired
};
}

182
modules/client/browse/DirectoryViewer.js

@ -0,0 +1,182 @@
/** @jsx jsx */
import { jsx } from '@emotion/core';
import PropTypes from 'prop-types';
import VisuallyHidden from '@reach/visually-hidden';
import sortBy from 'sort-by';
import { formatBytes } from '../utils/format.js';
import { DirectoryIcon, CodeFileIcon } from './Icons.js';
const linkStyle = {
color: '#0076ff',
textDecoration: 'none',
':hover': {
textDecoration: 'underline'
}
};
const tableCellStyle = {
paddingTop: 6,
paddingRight: 3,
paddingBottom: 6,
paddingLeft: 3,
borderTop: '1px solid #eaecef'
};
const iconCellStyle = {
...tableCellStyle,
color: '#424242',
width: 17,
paddingRight: 2,
paddingLeft: 10,
'@media (max-width: 700px)': {
paddingLeft: 20
}
};
const typeCellStyle = {
...tableCellStyle,
textAlign: 'right',
paddingRight: 10,
'@media (max-width: 700px)': {
paddingRight: 20
}
};
function getRelName(path, base) {
return path.substr(base.length > 1 ? base.length + 1 : 1);
}
export default function DirectoryViewer({ path, details: entries }) {
const rows = [];
if (path !== '/') {
rows.push(
<tr key="..">
<td css={iconCellStyle} />
<td css={tableCellStyle}>
<a title="Parent directory" href="../" css={linkStyle}>
..
</a>
</td>
<td css={tableCellStyle}></td>
<td css={typeCellStyle}></td>
</tr>
);
}
const { subdirs, files } = Object.keys(entries).reduce(
(memo, key) => {
const { subdirs, files } = memo;
const entry = entries[key];
if (entry.type === 'directory') {
subdirs.push(entry);
} else if (entry.type === 'file') {
files.push(entry);
}
return memo;
},
{ subdirs: [], files: [] }
);
subdirs.sort(sortBy('path')).forEach(({ path: dirname }) => {
const relName = getRelName(dirname, path);
const href = relName + '/';
rows.push(
<tr key={relName}>
<td css={iconCellStyle}>
<DirectoryIcon />
</td>
<td css={tableCellStyle}>
<a title={relName} href={href} css={linkStyle}>
{relName}
</a>
</td>
<td css={tableCellStyle}>-</td>
<td css={typeCellStyle}>-</td>
</tr>
);
});
files
.sort(sortBy('path'))
.forEach(({ path: filename, size, contentType }) => {
const relName = getRelName(filename, path);
const href = relName;
rows.push(
<tr key={relName}>
<td css={iconCellStyle}>
<CodeFileIcon />
</td>
<td css={tableCellStyle}>
<a title={relName} href={href} css={linkStyle}>
{relName}
</a>
</td>
<td css={tableCellStyle}>{formatBytes(size)}</td>
<td css={typeCellStyle}>{contentType}</td>
</tr>
);
});
return (
<div
css={{
border: '1px solid #dfe2e5',
borderRadius: 3,
borderTopWidth: 0,
'@media (max-width: 700px)': {
borderRightWidth: 0,
borderLeftWidth: 0
}
}}
>
<table
css={{
width: '100%',
borderCollapse: 'collapse',
borderRadius: 2,
background: '#fff'
}}
>
<thead>
<tr>
<th>
<VisuallyHidden>Icon</VisuallyHidden>
</th>
<th>
<VisuallyHidden>Name</VisuallyHidden>
</th>
<th>
<VisuallyHidden>Size</VisuallyHidden>
</th>
<th>
<VisuallyHidden>Content Type</VisuallyHidden>
</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
</div>
);
}
if (process.env.NODE_ENV !== 'production') {
DirectoryViewer.propTypes = {
path: PropTypes.string.isRequired,
details: PropTypes.objectOf(
PropTypes.shape({
path: PropTypes.string.isRequired,
type: PropTypes.oneOf(['directory', 'file']).isRequired,
contentType: PropTypes.string, // file only
integrity: PropTypes.string, // file only
size: PropTypes.number // file only
})
).isRequired
};
}

212
modules/client/browse/FileViewer.js

@ -0,0 +1,212 @@
/** @jsx jsx */
import { jsx } from '@emotion/core';
import PropTypes from 'prop-types';
import { formatBytes } from '../utils/format.js';
import { createHTML } from '../utils/markup.js';
import { usePackageInfo } from './PackageInfo.js';
function getBasename(path) {
const segments = path.split('/');
return segments[segments.length - 1];
}
function ImageViewer({ path, uri }) {
return (
<div css={{ padding: 20, textAlign: 'center' }}>
<img title={getBasename(path)} src={uri} />
</div>
);
}
function CodeListing({ highlights }) {
const lines = highlights.slice(0);
const hasTrailingNewline = lines.length && lines[lines.length - 1] === '';
if (hasTrailingNewline) {
lines.pop();
}
return (
<div
className="code-listing"
css={{
overflowX: 'auto',
overflowY: 'hidden',
paddingTop: 5,
paddingBottom: 5
}}
>
<table
css={{
border: 'none',
borderCollapse: 'collapse',
borderSpacing: 0
}}
>
<tbody>
{lines.map((line, index) => {
const lineNumber = index + 1;