New "browse" UI
Also, separated out browse, ?meta, and ?module request handlers. Fixes #82
@ -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('/react@16.8.0/umd/')
|
||||
.get('/browse/react@16.8.0/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('/react@16.8.0/not-here/')
|
||||
.get('/browse/react@16.8.0/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
Normal file
@ -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/react@16.8.0/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/react@16.8.0/not-here.js')
|
||||
.end((err, res) => {
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.headers['content-type']).toMatch(/\btext\/html\b/);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -27,4 +27,14 @@ describe('Legacy URLs', () => {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('redirect */ to /browse/*/', done => {
|
||||
request(server)
|
||||
.get('/react@16.8.0/umd/')
|
||||
.end((err, res) => {
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toEqual('/browse/react@16.8.0/umd/');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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/core@10.0.6/dist/core.umd.min.js',
|
||||
react: '/react@16.7.0/umd/react.production.min.js',
|
||||
'react-dom': '/react-dom@16.7.0/umd/react-dom.production.min.js'
|
||||
react: '/react@16.8.6/umd/react.production.min.js',
|
||||
'react-dom': '/react-dom@16.8.6/umd/react-dom.production.min.js'
|
||||
}
|
||||
: {
|
||||
'@emotion/core': '/@emotion/core@10.0.6/dist/core.umd.min.js',
|
||||
react: '/react@16.7.0/umd/react.development.js',
|
||||
'react-dom': '/react-dom@16.7.0/umd/react-dom.development.js'
|
||||
react: '/react@16.8.6/umd/react.development.js',
|
||||
'react-dom': '/react-dom@16.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
Normal file
@ -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
Normal file
@ -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);
|
||||
}
|
@ -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';
|
||||
|
||||
import getContentTypeHeader from '../utils/getContentTypeHeader.js';
|
||||
|
||||
/**
|
||||
* Send the file, JSON metadata, or HTML directory listing.
|
||||
*/
|
||||
export default function serveFile(req, res) {
|
||||
if (req.query.meta != null) {
|
||||
return serveMetadata(req, res);
|
||||
const tags = ['file'];
|
||||
|
||||
const ext = path.extname(req.entry.path).substr(1);
|
||||
if (ext) {
|
||||
tags.push(`${ext}-file`);
|
||||
}
|
||||
|
||||
if (req.entry.type === 'directory') {
|
||||
return serveAutoIndexPage(req, res);
|
||||
}
|
||||
|
||||
if (req.query.module != null) {
|
||||
return serveModule(req, res);
|
||||
}
|
||||
|
||||
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
Normal file
@ -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
Normal file
@ -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);
|
||||
}
|
@ -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/core@10.0.6/dist/core.umd.min.js',
|
||||
react: '/react@16.7.0/umd/react.production.min.js',
|
||||
'react-dom': '/react-dom@16.7.0/umd/react-dom.production.min.js'
|
||||
react: '/react@16.8.6/umd/react.production.min.js',
|
||||
'react-dom': '/react-dom@16.8.6/umd/react-dom.production.min.js'
|
||||
}
|
||||
: {
|
||||
'@emotion/core': '/@emotion/core@10.0.6/dist/core.umd.min.js',
|
||||
react: '/react@16.7.0/umd/react.development.js',
|
||||
'react-dom': '/react-dom@16.7.0/umd/react-dom.development.js'
|
||||
react: '/react@16.8.6/umd/react.development.js',
|
||||
'react-dom': '/react-dom@16.8.6/umd/react-dom.development.js'
|
||||
};
|
||||
|
||||
export default function serveMainPage(req, res) {
|
||||
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
@ -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;
|
||||
|
@ -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": {
|
||||
|
@ -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
|
||||
};
|
||||
}
|
@ -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
|
||||
};
|
||||
}
|
@ -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
Normal file
@ -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>© {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
Normal file
@ -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
Normal file
@ -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;
|
||||
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td
|
||||
id={`L${lineNumber}`}
|
||||
css={{
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
color: 'rgba(27,31,35,.3)',
|
||||
textAlign: 'right',
|
||||
verticalAlign: 'top',
|
||||
width: '1%',
|
||||
minWidth: 50,
|
||||
userSelect: 'none'
|
||||
}}
|
||||
>
|
||||
<span>{lineNumber}</span>
|
||||
</td>
|
||||
<td
|
||||
id={`LC${lineNumber}`}
|
||||
css={{
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
color: '#24292e',
|
||||
whiteSpace: 'pre'
|
||||
}}
|
||||
>
|
||||
<code dangerouslySetInnerHTML={createHTML(line)} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!hasTrailingNewline && (
|
||||
<tr key="no-newline">
|
||||
<td
|
||||
css={{
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
color: 'rgba(27,31,35,.3)',
|
||||
textAlign: 'right',
|
||||
verticalAlign: 'top',
|
||||
width: '1%',
|
||||
minWidth: 50,
|
||||
userSelect: 'none'
|
||||
}}
|
||||
>
|
||||
\
|
||||
</td>
|
||||
<td
|
||||
css={{
|
||||
paddingLeft: 10,
|
||||
color: 'rgba(27,31,35,.3)',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
>
|
||||
No newline at end of file
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BinaryViewer() {
|
||||
return (
|
||||
<div css={{ padding: 20 }}>
|
||||
<p css={{ textAlign: 'center' }}>No preview available.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FileViewer({ path, details }) {
|
||||
const { packageName, packageVersion } = usePackageInfo();
|
||||
const { highlights, uri, language, size } = details;
|
||||
|
||||
const segments = path.split('/');
|
||||
const filename = segments[segments.length - 1];
|
||||
|
||||
return (
|
||||
<div
|
||||
css={{
|
||||
border: '1px solid #dfe2e5',
|
||||
borderRadius: 3,
|
||||
'@media (max-width: 700px)': {
|
||||
borderRightWidth: 0,
|
||||
borderLeftWidth: 0
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
css={{
|
||||
padding: 10,
|
||||
background: '#f6f8fa',
|
||||
color: '#424242',
|
||||
border: '1px solid #d1d5da',
|
||||
borderTopLeftRadius: 3,
|
||||
borderTopRightRadius: 3,
|
||||
margin: '-1px -1px 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
'@media (max-width: 700px)': {
|
||||
paddingRight: 20,
|
||||
paddingLeft: 20
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{formatBytes(size)}</span> <span>{language}</span>{' '}
|
||||
<a
|
||||
title={filename}
|
||||
href={`/${packageName}@${packageVersion}${path}`}
|
||||
css={{
|
||||
display: 'inline-block',
|
||||
textDecoration: 'none',
|
||||
padding: '2px 8px',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9rem',
|
||||
color: '#24292e',
|
||||
backgroundColor: '#eff3f6',
|
||||
border: '1px solid rgba(27,31,35,.2)',
|
||||
borderRadius: 3,
|
||||
':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)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
View Raw
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{highlights ? (
|
||||
<CodeListing highlights={highlights} />
|
||||
) : uri ? (
|
||||
<ImageViewer path={path} uri={uri} />
|
||||
) : (
|
||||
<BinaryViewer />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
FileViewer.propTypes = {
|
||||
path: PropTypes.string.isRequired,
|
||||
details: PropTypes.shape({
|
||||
contentType: PropTypes.string.isRequired,
|
||||
highlights: PropTypes.arrayOf(PropTypes.string), // code
|
||||
uri: PropTypes.string, // images
|
||||
integrity: PropTypes.string.isRequired,
|
||||
language: PropTypes.string.isRequired,
|
||||
size: PropTypes.number.isRequired
|
||||
}).isRequired
|
||||
};
|
||||
}
|
24
modules/client/browse/Icons.js
Normal file
@ -0,0 +1,24 @@
|
||||
/** @jsx jsx */
|
||||
import { jsx } from '@emotion/core';
|
||||
import { GoFileDirectory, GoFile } from 'react-icons/go';
|
||||
import { FaTwitter, FaGithub } from 'react-icons/fa';
|
||||
|
||||
function createIcon(Type, { css, ...rest }) {
|
||||
return <Type css={{ ...css, verticalAlign: 'text-bottom' }} {...rest} />;
|
||||
}
|
||||
|
||||
export function DirectoryIcon(props) {
|
||||
return createIcon(GoFileDirectory, props);
|
||||
}
|
||||
|
||||
export function CodeFileIcon(props) {
|
||||
return createIcon(GoFile, props);
|
||||
}
|
||||
|
||||
export function TwitterIcon(props) {
|
||||
return createIcon(FaTwitter, props);
|
||||
}
|
||||
|
||||
export function GitHubIcon(props) {
|
||||
return createIcon(FaGithub, props);
|
||||
}
|
11
modules/client/browse/PackageInfo.js
Normal file
@ -0,0 +1,11 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
|
||||
const Context = createContext();
|
||||
|
||||
export function PackageInfoProvider({ children, ...rest }) {
|
||||
return <Context.Provider children={children} value={rest} />;
|
||||
}
|
||||
|
||||
export function usePackageInfo() {
|
||||
return useContext(Context);
|
||||
}
|
BIN
modules/client/browse/images/DownArrow.png
Normal file
After Width: | Height: | Size: 307 B |
BIN
modules/client/browse/images/SelectDownArrow.png
Normal file
After Width: | Height: | Size: 343 B |
@ -1,40 +1,45 @@
|
||||
/** @jsx jsx */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Global, css, jsx } from '@emotion/core';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import formatBytes from 'pretty-bytes';
|
||||
import formatDate from 'date-fns/format';
|
||||
import parseDate from 'date-fns/parse';
|
||||
|
||||
import formatNumber from '../utils/formatNumber.js';
|
||||
import formatPercent from '../utils/formatPercent.js';
|
||||
import { formatNumber, formatPercent } from '../utils/format.js';
|
||||
import { fontSans, fontMono } from '../utils/style.js';
|
||||
|
||||
import cloudflareLogo from './CloudflareLogo.png';
|
||||
import angularLogo from './AngularLogo.png';
|
||||
import googleCloudLogo from './GoogleCloudLogo.png';
|
||||
import { TwitterIcon, GitHubIcon } from './Icons.js';
|
||||
import CloudflareLogo from './images/CloudflareLogo.png';
|
||||
import AngularLogo from './images/AngularLogo.png';
|
||||
|
||||
const globalStyles = css`
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 14px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
Helvetica, Arial, sans-serif;
|
||||
line-height: 1.7;
|
||||
padding: 5px 20px;
|
||||
${fontSans}
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
body {
|
||||
padding: 40px 20px 120px;
|
||||
}
|
||||
}
|
||||
|
||||
a:link {
|
||||
color: blue;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:visited {
|
||||
color: rebeccapurple;
|
||||
code {
|
||||
${fontMono}
|
||||
}
|
||||
|
||||
dd,
|
||||
@ -42,23 +47,18 @@ const globalStyles = css`
|
||||
margin-left: 0;
|
||||
padding-left: 25px;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
`;
|
||||
|
||||
const styles = {
|
||||
heading: {
|
||||
margin: '0.8em 0',
|
||||
textTransform: 'uppercase',
|
||||
textAlign: 'center',
|
||||
fontSize: '5em'
|
||||
},
|
||||
subheading: {
|
||||
fontSize: '1.6em'
|
||||
},
|
||||
example: {
|
||||
textAlign: 'center',
|
||||
backgroundColor: '#eee',
|
||||
margin: '2em 0',
|
||||
padding: '5px 0'
|
||||
const linkStyle = {
|
||||
color: '#0076ff',
|
||||
textDecoration: 'none',
|
||||
':hover': {
|
||||
textDecoration: 'underline'
|
||||
}
|
||||
};
|
||||
|
||||
@ -90,59 +90,73 @@ function Stats({ data }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default class App extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
export default function App() {
|
||||
const [stats, setStats] = useState(
|
||||
typeof window === 'object' &&
|
||||
window.localStorage &&
|
||||
window.localStorage.savedStats
|
||||
? JSON.parse(window.localStorage.savedStats)
|
||||
: null
|
||||
);
|
||||
const hasStats = !!(stats && !stats.error);
|
||||
const stringStats = JSON.stringify(stats);
|
||||
|
||||
this.state = { stats: null };
|
||||
useEffect(() => {
|
||||
window.localStorage.savedStats = stringStats;
|
||||
}, [stringStats]);
|
||||
|
||||
if (typeof window === 'object' && window.localStorage) {
|
||||
const savedStats = window.localStorage.savedStats;
|
||||
|
||||
if (savedStats) {
|
||||
this.state.stats = JSON.parse(savedStats);
|
||||
}
|
||||
|
||||
window.onbeforeunload = () => {
|
||||
window.localStorage.savedStats = JSON.stringify(this.state.stats);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Refresh latest stats.
|
||||
useEffect(() => {
|
||||
fetch('/api/stats?period=last-month')
|
||||
.then(res => res.json())
|
||||
.then(stats => this.setState({ stats }));
|
||||
}
|
||||
.then(setStats);
|
||||
}, []);
|
||||
|
||||
render() {
|
||||
const { stats } = this.state;
|
||||
const hasStats = !!(stats && !stats.error);
|
||||
|
||||
return (
|
||||
<div css={{ maxWidth: 700, margin: '0 auto' }}>
|
||||
return (
|
||||
<Fragment>
|
||||
<div
|
||||
css={{
|
||||
maxWidth: 740,
|
||||
margin: '0 auto',
|
||||
padding: '0 20px'
|
||||
}}
|
||||
>
|
||||
<Global styles={globalStyles} />
|
||||
|
||||
<header>
|
||||
<h1 css={styles.heading}>unpkg</h1>
|
||||
<h1
|
||||
css={{
|
||||
textTransform: 'uppercase',
|
||||
textAlign: 'center',
|
||||
fontSize: '5em'
|
||||
}}
|
||||
>
|
||||
unpkg
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
unpkg is a fast, global{' '}
|
||||
<a href="https://en.wikipedia.org/wiki/Content_delivery_network">
|
||||
content delivery network
|
||||
</a>{' '}
|
||||
for everything on <a href="https://www.npmjs.com/">npm</a>. Use it
|
||||
to quickly and easily load any file from any package using a URL
|
||||
like:
|
||||
unpkg is a fast, global content delivery network for everything on{' '}
|
||||
<a href="https://www.npmjs.com/" css={linkStyle}>
|
||||
npm
|
||||
</a>
|
||||
. Use it to quickly and easily load any file from any package using
|
||||
a URL like:
|
||||
</p>
|
||||
|
||||
<div css={styles.example}>unpkg.com/:package@:version/:file</div>
|
||||
<div
|
||||
css={{
|
||||
textAlign: 'center',
|
||||
backgroundColor: '#eee',
|
||||
margin: '2em 0',
|
||||
padding: '5px 0'
|
||||
}}
|
||||
>
|
||||
unpkg.com/:package@:version/:file
|
||||
</div>
|
||||
|
||||
{hasStats && <Stats data={stats} />}
|
||||
</header>
|
||||
|
||||
<h3 css={styles.subheading} id="examples">
|
||||
<h3 css={{ fontSize: '1.6em' }} id="examples">
|
||||
Examples
|
||||
</h3>
|
||||
|
||||
@ -150,12 +164,20 @@ export default class App extends React.Component {
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/react@16.7.0/umd/react.production.min.js">
|
||||
<a
|
||||
title="react.production.min.js"
|
||||
href="/react@16.7.0/umd/react.production.min.js"
|
||||
css={linkStyle}
|
||||
>
|
||||
unpkg.com/react@16.7.0/umd/react.production.min.js
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/react-dom@16.7.0/umd/react-dom.production.min.js">
|
||||
<a
|
||||
title="react-dom.production.min.js"
|
||||
href="/react-dom@16.7.0/umd/react-dom.production.min.js"
|
||||
css={linkStyle}
|
||||
>
|
||||
unpkg.com/react-dom@16.7.0/umd/react-dom.production.min.js
|
||||
</a>
|
||||
</li>
|
||||
@ -163,20 +185,41 @@ export default class App extends React.Component {
|
||||
|
||||
<p>
|
||||
You may also use a{' '}
|
||||
<a href="https://docs.npmjs.com/misc/semver">semver range</a> or a{' '}
|
||||
<a href="https://docs.npmjs.com/cli/dist-tag">tag</a> instead of a
|
||||
fixed version number, or omit the version/tag entirely to use the{' '}
|
||||
<code>latest</code> tag.
|
||||
<a
|
||||
title="semver"
|
||||
href="https://docs.npmjs.com/misc/semver"
|
||||
css={linkStyle}
|
||||
>
|
||||
semver range
|
||||
</a>{' '}
|
||||
or a{' '}
|
||||
<a
|
||||
title="tags"
|
||||
href="https://docs.npmjs.com/cli/dist-tag"
|
||||
css={linkStyle}
|
||||
>
|
||||
tag
|
||||
</a>{' '}
|
||||
instead of a fixed version number, or omit the version/tag entirely to
|
||||
use the <code>latest</code> tag.
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/react@^16/umd/react.production.min.js">
|
||||
<a
|
||||
title="react.production.min.js"
|
||||
href="/react@^16/umd/react.production.min.js"
|
||||
css={linkStyle}
|
||||
>
|
||||
unpkg.com/react@^16/umd/react.production.min.js
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/react/umd/react.production.min.js">
|
||||
<a
|
||||
title="react.production.min.js"
|
||||
href="/react/umd/react.production.min.js"
|
||||
css={linkStyle}
|
||||
>
|
||||
unpkg.com/react/umd/react.production.min.js
|
||||
</a>
|
||||
</li>
|
||||
@ -190,10 +233,14 @@ export default class App extends React.Component {
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/jquery">unpkg.com/jquery</a>
|
||||
<a title="jQuery" href="/jquery" css={linkStyle}>
|
||||
unpkg.com/jquery
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/three">unpkg.com/three</a>
|
||||
<a title="Three.js" href="/three" css={linkStyle}>
|
||||
unpkg.com/three
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@ -204,14 +251,26 @@ export default class App extends React.Component {
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/react/">unpkg.com/react/</a>
|
||||
<a
|
||||
title="Index of the react package"
|
||||
href="/react/"
|
||||
css={linkStyle}
|
||||
>
|
||||
unpkg.com/react/
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/lodash/">unpkg.com/lodash/</a>
|
||||
<a
|
||||
title="Index of the react-router package"
|
||||
href="/react-router/"
|
||||
css={linkStyle}
|
||||
>
|
||||
unpkg.com/react-router/
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 css={styles.subheading} id="query-params">
|
||||
<h3 css={{ fontSize: '1.6em' }} id="query-params">
|
||||
Query Parameters
|
||||
</h3>
|
||||
|
||||
@ -229,7 +288,11 @@ export default class App extends React.Component {
|
||||
</dt>
|
||||
<dd>
|
||||
Expands all{' '}
|
||||
<a href="https://html.spec.whatwg.org/multipage/webappapis.html#resolve-a-module-specifier">
|
||||
<a
|
||||
title="bare import specifiers"
|
||||
href="https://html.spec.whatwg.org/multipage/webappapis.html#resolve-a-module-specifier"
|
||||
css={linkStyle}
|
||||
>
|
||||
“bare” <code>import</code> specifiers
|
||||
</a>{' '}
|
||||
in JavaScript modules to unpkg URLs. This feature is{' '}
|
||||
@ -237,7 +300,7 @@ export default class App extends React.Component {
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<h3 css={styles.subheading} id="cache-behavior">
|
||||
<h3 css={{ fontSize: '1.6em' }} id="cache-behavior">
|
||||
Cache Behavior
|
||||
</h3>
|
||||
|
||||
@ -255,8 +318,14 @@ export default class App extends React.Component {
|
||||
URLs that do not specify a package version number redirect to one that
|
||||
does. This is the <code>latest</code> version when no version is
|
||||
specified, or the <code>maxSatisfying</code> version when a{' '}
|
||||
<a href="https://github.com/npm/node-semver">semver version</a> is
|
||||
given. Redirects are cached for 10 minutes at the CDN, 1 minute in
|
||||
<a
|
||||
title="semver"
|
||||
href="https://github.com/npm/node-semver"
|
||||
css={linkStyle}
|
||||
>
|
||||
semver version
|
||||
</a>{' '}
|
||||
is given. Redirects are cached for 10 minutes at the CDN, 1 minute in
|
||||
browsers.
|
||||
</p>
|
||||
<p>
|
||||
@ -267,15 +336,18 @@ export default class App extends React.Component {
|
||||
latest version and redirect them.
|
||||
</p>
|
||||
|
||||
<h3 css={styles.subheading} id="workflow">
|
||||
<h3 css={{ fontSize: '1.6em' }} id="workflow">
|
||||
Workflow
|
||||
</h3>
|
||||
|
||||
<p>
|
||||
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 <a href="https://github.com/umdjs/umd">UMD</a> build in
|
||||
your npm package (not your repo, that's different!).
|
||||
include your{' '}
|
||||
<a title="UMD" href="https://github.com/umdjs/umd" css={linkStyle}>
|
||||
UMD
|
||||
</a>{' '}
|
||||
build in your npm package (not your repo, that's different!).
|
||||
</p>
|
||||
|
||||
<p>You can do this easily using the following setup:</p>
|
||||
@ -287,7 +359,11 @@ export default class App extends React.Component {
|
||||
</li>
|
||||
<li>
|
||||
Add the <code>umd</code> directory to your{' '}
|
||||
<a href="https://docs.npmjs.com/files/package.json#files">
|
||||
<a
|
||||
title="package.json files array"
|
||||
href="https://docs.npmjs.com/files/package.json#files"
|
||||
css={linkStyle}
|
||||
>
|
||||
files array
|
||||
</a>{' '}
|
||||
in <code>package.json</code>
|
||||
@ -303,24 +379,50 @@ export default class App extends React.Component {
|
||||
a version available on unpkg as well.
|
||||
</p>
|
||||
|
||||
<h3 css={styles.subheading} id="about">
|
||||
<h3 css={{ fontSize: '1.6em' }} id="about">
|
||||
About
|
||||
</h3>
|
||||
|
||||
<p>
|
||||
unpkg is an <a href="https://github.com/unpkg">open source</a> project
|
||||
built and maintained by{' '}
|
||||
<a href="https://twitter.com/mjackson">Michael Jackson</a>. unpkg is
|
||||
not affiliated with or supported by npm, Inc. in any way. Please do
|
||||
not contact npm for help with unpkg. Instead, please reach out to{' '}
|
||||
<a href="https://twitter.com/unpkg">@unpkg</a> with any questions or
|
||||
concerns.
|
||||
unpkg is an{' '}
|
||||
<a
|
||||
title="unpkg on GitHub"
|
||||
href="https://github.com/unpkg"
|
||||
css={linkStyle}
|
||||
>
|
||||
open source
|
||||
</a>{' '}
|
||||
project built and maintained by{' '}
|
||||
<a
|
||||
title="mjackson on Twitter"
|
||||
href="https://twitter.com/mjackson"
|
||||
css={linkStyle}
|
||||
>
|
||||
Michael Jackson
|
||||
</a>
|
||||
. unpkg is not affiliated with or supported by npm, Inc. in any way.
|
||||
Please do not contact npm for help with unpkg. Instead, please reach
|
||||
out to{' '}
|
||||
<a
|
||||
title="unpkg on Twitter"
|
||||
href="https://twitter.com/unpkg"
|
||||
css={linkStyle}
|
||||
>
|
||||
@unpkg
|
||||
</a>{' '}
|
||||
with any questions or concerns.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The unpkg CDN is powered by{' '}
|
||||
<a href="https://www.cloudflare.com">Cloudflare</a>, one of the
|
||||
world's largest and fastest cloud network platforms.{' '}
|
||||
<a
|
||||
title="Cloudflare"
|
||||
href="https://www.cloudflare.com"
|
||||
css={linkStyle}
|
||||
>
|
||||
Cloudflare
|
||||
</a>
|
||||
, one of the world's largest and fastest cloud network platforms.{' '}
|
||||
{hasStats && (
|
||||
<span>
|
||||
In the past month, Cloudflare served over{' '}
|
||||
@ -339,19 +441,27 @@ export default class App extends React.Component {
|
||||
}}
|
||||
>
|
||||
<AboutLogo>
|
||||
<a href="https://www.cloudflare.com">
|
||||
<AboutLogoImage src={cloudflareLogo} height="100" />
|
||||
<a title="Cloudflare" href="https://www.cloudflare.com">
|
||||
<AboutLogoImage src={CloudflareLogo} height="100" />
|
||||
</a>
|
||||
</AboutLogo>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
The origin servers for unpkg are powered by{' '}
|
||||
<a href="https://cloud.google.com/">Google Cloud</a> and made possible
|
||||
by a generous donation from the{' '}
|
||||
<a href="https://angular.io">Angular web framework</a>, one of the
|
||||
world's most popular libraries for building incredible user
|
||||
experiences on both desktop and mobile.
|
||||
<a
|
||||
title="Google Cloud"
|
||||
href="https://cloud.google.com/"
|
||||
css={linkStyle}
|
||||
>
|
||||
Google Cloud
|
||||
</a>{' '}
|
||||
and made possible by a generous donation from the{' '}
|
||||
<a title="Angular" href="https://angular.io" css={linkStyle}>
|
||||
Angular web framework
|
||||
</a>
|
||||
, one of the world's most popular libraries for building
|
||||
incredible user experiences on both desktop and mobile.
|
||||
</p>
|
||||
|
||||
<div
|
||||
@ -362,37 +472,61 @@ export default class App extends React.Component {
|
||||
}}
|
||||
>
|
||||
<AboutLogo>
|
||||
<a href="https://angular.io">
|
||||
<AboutLogoImage src={angularLogo} width="200" />
|
||||
<a title="Angular" href="https://angular.io">
|
||||
<AboutLogoImage src={AngularLogo} width="200" />
|
||||
</a>
|
||||
</AboutLogo>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer
|
||||
<footer
|
||||
css={{
|
||||
marginTop: '5rem',
|
||||
background: 'black',
|
||||
color: '#aaa'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
css={{
|
||||
marginTop: '10em',
|
||||
color: '#aaa'
|
||||
maxWidth: 740,
|
||||
padding: '10px 20px',
|
||||
margin: '0 auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
>
|
||||
<p css={{ textAlign: 'center' }}>
|
||||
© {new Date().getFullYear()} unpkg — powered
|
||||
by{' '}
|
||||
<a href="https://cloud.google.com/">
|
||||
<img
|
||||
src={googleCloudLogo}
|
||||
height="32"
|
||||
css={{
|
||||
verticalAlign: 'middle',
|
||||
marginTop: -2,
|
||||
marginLeft: -10
|
||||
}}
|
||||
/>
|
||||
<p>© {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>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
</footer>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
|
Before Width: | Height: | Size: 25 KiB |
15
modules/client/main/Icons.js
Normal file
@ -0,0 +1,15 @@
|
||||
/** @jsx jsx */
|
||||
import { jsx } from '@emotion/core';
|
||||
import { FaTwitter, FaGithub } from 'react-icons/fa';
|
||||
|
||||
function createIcon(Type, { css, ...rest }) {
|
||||
return <Type css={{ ...css, verticalAlign: 'text-bottom' }} {...rest} />;
|
||||
}
|
||||
|
||||
export function TwitterIcon(props) {
|
||||
return createIcon(FaTwitter, props);
|
||||
}
|
||||
|
||||
export function GitHubIcon(props) {
|
||||
return createIcon(FaGithub, props);
|
||||
}
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
18
modules/client/utils/format.js
Normal file
@ -0,0 +1,18 @@
|
||||
import formatBytes from 'pretty-bytes';
|
||||
|
||||
export { formatBytes };
|
||||
|
||||
export function formatNumber(n) {
|
||||
const digits = String(n).split('');
|
||||
const groups = [];
|
||||
|
||||
while (digits.length) {
|
||||
groups.unshift(digits.splice(-3).join(''));
|
||||
}
|
||||
|
||||
return groups.join(',');
|
||||
}
|
||||
|
||||
export function formatPercent(n, decimals = 1) {
|
||||
return (n * 100).toPrecision(decimals + 2);
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
export default function formatNumber(n) {
|
||||
const digits = String(n).split('');
|
||||
const groups = [];
|
||||
|
||||
while (digits.length) {
|
||||
groups.unshift(digits.splice(-3).join(''));
|
||||
}
|
||||
|
||||
return groups.join(',');
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
export default function formatPercent(n, decimals = 1) {
|
||||
return (n * 100).toPrecision(decimals + 2);
|
||||
}
|
3
modules/client/utils/markup.js
Normal file
@ -0,0 +1,3 @@
|
||||
export function createHTML(content) {
|
||||
return { __html: content };
|
||||
}
|
24
modules/client/utils/style.js
Normal file
@ -0,0 +1,24 @@
|
||||
export const fontSans = `
|
||||
font-family: -apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
"Roboto",
|
||||
"Oxygen",
|
||||
"Ubuntu",
|
||||
"Cantarell",
|
||||
"Fira Sans",
|
||||
"Droid Sans",
|
||||
"Helvetica Neue",
|
||||
sans-serif;
|
||||
`;
|
||||
|
||||
export const fontMono = `
|
||||
font-family: Menlo,
|
||||
Monaco,
|
||||
Lucida Console,
|
||||
Liberation Mono,
|
||||
DejaVu Sans Mono,
|
||||
Bitstream Vera Sans Mono,
|
||||
Courier New,
|
||||
monospace;
|
||||
`;
|
@ -1,49 +1,148 @@
|
||||
import express from 'express';
|
||||
|
||||
import serveDirectoryBrowser from './actions/serveDirectoryBrowser.js';
|
||||
import serveDirectoryMetadata from './actions/serveDirectoryMetadata.js';
|
||||
import serveFileBrowser from './actions/serveFileBrowser.js';
|
||||
import serveFileMetadata from './actions/serveFileMetadata.js';
|
||||
import serveFile from './actions/serveFile.js';
|
||||
import serveMainPage from './actions/serveMainPage.js';
|
||||
import serveModule from './actions/serveModule.js';
|
||||
import serveStats from './actions/serveStats.js';
|
||||
|
||||
import cors from './middleware/cors.js';
|
||||
import fetchPackage from './middleware/fetchPackage.js';
|
||||
import findFile from './middleware/findFile.js';
|
||||
import findEntry from './middleware/findEntry.js';
|
||||
import logger from './middleware/logger.js';
|
||||
import redirectLegacyURLs from './middleware/redirectLegacyURLs.js';
|
||||
import staticFiles from './middleware/staticFiles.js';
|
||||
import validateFilename from './middleware/validateFilename.js';
|
||||
import validatePackageURL from './middleware/validatePackageURL.js';
|
||||
import validatePackageName from './middleware/validatePackageName.js';
|
||||
import validateQuery from './middleware/validateQuery.js';
|
||||
import validateVersion from './middleware/validateVersion.js';
|
||||
|
||||
export default function createServer() {
|
||||
function createApp(callback) {
|
||||
const app = express();
|
||||
|
||||
app.disable('x-powered-by');
|
||||
app.enable('trust proxy');
|
||||
|
||||
app.use(logger);
|
||||
app.use(cors);
|
||||
app.use(staticFiles);
|
||||
|
||||
// Special startup request from App Engine
|
||||
// https://cloud.google.com/appengine/docs/standard/nodejs/how-instances-are-managed
|
||||
app.get('/_ah/start', (req, res) => {
|
||||
res.status(200).end();
|
||||
});
|
||||
|
||||
app.get('/', serveMainPage);
|
||||
app.get('/api/stats', serveStats);
|
||||
|
||||
app.use(redirectLegacyURLs);
|
||||
|
||||
app.get(
|
||||
'*',
|
||||
validatePackageURL,
|
||||
validatePackageName,
|
||||
validateQuery,
|
||||
fetchPackage,
|
||||
findFile,
|
||||
serveFile
|
||||
);
|
||||
|
||||
callback(app);
|
||||
return app;
|
||||
}
|
||||
|
||||
export default function createServer() {
|
||||
return createApp(app => {
|
||||
app.disable('x-powered-by');
|
||||
app.enable('trust proxy');
|
||||
app.enable('strict routing');
|
||||
|
||||
app.use(logger);
|
||||
app.use(cors);
|
||||
app.use(staticFiles);
|
||||
|
||||
// Special startup request from App Engine
|
||||
// https://cloud.google.com/appengine/docs/standard/nodejs/how-instances-are-managed
|
||||
app.get('/_ah/start', (req, res) => {
|
||||
res.status(200).end();
|
||||
});
|
||||
|
||||
app.get('/', serveMainPage);
|
||||
app.get('/api/stats', serveStats);
|
||||
|
||||
app.use(redirectLegacyURLs);
|
||||
|
||||
app.use(
|
||||
'/browse',
|
||||
createApp(app => {
|
||||
app.enable('strict routing');
|
||||
|
||||
app.get(
|
||||
'*/',
|
||||
validatePackageURL,
|
||||
validatePackageName,
|
||||
validateQuery,
|
||||
validateVersion,
|
||||
serveDirectoryBrowser
|
||||
);
|
||||
|
||||
app.get(
|
||||
'*',
|
||||
validatePackageURL,
|
||||
validatePackageName,
|
||||
validateQuery,
|
||||
validateVersion,
|
||||
serveFileBrowser
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// We need to route in this weird way because Express
|
||||
// doesn't have a way to route based on query params.
|
||||
const metadataApp = createApp(app => {
|
||||
app.enable('strict routing');
|
||||
|
||||
app.get(
|
||||
'*/',
|
||||
validatePackageURL,
|
||||
validatePackageName,
|
||||
validateQuery,
|
||||
validateVersion,
|
||||
validateFilename,
|
||||
serveDirectoryMetadata
|
||||
);
|
||||
|
||||
app.get(
|
||||
'*',
|
||||
validatePackageURL,
|
||||
validatePackageName,
|
||||
validateQuery,
|
||||
validateVersion,
|
||||
validateFilename,
|
||||
serveFileMetadata
|
||||
);
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.query.meta != null) {
|
||||
metadataApp(req, res);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
const moduleApp = createApp(app => {
|
||||
app.enable('strict routing');
|
||||
|
||||
app.get(
|
||||
'*',
|
||||
validatePackageURL,
|
||||
validatePackageName,
|
||||
validateQuery,
|
||||
validateVersion,
|
||||
validateFilename,
|
||||
findEntry,
|
||||
serveModule
|
||||
);
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.query.module != null) {
|
||||
moduleApp(req, res);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
// Send old */ requests to the new /browse UI.
|
||||
app.get('*/', (req, res) => {
|
||||
res.redirect(302, '/browse' + req.url);
|
||||
});
|
||||
|
||||
app.get(
|
||||
'*',
|
||||
validatePackageURL,
|
||||
validatePackageName,
|
||||
validateQuery,
|
||||
validateVersion,
|
||||
validateFilename,
|
||||
findEntry,
|
||||
serveFile
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -9,13 +9,8 @@ import getIntegrity from '../utils/getIntegrity.js';
|
||||
import getContentType from '../utils/getContentType.js';
|
||||
import bufferStream from '../utils/bufferStream.js';
|
||||
|
||||
const leadingSlashes = /^\/*/;
|
||||
const multipleSlashes = /\/*/;
|
||||
const trailingSlashes = /\/*$/;
|
||||
const leadingSegment = /^[^/]+\/?/;
|
||||
|
||||
function fileRedirect(req, res, entry) {
|
||||
// Redirect to the file with the extension so it's more
|
||||
// Redirect to the file with the extension so it's
|
||||
// clear which file is being served.
|
||||
res
|
||||
.set({
|
||||
@ -27,7 +22,7 @@ function fileRedirect(req, res, entry) {
|
||||
createPackageURL(
|
||||
req.packageName,
|
||||
req.packageVersion,
|
||||
entry.name.replace(leadingSlashes, '/'),
|
||||
entry.path,
|
||||
createSearch(req.query)
|
||||
)
|
||||
);
|
||||
@ -46,7 +41,7 @@ function indexRedirect(req, res, entry) {
|
||||
createPackageURL(
|
||||
req.packageName,
|
||||
req.packageVersion,
|
||||
entry.name.replace(leadingSlashes, '/'),
|
||||
entry.path,
|
||||
createSearch(req.query)
|
||||
)
|
||||
);
|
||||
@ -57,16 +52,17 @@ function indexRedirect(req, res, entry) {
|
||||
* Follows node's resolution algorithm.
|
||||
* https://nodejs.org/api/modules.html#modules_all_together
|
||||
*/
|
||||
function searchEntries(stream, entryName, wantsIndex) {
|
||||
function searchEntries(stream, filename) {
|
||||
// filename = /some/file/name.js or /some/dir/name
|
||||
return new Promise((accept, reject) => {
|
||||
const jsEntryName = `${entryName}.js`;
|
||||
const jsonEntryName = `${entryName}.json`;
|
||||
const entries = {};
|
||||
const jsEntryFilename = `${filename}.js`;
|
||||
const jsonEntryFilename = `${filename}.json`;
|
||||
|
||||
const matchingEntries = {};
|
||||
let foundEntry;
|
||||
|
||||
if (entryName === '') {
|
||||
foundEntry = entries[''] = { name: '', type: 'directory' };
|
||||
if (filename === '/') {
|
||||
foundEntry = matchingEntries['/'] = { name: '/', type: 'directory' };
|
||||
}
|
||||
|
||||
stream
|
||||
@ -79,41 +75,43 @@ function searchEntries(stream, entryName, wantsIndex) {
|
||||
// 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.
|
||||
name: header.name.replace(leadingSegment, ''),
|
||||
path: header.name.replace(/^[^/]+/g, ''),
|
||||
type: header.type
|
||||
};
|
||||
|
||||
// Skip non-files and files that don't match the entryName.
|
||||
if (entry.type !== 'file' || entry.name.indexOf(entryName) !== 0) {
|
||||
if (entry.type !== 'file' || !entry.path.startsWith(filename)) {
|
||||
stream.resume();
|
||||
stream.on('end', next);
|
||||
return;
|
||||
}
|
||||
|
||||
entries[entry.name] = entry;
|
||||
matchingEntries[entry.path] = entry;
|
||||
|
||||
// Dynamically create "directory" entries for all directories
|
||||
// that are in this file's path. Some tarballs omit these entries
|
||||
// for some reason, so this is the "brute force" method.
|
||||
let dir = path.dirname(entry.name);
|
||||
while (dir !== '.') {
|
||||
entries[dir] = entries[dir] || { name: dir, type: 'directory' };
|
||||
let dir = path.dirname(entry.path);
|
||||
while (dir !== '/') {
|
||||
if (!matchingEntries[dir]) {
|
||||
matchingEntries[dir] = { name: dir, type: 'directory' };
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
|
||||
if (
|
||||
entry.name === entryName ||
|
||||
entry.path === filename ||
|
||||
// Allow accessing e.g. `/index.js` or `/index.json`
|
||||
// using `/index` for compatibility with npm
|
||||
(!wantsIndex && entry.name === jsEntryName) ||
|
||||
(!wantsIndex && entry.name === jsonEntryName)
|
||||
entry.path === jsEntryFilename ||
|
||||
entry.path === jsonEntryFilename
|
||||
) {
|
||||
if (foundEntry) {
|
||||
if (
|
||||
foundEntry.name !== entryName &&
|
||||
(entry.name === entryName ||
|
||||
(entry.name === jsEntryName &&
|
||||
foundEntry.name === jsonEntryName))
|
||||
foundEntry.path !== filename &&
|
||||
(entry.path === filename ||
|
||||
(entry.path === jsEntryFilename &&
|
||||
foundEntry.path === jsonEntryFilename))
|
||||
) {
|
||||
// This entry is higher priority than the one
|
||||
// we already found. Replace it.
|
||||
@ -127,9 +125,7 @@ function searchEntries(stream, entryName, wantsIndex) {
|
||||
|
||||
const content = await bufferStream(stream);
|
||||
|
||||
// Set some extra properties for files that we will
|
||||
// need to serve them and for ?meta listings.
|
||||
entry.contentType = getContentType(entry.name);
|
||||
entry.contentType = getContentType(entry.path);
|
||||
entry.integrity = getIntegrity(content);
|
||||
entry.lastModified = header.mtime.toUTCString();
|
||||
entry.size = content.length;
|
||||
@ -144,10 +140,10 @@ function searchEntries(stream, entryName, wantsIndex) {
|
||||
})
|
||||
.on('finish', () => {
|
||||
accept({
|
||||
entries,
|
||||
// If we didn't find a matching file entry,
|
||||
// try a directory entry with the same name.
|
||||
foundEntry: foundEntry || entries[entryName] || null
|
||||
foundEntry: foundEntry || matchingEntries[filename] || null,
|
||||
matchingEntries: matchingEntries
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -157,23 +153,14 @@ function searchEntries(stream, entryName, wantsIndex) {
|
||||
* Fetch and search the archive to try and find the requested file.
|
||||
* Redirect to the "index" file if a directory was requested.
|
||||
*/
|
||||
export default async function findFile(req, res, next) {
|
||||
const wantsIndex = req.filename.endsWith('/');
|
||||
|
||||
// The name of the file/directory we're looking for.
|
||||
const entryName = req.filename
|
||||
.replace(multipleSlashes, '/')
|
||||
.replace(trailingSlashes, '')
|
||||
.replace(leadingSlashes, '');
|
||||
|
||||
export default async function findEntry(req, res, next) {
|
||||
const stream = await getPackage(req.packageName, req.packageVersion);
|
||||
const { entries, foundEntry } = await searchEntries(
|
||||
const { foundEntry: entry, matchingEntries: entries } = await searchEntries(
|
||||
stream,
|
||||
entryName,
|
||||
wantsIndex
|
||||
req.filename
|
||||
);
|
||||
|
||||
if (!foundEntry) {
|
||||
if (!entry) {
|
||||
return res
|
||||
.status(404)
|
||||
.set({
|
||||
@ -184,18 +171,17 @@ export default async function findFile(req, res, next) {
|
||||
.send(`Cannot find "${req.filename}" in ${req.packageSpec}`);
|
||||
}
|
||||
|
||||
if (foundEntry.type === 'file' && foundEntry.name !== entryName) {
|
||||
return fileRedirect(req, res, foundEntry);
|
||||
if (entry.type === 'file' && entry.path !== req.filename) {
|
||||
return fileRedirect(req, res, entry);
|
||||
}
|
||||
|
||||
// If the foundEntry is a directory and there is no trailing slash
|
||||
// on the request path, we need to redirect to some "index" file
|
||||
// inside that directory. This is so our URLs work in a similar way
|
||||
// to require("lib") in node where it searches for `lib/index.js`
|
||||
// and `lib/index.json` when `lib` is a directory.
|
||||
if (foundEntry.type === 'directory' && !wantsIndex) {
|
||||
if (entry.type === 'directory') {
|
||||
// We need to redirect to some "index" file inside the directory so
|
||||
// our URLs work in a similar way to require("lib") in node where it
|
||||
// uses `lib/index.js` when `lib` is a directory.
|
||||
const indexEntry =
|
||||
entries[`${entryName}/index.js`] || entries[`${entryName}/index.json`];
|
||||
entries[`${req.filename}/index.js`] ||
|
||||
entries[`${req.filename}/index.json`];
|
||||
|
||||
if (indexEntry && indexEntry.type === 'file') {
|
||||
return indexRedirect(req, res, indexEntry);
|
||||
@ -211,8 +197,7 @@ export default async function findFile(req, res, next) {
|
||||
.send(`Cannot find an index in "${req.filename}" in ${req.packageSpec}`);
|
||||
}
|
||||
|
||||
req.entries = entries;
|
||||
req.entry = foundEntry;
|
||||
req.entry = entry;
|
||||
|
||||
next();
|
||||
}
|
@ -1,18 +1,5 @@
|
||||
import createPackageURL from '../utils/createPackageURL.js';
|
||||
import createSearch from '../utils/createSearch.js';
|
||||
import { getPackageConfig, resolveVersion } from '../utils/npm.js';
|
||||
|
||||
function semverRedirect(req, res, newVersion) {
|
||||
res
|
||||
.set({
|
||||
'Cache-Control': 'public, s-maxage=600, max-age=60', // 10 mins on CDN, 1 min on clients
|
||||
'Cache-Tag': 'redirect, semver-redirect'
|
||||
})
|
||||
.redirect(
|
||||
302,
|
||||
createPackageURL(req.packageName, newVersion, req.filename, req.search)
|
||||
);
|
||||
}
|
||||
|
||||
const leadingSlashes = /^\/*/;
|
||||
|
||||
@ -83,37 +70,9 @@ function filenameRedirect(req, res) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the package config. Redirect to the exact version if the request
|
||||
* targets a tag or uses semver, or to the exact filename if the request
|
||||
* omits the filename.
|
||||
* Redirect to the exact filename if the request omits one.
|
||||
*/
|
||||
export default async function fetchPackage(req, res, next) {
|
||||
const version = await resolveVersion(req.packageName, req.packageVersion);
|
||||
|
||||
if (!version) {
|
||||
return res
|
||||
.status(404)
|
||||
.type('text')
|
||||
.send(`Cannot find package ${req.packageSpec}`);
|
||||
}
|
||||
|
||||
if (version !== req.packageVersion) {
|
||||
return semverRedirect(req, res, version);
|
||||
}
|
||||
|
||||
req.packageConfig = await getPackageConfig(
|
||||
req.packageName,
|
||||
req.packageVersion
|
||||
);
|
||||
|
||||
if (!req.packageConfig) {
|
||||
// TODO: Log why.
|
||||
return res
|
||||
.status(500)
|
||||
.type('text')
|
||||
.send(`Cannot get config for package ${req.packageSpec}`);
|
||||
}
|
||||
|
||||
export default async function validateFilename(req, res, next) {
|
||||
if (!req.filename) {
|
||||
return filenameRedirect(req, res);
|
||||
}
|
49
modules/middleware/validateVersion.js
Normal file
@ -0,0 +1,49 @@
|
||||
import createPackageURL from '../utils/createPackageURL.js';
|
||||
import { getPackageConfig, resolveVersion } from '../utils/npm.js';
|
||||
|
||||
function semverRedirect(req, res, newVersion) {
|
||||
res
|
||||
.set({
|
||||
'Cache-Control': 'public, s-maxage=600, max-age=60', // 10 mins on CDN, 1 min on clients
|
||||
'Cache-Tag': 'redirect, semver-redirect'
|
||||
})
|
||||
.redirect(
|
||||
302,
|
||||
createPackageURL(req.packageName, newVersion, req.filename, req.search)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the package version/tag in the URL and make sure it's good. Also
|
||||
* fetch the package config and add it to req.packageConfig. Redirect to
|
||||
* the resolved version number if necessary.
|
||||
*/
|
||||
export default async function validateVersion(req, res, next) {
|
||||
const version = await resolveVersion(req.packageName, req.packageVersion);
|
||||
|
||||
if (!version) {
|
||||
return res
|
||||
.status(404)
|
||||
.type('text')
|
||||
.send(`Cannot find package ${req.packageSpec}`);
|
||||
}
|
||||
|
||||
if (version !== req.packageVersion) {
|
||||
return semverRedirect(req, res, version);
|
||||
}
|
||||
|
||||
req.packageConfig = await getPackageConfig(
|
||||
req.packageName,
|
||||
req.packageVersion
|
||||
);
|
||||
|
||||
if (!req.packageConfig) {
|
||||
// TODO: Log why.
|
||||
return res
|
||||
.status(500)
|
||||
.type('text')
|
||||
.send(`Cannot get config for package ${req.packageSpec}`);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
@ -1,10 +1,11 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import encodeJSONForScript from '../utils/encodeJSONForScript.js';
|
||||
import {
|
||||
createElement as e,
|
||||
createHTML as h,
|
||||
createScript as x
|
||||
} from './markupHelpers.js';
|
||||
} from '../utils/markup.js';
|
||||
|
||||
const promiseShim =
|
||||
'window.Promise || document.write(\'\\x3Cscript src="/es6-promise@4.2.5/dist/es6-promise.min.js">\\x3C/script>\\x3Cscript>ES6Promise.polyfill()\\x3C/script>\')';
|
||||
@ -47,7 +48,7 @@ gtag('config', 'UA-140352188-1');`),
|
||||
e('title', null, title),
|
||||
x(promiseShim),
|
||||
x(fetchShim),
|
||||
data && x(`window.__DATA__ = ${JSON.stringify(data)}`)
|
||||
data && x(`window.__DATA__ = ${encodeJSONForScript(data)}`)
|
||||
),
|
||||
e(
|
||||
'body',
|
@ -1,39 +1,47 @@
|
||||
import getContentType from '../getContentType.js';
|
||||
|
||||
it('gets a content type of text/plain for LICENSE|README|CHANGES|AUTHORS|Makefile', () => {
|
||||
expect(getContentType('AUTHORS')).toBe('text/plain');
|
||||
expect(getContentType('CHANGES')).toBe('text/plain');
|
||||
expect(getContentType('LICENSE')).toBe('text/plain');
|
||||
expect(getContentType('Makefile')).toBe('text/plain');
|
||||
expect(getContentType('PATENTS')).toBe('text/plain');
|
||||
expect(getContentType('README')).toBe('text/plain');
|
||||
});
|
||||
describe('getContentType', () => {
|
||||
it('returns text/plain for LICENSE|README|CHANGES|AUTHORS|Makefile', () => {
|
||||
expect(getContentType('AUTHORS')).toBe('text/plain');
|
||||
expect(getContentType('CHANGES')).toBe('text/plain');
|
||||
expect(getContentType('LICENSE')).toBe('text/plain');
|
||||
expect(getContentType('Makefile')).toBe('text/plain');
|
||||
expect(getContentType('PATENTS')).toBe('text/plain');
|
||||
expect(getContentType('README')).toBe('text/plain');
|
||||
});
|
||||
|
||||
it('gets a content type of text/plain for .*rc files', () => {
|
||||
expect(getContentType('.eslintrc')).toBe('text/plain');
|
||||
expect(getContentType('.babelrc')).toBe('text/plain');
|
||||
expect(getContentType('.anythingrc')).toBe('text/plain');
|
||||
});
|
||||
it('returns text/plain for .*rc files', () => {
|
||||
expect(getContentType('.eslintrc')).toBe('text/plain');
|
||||
expect(getContentType('.babelrc')).toBe('text/plain');
|
||||
expect(getContentType('.anythingrc')).toBe('text/plain');
|
||||
});
|
||||
|
||||
it('gets a content type of text/plain for .git* files', () => {
|
||||
expect(getContentType('.gitignore')).toBe('text/plain');
|
||||
expect(getContentType('.gitanything')).toBe('text/plain');
|
||||
});
|
||||
it('returns text/plain for .git* files', () => {
|
||||
expect(getContentType('.gitignore')).toBe('text/plain');
|
||||
expect(getContentType('.gitanything')).toBe('text/plain');
|
||||
});
|
||||
|
||||
it('gets a content type of text/plain for .*ignore files', () => {
|
||||
expect(getContentType('.eslintignore')).toBe('text/plain');
|
||||
expect(getContentType('.anythingignore')).toBe('text/plain');
|
||||
});
|
||||
it('returns text/plain for .*ignore files', () => {
|
||||
expect(getContentType('.eslintignore')).toBe('text/plain');
|
||||
expect(getContentType('.anythingignore')).toBe('text/plain');
|
||||
});
|
||||
|
||||
it('gets a content type of text/plain for .ts files', () => {
|
||||
expect(getContentType('app.ts')).toBe('text/plain');
|
||||
expect(getContentType('app.d.ts')).toBe('text/plain');
|
||||
});
|
||||
it('returns text/plain for .ts(x) files', () => {
|
||||
expect(getContentType('app.ts')).toBe('text/plain');
|
||||
expect(getContentType('app.d.ts')).toBe('text/plain');
|
||||
expect(getContentType('app.tsx')).toBe('text/plain');
|
||||
});
|
||||
|
||||
it('gets a content type of text/plain for .flow files', () => {
|
||||
expect(getContentType('app.js.flow')).toBe('text/plain');
|
||||
});
|
||||
it('returns text/plain for .flow files', () => {
|
||||
expect(getContentType('app.js.flow')).toBe('text/plain');
|
||||
});
|
||||
|
||||
it('gets a content type of text/plain for .lock files', () => {
|
||||
expect(getContentType('yarn.lock')).toBe('text/plain');
|
||||
it('returns text/plain for .lock files', () => {
|
||||
expect(getContentType('yarn.lock')).toBe('text/plain');
|
||||
});
|
||||
|
||||
it('returns application/json for .map files', () => {
|
||||
expect(getContentType('react.js.map')).toBe('application/json');
|
||||
expect(getContentType('react.json.map')).toBe('application/json');
|
||||
});
|
||||
});
|
||||
|
84
modules/utils/__tests__/getLanguageName-test.js
Normal file
@ -0,0 +1,84 @@
|
||||
import getLanguageName from '../getLanguageName.js';
|
||||
|
||||
describe('getLanguageName', () => {
|
||||
// Hard-coded overrides
|
||||
|
||||
it('detects Flow files', () => {
|
||||
expect(getLanguageName('react.flow')).toBe('Flow');
|
||||
});
|
||||
|
||||
it('detects source maps', () => {
|
||||
expect(getLanguageName('react.map')).toBe('Source Map (JSON)');
|
||||
expect(getLanguageName('react.js.map')).toBe('Source Map (JSON)');
|
||||
expect(getLanguageName('react.json.map')).toBe('Source Map (JSON)');
|
||||
});
|
||||
|
||||
it('detects TypeScript files', () => {
|
||||
expect(getLanguageName('react.d.ts')).toBe('TypeScript');
|
||||
expect(getLanguageName('react.tsx')).toBe('TypeScript');
|
||||
});
|
||||
|
||||
// Content-Type lookups
|
||||
|
||||
it('detects JavaScript files', () => {
|
||||
expect(getLanguageName('react.js')).toBe('JavaScript');
|
||||
});
|
||||
|
||||
it('detects JSON files', () => {
|
||||
expect(getLanguageName('react.json')).toBe('JSON');
|
||||
});
|
||||
|
||||
it('detects binary files', () => {
|
||||
expect(getLanguageName('ionicons.bin')).toBe('Binary');
|
||||
});
|
||||
|
||||
it('detects EOT files', () => {
|
||||
expect(getLanguageName('ionicons.eot')).toBe('Embedded OpenType');
|
||||
});
|
||||
|
||||
it('detects SVG files', () => {
|
||||
expect(getLanguageName('react.svg')).toBe('SVG');
|
||||
});
|
||||
|
||||
it('detects TTF files', () => {
|
||||
expect(getLanguageName('ionicons.ttf')).toBe('TrueType Font');
|
||||
});
|
||||
|
||||
it('detects WOFF files', () => {
|
||||
expect(getLanguageName('ionicons.woff')).toBe('WOFF');
|
||||
});
|
||||
|
||||
it('detects WOFF2 files', () => {
|
||||
expect(getLanguageName('ionicons.woff2')).toBe('WOFF2');
|
||||
});
|
||||
|
||||
it('detects CSS files', () => {
|
||||
expect(getLanguageName('react.css')).toBe('CSS');
|
||||
});
|
||||
|
||||
it('detects HTML files', () => {
|
||||
expect(getLanguageName('react.html')).toBe('HTML');
|
||||
});
|
||||
|
||||
it('detects JSX files', () => {
|
||||
expect(getLanguageName('react.jsx')).toBe('JSX');
|
||||
});
|
||||
|
||||
it('detects Markdown files', () => {
|
||||
expect(getLanguageName('README.md')).toBe('Markdown');
|
||||
});
|
||||
|
||||
it('detects plain text files', () => {
|
||||
expect(getLanguageName('README')).toBe('Plain Text');
|
||||
expect(getLanguageName('LICENSE')).toBe('Plain Text');
|
||||
});
|
||||
|
||||
it('detects SCSS files', () => {
|
||||
expect(getLanguageName('some.scss')).toBe('SCSS');
|
||||
});
|
||||
|
||||
it('detects YAML files', () => {
|
||||
expect(getLanguageName('config.yml')).toBe('YAML');
|
||||
expect(getLanguageName('config.yaml')).toBe('YAML');
|
||||
});
|
||||
});
|
3
modules/utils/createDataURI.js
Normal file
@ -0,0 +1,3 @@
|
||||
export default function createDataURI(contentType, content) {
|
||||
return `data:${contentType};base64,${content.toString('base64')}`;
|
||||
}
|
8
modules/utils/encodeJSONForScript.js
Normal file
@ -0,0 +1,8 @@
|
||||
import jsesc from 'jsesc';
|
||||
|
||||
/**
|
||||
* Encodes some data as JSON that may safely be included in HTML.
|
||||
*/
|
||||
export default function encodeJSONForScript(data) {
|
||||
return jsesc(data, { json: true, isScriptContext: true });
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import path from 'path';
|
||||
import mime from 'mime';
|
||||
|
||||
mime.define(
|
||||
@ -19,5 +20,9 @@ mime.define(
|
||||
const textFiles = /\/?(\.[a-z]*rc|\.git[a-z]*|\.[a-z]*ignore|\.lock)$/i;
|
||||
|
||||
export default function getContentType(file) {
|
||||
return textFiles.test(file) ? 'text/plain' : mime.getType(file);
|
||||
const name = path.basename(file);
|
||||
|
||||
return textFiles.test(name)
|
||||
? 'text/plain'
|
||||
: mime.getType(name) || 'text/plain';
|
||||
}
|
||||
|
82
modules/utils/getHighlights.js
Normal file
@ -0,0 +1,82 @@
|
||||
import path from 'path';
|
||||
import hljs from 'highlight.js';
|
||||
|
||||
import getContentType from './getContentType.js';
|
||||
|
||||
function escapeHTML(code) {
|
||||
return code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// These should probably be added to highlight.js auto-detection.
|
||||
const extLanguages = {
|
||||
map: 'json',
|
||||
mjs: 'javascript',
|
||||
tsbuildinfo: 'json',
|
||||
tsx: 'typescript',
|
||||
vue: 'html'
|
||||
};
|
||||
|
||||
function getLanguage(file) {
|
||||
// Try to guess the language based on the file extension.
|
||||
const ext = path.extname(file).substr(1);
|
||||
|
||||
if (ext) {
|
||||
return extLanguages[ext] || ext;
|
||||
}
|
||||
|
||||
const contentType = getContentType(file);
|
||||
|
||||
if (contentType === 'text/plain') {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getLines(code) {
|
||||
return code
|
||||
.split('\n')
|
||||
.map((line, index, array) =>
|
||||
index === array.length - 1 ? line : line + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of HTML strings that highlight the given source code.
|
||||
*/
|
||||
export default function getHighlights(code, file) {
|
||||
const language = getLanguage(file);
|
||||
|
||||
if (!language) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (language === 'text') {
|
||||
return getLines(code).map(escapeHTML);
|
||||
}
|
||||
|
||||
try {
|
||||
let continuation = false;
|
||||
const hi = getLines(code).map(line => {
|
||||
const result = hljs.highlight(language, line, false, continuation);
|
||||
continuation = result.top;
|
||||
return result;
|
||||
});
|
||||
|
||||
return hi.map(result =>
|
||||
result.value.replace(
|
||||
/<span class="hljs-(\w+)">/g,
|
||||
'<span class="code-$1">'
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
// Probably an "unknown language" error.
|
||||
// console.error(error);
|
||||
return null;
|
||||
}
|
||||
}
|
36
modules/utils/getLanguageName.js
Normal file
@ -0,0 +1,36 @@
|
||||
import getContentType from './getContentType.js';
|
||||
|
||||
const contentTypeNames = {
|
||||
'application/javascript': 'JavaScript',
|
||||
'application/json': 'JSON',
|
||||
'application/octet-stream': 'Binary',
|
||||
'application/vnd.ms-fontobject': 'Embedded OpenType',
|
||||
'application/xml': 'XML',
|
||||
'image/svg+xml': 'SVG',
|
||||
'font/ttf': 'TrueType Font',
|
||||
'font/woff': 'WOFF',
|
||||
'font/woff2': 'WOFF2',
|
||||
'text/css': 'CSS',
|
||||
'text/html': 'HTML',
|
||||
'text/jsx': 'JSX',
|
||||
'text/markdown': 'Markdown',
|
||||
'text/plain': 'Plain Text',
|
||||
'text/x-scss': 'SCSS',
|
||||
'text/yaml': 'YAML'
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a human-friendly name for whatever is in the given file.
|
||||
*/
|
||||
export default function getLanguageName(file) {
|
||||
// Content-Type is text/plain, but we can be more descriptive.
|
||||
if (/\.flow$/.test(file)) return 'Flow';
|
||||
if (/\.(d\.ts|tsx)$/.test(file)) return 'TypeScript';
|
||||
|
||||
// Content-Type is application/json, but we can be more descriptive.
|
||||
if (/\.map$/.test(file)) return 'Source Map (JSON)';
|
||||
|
||||
const contentType = getContentType(file);
|
||||
|
||||
return contentTypeNames[contentType] || contentType;
|
||||
}
|
@ -2,7 +2,7 @@
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import entryManifest from 'entry-manifest';
|
||||
|
||||
import { createElement, createScript } from './markupHelpers.js';
|
||||
import { createElement, createScript } from './markup.js';
|
||||
|
||||
function getEntryPoint(name, format) {
|
||||
let entryPoints;
|
@ -19,7 +19,7 @@ export default function parsePackageURL(originalURL) {
|
||||
|
||||
const packageName = match[1];
|
||||
const packageVersion = match[2] || 'latest';
|
||||
const filename = match[3] || '';
|
||||
const filename = (match[3] || '').replace(/\/\/+/g, '/');
|
||||
|
||||
return {
|
||||
// If the URL is /@scope/name@version/file.js?main=browser:
|
||||
|