@skriptfabrik/elements-cli
Version:
The missing CLI for beautiful, interactive API docs powered by with Stoplight Elements
415 lines (355 loc) • 18 kB
JavaScript
import chalk from 'chalk';
import chokidar from 'chokidar';
import corsAnywhere from 'cors-anywhere';
import express from 'express';
import { engine } from 'express-handlebars';
import { readFile } from 'fs/promises';
import handlebars from 'handlebars';
import gracefulShutdown from 'http-graceful-shutdown';
import minimist from 'minimist';
import { createRequire } from 'module';
import path from 'path';
import send from 'send';
import { WebSocketServer } from 'ws';
import { fileURLToPath, URL } from 'url';
// Compat
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const require = createRequire(import.meta.url);
// Package info
const pkg = JSON.parse(await readFile(path.join(__dirname, 'package.json')));
// Argument defaults
const argd = {
'base-path': process.env.ELEMENTS_BASE_PATH || process.env.BASE_PATH || '/',
'credentials-policy': process.env.ELEMENTS_CREDENTIALS_POLICY || process.env.CREDENTIALS_POLICY || 'omit',
hostname: process.env.ELEMENTS_HOSTNAME || 'localhost',
layout: process.env.ELEMENTS_LAYOUT || process.env.LAYOUT || 'sidebar',
logo: process.env.ELEMENTS_LOGO || process.env.LOGO,
port: parseInt(process.env.ELEMENTS_PORT || '8000'),
router: process.env.ELEMENTS_ROUTER || process.env.ROUTER || 'history',
style: process.env.ELEMENTS_STYLE || process.env.STYLE || 'flex: 1 0 0; overflow: hidden;',
title: process.env.ELEMENTS_TITLE || process.env.TITLE || 'My API Docs',
variable: (process.env.ELEMENTS_VARIABLE || process.env.VARIABLE || '').split('\n').map(variable => variable.trim()),
'virtual-host': process.env.ELEMENTS_VIRTUAL_HOST || 'localhost',
'virtual-port': process.env.ELEMENTS_VIRTUAL_PORT || '8000',
'working-dir': process.cwd(),
};
// Parse arguments
const argv = minimist(process.argv.slice(2), {
boolean: ['c', 'f', 'h', 'n', 'p', 'v', 'w'],
alias: {
c: 'with-cors-proxy',
f: 'filter-internal',
h: 'help',
n: 'no-try-it',
p: 'poll',
v: 'version',
w: 'watch',
},
default: argd,
});
// Print version number
if (argv.version) {
console.log(pkg.version);
process.exit(0);
}
// Display help message
if (argv.help || argv._.length < 2 || !['export', 'preview'].includes(argv._[0])) {
if (argv._[0] === 'export') {
console.error(
`Elements CLI\n\n${chalk.yellow('Usage:')}\n%s\n\n${chalk.yellow('Arguments:')}\n%s\n\n${chalk.yellow('Options:')}\n%s\n\n${chalk.yellow('Examples:')}\n%s`,
` ${path.basename(process.argv[1])} export [options] <openapi_json>`,
` ${chalk.green('openapi_json')} The path or URL of the OpenAPI JSON file`,
[
` ${chalk.green(' --base-path=BASE_PATH')} Use the given base path ${chalk.yellow('[default: "' + argd['base-path'] + '"]')}`,
` ${chalk.green(' --credentials-policy=CREDENTIALS_POLICY')} Credentials policy for "Try It" feature: omit, include, same-origin ${chalk.yellow('[default: "' + argd['credentials-policy'] + '"]')}`,
` ${chalk.green(' --cors-proxy=CORS_PROXY')} Provide CORS proxy`,
` ${chalk.green('-f, --filter-internal')} Filter out any content which has been marked as internal with x-internal`,
` ${chalk.green('-h, --help')} Display this help message`,
` ${chalk.green(' --layout=LAYOUT')} Layout for Elements: sidebar, stacked ${chalk.yellow('[default: "' + argd.layout + '"]')}`,
` ${chalk.green(' --logo=LOGO')} URL of an image that will show as a small square logo next to the title`,
` ${chalk.green('-n --no-try-it')} Hide the "Try It" panel (the interactive API console)`,
` ${chalk.green(' --router=ROUTER')} Determines how navigation should work: history, hash, memory, static ${chalk.yellow('[default: "' + argd.router + '"]')}`,
` ${chalk.green(' --style=STYLE')} Additional style for Elements ${chalk.yellow('[default: "' + argd.style + '"]')}`,
` ${chalk.green(' --title=TITLE')} API docs title ${chalk.yellow('[default: "' + argd.title + '"]')}`,
` ${chalk.green(' --variable=VARIABLE')} Variable to be replaced in the OpenAPI document`,
` ${chalk.green('-v, --version')} Print version number`,
].join('\n'),
[
` Export rendered API docs based on local ${chalk.magenta('openapi.json')} path as ${chalk.magenta('index.html')}:`,
``,
` ${chalk.green(path.basename(process.argv[1]) + ' export openapi.json > index.html')}`,
``,
` Export rendered Swagger Petstore docs based on remote ${chalk.magenta('https://petstore.swagger.io/v2/swagger.json')} URL as ${chalk.magenta('index.html')}:`,
``,
` ${chalk.green(path.basename(process.argv[1]) + ' export --title="Swagger Petstore" https://petstore.swagger.io/v2/swagger.json > index.html')}`,
].join('\n'),
);
} else if (argv._[0] === 'preview') {
console.error(
`Elements CLI\n\n${chalk.yellow('Usage:')}\n%s\n\n${chalk.yellow('Arguments:')}\n%s\n\n${chalk.yellow('Options:')}\n%s\n\n${chalk.yellow('Examples:')}\n%s`,
` ${path.basename(process.argv[1])} preview [options] <openapi_json>`,
` ${chalk.green('openapi_json')} The path or URL of the OpenAPI JSON file`,
[
` ${chalk.green(' --base-path=BASE_PATH')} Use the given base path ${chalk.yellow('[default: "' + argd['base-path'] + '"]')}`,
` ${chalk.green(' --credentials-policy=CREDENTIALS_POLICY')} Credentials policy for "Try It" feature: omit, include, same-origin ${chalk.yellow('[default: "' + argd['credentials-policy'] + '"]')}`,
` ${chalk.green('-c --with-cors-proxy')} Enable CORS proxy capabilities`,
` ${chalk.green('-f, --filter-internal')} Filter out any content which has been marked as internal with x-internal`,
` ${chalk.green('-h, --help')} Display this help message`,
` ${chalk.green(' --hostname=HOSTNAME')} Server hostname ${chalk.yellow('[default: "' + argd.hostname + '"]')}`,
` ${chalk.green(' --layout=LAYOUT')} Layout for Elements: sidebar, stacked ${chalk.yellow('[default: "' + argd.layout + '"]')}`,
` ${chalk.green(' --logo=LOGO')} URL of an image that will show as a small square logo next to the title`,
` ${chalk.green('-n --no-try-it')} Hide the "Try It" panel (the interactive API console)`,
` ${chalk.green('-p, --poll')} Use polling instead of file system events`,
` ${chalk.green(' --port=PORT')} Server port ${chalk.yellow('[default: ' + argd.port + ']')}`,
` ${chalk.green(' --router=ROUTER')} Determines how navigation should work: history, hash, memory, static ${chalk.yellow('[default: "' + argd.router + '"]')}`,
` ${chalk.green(' --style=STYLE')} Additional style for Elements ${chalk.yellow('[default: "' + argd.style + '"]')}`,
` ${chalk.green(' --title=TITLE')} API docs title ${chalk.yellow('[default: "' + argd.title + '"]')}`,
` ${chalk.green(' --variable=VARIABLE')} Variable to be replaced in the OpenAPI document`,
` ${chalk.green('-v, --version')} Print version number`,
` ${chalk.green('-w --watch')} Watch for changes and reload (only for local files)`,
` ${chalk.green(' --virtual-host=VIRTUAL_HOST')} Reported hostname ${chalk.yellow('[default: ' + argd['virtual-host'] + ']')}`,
` ${chalk.green(' --virtual-port=VIRTUAL_PORT')} Reported port ${chalk.yellow('[default: ' + argd['virtual-port'] + ']')}`,
` ${chalk.green(' --working-dir=PWD')} Use the given directory as working directory`,
].join('\n'),
[
` Preview rendered API docs based on local ${chalk.magenta('openapi.json')} path:`,
``,
` ${chalk.green(path.basename(process.argv[1]) + ' preview openapi.json')}`,
``,
` Preview rendered Swagger Petstore docs based on remote ${chalk.magenta('https://petstore.swagger.io/v2/swagger.json')} URL:`,
``,
` ${chalk.green(path.basename(process.argv[1]) + ' preview --title="Swagger Petstore" https://petstore.swagger.io/v2/swagger.json')}`,
'',
` Preview local API docs, enable CORS proxy and watch/reload on data changes:`,
``,
` ${chalk.green(path.basename(process.argv[1]) + ' preview -cw openapi.json')}`,
].join('\n'),
);
} else {
console.error(
`Elements CLI\n\n${chalk.yellow('Usage:')}\n%s\n\n${chalk.yellow('Options:')}\n%s\n\n${chalk.yellow('Commands:')}\n%s`,
` ${path.basename(process.argv[1])} command [options] [arguments]`,
[
` ${chalk.green('-h, --help')} Display this help message`,
` ${chalk.green('-v, --version')} Print version number`,
].join('\n'),
[
` ${chalk.green('export')} Export rendered API docs`,
` ${chalk.green('preview')} Preview rendered API docs`,
].join('\n'),
);
}
process.exit(argv.help ? 0 : 1);
}
// Watching remote files is not supported
if (/^http(s)?:\/\//i.test(argv._[1])) {
argv.watch = false;
}
/**
* Replace double forward slashes, removes trailing slashes and optionally appends suffix
*
* @param {string} str The input string
* @param {string} suffix The optional suffix
*
* @returns {string}
*/
function sanitize(str, suffix = '') {
return str.replace(/\/+/g, '/').replace(/\/$/, '') + suffix;
}
/**
* Upgrade HTTP server with web socket server capabilities
*
* @param {http.Server} server The HTTP server instance
*
* @returns {ws.WebSocketServer}
*/
function upgrade(server) {
const wss = new WebSocketServer({ server });
return wss.on('connection', (socket) => {
socket.on('message', (message) => {
const request = JSON.parse(message);
if (request.command === 'hello') {
const data = JSON.stringify({
command: 'hello',
protocols: [
'http://livereload.com/protocols/official-7',
'http://livereload.com/protocols/official-8',
'http://livereload.com/protocols/official-9',
'http://livereload.com/protocols/2.x-origin-version-negotiation',
'http://livereload.com/protocols/2.x-remote-control',
],
serverName: 'elements-server',
});
socket.send(data);
}
});
});
}
/**
* Create file system watcher and broatcast all file changes to every client
*
* @param {string} filePath The file path to watch
* @param {ws.WebSocketServer} server The web socket server instance
*
* @returns {chokidar.FSWatcher}
*/
function watch(filePath, server) {
const watcher = chokidar.watch(filePath, {
ignoreInitial: true,
usePolling: argv.poll,
});
return watcher.on('all', (filePath) => {
const data = JSON.stringify({
command: 'reload',
path: filePath,
});
server.clients.forEach((socket) => socket.send(data));
});
}
// Define base href
const baseHref = sanitize(`/${argv['base-path']}`, '/');
// Define delimiters and variables
const delimiters = { open: '{{', close: '}}' },
variables = [argv.variable]
.flat()
.filter((variable) => !!variable)
.reduce((variables, variable) => {
const [name, value] = variable.split('=');
variables[name] = value;
return variables;
}, {});
// Export rendered API docs
if (argv._[0] === 'export') {
const input = await readFile(path.resolve(__dirname, 'views', 'index.handlebars'));
const template = handlebars.compile(input.toString('utf8'));
const version = pkg.dependencies['@stoplight/elements'];
let tryItCorsProxy;
if (argv['cors-proxy'] && !argv['no-try-it']) {
tryItCorsProxy = argv['cors-proxy'];
}
console.log(
template({
baseHref,
delimiters,
elements: {
apiDescriptionUrl: argv._[1],
basePath: baseHref,
hideInternal: argv['filter-internal'] ? 'true' : undefined,
hideTryIt: argv['no-try-it'] ? 'true' : undefined,
tryItCorsProxy,
tryItCredentialsPolicy: argv['credentials-policy'],
layout: argv.layout,
logo: argv.logo,
router: argv.router,
style: argv.style,
},
'elements-css': `https://unpkg.com/@stoplight/elements@${version}/styles.min.css`,
'elements-js': `https://unpkg.com/@stoplight/elements@${version}/web-components.min.js`,
layout: false,
livereload: false,
title: argv.title,
variables,
})
);
process.exit(0);
}
// Create express app
const app = express();
// Enable Handlebars view engine
app.engine('handlebars', engine());
app.set('view engine', 'handlebars');
app.set('views', path.join(__dirname, 'views'));
// Serve assets from node_modules
const assets = {
'livereload.js': require.resolve('livereload-js/dist/livereload.min.js'),
'styles.min.css': require.resolve('@stoplight/elements/styles.min.css'),
'web-components.min.js': require.resolve('@stoplight/elements/web-components.min.js'),
};
app.get(
Object.keys(assets).map((asset) =>
sanitize(`/${argv['base-path']}/${asset}`)
),
(req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
send(req, assets[path.basename(url.pathname)]).pipe(res);
}
);
// Serve static files from working directory
app.use(
sanitize(`/${argv['base-path']}`),
express.static(argv['working-dir'], { index: false })
);
// Handle CORS proxy requests
if (argv['with-cors-proxy'] && !argv['no-try-it']) {
const proxy = corsAnywhere.createServer({
originWhitelist: [], // Allow all origins
requireHeaders: [], // Do not require any headers
removeHeaders: [], // Do not remove any headers
});
app.all(sanitize(`/${argv["base-path"]}/_/*basePath`), (req, res) => {
const pos = req.originalUrl.indexOf("?");
const queryString = pos === -1 ? "" : req.originalUrl.substring(pos);
req.url = `/${req.params["0"]}${queryString}`;
proxy.emit("request", req, res);
});
}
// Render and serve index template
app.get(
[sanitize(`/${argv['base-path']}`, '*basePath'), sanitize(`/${argv['base-path']}`)],
(req, res) => {
let tryItCorsProxy;
if (argv['with-cors-proxy'] && !argv['no-try-it']) {
tryItCorsProxy = `http://${req.headers.host}${baseHref}_/`;
}
res.render('index', {
baseHref,
delimiters,
elements: {
apiDescriptionUrl: argv._[1],
basePath: baseHref,
hideInternal: argv['filter-internal'] ? 'true' : undefined,
hideTryIt: argv['no-try-it'] ? 'true' : undefined,
tryItCorsProxy,
tryItCredentialsPolicy: argv['credentials-policy'],
layout: argv.layout,
logo: argv.logo,
router: argv.router,
style: argv.style,
},
'elements-css': 'styles.min.css',
'elements-js': 'web-components.min.js',
layout: false,
'livereload-js': argv.watch ? 'livereload.js' : undefined,
title: argv.title,
variables,
});
}
);
// Listen for HTTP connections
const server = app.listen(argv.port, argv.hostname, () => {
console.error(`Elements server listening on ${argv.hostname}:${argv.port}`);
console.error(`Visit http://${argv['virtual-host']}:${argv['virtual-port']}${baseHref}`);
});
// Watch files in working directory and launch web socket server
const watcher = argv.watch
? watch(
argv['working-dir'],
upgrade(server).on('error', (err) => console.error(err))
)
.once('ready', () =>
console.error(`Watching ${path.resolve(argv['working-dir'])}`)
)
.on('error', (err) => console.error(err))
: undefined;
// Enable the graceful shutdown
gracefulShutdown(server, {
onShutdown: () =>
new Promise((resolve) => {
if (watcher) {
watcher.close();
}
resolve();
}),
});