feat(docutils): enable updating of mkdocs nav based on commands
also fix bad arg names
MODIFY
packages/docutils/lib/cli/command/build.ts
packages/docutils/lib/cli/command/build.ts
@@ -2,6 +2,7 @@ import {CommandModule, InferredOptionTypes, Options} from 'yargs';
import {buildMkDocs} from '../../mkdocs';
import {buildReference} from '../../typedoc';
import logger from '../../logger';
+import {updateNav} from '../../nav';
const log = logger.withTag('build');
@@ -66,6 +67,14 @@ const opts = {
requiresArg: true,
type: 'string',
},
+ 'reference-header': {
+ describe: 'Navigation header for API reference',
+ default: 'Reference',
+ group: NAME_GROUP_BUILD,
+ nargs: 1,
+ requiresArg: true,
+ type: 'string',
+ },
} as const;
opts as Record<string, Options>;
@@ -77,10 +86,11 @@ const buildCommand: CommandModule<{}, BuildOptions> = {
builder: opts,
async handler(args) {
log.debug('Build command called with args: %O', args);
- if (!args.onlyWeb) {
+ if (args.site) {
await buildReference(args);
}
- if (!args.onlyReference) {
+ if (args.reference) {
+ await updateNav(args);
await buildMkDocs(args);
}
},

MODIFY
packages/docutils/lib/model.ts
packages/docutils/lib/model.ts
@@ -35,9 +35,9 @@ export type MkDocsYml = Jsonify<{
extra?: Record<string, JsonValue>;
hooks?: string[];
INHERIT?: string;
- markdown_extensions?: (string | Record<string, JsonValue>)[];
- nav?: (string | Record<string, string>)[];
- plugins?: (string | Record<string, JsonValue>)[];
+ markdown_extensions?: Array<string | Record<string, JsonValue>>;
+ nav?: Array<string | Record<string, string[]>>;
+ plugins?: Array<string | Record<string, JsonValue>>;
repo_name?: string;
repo_url?: string;
site_dir?: string;

ADD
packages/docutils/lib/nav.ts
+import _ from 'lodash';
+import path from 'node:path';
+import {fs} from '@appium/support';
+import {
+ findMkDocsYml,
+ findTypeDocJson,
+ readTypedocJson,
+ readYaml,
+ relative,
+ safeWriteFile,
+ stringifyYaml,
+} from './util';
+import {DEFAULT_REL_TYPEDOC_OUT_PATH} from './constants';
+import logger from './logger';
+import {MkDocsYml} from './model';
+import {IterableElement} from 'type-fest';
+
+const DEFAULT_REFERENCE_HEADER = 'Reference';
+
+const log = logger.withTag('mkdocs-nav');
+
+/**
+ *
+ * @param opts - Options
+ * @todo implement `dryRun` option
+ */
+export async function updateNav<S extends string>({
+ cwd = process.cwd(),
+ mkdocsYml: mkDocsYmlPath,
+ packageJson: packageJsonPath,
+ referenceHeader = <S>DEFAULT_REFERENCE_HEADER,
+ typedocJson: typeDocJsonPath,
+ dryRun = false,
+}: UpdateNavOpts<S> = {}) {
+ [mkDocsYmlPath, typeDocJsonPath] = await Promise.all([
+ mkDocsYmlPath ?? findMkDocsYml(cwd, packageJsonPath),
+ typeDocJsonPath ?? findTypeDocJson(cwd, packageJsonPath),
+ ]);
+ const relativePath = relative(cwd);
+ const relMkDocsYmlPath = relativePath(mkDocsYmlPath);
+ const typeDocJson = readTypedocJson(typeDocJsonPath);
+ const mkDocsYml = (await readYaml(mkDocsYmlPath)) as MkDocsYml;
+ const findRefDictIndex: (nav: MkDocsYml['nav']) => number = _.partial(
+ _.findIndex,
+ _,
+ _.overEvery([_.isObject, _.partial(_.has, _, referenceHeader)])
+ );
+
+ /**
+ * Absolute path to `typedoc.json`
+ */
+ const absTypeDocJsonPath = path.isAbsolute(typeDocJsonPath)
+ ? typeDocJsonPath
+ : path.resolve(cwd, typeDocJsonPath);
+
+ /**
+ * Absolute path to TypeDoc's output directory (`out`)
+ */
+ const typeDocOutDir = path.resolve(
+ path.dirname(absTypeDocJsonPath),
+ typeDocJson.out ? typeDocJson.out : DEFAULT_REL_TYPEDOC_OUT_PATH
+ );
+
+ /**
+ * Absolute path to `mkdocs.yml`
+ */
+ const absMkdocsYmlPath = path.isAbsolute(mkDocsYmlPath)
+ ? mkDocsYmlPath
+ : path.resolve(cwd, mkDocsYmlPath);
+
+ const {docs_dir: docsDir, nav = []} = mkDocsYml;
+ /**
+ * Absolute path to the directory containing MkDocs input docs
+ */
+ const mkDocsDocsDir = path.resolve(path.dirname(absMkdocsYmlPath), docsDir ?? 'docs');
+
+ /**
+ * The dir we need to prepend to all entries within `nav`
+ */
+ const relReferenceDir = path.relative(mkDocsDocsDir, typeDocOutDir);
+
+ const partitionRefArray: <T, U extends T>(arr: T[]) => [U[], Array<Exclude<T, U>>] =
+ _.partialRight(_.partition, _.partialRight(_.startsWith, `${relReferenceDir}/`));
+
+ const newRefFilepaths: string[] = [];
+
+ // TODO: this doesn't respect the 'commandsDir' option for typedoc-plugin-appium. in fact,
+ // typeDocJson does not even include it, because it's unknown. I suppose that will mean we need
+ // to load plugins, but that means bootstrapping TypeDoc entirely just to read a `typedoc.json`
+ // file, which is slow.
+ const commandDir = path.join(typeDocOutDir, 'commands');
+ const relCommandDir = path.relative(mkDocsDocsDir, commandDir);
+ const commandDocFileEnts = await fs.readdir(commandDir, {withFileTypes: true});
+ if (!commandDocFileEnts.length) {
+ log.warn('No reference API docs were found in %s; skipping navigation update', commandDir);
+ return;
+ }
+ for (const ent of commandDocFileEnts) {
+ if (ent.isFile() && ent.name.endsWith('.md')) {
+ newRefFilepaths.push(path.join(relCommandDir, ent.name));
+ }
+ }
+
+ log.debug('New reference filepaths: %O', newRefFilepaths);
+
+ const navUsesHeaders = !isStringArray(nav);
+ let shouldWriteMkDocsYml = false;
+ let refFilepaths: string[];
+ let nonRefFilepaths: string[];
+
+ const refDictIdx = findRefDictIndex(nav);
+ if (refDictIdx >= 0) {
+ const refDict = nav[refDictIdx] as Record<S, string[]>;
+ const refArray = refDict[referenceHeader];
+ [refFilepaths, nonRefFilepaths] = partitionRefArray(refArray);
+ } else {
+ [refFilepaths, nonRefFilepaths] = partitionRefArray(<string[]>nav);
+ }
+
+ const symmetricDiff = _.xor(newRefFilepaths, refFilepaths);
+ if (symmetricDiff.length) {
+ log.debug('Difference in old nav vs new: %O', symmetricDiff);
+ shouldWriteMkDocsYml = true;
+ if (navUsesHeaders) {
+ if (refDictIdx >= 0) {
+ const res = [...nonRefFilepaths, ...newRefFilepaths];
+ (mkDocsYml.nav![refDictIdx] as Record<S, string[]>)[referenceHeader] = res;
+ log.debug('Replaced "%s" section with %O', referenceHeader, res);
+ } else {
+ mkDocsYml.nav = [...nonRefFilepaths, {[referenceHeader]: newRefFilepaths}];
+ log.debug('Added "%s" section with %O', referenceHeader, newRefFilepaths);
+ }
+ } else {
+ mkDocsYml.nav = [...nonRefFilepaths, ...newRefFilepaths];
+ log.debug('Replaced nav with %O', mkDocsYml.nav);
+ }
+ }
+
+ if (shouldWriteMkDocsYml) {
+ const yaml = stringifyYaml(mkDocsYml);
+ log.debug(yaml);
+ await safeWriteFile(mkDocsYmlPath, yaml, true);
+ log.success('Updated navigation for reference documents in %s', relMkDocsYmlPath);
+ } else {
+ log.info('No changes to navigation for reference documents in %s', relMkDocsYmlPath);
+ }
+}
+
+/**
+ * Type guard to narrow an array to a string array
+ * @param value any value
+ * @returns `true` if the array is `string[]`
+ */
+const isStringArray = _.overEvery(_.isArray, _.partial(_.every, _, _.isString)) as (
+ value: any
+) => value is string[];
+
+export interface UpdateNavOpts<S extends string> {
+ cwd?: string;
+ mkdocsYml?: string;
+ packageJson?: string;
+ referenceHeader?: S;
+ typedocJson?: string;
+ dryRun?: boolean;
+}

MODIFY
packages/docutils/lib/typedoc.ts
packages/docutils/lib/typedoc.ts
@@ -74,11 +74,17 @@ export interface BuildReferenceOptions {
type LogLevelName = 'debug' | 'info' | 'error' | 'warn';
-const LogLevelMap: Record<LogLevelName, string> = {
+/**
+ * Mapping of whatever our log level is to whatever TypeDoc's should be.
+ *
+ * TypeDoc's "info" is too verbose for our needs, and it's our default, so
+ * we map it to "warn".
+ */
+const TypeDocLogLevelMap: Record<LogLevelName, string> = {
debug: 'Verbose',
- info: 'Info',
- error: 'Error',
+ info: 'Warn',
warn: 'Warn',
+ error: 'Error',
};
export async function buildReference({
@@ -124,7 +130,7 @@ export async function buildReference({
}
const extraTypedocOpts = _.pickBy(
- {tsconfig, name: title, out, logLevel: LogLevelMap[logLevel]},
+ {tsconfig, name: title, out, logLevel: TypeDocLogLevelMap[logLevel]},
Boolean
) as Record<string, string>;

MODIFY
packages/docutils/lib/util.ts
packages/docutils/lib/util.ts
@@ -11,6 +11,7 @@ import _pkgDir from 'pkg-dir';
import readPkg, {NormalizedPackageJson} from 'read-pkg';
import {JsonValue, PackageJson} from 'type-fest';
import {Application, LogLevel, TypeDocReader} from 'typedoc';
+import {discoverPlugins, loadPlugins} from 'typedoc/dist/lib/utils';
import YAML from 'yaml';
import {NAME_MKDOCS_YML, NAME_PACKAGE_JSON, NAME_TYPEDOC_JSON} from './constants';
import {DocutilsError} from './error';
@@ -145,12 +146,12 @@ export const readPackageJson = _.memoize(_readPkgJson);
*/
export const readTypedocJson = _.memoize((typedocJsonPath: string) => {
const app = new Application();
- // might want to loosen this a bit in the future
- app.logger.level = LogLevel.Error;
+ app.options.setValue('plugin', 'none');
+ app.options.setValue('logger', 'none');
// yes, this is how you do it. yes, it could be easier. no, I don't know why.
app.options.setValue('options', typedocJsonPath);
app.options.addReader(new TypeDocReader());
- app.options.read(app.logger);
+ app.bootstrap();
return app.options.getRawValues();
});
@@ -170,3 +171,7 @@ export const stringifyYaml = (value: JsonValue) => YAML.stringify(value, {indent
* @returns JSON5 string
*/
export const stringifyJson5 = (value: JsonValue) => JSON5.stringify(value, undefined, 2);
+
+export const readYaml = _.memoize(async (filepath: string) =>
+ YAML.parse(await fs.readFile(filepath, 'utf8'))
+);

feat(docutils): enable updating of mkdocs nav based on commands

- also fix bad arg names