redoc/cli/index.ts

327 lines
8.8 KiB
TypeScript
Raw Normal View History

2018-03-13 17:56:38 +03:00
#!/usr/bin/env node
2018-05-17 11:49:38 +03:00
/* tslint:disable:no-implicit-dependencies */
2018-03-13 17:56:38 +03:00
import * as React from 'react';
import { renderToString } from 'react-dom/server';
import { ServerStyleSheet } from 'styled-components';
2018-05-17 11:49:38 +03:00
2018-03-20 12:00:45 +03:00
import { compile } from 'handlebars';
2018-10-18 14:10:32 +03:00
import { createServer, IncomingMessage, ServerResponse } from 'http';
2018-05-17 11:49:38 +03:00
import { dirname, join } from 'path';
import * as zlib from 'zlib';
2018-03-13 17:56:38 +03:00
// @ts-ignore
2018-05-17 11:49:38 +03:00
import { createStore, loadAndBundleSpec, Redoc } from 'redoc';
2018-03-13 17:56:38 +03:00
2018-05-17 11:49:38 +03:00
import { createReadStream, existsSync, readFileSync, ReadStream, watch, writeFileSync } from 'fs';
import * as mkdirp from 'mkdirp';
2018-03-13 17:56:38 +03:00
2018-05-17 11:49:38 +03:00
import * as YargsParser from 'yargs';
2018-03-13 17:56:38 +03:00
2018-05-17 11:49:38 +03:00
interface Options {
2018-03-13 17:56:38 +03:00
ssr?: boolean;
watch?: boolean;
cdn?: boolean;
output?: string;
2018-03-20 11:21:05 +03:00
title?: string;
2018-03-20 12:00:45 +03:00
templateFileName?: string;
redocOptions?: any;
2018-05-17 11:49:38 +03:00
}
2018-03-13 17:56:38 +03:00
const BUNDLES_DIR = dirname(require.resolve('redoc'));
2018-05-17 11:49:38 +03:00
/* tslint:disable-next-line */
YargsParser.command(
'serve <spec>',
2018-05-17 11:49:38 +03:00
'start the server',
yargs => {
yargs.positional('spec', {
describe: 'path or URL to your spec',
});
yargs.option('s', {
alias: 'ssr',
describe: 'Enable server-side rendering',
type: 'boolean',
});
yargs.option('p', {
alias: 'port',
type: 'number',
default: 8080,
});
yargs.option('w', {
alias: 'watch',
type: 'boolean',
});
yargs.demandOption('spec');
return yargs;
},
async argv => {
const config = {
ssr: argv.ssr,
watch: argv.watch,
templateFileName: argv.template,
redocOptions: argv.options || {},
};
try {
await serve(argv.port, argv.spec, config);
} catch (e) {
handleError(e);
}
2018-05-17 11:49:38 +03:00
},
)
2018-03-13 17:56:38 +03:00
.command(
'bundle <spec>',
2018-03-13 17:56:38 +03:00
'bundle spec into zero-dependency HTML-file',
yargs => {
yargs.positional('spec', {
describe: 'path or URL to your spec',
});
yargs.option('o', {
describe: 'Output file',
alias: 'output',
2018-03-20 14:01:45 +03:00
type: 'string',
2018-03-13 17:56:38 +03:00
default: 'redoc-static.html',
});
2018-03-20 11:21:05 +03:00
yargs.options('title', {
describe: 'Page Title',
type: 'string',
default: 'ReDoc documentation',
});
2018-03-13 17:56:38 +03:00
yargs.option('cdn', {
describe: 'Do not include ReDoc source code into html page, use link to CDN instead',
type: 'boolean',
default: false,
});
2018-03-14 09:24:45 +03:00
yargs.demandOption('spec');
return yargs;
2018-03-13 17:56:38 +03:00
},
async argv => {
const config = {
ssr: true,
output: argv.o,
cdn: argv.cdn,
title: argv.title,
templateFileName: argv.template,
redocOptions: argv.options || {},
};
try {
await bundle(argv.spec, config);
} catch (e) {
handleError(e);
}
2018-03-13 17:56:38 +03:00
},
2018-06-25 14:43:18 +03:00
)
2018-03-20 13:49:25 +03:00
.demandCommand()
2018-03-20 12:00:45 +03:00
.options('t', {
alias: 'template',
describe: 'Path to handlebars page template, see https://git.io/vh8fP for the example ',
2018-03-20 12:00:45 +03:00
type: 'string',
})
.options('options', {
describe: 'ReDoc options, use dot notation, e.g. options.nativeScrollbars',
2018-03-20 13:49:25 +03:00
}).argv;
2018-03-13 17:56:38 +03:00
async function serve(port: number, pathToSpec: string, options: Options = {}) {
let spec = await loadAndBundleSpec(pathToSpec);
let pageHTML = await getPageHTML(spec, pathToSpec, options);
const server = createServer((request, response) => {
console.time('GET ' + request.url);
if (request.url === '/redoc.standalone.js') {
respondWithGzip(
createReadStream(join(BUNDLES_DIR, 'redoc.standalone.js'), 'utf8'),
request,
response,
{
'Content-Type': 'application/javascript',
},
);
2018-03-13 17:56:38 +03:00
} else if (request.url === '/') {
respondWithGzip(pageHTML, request, response);
} else if (request.url === '/spec.json') {
const specStr = JSON.stringify(spec, null, 2);
respondWithGzip(specStr, request, response, {
'Content-Type': 'application/json',
});
} else {
response.writeHead(404);
response.write('Not found');
response.end();
}
console.timeEnd('GET ' + request.url);
});
console.log();
server.listen(port, () => console.log(`Server started: http://127.0.0.1:${port}`));
if (options.watch && existsSync(pathToSpec)) {
const pathToSpecDirectory = dirname(pathToSpec);
const watchOptions = {
2018-06-25 14:43:18 +03:00
recursive: true,
};
2018-03-13 17:56:38 +03:00
watch(
pathToSpecDirectory,
watchOptions,
2018-03-13 17:56:38 +03:00
debounce(async (event, filename) => {
if (event === 'change' || event === 'rename') {
console.log(`${join(pathToSpecDirectory, filename)} changed, updating docs`);
2018-03-13 17:56:38 +03:00
try {
spec = await loadAndBundleSpec(pathToSpec);
pageHTML = await getPageHTML(spec, pathToSpec, options);
console.log('Updated successfully');
} catch (e) {
console.error('Error while updating: ', e.message);
}
}
}, 2200),
);
console.log(`👀 Watching ${pathToSpecDirectory} for changes...`);
2018-03-13 17:56:38 +03:00
}
}
async function bundle(pathToSpec, options: Options = {}) {
const start = Date.now();
2018-03-13 17:56:38 +03:00
const spec = await loadAndBundleSpec(pathToSpec);
const pageHTML = await getPageHTML(spec, pathToSpec, { ...options, ssr: true });
mkdirp.sync(dirname(options.output!));
2018-03-13 17:56:38 +03:00
writeFileSync(options.output!, pageHTML);
const sizeInKiB = Math.ceil(Buffer.byteLength(pageHTML) / 1024);
const time = Date.now() - start;
console.log(
`\n🎉 bundled successfully in: ${options.output!} (${sizeInKiB} KiB) [⏱ ${time / 1000}s]`,
);
2018-03-13 17:56:38 +03:00
}
2018-03-20 12:00:45 +03:00
async function getPageHTML(
spec: any,
pathToSpec: string,
{ ssr, cdn, title, templateFileName, redocOptions = {} }: Options,
2018-03-20 12:00:45 +03:00
) {
2018-05-17 11:49:38 +03:00
let html;
let css;
let state;
2018-03-13 17:56:38 +03:00
let redocStandaloneSrc;
if (ssr) {
console.log('Prerendering docs');
const specUrl = redocOptions.specUrl || (isURL(pathToSpec) ? pathToSpec : undefined);
const store = await createStore(spec, specUrl, redocOptions);
2018-03-13 17:56:38 +03:00
const sheet = new ServerStyleSheet();
html = renderToString(sheet.collectStyles(React.createElement(Redoc, { store })));
css = sheet.getStyleTags();
state = await store.toJS();
if (!cdn) {
redocStandaloneSrc = readFileSync(join(BUNDLES_DIR, 'redoc.standalone.js'));
2018-03-13 17:56:38 +03:00
}
}
2018-03-20 12:00:45 +03:00
templateFileName = templateFileName ? templateFileName : join(__dirname, './template.hbs');
const template = compile(readFileSync(templateFileName).toString());
return template({
redocHTML: `
<div id="redoc">${(ssr && html) || ''}</div>
2018-03-20 12:00:45 +03:00
<script>
${(ssr && `const __redoc_state = ${sanitizeJSONString(JSON.stringify(state))};`) || ''}
var container = document.getElementById('redoc');
Redoc.${
ssr
? 'hydrate(__redoc_state, container);'
: `init("spec.json", ${JSON.stringify(redocOptions)}, container)`
2018-06-25 14:43:18 +03:00
};
</script>`,
2018-03-20 12:00:45 +03:00
redocHead: ssr
? (cdn
2018-06-25 14:43:18 +03:00
? '<script src="https://unpkg.com/redoc@next/bundles/redoc.standalone.js"></script>'
: `<script>${redocStandaloneSrc}</script>`) + css
2018-03-20 12:00:45 +03:00
: '<script src="redoc.standalone.js"></script>',
2018-05-17 11:49:38 +03:00
title,
2018-03-20 12:00:45 +03:00
});
2018-03-13 17:56:38 +03:00
}
// credits: https://stackoverflow.com/a/9238214/1749888
function respondWithGzip(
contents: string | ReadStream,
2018-10-18 14:10:32 +03:00
request: IncomingMessage,
2018-03-13 17:56:38 +03:00
response: ServerResponse,
headers = {},
) {
let compressedStream;
const acceptEncoding = (request.headers['accept-encoding'] as string) || '';
if (acceptEncoding.match(/\bdeflate\b/)) {
response.writeHead(200, { ...headers, 'content-encoding': 'deflate' });
compressedStream = zlib.createDeflate();
} else if (acceptEncoding.match(/\bgzip\b/)) {
response.writeHead(200, { ...headers, 'content-encoding': 'gzip' });
compressedStream = zlib.createGzip();
} else {
response.writeHead(200, headers);
if (typeof contents === 'string') {
response.write(contents);
response.end();
} else {
contents.pipe(response);
}
return;
}
if (typeof contents === 'string') {
compressedStream.write(contents);
compressedStream.pipe(response);
compressedStream.end();
return;
} else {
contents.pipe(compressedStream).pipe(response);
}
}
2018-05-17 11:49:38 +03:00
function debounce(callback: (...args) => void, time: number) {
2018-03-13 17:56:38 +03:00
let interval;
return (...args) => {
clearTimeout(interval);
interval = setTimeout(() => {
interval = null;
callback(...args);
}, time);
};
}
function isURL(str: string): boolean {
return /^(https?:)\/\//m.test(str);
}
function sanitizeJSONString(str: string) {
return escapeClosingScriptTag(escapeUnicode(str));
}
// see http://www.thespanner.co.uk/2011/07/25/the-json-specification-is-now-wrong/
function escapeClosingScriptTag(str) {
return str.replace(/<\/script>/g, '<\\/script>');
}
// see http://www.thespanner.co.uk/2011/07/25/the-json-specification-is-now-wrong/
function escapeUnicode(str) {
return str.replace(/\u2028|\u2029/g, m => '\\u202' + (m === '\u2028' ? '8' : '9'));
}
function handleError(error: Error) {
console.error(error.stack);
process.exit(1);
}