mirror of
https://github.com/LukeHagar/redocly-cli.git
synced 2025-12-06 04:21:09 +00:00
feat: add basic preview-docs command (#91)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -46,6 +46,7 @@ export function getConfig(options) {
|
||||
}
|
||||
|
||||
const resolvedConfig = merge(defaultConfig, config, options);
|
||||
resolvedConfig.configPath = configPath;
|
||||
|
||||
const lintConfig = resolvedConfig.lint;
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ function createContext(node, sourceFile, filePath, config) {
|
||||
getRule,
|
||||
resolveNode: resolveNodeNoSideEffects,
|
||||
|
||||
fileDependencies: new Set(),
|
||||
|
||||
resolveCache: {},
|
||||
};
|
||||
}
|
||||
|
||||
23
src/preview-docs/default.hbs
Normal file
23
src/preview-docs/default.hbs
Normal 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
43
src/preview-docs/hot.js
Normal 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
85
src/preview-docs/index.js
Normal 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);
|
||||
}
|
||||
84
src/preview-docs/server.js
Normal file
84
src/preview-docs/server.js
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user