New "browse" UI

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

Fixes #82
This commit is contained in:
Michael Jackson
2019-07-24 17:55:13 -07:00
parent ea35b3c4b0
commit 34baab07ab
57 changed files with 2431 additions and 686 deletions

View File

@ -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');
});
});

View 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');
});
});

View File

@ -0,0 +1,74 @@
import fetch from 'isomorphic-fetch';
const cloudflareURL = 'https://api.cloudflare.com/client/v4';
const cloudflareEmail = process.env.CLOUDFLARE_EMAIL;
const cloudflareKey = process.env.CLOUDFLARE_KEY;
if (process.env.NODE_ENV !== 'production') {
if (!cloudflareEmail) {
throw new Error('Missing the $CLOUDFLARE_EMAIL environment variable');
}
if (!cloudflareKey) {
throw new Error('Missing the $CLOUDFLARE_KEY environment variable');
}
}
function get(path, headers) {
return fetch(`${cloudflareURL}${path}`, {
headers: Object.assign({}, headers, {
'X-Auth-Email': cloudflareEmail,
'X-Auth-Key': cloudflareKey
})
});
}
function getJSON(path, headers) {
return get(path, headers)
.then(res => {
return res.json();
})
.then(data => {
if (!data.success) {
console.error(`cloudflare.getJSON failed at ${path}`);
console.error(data);
throw new Error('Failed to getJSON from Cloudflare');
}
return data.result;
});
}
export function getZones(domains) {
return Promise.all(
(Array.isArray(domains) ? domains : [domains]).map(domain =>
getJSON(`/zones?name=${domain}`)
)
).then(results => results.reduce((memo, zones) => memo.concat(zones)));
}
function reduceResults(target, values) {
Object.keys(values).forEach(key => {
const value = values[key];
if (typeof value === 'object' && value) {
target[key] = reduceResults(target[key] || {}, value);
} else if (typeof value === 'number') {
target[key] = (target[key] || 0) + values[key];
}
});
return target;
}
export function getZoneAnalyticsDashboard(zones, since, until) {
return Promise.all(
(Array.isArray(zones) ? zones : [zones]).map(zone => {
return getJSON(
`/zones/${
zone.id
}/analytics/dashboard?since=${since.toISOString()}&until=${until.toISOString()}`
);
})
).then(results => results.reduce(reduceResults));
}

View File

@ -0,0 +1,3 @@
export default function createDataURI(contentType, content) {
return `data:${contentType};base64,${content.toString('base64')}`;
}

View 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 });
}

View File

@ -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';
}

View 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// 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;
}
}

View 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;
}

View File

@ -0,0 +1,42 @@
// Virtual module id; see rollup.config.js
// eslint-disable-next-line import/no-unresolved
import entryManifest from 'entry-manifest';
import { createElement, createScript } from './markup.js';
function getEntryPoint(name, format) {
let entryPoints;
entryManifest.forEach(manifest => {
if (name in manifest) {
entryPoints = manifest[name];
}
});
if (entryPoints) {
return entryPoints.find(e => e.format === format);
}
return null;
}
function getGlobalScripts(entryPoint, globalURLs) {
return entryPoint.globalImports.map(id => {
if (process.env.NODE_ENV !== 'production') {
if (!globalURLs[id]) {
throw new Error('Missing global URL for id "%s"', id);
}
}
return createElement('script', { src: globalURLs[id] });
});
}
export default function getScripts(entryName, format, globalURLs) {
const entryPoint = getEntryPoint(entryName, format);
if (!entryPoint) return [];
return getGlobalScripts(entryPoint, globalURLs).concat(
createScript(entryPoint.code)
);
}

38
modules/utils/getStats.js Normal file
View File

@ -0,0 +1,38 @@
import { getZones, getZoneAnalyticsDashboard } from './cloudflare.js';
function extractPublicInfo(data) {
return {
since: data.since,
until: data.until,
requests: {
all: data.requests.all,
cached: data.requests.cached,
country: data.requests.country,
status: data.requests.http_status
},
bandwidth: {
all: data.bandwidth.all,
cached: data.bandwidth.cached,
country: data.bandwidth.country
},
threats: {
all: data.threats.all,
country: data.threats.country
},
uniques: {
all: data.uniques.all
}
};
}
const DomainNames = ['unpkg.com', 'npmcdn.com'];
export default async function getStats(since, until) {
const zones = await getZones(DomainNames);
const dashboard = await getZoneAnalyticsDashboard(zones, since, until);
return {
timeseries: dashboard.timeseries.map(extractPublicInfo),
totals: extractPublicInfo(dashboard.totals)
};
}

13
modules/utils/markup.js Normal file
View File

@ -0,0 +1,13 @@
import { createElement } from 'react';
export { createElement };
export function createHTML(code) {
return { __html: code };
}
export function createScript(script) {
return createElement('script', {
dangerouslySetInnerHTML: createHTML(script)
});
}

View File

@ -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: