feat: add basic preview-docs command (#91)

This commit is contained in:
Roman Hotsiy
2020-01-15 22:42:03 +02:00
parent 43a92bd0df
commit e8c2c29443
14 changed files with 367 additions and 9 deletions

View File

@@ -18,4 +18,6 @@ rules:
import/no-dynamic-require: off
no-plusplus: off
no-restricted-syntax: off
no-console: off
no-use-before-define: off

View File

@@ -4,7 +4,7 @@
"description": "",
"main": "./dist/index.js",
"scripts": {
"build": "babel src --out-dir dist --source-maps inline && chmod +x ./dist/index.js && cp ./package.json ./dist/package.json && cp src/.redocly.yaml dist/.redocly.yaml",
"build": "babel src --out-dir dist --source-maps inline && chmod +x ./dist/index.js && cp ./package.json ./dist/package.json && cp src/.redocly.yaml dist/.redocly.yaml && cp src/preview-docs/default.hbs dist/preview-docs",
"lint": "eslint ./src",
"prepublishOnly": "npm run build && cp src/.redocly.yaml dist/.redocly.yaml",
"test": "jest --coverage"
@@ -54,10 +54,14 @@
},
"dependencies": {
"chalk": "^2.4.2",
"chokidar": "^3.3.1",
"commander": "^3.0.1",
"handlebars": "^4.7.2",
"js-yaml": "^3.13.1",
"lodash.isequal": "^4.5.0",
"merge-deep": "^3.0.2",
"portfinder": "^1.0.25",
"simple-websocket": "^8.1.1",
"xmlhttprequest": "^1.8.0",
"yaml-ast-parser": "0.0.43"
}

View File

@@ -1,7 +1,6 @@
import fs from 'fs';
import yaml from 'js-yaml';
import { getLintConfig } from './config';
import traverseNode from './traverse';
import createContext from './context';
@@ -38,7 +37,7 @@ export const bundleToFile = (fName, outputFile, force) => {
return ctx.result;
};
export const bundle = (fName, force) => {
export const bundle = (fName, force, options) => {
const resolvedFileName = fName; // path.resolve(fName);
const doc = fs.readFileSync(resolvedFileName, 'utf-8');
let document;
@@ -51,7 +50,7 @@ export const bundle = (fName, force) => {
if (!document.openapi) { return []; }
const config = getLintConfig({});
const config = getLintConfig(options);
config.rules = {
...config.rules,
bundler: {
@@ -65,7 +64,7 @@ export const bundle = (fName, force) => {
traverseNode(document, OpenAPIRoot, ctx);
return ctx.bundlingResult;
return { bundle: ctx.bundlingResult, result: ctx.result, fileDependencies: ctx.fileDependencies };
};
export default bundleToFile;

View File

@@ -6,15 +6,18 @@ import fs from 'fs';
import {
join, basename, dirname, extname,
} from 'path';
import * as chockidar from 'chokidar';
import { validateFromFile, validateFromUrl } from '../validate';
import { bundleToFile } from '../bundle';
import { bundle, bundleToFile } from '../bundle';
import { isFullyQualifiedUrl } from '../utils';
import { isFullyQualifiedUrl, debounce } from '../utils';
import { outputMessages, printValidationHeader } from './outputMessages';
import { getFallbackEntryPointsOrExit, getConfig } from '../config';
import startPreviewServer from '../preview-docs';
const validateFile = (filePath, options, cmdObj) => {
let result;
@@ -61,7 +64,6 @@ const cli = () => {
// eslint-disable-next-line no-param-reassign
entryPoints = getFallbackEntryPointsOrExit(entryPoints, config);
const isOutputDir = cmdObj.output && !extname(cmdObj.output);
const ext = cmdObj.ext || extname(cmdObj.output || '').substring(1) || 'yaml';
const dir = isOutputDir ? cmdObj.output : dirname(cmdObj.output || '');
@@ -138,6 +140,97 @@ const cli = () => {
process.exit(results.errors > 0 ? 1 : 0);
});
function myParseInt(value) {
return parseInt(value, 10);
}
program
.command('preview-docs [entryPoint]')
.description('Preview API Reference docs for the specified entrypoint OAS definition')
.option('-p, --port <value>', 'Preview port', myParseInt, 8080)
.action(async (entryPoint, cmdObj) => {
const output = 'dist/openapi.yaml';
let config = getConfig({});
if (!entryPoint) {
// eslint-disable-next-line no-param-reassign, prefer-destructuring
entryPoint = getFallbackEntryPointsOrExit([], config)[0];
}
let cachedBundle;
const deps = new Set();
async function getBundle() {
return cachedBundle;
}
function updateBundle() {
cachedBundle = new Promise((resolve) => {
process.stdout.write('\nBundling...\n\n');
const { bundle: openapiBundle, result, fileDependencies } = bundle(entryPoint, output, {
lint: {
codeframes: false,
},
});
const removed = [...deps].filter((x) => !fileDependencies.has(x));
watcher.unwatch(removed);
watcher.add([...fileDependencies]);
deps.clear();
fileDependencies.forEach(deps.add, deps);
const resultStats = outputMessages(result, { short: true });
if (resultStats.totalErrors === 0) {
process.stdout.write(
resultStats.totalErrors === 0
? `Created a bundle for ${entryPoint} ${resultStats.totalWarnings > 0 ? 'with warnings' : 'successfully'}\n`
: chalk.yellow(`Created a bundle for ${entryPoint} with errors. Docs may be broken or not accurate\n`),
);
}
resolve(openapiBundle);
});
}
setImmediate(() => updateBundle()); // initial cache
const hotClients = await startPreviewServer(cmdObj.port, {
getBundle,
getOptions: () => config.referenceDocs,
});
const watcher = chockidar.watch([entryPoint, config.configPath], {
disableGlobbing: true,
ignoreInitial: true,
});
const debouncedUpdatedeBundle = debounce(async () => {
updateBundle();
await cachedBundle;
hotClients.broadcast('{"type": "reload", "bundle": true}');
}, 2000);
const changeHandler = async (type, file) => {
process.stdout.write(`${chalk.green('watch')} ${type} ${chalk.blue(file)}\n`);
if (file === config.configPath) {
config = getConfig({ configPath: file });
hotClients.broadcast(JSON.stringify({ type: 'reload' }));
return;
}
debouncedUpdatedeBundle();
};
watcher.on('change', changeHandler.bind(undefined, 'changed'));
watcher.on('add', changeHandler.bind(undefined, 'added'));
watcher.on('unlink', changeHandler.bind(undefined, 'removed'));
watcher.on('ready', () => {
process.stdout.write(`\n 👀 Watching ${chalk.blue(entryPoint)} and all related resources for changes\n`);
});
});
program.on('command:*', () => {
process.stderr.write(`\nUnknown command ${program.args.join(' ')}\n\n`);
program.outputHelp();

View File

@@ -46,6 +46,7 @@ export function getConfig(options) {
}
const resolvedConfig = merge(defaultConfig, config, options);
resolvedConfig.configPath = configPath;
const lintConfig = resolvedConfig.lint;

View File

@@ -54,6 +54,8 @@ function createContext(node, sourceFile, filePath, config) {
getRule,
resolveNode: resolveNodeNoSideEffects,
fileDependencies: new Set(),
resolveCache: {},
};
}

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf8" />
<title>{{title}}</title>
<!-- needed for adaptive design -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
padding: 0;
margin: 0;
}
</style>
{{{redocHead}}}
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
</head>
<body>
{{{redocHTML}}}
</body>
</html>

43
src/preview-docs/hot.js Normal file
View File

@@ -0,0 +1,43 @@
/* eslint-disable no-underscore-dangle */
(function run() {
const Socket = window.SimpleWebsocket;
const port = window.__OPENAPI_CLI_WS_PORT;
let socket;
reconnect();
function reconnect() {
socket = new Socket(`ws://127.0.0.1:${port}`);
socket.on('connect', () => {
socket.send('{"type": "ping"}');
});
socket.on('data', (data) => {
const message = JSON.parse(data);
switch (message.type) {
case 'pong':
console.log('[hot] hot reloading connected');
break;
case 'reload':
console.log('[hot] full page reload');
window.location.reload();
break;
default:
console.log(`[hot] ${message.type} received`);
}
});
socket.on('close', () => {
socket.destroy();
console.log('Connection lost, trying to reconnect in 4s');
setTimeout(() => {
reconnect();
}, 4000);
});
socket.on('error', () => {
socket.destroy();
});
}
}());

85
src/preview-docs/index.js Normal file
View File

@@ -0,0 +1,85 @@
// import { watch } from 'chokidar';
import { compile } from 'handlebars';
import chalk from 'chalk';
import * as portfinder from 'portfinder';
import { readFileSync, promises as fsPromises } from 'fs';
import * as path from 'path';
import {
startHttpServer, startWsServer, respondWithGzip, mimeTypes,
} from './server';
function getPageHTML(htmlTemplate, redocOptions = {}, wsPort) {
const template = compile(readFileSync(htmlTemplate, 'utf-8'));
return template({
redocHead: `
<script>
window.__REDOC_EXPORT = '${redocOptions.licenseKey ? 'RedoclyAPIReference' : 'Redoc'}';
window.__OPENAPI_CLI_WS_PORT = ${wsPort};
</script>
<script src="/simplewebsocket.min.js"></script>
<script src="/hot.js"></script>
<script src="${redocOptions.licenseKey
? 'https://cdn.jsdelivr.net/npm/@redocly/api-reference@latest/dist/redocly-api-reference.min.js'
: 'https://cdn.jsdelivr.net/npm/redoc@latest/bundles/redoc.standalone.js'}"></script>
`,
redocHTML: `
<div id="redoc"></div>
<script>
var container = document.getElementById('redoc');
${redocOptions.licenseKey ? "window[window.__REDOC_EXPORT].setPublicPath('https://cdn.jsdelivr.net/npm/@redocly/api-reference@latest/dist/');" : ''}
window[window.__REDOC_EXPORT].init("openapi.json", ${JSON.stringify(redocOptions)}, container)
</script>`,
});
}
export default async function startPreviewServer(port, {
getBundle,
getOptions,
htmlTemplate = path.join(__dirname, 'default.hbs'),
}) {
const handler = async (request, response) => {
console.time(chalk.dim(`GET ${request.url}`));
if (request.url === '/') {
respondWithGzip(getPageHTML(htmlTemplate, getOptions(), wsPort), request, response, {
'Content-Type': 'text/html',
});
} else if (request.url === '/openapi.json') {
respondWithGzip(JSON.stringify(await getBundle()), request, response, {
'Content-Type': 'application/json',
});
} else {
const filePath = {
'/hot.js': path.join(__dirname, 'hot.js'),
'/simplewebsocket.min.js': require.resolve('simple-websocket/simplewebsocket.min.js'),
}[request.url] || path.resolve(path.dirname(htmlTemplate), `.${request.url}`);
const extname = String(path.extname(filePath)).toLowerCase();
const contentType = mimeTypes[extname] || 'application/octet-stream';
try {
respondWithGzip(await fsPromises.readFile(filePath, 'utf-8'), request, response, {
'Content-Type': contentType,
});
} catch (e) {
if (e.code === 'ENOENT') {
respondWithGzip('404 Not Found', request, response, { 'Content-Type': 'text/html' }, 404);
} else {
respondWithGzip(`Something went wrong: ${e.code}...\n`, request, response, {}, 500);
}
}
}
console.timeEnd(chalk.dim(`GET ${request.url}`));
};
let wsPort = await portfinder.getPortPromise({ port: 32201 });
const server = startHttpServer(port, handler);
server.on('listening', () => {
process.stdout.write(`\n 🔎 Preview server running at ${chalk.blue(`http://127.0.0.1:${port}\n`)}`);
});
return startWsServer(wsPort);
}

View File

@@ -0,0 +1,84 @@
import * as http from 'http';
import * as zlib from 'zlib';
const SocketServer = require('simple-websocket/server');
export const mimeTypes = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.wav': 'audio/wav',
'.mp4': 'video/mp4',
'.woff': 'application/font-woff',
'.ttf': 'application/font-ttf',
'.eot': 'application/vnd.ms-fontobject',
'.otf': 'application/font-otf',
'.wasm': 'application/wasm',
};
// credits: https://stackoverflow.com/a/9238214/1749888
export function respondWithGzip(contents, request, response, headers = {}, code = 200) {
let compressedStream;
const acceptEncoding = request.headers['accept-encoding'] || '';
if (acceptEncoding.match(/\bdeflate\b/)) {
response.writeHead(code, { ...headers, 'content-encoding': 'deflate' });
compressedStream = zlib.createDeflate();
} else if (acceptEncoding.match(/\bgzip\b/)) {
response.writeHead(code, { ...headers, 'content-encoding': 'gzip' });
compressedStream = zlib.createGzip();
} else {
response.writeHead(code, 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();
} else {
contents.pipe(compressedStream).pipe(response);
}
}
export function startHttpServer(port, handler) {
return http.createServer(handler).listen(port);
}
export function startWsServer(port) {
const socketServer = new SocketServer({ port, clientTracking: true });
socketServer.on('connection', (socket) => {
socket.on('data', (data) => {
const message = JSON.parse(data);
switch (message.type) {
case 'ping':
socket.send('{"type": "pong"}');
break;
default:
// nope
}
});
});
socketServer.broadcast = (message) => {
// eslint-disable-next-line no-underscore-dangle
socketServer._server.clients.forEach((client) => {
if (client.readyState === 1) { // OPEN
client.send(message);
}
});
};
return socketServer;
}

View File

@@ -60,6 +60,7 @@ function resolve(link, ctx, visited = []) {
if (ctx.resolveCache[resolvedFilePath]) {
({ source, document } = ctx.resolveCache[resolvedFilePath]);
} else if (fs.existsSync(resolvedFilePath)) {
ctx.fileDependencies.add(resolvedFilePath);
// FIXME: if refernced e.g. md file, no need to parse
source = fs.readFileSync(resolvedFilePath, 'utf-8');
document = yaml.safeLoad(source);

View File

@@ -11,6 +11,7 @@ export default function resolveScalars(resolvedNode, definition, ctx) {
.filter((k) => resolvedNode[k].$ref)
.forEach((k) => {
const resolvedFilePath = path.resolve(path.dirname(ctx.filePath), resolvedNode[k].$ref);
ctx.fileDependencies.add(resolvedFilePath);
if (fs.existsSync(resolvedFilePath)) {
resolvedNode[k] = fs.readFileSync(resolvedFilePath, 'utf-8');
} else {

View File

@@ -1,6 +1,5 @@
/* eslint-disable no-underscore-dangle */
/* eslint-disable no-case-declarations */
/* eslint-disable no-use-before-define */
import path from 'path';
import resolveNode, { popPath } from './resolver';

View File

@@ -185,3 +185,24 @@ export const getFileSync = (link) => {
export function isRef(node) {
return node && Object.prototype.hasOwnProperty.call(node, '$ref');
}
export function debounce(func, wait, immediate) {
let timeout;
return function executedFunction(...args) {
const context = this;
const later = () => {
timeout = null;
if (!immediate) func.apply(context, args);
};
const callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
}