feat: add build-docs command (#863)

This commit is contained in:
Alex Varchuk
2022-09-19 11:39:31 +03:00
committed by GitHub
parent bb8b9121c4
commit 6f21fc4cb8
16 changed files with 1866 additions and 294 deletions

1
.gitignore vendored
View File

@@ -11,3 +11,4 @@ output/
*.tar.gz
*.tsbuildinfo
*.tgz
redoc-static.html

1815
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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();

View 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(() => '{}') }));

View 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);
});
});

View 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);
}
};

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}}}
{{#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>

View 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>;
};

View 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'));
}

View File

@@ -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
View File

@@ -0,0 +1 @@
type GenericObject = Record<string, any>;

View File

@@ -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;

View File

@@ -13,7 +13,7 @@
"strictFunctionTypes": true,
"forceConsistentCasingInFileNames": true,
"allowJs": false,
"lib": ["es2020", "es2020.string"],
"lib": ["es2020", "es2020.string", "dom"],
"baseUrl": "./packages"
}
}

View File

@@ -12,9 +12,6 @@ module.exports = {
{
loader: 'ts-loader',
},
{
loader: 'shebang-loader',
},
],
exclude: /node_modules/,
},