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-11-03 18:59:36 +03:00
|
|
|
import { dirname, join, resolve } from 'path';
|
2018-05-17 11:49:38 +03:00
|
|
|
|
|
|
|
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
|
|
|
|
2019-03-11 18:58:23 +03:00
|
|
|
import { watch } from 'chokidar';
|
2018-11-03 18:59:36 +03:00
|
|
|
import { createReadStream, existsSync, readFileSync, ReadStream, writeFileSync } from 'fs';
|
2018-05-29 12:46:31 +03:00
|
|
|
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;
|
2019-09-25 19:14:21 +03:00
|
|
|
disableGoogleFont?: boolean;
|
2019-03-11 18:58:23 +03:00
|
|
|
port?: number;
|
2018-03-20 12:00:45 +03:00
|
|
|
templateFileName?: string;
|
2019-01-31 04:51:44 +03:00
|
|
|
templateOptions?: any;
|
2018-03-20 14:22:41 +03:00
|
|
|
redocOptions?: any;
|
2018-05-17 11:49:38 +03:00
|
|
|
}
|
2018-03-13 17:56:38 +03:00
|
|
|
|
2018-03-18 18:39:50 +03:00
|
|
|
const BUNDLES_DIR = dirname(require.resolve('redoc'));
|
|
|
|
|
2018-05-17 11:49:38 +03:00
|
|
|
/* tslint:disable-next-line */
|
|
|
|
YargsParser.command(
|
2018-05-31 12:56:35 +03:00
|
|
|
'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 => {
|
2019-03-11 18:58:23 +03:00
|
|
|
const config: Options = {
|
|
|
|
ssr: argv.ssr as boolean,
|
|
|
|
watch: argv.watch as boolean,
|
|
|
|
templateFileName: argv.template as string,
|
2019-01-31 04:51:44 +03:00
|
|
|
templateOptions: argv.templateOptions || {},
|
2019-09-30 12:56:03 +03:00
|
|
|
redocOptions: getObjectOrJSON(argv.options),
|
2018-05-31 12:56:35 +03:00
|
|
|
};
|
|
|
|
|
2019-09-30 12:56:03 +03:00
|
|
|
console.log(config);
|
|
|
|
|
2018-05-31 12:56:35 +03:00
|
|
|
try {
|
2019-03-11 18:58:23 +03:00
|
|
|
await serve(argv.port as number, argv.spec as string, config);
|
2018-05-31 12:56:35 +03:00
|
|
|
} catch (e) {
|
|
|
|
handleError(e);
|
|
|
|
}
|
2018-05-17 11:49:38 +03:00
|
|
|
},
|
|
|
|
)
|
2018-03-13 17:56:38 +03:00
|
|
|
.command(
|
2018-05-31 12:56:35 +03:00
|
|
|
'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',
|
|
|
|
});
|
|
|
|
|
2019-09-25 19:14:21 +03:00
|
|
|
yargs.options('disableGoogleFont', {
|
|
|
|
describe: 'Disable Google Font',
|
|
|
|
type: 'boolean',
|
|
|
|
default: false,
|
|
|
|
});
|
|
|
|
|
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
|
|
|
},
|
2019-09-30 10:51:12 +03:00
|
|
|
async (argv: any) => {
|
|
|
|
const config = {
|
2018-05-29 17:51:15 +03:00
|
|
|
ssr: true,
|
2019-03-11 18:58:23 +03:00
|
|
|
output: argv.o as string,
|
|
|
|
cdn: argv.cdn as boolean,
|
|
|
|
title: argv.title as string,
|
2019-09-25 19:14:21 +03:00
|
|
|
disableGoogleFont: argv.disableGoogleFont as boolean,
|
2019-03-11 18:58:23 +03:00
|
|
|
templateFileName: argv.template as string,
|
2019-01-31 04:51:44 +03:00
|
|
|
templateOptions: argv.templateOptions || {},
|
2019-09-30 12:56:03 +03:00
|
|
|
redocOptions: getObjectOrJSON(argv.options),
|
2018-05-31 12:56:35 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
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',
|
2018-07-17 11:12:41 +03:00
|
|
|
describe: 'Path to handlebars page template, see https://git.io/vh8fP for the example ',
|
2018-03-20 12:00:45 +03:00
|
|
|
type: 'string',
|
|
|
|
})
|
2019-01-31 04:51:44 +03:00
|
|
|
.options('templateOptions', {
|
2019-03-11 18:58:23 +03:00
|
|
|
describe:
|
|
|
|
'Additional options that you want pass to template. Use dot notation, e.g. templateOptions.metaDescription',
|
2019-01-31 04:51:44 +03:00
|
|
|
})
|
2018-03-20 12:31:36 +03:00
|
|
|
.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') {
|
2018-03-18 18:39:50 +03:00
|
|
|
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 === '/') {
|
2019-06-28 17:02:05 +03:00
|
|
|
respondWithGzip(pageHTML, request, response, {
|
|
|
|
'Content-Type': 'text/html',
|
|
|
|
});
|
2018-03-13 17:56:38 +03:00
|
|
|
} 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)) {
|
2018-11-03 18:59:36 +03:00
|
|
|
const pathToSpecDirectory = resolve(dirname(pathToSpec));
|
2018-06-25 14:15:00 +03:00
|
|
|
const watchOptions = {
|
2019-09-30 10:51:12 +03:00
|
|
|
ignored: [/(^|[\/\\])\../, /___jb_[a-z]+___$/],
|
|
|
|
ignoreInitial: true,
|
2018-06-25 14:15:00 +03:00
|
|
|
};
|
|
|
|
|
2018-11-03 18:59:36 +03:00
|
|
|
const watcher = watch(pathToSpecDirectory, watchOptions);
|
|
|
|
const log = console.log.bind(console);
|
2019-09-30 10:51:12 +03:00
|
|
|
|
|
|
|
const handlePath = async path => {
|
|
|
|
try {
|
|
|
|
spec = await loadAndBundleSpec(pathToSpec);
|
|
|
|
pageHTML = await getPageHTML(spec, pathToSpec, options);
|
|
|
|
log('Updated successfully');
|
|
|
|
} catch (e) {
|
|
|
|
console.error('Error while updating: ', e.message);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2018-11-03 18:59:36 +03:00
|
|
|
watcher
|
|
|
|
.on('change', async path => {
|
|
|
|
log(`${path} changed, updating docs`);
|
2019-09-30 10:51:12 +03:00
|
|
|
handlePath(path);
|
|
|
|
})
|
|
|
|
.on('add', async path => {
|
|
|
|
log(`File ${path} added, updating docs`);
|
|
|
|
handlePath(path);
|
|
|
|
})
|
|
|
|
.on('addDir', path => {
|
|
|
|
log(`↗ Directory ${path} added. Files in here will trigger reload.`);
|
2019-03-11 18:58:23 +03:00
|
|
|
})
|
2018-11-03 18:59:36 +03:00
|
|
|
.on('error', error => console.error(`Watcher error: ${error}`))
|
|
|
|
.on('ready', () => log(`👀 Watching ${pathToSpecDirectory} for changes...`));
|
2018-03-13 17:56:38 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function bundle(pathToSpec, options: Options = {}) {
|
2018-03-18 12:15:17 +03:00
|
|
|
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 });
|
2018-03-18 12:15:17 +03:00
|
|
|
|
2018-05-29 12:46:31 +03:00
|
|
|
mkdirp.sync(dirname(options.output!));
|
2018-03-13 17:56:38 +03:00
|
|
|
writeFileSync(options.output!, pageHTML);
|
2018-03-18 12:15:17 +03:00
|
|
|
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,
|
2019-09-25 19:14:21 +03:00
|
|
|
{
|
|
|
|
ssr,
|
|
|
|
cdn,
|
|
|
|
title,
|
|
|
|
disableGoogleFont,
|
|
|
|
templateFileName,
|
|
|
|
templateOptions,
|
|
|
|
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');
|
2018-03-20 14:22:41 +03:00
|
|
|
|
|
|
|
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) {
|
2018-03-18 18:39:50 +03:00
|
|
|
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: `
|
2018-03-22 18:49:15 +03:00
|
|
|
<div id="redoc">${(ssr && html) || ''}</div>
|
2018-03-20 12:00:45 +03:00
|
|
|
<script>
|
2018-07-17 15:16:06 +03:00
|
|
|
${(ssr && `const __redoc_state = ${sanitizeJSONString(JSON.stringify(state))};`) || ''}
|
2018-05-17 11:43:53 +03:00
|
|
|
|
2018-03-22 18:49:15 +03:00
|
|
|
var container = document.getElementById('redoc');
|
|
|
|
Redoc.${
|
|
|
|
ssr
|
|
|
|
? 'hydrate(__redoc_state, container);'
|
|
|
|
: `init("spec.json", ${JSON.stringify(redocOptions)}, container)`
|
2019-03-11 18:58:23 +03:00
|
|
|
};
|
2018-03-22 18:49:15 +03:00
|
|
|
|
|
|
|
</script>`,
|
2018-03-20 12:00:45 +03:00
|
|
|
redocHead: ssr
|
|
|
|
? (cdn
|
2019-03-11 18:58:23 +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,
|
2019-09-25 19:14:21 +03:00
|
|
|
disableGoogleFont,
|
2019-01-31 04:51:44 +03:00
|
|
|
templateOptions,
|
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-03-20 14:22:41 +03:00
|
|
|
function isURL(str: string): boolean {
|
|
|
|
return /^(https?:)\/\//m.test(str);
|
|
|
|
}
|
2018-05-17 11:43:53 +03:00
|
|
|
|
2018-07-17 15:16:06 +03:00
|
|
|
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>');
|
|
|
|
}
|
|
|
|
|
2018-05-17 11:43:53 +03:00
|
|
|
// 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'));
|
|
|
|
}
|
2018-05-31 12:56:35 +03:00
|
|
|
|
|
|
|
function handleError(error: Error) {
|
|
|
|
console.error(error.stack);
|
|
|
|
process.exit(1);
|
|
|
|
}
|
2019-09-30 12:56:03 +03:00
|
|
|
|
|
|
|
function getObjectOrJSON(options) {
|
|
|
|
try {
|
|
|
|
return options && typeof options === 'string'
|
|
|
|
? JSON.parse(options) : options
|
|
|
|
? options
|
|
|
|
: {};
|
|
|
|
} catch (e) {
|
|
|
|
console.log(`Encountered error:\n${options}\nis not a valid JSON.`);
|
|
|
|
handleError(e);
|
|
|
|
}
|
|
|
|
}
|