Repository: Redocly/redocly-cli
Pull request #881 (2022-09-21 12:59:21)

fix: improve build-docs command
## What/Why/How?

- fix `template.hbs` problem (we do not include `template.hbs` in publish flow for `build-docs` command. This should fix it.)
- add `apis` to build-docs
- rename `options` to `features.openapi`
## Reference

## Testing

## Screenshots (optional)
**BEFORE**
<img width="1433" alt="Screenshot 2022-09-21 at 17 09 10" src="https://user-images.githubusercontent.com/14113673/191527049-8b511904-413d-4362-841d-4217992d1014.png">


**AFTER**
<img width="1433" alt="Screenshot 2022-09-21 at 17 07 42" src="https://user-images.githubusercontent.com/14113673/191526765-ff96a95b-2c64-4b2d-add3-be76c35f9c9a.png">

## Check yourself

- [x] Code is linted
- [x] Tested with redoc/reference-docs/workflows
- [x] All new/updated code is covered with tests

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines



Patch:
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 86978aa6..cb284376 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -13,7 +13,7 @@
   "engineStrict": true,
   "scripts": {
     "compile": "tsc",
-    "copy-assets": "cp src/commands/preview-docs/preview-server/default.hbs lib/commands/preview-docs/preview-server/default.hbs && cp src/commands/preview-docs/preview-server/hot.js lib/commands/preview-docs/preview-server/hot.js && cp src/commands/preview-docs/preview-server/oauth2-redirect.html lib/commands/preview-docs/preview-server/oauth2-redirect.html",
+    "copy-assets": "cp src/commands/preview-docs/preview-server/default.hbs lib/commands/preview-docs/preview-server/default.hbs && cp src/commands/preview-docs/preview-server/hot.js lib/commands/preview-docs/preview-server/hot.js && cp src/commands/preview-docs/preview-server/oauth2-redirect.html lib/commands/preview-docs/preview-server/oauth2-redirect.html && cp src/commands/build-docs/template.hbs lib/commands/build-docs/template.hbs",
     "prepublishOnly": "npm run copy-assets"
   },
   "repository": {
diff --git a/packages/cli/src/commands/build-docs/index.ts b/packages/cli/src/commands/build-docs/index.ts
index 556d6033..5d98bd66 100644
--- a/packages/cli/src/commands/build-docs/index.ts
+++ b/packages/cli/src/commands/build-docs/index.ts
@@ -5,33 +5,43 @@ import { performance } from 'perf_hooks';
 
 import { getObjectOrJSON, isURL, getPageHTML } from './utils';
 import type { BuildDocsArgv } from './types';
-import { exitWithError, getExecutionTime } from '../../utils';
+import { exitWithError, getExecutionTime, getFallbackApisOrExit } from '../../utils';
+import { getMergedConfig, loadConfig } from '@redocly/openapi-core';
 
 export const handlerBuildCommand = async (argv: BuildDocsArgv) => {
   const startedAt = performance.now();
-  const config = {
+
+  const configFromFile = await loadConfig({ configPath: argv.config });
+  const config = getMergedConfig(configFromFile, argv.api);
+
+  const apis = await getFallbackApisOrExit(argv.api ? [argv.api] : [], config);
+  const { path: pathToApi } = apis[0];
+
+  const options = {
     output: argv.o,
     cdn: argv.cdn,
     title: argv.title,
     disableGoogleFont: argv.disableGoogleFont,
     templateFileName: argv.template,
     templateOptions: argv.templateOptions || {},
-    redocOptions: getObjectOrJSON(argv.options),
+    redocOptions: getObjectOrJSON(argv.features?.openapi, config),
   };
 
   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 api = await loadAndBundleSpec(
+      isURL(pathToApi) ? pathToApi : resolve(argv.config ? dirname(argv.config) : '', pathToApi)
+    );
+    const pageHTML = await getPageHTML(api, pathToApi, { ...options, redocCurrentVersion });
+
+    mkdirSync(dirname(options.output), { recursive: true });
+    writeFileSync(options.output, pageHTML);
     const sizeInKiB = Math.ceil(Buffer.byteLength(pageHTML) / 1024);
     process.stderr.write(
-      `\n🎉 bundled successfully in: ${config.output} (${sizeInKiB} KiB) [⏱ ${elapsed}].\n`
+      `\n🎉 bundled successfully in: ${options.output} (${sizeInKiB} KiB) [⏱ ${elapsed}].\n`
     );
   } catch (e) {
     exitWithError(e);
diff --git a/packages/cli/src/commands/build-docs/types.ts b/packages/cli/src/commands/build-docs/types.ts
index 8112e8fc..4c17255d 100644
--- a/packages/cli/src/commands/build-docs/types.ts
+++ b/packages/cli/src/commands/build-docs/types.ts
@@ -19,5 +19,8 @@ export type BuildDocsArgv = {
   disableGoogleFont?: boolean;
   template?: string;
   templateOptions: Record<string, any>;
-  options: string | Record<string, unknown>;
+  features: {
+    openapi: string | Record<string, unknown>;
+  };
+  config?: string;
 };
diff --git a/packages/cli/src/commands/build-docs/utils.ts b/packages/cli/src/commands/build-docs/utils.ts
index d83339b9..1d0fffcb 100644
--- a/packages/cli/src/commands/build-docs/utils.ts
+++ b/packages/cli/src/commands/build-docs/utils.ts
@@ -1,6 +1,6 @@
 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';
@@ -8,42 +8,38 @@ import { join } from 'path';
 import { existsSync, lstatSync, readFileSync } from 'fs';
 
 import type { BuildDocsOptions } from './types';
-import { red, yellow } from 'colorette';
+import type { Config } from '@redocly/openapi-core';
+import { red } from 'colorette';
 import { exitWithError } from '../../utils';
 
 export function getObjectOrJSON(
-  options: string | Record<string, unknown>
+  openapiOptions: string | Record<string, unknown>,
+  config: Config
 ): JSON | Record<string, unknown> | Config {
-  switch (typeof options) {
+  switch (typeof openapiOptions) {
     case 'object':
-      return options;
+      return openapiOptions;
     case 'string':
       try {
-        if (existsSync(options) && lstatSync(options).isFile()) {
-          return JSON.parse(readFileSync(options, 'utf-8'));
+        if (existsSync(openapiOptions) && lstatSync(openapiOptions).isFile()) {
+          return JSON.parse(readFileSync(openapiOptions, 'utf-8'));
         } else {
-          return JSON.parse(options);
+          return JSON.parse(openapiOptions);
         }
       } 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.`
+            `Encountered error:\n\n${openapiOptions}\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;
+      if (config) {
+        process.stderr.write(`Found ${config.configFile} and using features.openapi options`);
 
-          return config['features.openapi'];
-        } catch (e) {
-          process.stderr.write(yellow(`Found ${configFile} but failed to parse: ${e.message}`));
-        }
+        return config['features.openapi'];
       }
       return {};
     }
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 548df598..2e060555 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -362,53 +362,53 @@ yargs
   .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;
-    },
+    (yargs) =>
+      yargs
+        .positional('api', { type: 'string' })
+        .options({
+          o: {
+            describe: 'Output file',
+            alias: 'output',
+            type: 'string',
+            default: 'redoc-static.html',
+          },
+          title: {
+            describe: 'Page Title',
+            type: 'string',
+          },
+          disableGoogleFont: {
+            describe: 'Disable Google Font',
+            type: 'boolean',
+            default: false,
+          },
+          cdn: {
+            describe: 'Do not include Redoc source code into html page, use link to CDN instead',
+            type: 'boolean',
+            default: false,
+          },
+          t: {
+            alias: 'template',
+            describe: 'Path to handlebars page template, see https://git.io/vh8fP for the example',
+            type: 'string',
+          },
+          templateOptions: {
+            describe:
+              'Additional options that you want pass to template. Use dot notation, e.g. templateOptions.metaDescription',
+          },
+          features: {
+            describe:
+              'Redoc features.openapi, use dot notation, e.g. features.openapi.nativeScrollbars',
+          },
+          config: {
+            describe: 'Specify path to the config file.',
+            type: 'string',
+          },
+        })
+        .check((argv: any) => {
+          if (argv.features && !argv.features?.openapi)
+            throw Error('Invalid option: features.openapi not set');
+          return true;
+        }),
     async (argv) => {
       process.env.REDOCLY_CLI_COMMAND = 'build-docs';
       handlerBuildCommand(argv as unknown as BuildDocsArgv);


Test patch:
diff --git a/packages/cli/src/__tests__/commands/build-docs.test.ts b/packages/cli/src/__tests__/commands/build-docs.test.ts
index 031e94c5..39dde6ef 100644
--- a/packages/cli/src/__tests__/commands/build-docs.test.ts
+++ b/packages/cli/src/__tests__/commands/build-docs.test.ts
@@ -3,9 +3,11 @@ 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';
+import { getFallbackApisOrExit } from '../../utils';
 
 jest.mock('redoc');
 jest.mock('fs');
+jest.mock('../../utils');
 
 const config = {
   output: '',
@@ -49,10 +51,11 @@ describe('build-docs', () => {
       disableGoogleFont: false,
       template: '',
       templateOptions: {},
-      options: {},
+      features: { openapi: {} },
       api: '../some-path/openapi.yaml',
     } as BuildDocsArgv);
     expect(loadAndBundleSpec).toBeCalledTimes(1);
+    expect(getFallbackApisOrExit).toBeCalledTimes(1);
     expect(processExitMock).toBeCalledTimes(0);
   });
 });