mirror of
https://github.com/LukeHagar/redocly-cli.git
synced 2025-12-06 04:21:09 +00:00
feat: add build-docs command (#863)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,3 +11,4 @@ output/
|
||||
*.tar.gz
|
||||
*.tsbuildinfo
|
||||
*.tgz
|
||||
redoc-static.html
|
||||
|
||||
1815
package-lock.json
generated
1815
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@
|
||||
"stats": "npm run cli stats resources/pets.yaml",
|
||||
"split": "npm run cli split resources/pets.yaml -- --outDir output",
|
||||
"preview": "npm run cli preview-docs resources/pets.yaml",
|
||||
"build-docs": "npm run cli build-docs resources/pets.yaml",
|
||||
"benchmark": "node --expose-gc --noconcurrent_sweeping --predictable packages/core/src/benchmark/benchmark.js",
|
||||
"webpack-bundle": "webpack --config webpack.config.ts",
|
||||
"upload": "node scripts/archive-and-upload-bundle.js",
|
||||
@@ -55,7 +56,10 @@
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/mark.js": "^8.11.5",
|
||||
"@types/marked": "^4.0.3",
|
||||
"@types/node": "^17.0.31",
|
||||
"@types/react-tabs": "^2.3.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.33.0",
|
||||
"@typescript-eslint/parser": "^5.33.0",
|
||||
"eslint": "^8.22.0",
|
||||
@@ -63,7 +67,6 @@
|
||||
"null-loader": "^4.0.0",
|
||||
"outdent": "^0.7.1",
|
||||
"prettier": "^2.1.2",
|
||||
"shebang-loader": "0.0.1",
|
||||
"ts-jest": "^26.4.4",
|
||||
"ts-loader": "^8.0.2",
|
||||
"ts-node": "^9.0.0",
|
||||
|
||||
@@ -40,11 +40,20 @@
|
||||
"glob": "^7.1.6",
|
||||
"glob-promise": "^3.4.0",
|
||||
"handlebars": "^4.7.6",
|
||||
"mobx": "^6.3.2",
|
||||
"portfinder": "^1.0.26",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"redoc": "~2.0.0",
|
||||
"simple-websocket": "^9.0.0",
|
||||
"styled-components": "^5.3.0",
|
||||
"yargs": "17.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/configstore": "^5.0.1",
|
||||
"@types/react": "^17.0.8",
|
||||
"@types/react-dom": "^17.0.5",
|
||||
"@types/styled-components": "^5.1.1",
|
||||
"@types/yargs": "16.0.2",
|
||||
"typescript": "^4.0.3"
|
||||
}
|
||||
|
||||
@@ -2,3 +2,5 @@ export const existsSync = jest.fn();
|
||||
export const readFileSync = jest.fn(() => '');
|
||||
export const statSync = jest.fn(() => ({ size: 0 }));
|
||||
export const createReadStream = jest.fn();
|
||||
export const writeFileSync = jest.fn();
|
||||
export const mkdirSync = jest.fn();
|
||||
|
||||
2
packages/cli/src/__mocks__/redoc.ts
Normal file
2
packages/cli/src/__mocks__/redoc.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const loadAndBundleSpec = jest.fn(() => Promise.resolve({ openapi: '3.0.0' }));
|
||||
export const createStore = jest.fn(() => Promise.resolve({ toJS: jest.fn(() => '{}') }));
|
||||
58
packages/cli/src/__tests__/commands/build-docs.test.ts
Normal file
58
packages/cli/src/__tests__/commands/build-docs.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { createStore, loadAndBundleSpec } from 'redoc';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { handlerBuildCommand } from '../../commands/build-docs';
|
||||
import { BuildDocsArgv } from '../../commands/build-docs/types';
|
||||
import { getPageHTML } from '../../commands/build-docs/utils';
|
||||
|
||||
jest.mock('redoc');
|
||||
jest.mock('fs');
|
||||
|
||||
const config = {
|
||||
output: '',
|
||||
cdn: false,
|
||||
title: 'Test',
|
||||
disableGoogleFont: false,
|
||||
templateFileName: '',
|
||||
templateOptions: {},
|
||||
redocOptions: {},
|
||||
};
|
||||
|
||||
jest.mock('react-dom/server', () => ({
|
||||
renderToString: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('handlebars', () => ({
|
||||
compile: jest.fn(() => jest.fn(() => '<html></html>')),
|
||||
}));
|
||||
|
||||
jest.mock('mkdirp', () => ({
|
||||
sync: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('build-docs', () => {
|
||||
it('should return correct html and call function for ssr', async () => {
|
||||
const result = await getPageHTML({}, '../some-path/openapi.yaml', {
|
||||
...config,
|
||||
redocCurrentVersion: '2.0.0',
|
||||
});
|
||||
expect(renderToString).toBeCalledTimes(1);
|
||||
expect(createStore).toBeCalledTimes(1);
|
||||
expect(result).toBe('<html></html>');
|
||||
});
|
||||
|
||||
it('should work correctly when calling handlerBuildCommand', async () => {
|
||||
const processExitMock = jest.spyOn(process, 'exit').mockImplementation();
|
||||
await handlerBuildCommand({
|
||||
o: '',
|
||||
cdn: false,
|
||||
title: 'test',
|
||||
disableGoogleFont: false,
|
||||
template: '',
|
||||
templateOptions: {},
|
||||
options: {},
|
||||
api: '../some-path/openapi.yaml',
|
||||
} as BuildDocsArgv);
|
||||
expect(loadAndBundleSpec).toBeCalledTimes(1);
|
||||
expect(processExitMock).toBeCalledTimes(0);
|
||||
});
|
||||
});
|
||||
39
packages/cli/src/commands/build-docs/index.ts
Normal file
39
packages/cli/src/commands/build-docs/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { loadAndBundleSpec } from 'redoc';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
import { performance } from 'perf_hooks';
|
||||
|
||||
import { getObjectOrJSON, isURL, getPageHTML } from './utils';
|
||||
import type { BuildDocsArgv } from './types';
|
||||
import { exitWithError, getExecutionTime } from '../../utils';
|
||||
|
||||
export const handlerBuildCommand = async (argv: BuildDocsArgv) => {
|
||||
const startedAt = performance.now();
|
||||
const config = {
|
||||
output: argv.o,
|
||||
cdn: argv.cdn,
|
||||
title: argv.title,
|
||||
disableGoogleFont: argv.disableGoogleFont,
|
||||
templateFileName: argv.template,
|
||||
templateOptions: argv.templateOptions || {},
|
||||
redocOptions: getObjectOrJSON(argv.options),
|
||||
};
|
||||
|
||||
const redocCurrentVersion = require('../../../package.json').dependencies.redoc.substring(1); // remove ~
|
||||
const pathToApi = argv.api;
|
||||
|
||||
try {
|
||||
const elapsed = getExecutionTime(startedAt);
|
||||
const api = await loadAndBundleSpec(isURL(pathToApi) ? pathToApi : resolve(pathToApi));
|
||||
const pageHTML = await getPageHTML(api, pathToApi, { ...config, redocCurrentVersion });
|
||||
|
||||
mkdirSync(dirname(config.output), { recursive: true });
|
||||
writeFileSync(config.output, pageHTML);
|
||||
const sizeInKiB = Math.ceil(Buffer.byteLength(pageHTML) / 1024);
|
||||
process.stderr.write(
|
||||
`\n🎉 bundled successfully in: ${config.output} (${sizeInKiB} KiB) [⏱ ${elapsed}].\n`
|
||||
);
|
||||
} catch (e) {
|
||||
exitWithError(e);
|
||||
}
|
||||
};
|
||||
23
packages/cli/src/commands/build-docs/template.hbs
Normal file
23
packages/cli/src/commands/build-docs/template.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}}}
|
||||
{{#unless disableGoogleFont}}<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">{{/unless}}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{{{redocHTML}}}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
23
packages/cli/src/commands/build-docs/types.ts
Normal file
23
packages/cli/src/commands/build-docs/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export type BuildDocsOptions = {
|
||||
watch?: boolean;
|
||||
cdn?: boolean;
|
||||
output?: string;
|
||||
title?: string;
|
||||
disableGoogleFont?: boolean;
|
||||
port?: number;
|
||||
templateFileName?: string;
|
||||
templateOptions?: any;
|
||||
redocOptions?: any;
|
||||
redocCurrentVersion: string;
|
||||
};
|
||||
|
||||
export type BuildDocsArgv = {
|
||||
api: string;
|
||||
o: string;
|
||||
cdn: boolean;
|
||||
title?: string;
|
||||
disableGoogleFont?: boolean;
|
||||
template?: string;
|
||||
templateOptions: Record<string, any>;
|
||||
options: string | Record<string, unknown>;
|
||||
};
|
||||
116
packages/cli/src/commands/build-docs/utils.ts
Normal file
116
packages/cli/src/commands/build-docs/utils.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import * as React from 'react';
|
||||
import { createStore, Redoc } from 'redoc';
|
||||
import { parseYaml, findConfig, Config } from '@redocly/openapi-core';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { ServerStyleSheet } from 'styled-components';
|
||||
import { compile } from 'handlebars';
|
||||
import { join } from 'path';
|
||||
import { existsSync, lstatSync, readFileSync } from 'fs';
|
||||
|
||||
import type { BuildDocsOptions } from './types';
|
||||
import { red, yellow } from 'colorette';
|
||||
import { exitWithError } from '../../utils';
|
||||
|
||||
export function getObjectOrJSON(
|
||||
options: string | Record<string, unknown>
|
||||
): JSON | Record<string, unknown> | Config {
|
||||
switch (typeof options) {
|
||||
case 'object':
|
||||
return options;
|
||||
case 'string':
|
||||
try {
|
||||
if (existsSync(options) && lstatSync(options).isFile()) {
|
||||
return JSON.parse(readFileSync(options, 'utf-8'));
|
||||
} else {
|
||||
return JSON.parse(options);
|
||||
}
|
||||
} catch (e) {
|
||||
process.stderr.write(
|
||||
red(
|
||||
`Encountered error:\n\n${options}\n\nis neither a file with a valid JSON object neither a stringified JSON object.`
|
||||
)
|
||||
);
|
||||
exitWithError(e);
|
||||
}
|
||||
break;
|
||||
default: {
|
||||
const configFile = findConfig();
|
||||
if (configFile) {
|
||||
process.stderr.write(`Found ${configFile} and using features.openapi options`);
|
||||
try {
|
||||
const config = parseYaml(readFileSync(configFile, 'utf-8')) as Config;
|
||||
|
||||
return config['features.openapi'];
|
||||
} catch (e) {
|
||||
process.stderr.write(yellow(`Found ${configFile} but failed to parse: ${e.message}`));
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export async function getPageHTML(
|
||||
api: any,
|
||||
pathToApi: string,
|
||||
{
|
||||
cdn,
|
||||
title,
|
||||
disableGoogleFont,
|
||||
templateFileName,
|
||||
templateOptions,
|
||||
redocOptions = {},
|
||||
redocCurrentVersion,
|
||||
}: BuildDocsOptions
|
||||
) {
|
||||
process.stderr.write('Prerendering docs');
|
||||
|
||||
const apiUrl = redocOptions.specUrl || (isURL(pathToApi) ? pathToApi : undefined);
|
||||
const store = await createStore(api, apiUrl, redocOptions);
|
||||
const sheet = new ServerStyleSheet();
|
||||
|
||||
const html = renderToString(sheet.collectStyles(React.createElement(Redoc, { store })));
|
||||
const state = await store.toJS();
|
||||
const css = sheet.getStyleTags();
|
||||
|
||||
templateFileName = templateFileName ? templateFileName : join(__dirname, './template.hbs');
|
||||
const template = compile(readFileSync(templateFileName).toString());
|
||||
return template({
|
||||
redocHTML: `
|
||||
<div id="redoc">${html || ''}</div>
|
||||
<script>
|
||||
${`const __redoc_state = ${sanitizeJSONString(JSON.stringify(state))};` || ''}
|
||||
|
||||
var container = document.getElementById('redoc');
|
||||
Redoc.${'hydrate(__redoc_state, container)'};
|
||||
|
||||
</script>`,
|
||||
redocHead:
|
||||
(cdn
|
||||
? '<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>'
|
||||
: `<script src="https://cdn.redoc.ly/redoc/v${redocCurrentVersion}/bundles/redoc.standalone.js"></script>`) +
|
||||
css,
|
||||
title: title || api.info.title || 'ReDoc documentation',
|
||||
disableGoogleFont,
|
||||
templateOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export function isURL(str: string): boolean {
|
||||
return /^(https?:)\/\//m.test(str);
|
||||
}
|
||||
|
||||
export function sanitizeJSONString(str: string): string {
|
||||
return escapeClosingScriptTag(escapeUnicode(str));
|
||||
}
|
||||
|
||||
// see http://www.thespanner.co.uk/2011/07/25/the-json-specification-is-now-wrong/
|
||||
export function escapeClosingScriptTag(str: string): string {
|
||||
return str.replace(/<\/script>/g, '<\\/script>');
|
||||
}
|
||||
|
||||
// see http://www.thespanner.co.uk/2011/07/25/the-json-specification-is-now-wrong/
|
||||
export function escapeUnicode(str: string): string {
|
||||
return str.replace(/\u2028|\u2029/g, (m) => '\\u202' + (m === '\u2028' ? '8' : '9'));
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@redocly/openapi-core';
|
||||
import { getFallbackApisOrExit } from '../../utils';
|
||||
import startPreviewServer from './preview-server/preview-server';
|
||||
import type { Skips } from 'cli/src/types';
|
||||
import type { Skips } from '../../types';
|
||||
|
||||
export async function previewDocs(
|
||||
argv: {
|
||||
|
||||
1
packages/cli/src/custom.d.ts
vendored
Normal file
1
packages/cli/src/custom.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
type GenericObject = Record<string, any>;
|
||||
@@ -12,6 +12,8 @@ import { handlePush, transformPush } from './commands/push';
|
||||
import { handleLint } from './commands/lint';
|
||||
import { handleBundle } from './commands/bundle';
|
||||
import { handleLogin } from './commands/login';
|
||||
import { handlerBuildCommand } from './commands/build-docs';
|
||||
import type { BuildDocsArgv } from './commands/build-docs/types';
|
||||
const version = require('../package.json').version;
|
||||
|
||||
yargs
|
||||
@@ -357,6 +359,61 @@ yargs
|
||||
previewDocs(argv);
|
||||
}
|
||||
)
|
||||
.command(
|
||||
'build-docs [api]',
|
||||
'build definition into zero-dependency HTML-file',
|
||||
(yargs) => {
|
||||
yargs.positional('api', {
|
||||
describe: 'path or URL to your api',
|
||||
});
|
||||
|
||||
yargs.option('o', {
|
||||
describe: 'Output file',
|
||||
alias: 'output',
|
||||
type: 'string',
|
||||
default: 'redoc-static.html',
|
||||
});
|
||||
|
||||
yargs.options('title', {
|
||||
describe: 'Page Title',
|
||||
type: 'string',
|
||||
});
|
||||
|
||||
yargs.options('disableGoogleFont', {
|
||||
describe: 'Disable Google Font',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
});
|
||||
|
||||
yargs.option('cdn', {
|
||||
describe: 'Do not include ReDoc source code into html page, use link to CDN instead',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
});
|
||||
|
||||
yargs.options('t', {
|
||||
alias: 'template',
|
||||
describe: 'Path to handlebars page template, see https://git.io/vh8fP for the example ',
|
||||
type: 'string',
|
||||
});
|
||||
|
||||
yargs.options('templateOptions', {
|
||||
describe:
|
||||
'Additional options that you want pass to template. Use dot notation, e.g. templateOptions.metaDescription',
|
||||
});
|
||||
|
||||
yargs.options('options', {
|
||||
describe: 'ReDoc options, use dot notation, e.g. options.nativeScrollbars',
|
||||
});
|
||||
|
||||
yargs.demandOption('api');
|
||||
return yargs;
|
||||
},
|
||||
async (argv) => {
|
||||
process.env.REDOCLY_CLI_COMMAND = 'build-docs';
|
||||
handlerBuildCommand(argv as unknown as BuildDocsArgv);
|
||||
}
|
||||
)
|
||||
.completion('completion', 'Generate completion script.')
|
||||
.demandCommand(1)
|
||||
.strict().argv;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"strictFunctionTypes": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowJs": false,
|
||||
"lib": ["es2020", "es2020.string"],
|
||||
"lib": ["es2020", "es2020.string", "dom"],
|
||||
"baseUrl": "./packages"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,6 @@ module.exports = {
|
||||
{
|
||||
loader: 'ts-loader',
|
||||
},
|
||||
{
|
||||
loader: 'shebang-loader',
|
||||
},
|
||||
],
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user