57 changed files with 2423 additions and 678 deletions
@ -2,7 +2,7 @@ import request from 'supertest';
|
||||
|
||||
import createServer from '../createServer.js'; |
||||
|
||||
describe('A request that targets a directory with a trailing slash', () => { |
||||
describe('A request to browse a directory', () => { |
||||
let server; |
||||
beforeEach(() => { |
||||
server = createServer(); |
||||
@ -11,7 +11,7 @@ describe('A request that targets a directory with a trailing slash', () => {
|
||||
describe('when the directory exists', () => { |
||||
it('returns an HTML page', done => { |
||||
request(server) |
||||
.get('/[email protected]/umd/') |
||||
.get('/browse/[email protected]/umd/') |
||||
.end((err, res) => { |
||||
expect(res.statusCode).toBe(200); |
||||
expect(res.headers['content-type']).toMatch(/\btext\/html\b/); |
||||
@ -21,12 +21,12 @@ describe('A request that targets a directory with a trailing slash', () => {
|
||||
}); |
||||
|
||||
describe('when the directory does not exist', () => { |
||||
it('returns a 404 text error', done => { |
||||
it('returns a 404 HTML page', done => { |
||||
request(server) |
||||
.get('/[email protected]/not-here/') |
||||
.get('/browse/[email protected]/not-here/') |
||||
.end((err, res) => { |
||||
expect(res.statusCode).toBe(404); |
||||
expect(res.headers['content-type']).toMatch(/\btext\/plain\b/); |
||||
expect(res.headers['content-type']).toMatch(/\btext\/html\b/); |
||||
done(); |
||||
}); |
||||
}); |
@ -0,0 +1,34 @@
|
||||
import request from 'supertest'; |
||||
|
||||
import createServer from '../createServer.js'; |
||||
|
||||
describe('A request to browse a file', () => { |
||||
let server; |
||||
beforeEach(() => { |
||||
server = createServer(); |
||||
}); |
||||
|
||||
describe('when the file exists', () => { |
||||
it('returns an HTML page', done => { |
||||
request(server) |
||||
.get('/browse/[email protected]/umd/react.production.min.js') |
||||
.end((err, res) => { |
||||
expect(res.statusCode).toBe(200); |
||||
expect(res.headers['content-type']).toMatch(/\btext\/html\b/); |
||||
done(); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('when the file does not exist', () => { |
||||
it('returns a 404 HTML page', done => { |
||||
request(server) |
||||
.get('/browse/[email protected]/not-here.js') |
||||
.end((err, res) => { |
||||
expect(res.statusCode).toBe(404); |
||||
expect(res.headers['content-type']).toMatch(/\btext\/html\b/); |
||||
done(); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
@ -27,4 +27,14 @@ describe('Legacy URLs', () => {
|
||||
done(); |
||||
}); |
||||
}); |
||||
|
||||
it('redirect */ to /browse/*/', done => { |
||||
request(server) |
||||
.get('/[email protected]/umd/') |
||||
.end((err, res) => { |
||||
expect(res.statusCode).toBe(302); |
||||
expect(res.headers.location).toEqual('/browse/[email protected]/umd/'); |
||||
done(); |
||||
}); |
||||
}); |
||||
}); |
||||
|
@ -1,38 +1,36 @@
|
||||
import { renderToString, renderToStaticMarkup } from 'react-dom/server'; |
||||
|
||||
import AutoIndexApp from '../client/autoIndex/App.js'; |
||||
|
||||
import MainTemplate from './utils/MainTemplate.js'; |
||||
import getScripts from './utils/getScripts.js'; |
||||
import { createElement, createHTML } from './utils/markupHelpers.js'; |
||||
import BrowseApp from '../client/browse/App.js'; |
||||
import MainTemplate from '../templates/MainTemplate.js'; |
||||
import { getAvailableVersions } from '../utils/npm.js'; |
||||
import getScripts from '../utils/getScripts.js'; |
||||
import { createElement, createHTML } from '../utils/markup.js'; |
||||
|
||||
const doctype = '<!DOCTYPE html>'; |
||||
const globalURLs = |
||||
process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'staging' |
||||
? { |
||||
'@emotion/core': '/@emotion/[email protected]/dist/core.umd.min.js', |
||||
react: '/[email protected]7.0/umd/react.production.min.js', |
||||
'react-dom': '/[email protected]7.0/umd/react-dom.production.min.js' |
||||
react: '/[email protected]8.6/umd/react.production.min.js', |
||||
'react-dom': '/[email protected]8.6/umd/react-dom.production.min.js' |
||||
} |
||||
: { |
||||
'@emotion/core': '/@emotion/[email protected]/dist/core.umd.min.js', |
||||
react: '/[email protected]7.0/umd/react.development.js', |
||||
'react-dom': '/[email protected]7.0/umd/react-dom.development.js' |
||||
react: '/[email protected]8.6/umd/react.development.js', |
||||
'react-dom': '/[email protected]8.6/umd/react-dom.development.js' |
||||
}; |
||||
|
||||
export default async function serveAutoIndexPage(req, res) { |
||||
export default async function serveBrowsePage(req, res) { |
||||
const availableVersions = await getAvailableVersions(req.packageName); |
||||
const data = { |
||||
packageName: req.packageName, |
||||
packageVersion: req.packageVersion, |
||||
availableVersions: availableVersions, |
||||
filename: req.filename, |
||||
entry: req.entry, |
||||
entries: req.entries |
||||
target: req.browseTarget |
||||
}; |
||||
const content = createHTML(renderToString(createElement(AutoIndexApp, data))); |
||||
const elements = getScripts('autoIndex', 'iife', globalURLs); |
||||
const content = createHTML(renderToString(createElement(BrowseApp, data))); |
||||
const elements = getScripts('browse', 'iife', globalURLs); |
||||
|
||||
const html = |
||||
doctype + |
||||
@ -49,7 +47,7 @@ export default async function serveAutoIndexPage(req, res) {
|
||||
res |
||||
.set({ |
||||
'Cache-Control': 'public, max-age=14400', // 4 hours
|
||||
'Cache-Tag': 'auto-index' |
||||
'Cache-Tag': 'browse' |
||||
}) |
||||
.send(html); |
||||
} |
@ -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); |
||||
} |
@ -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'; |
||||
|
||||
/** |
||||
* Send the file, JSON metadata, or HTML directory listing. |
||||
*/ |
||||
export default function serveFile(req, res) { |
||||
if (req.query.meta != null) { |
||||
return serveMetadata(req, res); |
||||
} |
||||
import getContentTypeHeader from '../utils/getContentTypeHeader.js'; |
||||
|
||||
if (req.entry.type === 'directory') { |
||||
return serveAutoIndexPage(req, res); |
||||
} |
||||
export default function serveFile(req, res) { |
||||
const tags = ['file']; |
||||
|
||||
if (req.query.module != null) { |
||||
return serveModule(req, res); |
||||
const ext = path.extname(req.entry.path).substr(1); |
||||
if (ext) { |
||||
tags.push(`${ext}-file`); |
||||
} |
||||
|
||||
serveStaticFile(req, res); |
||||
res |
||||
.set({ |
||||
'Content-Type': getContentTypeHeader(req.entry.contentType), |
||||
'Content-Length': req.entry.size, |
||||
'Cache-Control': 'public, max-age=31536000', // 1 year
|
||||
'Last-Modified': req.entry.lastModified, |
||||
ETag: etag(req.entry.content), |
||||
'Cache-Tag': tags.join(', ') |
||||
}) |
||||
.send(req.entry.content); |
||||
} |
||||
|
@ -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); |
||||
} |
@ -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/[email protected]/dist/core.umd.min.js', |
||||
react: '/[email protected]7.0/umd/react.production.min.js', |
||||
'react-dom': '/[email protected]7.0/umd/react-dom.production.min.js' |
||||
react: '/[email protected]8.6/umd/react.production.min.js', |
||||
'react-dom': '/[email protected]8.6/umd/react-dom.production.min.js' |
||||
} |
||||
: { |
||||
'@emotion/core': '/@emotion/[email protected]/dist/core.umd.min.js', |
||||
react: '/[email protected]7.0/umd/react.development.js', |
||||
'react-dom': '/[email protected]7.0/umd/react-dom.development.js' |
||||
react: '/[email protected]8.6/umd/react.development.js', |
||||
'react-dom': '/[email protected]8.6/umd/react-dom.development.js' |
||||
}; |
||||
|
||||
export default function serveMainPage(req, res) { |
||||
|
@ -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,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,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__ || {}; |
||||
|
@ -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 |
||||
}; |
||||
} |
@ -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 |
||||
}; |
||||
} |
@ -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; |
||||
|
||||