Skip to content

Commit

Permalink
Fix and refactor the generate-docs subcommand
Browse files Browse the repository at this point in the history
  • Loading branch information
hellodword committed Apr 3, 2024
1 parent 1da368b commit edfba19
Show file tree
Hide file tree
Showing 8 changed files with 262 additions and 86 deletions.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
"test": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit src/test/*.test.ts",
"test-matrix": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit",
"test-container-features": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit src/test/container-features/*.test.ts",
"test-container-features-cli": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit src/test/container-features/featuresCLICommands.test.ts",
"test-container-templates": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit src/test/container-templates/*.test.ts"
},
"files": [
Expand Down
38 changes: 38 additions & 0 deletions src/spec-node/collectionCommonUtils/generateDocs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Argv } from 'yargs';
import { CLIHost } from '../../spec-common/cliHost';
import { Log } from '../../spec-utils/log';

const targetPositionalDescription = (collectionType: GenerateDocsCollectionType) => `
Generate docs of ${collectionType}s at provided [target] (default is cwd), where [target] is either:
1. A path to the src folder of the collection with [1..n] ${collectionType}s.
2. A path to a single ${collectionType} that contains a devcontainer-${collectionType}.json.
`;

export function GenerateDocsOptions(y: Argv, collectionType: GenerateDocsCollectionType) {
return y
.options({
'registry': { type: 'string', alias: 'r', default: 'ghcr.io', description: 'Name of the OCI registry.', hidden: collectionType !== 'feature' },
'namespace': { type: 'string', alias: 'n', require: collectionType === 'feature', description: `Unique indentifier for the collection of features. Example: <owner>/<repo>`, hidden: collectionType !== 'feature' },
'github-owner': { type: 'string', default: '', description: `GitHub owner for docs.` },
'github-repo': { type: 'string', default: '', description: `GitHub repo for docs.` },
'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level.' }
})
.positional('target', { type: 'string', default: '.', description: targetPositionalDescription(collectionType) })
.check(_argv => {
return true;
});
}

export type GenerateDocsCollectionType = 'feature' | 'template';

export interface GenerateDocsCommandInput {
cliHost: CLIHost;
targetFolder: string;
registry?: string;
namespace?: string;
gitHubOwner: string;
gitHubRepo: string;
output: Log;
disposables: (() => Promise<unknown> | undefined)[];
isSingle?: boolean; // Generating docs for a collection of many features/templates. Should autodetect.
}
105 changes: 64 additions & 41 deletions src/spec-node/collectionCommonUtils/generateDocsCommandImpl.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as fs from 'fs';
import * as path from 'path';
import * as jsonc from 'jsonc-parser';
import { Log, LogLevel } from '../../spec-utils/log';
import { LogLevel } from '../../spec-utils/log';
import { GenerateDocsCollectionType, GenerateDocsCommandInput } from './generateDocs';
import { isLocalFile, isLocalFolder, readLocalDir } from '../../spec-utils/pfs';

const FEATURES_README_TEMPLATE = `
# #{Name}
Expand Down Expand Up @@ -39,49 +41,42 @@ const TEMPLATE_README_TEMPLATE = `
_Note: This file was auto-generated from the [devcontainer-template.json](#{RepoUrl}). Add additional notes to a \`NOTES.md\`._
`;

export async function generateFeaturesDocumentation(
basePath: string,
ociRegistry: string,
namespace: string,
gitHubOwner: string,
gitHubRepo: string,
output: Log
) {
await _generateDocumentation(output, basePath, FEATURES_README_TEMPLATE,
'devcontainer-feature.json', ociRegistry, namespace, gitHubOwner, gitHubRepo);
}
const README_TEMPLATES = {
'feature': FEATURES_README_TEMPLATE,
'template': TEMPLATE_README_TEMPLATE,
};

export async function generateDocumentation(args: GenerateDocsCommandInput, collectionType: GenerateDocsCollectionType) {

const {
targetFolder: basePath,
registry,
namespace,
gitHubOwner,
gitHubRepo,
output,
isSingle,
} = args;

const readmeTemplate = README_TEMPLATES[collectionType];

const directories = isSingle ? ['.'] : await readLocalDir(basePath);

export async function generateTemplatesDocumentation(
basePath: string,
gitHubOwner: string,
gitHubRepo: string,
output: Log
) {
await _generateDocumentation(output, basePath, TEMPLATE_README_TEMPLATE,
'devcontainer-template.json', '', '', gitHubOwner, gitHubRepo);
}

async function _generateDocumentation(
output: Log,
basePath: string,
readmeTemplate: string,
metadataFile: string,
ociRegistry: string = '',
namespace: string = '',
gitHubOwner: string = '',
gitHubRepo: string = ''
) {
const directories = fs.readdirSync(basePath);
const metadataFile = `devcontainer-${collectionType}.json`;

await Promise.all(
directories.map(async (f: string) => {
if (!f.startsWith('.')) {
const readmePath = path.join(basePath, f, 'README.md');
output.write(`Generating ${readmePath}...`, LogLevel.Info);
if (f.startsWith('..')) {
return;
}

const readmePath = path.join(basePath, f, 'README.md');
output.write(`Generating ${readmePath}...`, LogLevel.Info);

const jsonPath = path.join(basePath, f, metadataFile);
const jsonPath = path.join(basePath, f, metadataFile);

if (!fs.existsSync(jsonPath)) {
if (!(await isLocalFile(jsonPath))) {
output.write(`(!) Warning: ${metadataFile} not found at path '${jsonPath}'. Skipping...`, LogLevel.Warning);
return;
}
Expand Down Expand Up @@ -176,8 +171,8 @@ async function _generateDocumentation(
.replace('#{Notes}', generateNotesMarkdown())
.replace('#{RepoUrl}', urlToConfig)
// Features Only
.replace('#{Registry}', ociRegistry)
.replace('#{Namespace}', namespace)
.replace('#{Registry}', registry || '')
.replace('#{Namespace}', namespace || '')
.replace('#{Version}', version)
.replace('#{Customizations}', extensions);

Expand All @@ -191,8 +186,36 @@ async function _generateDocumentation(
}

// Write new readme
fs.writeFileSync(readmePath, newReadme);
}
fs.writeFileSync(readmePath, newReadme);
})
);
}

export async function prepGenerateDocsCommand(args: GenerateDocsCommandInput, collectionType: GenerateDocsCollectionType): Promise<GenerateDocsCommandInput> {
const { cliHost, targetFolder, registry, namespace, gitHubOwner, gitHubRepo, output, disposables } = args;

const targetFolderResolved = cliHost.path.resolve(targetFolder);
if (!(await isLocalFolder(targetFolderResolved))) {
throw new Error(`Target folder '${targetFolderResolved}' does not exist`);
}

// Detect if we're dealing with a collection or a single feature/template
const isValidFolder = await isLocalFolder(cliHost.path.join(targetFolderResolved));
const isSingle = await isLocalFile(cliHost.path.join(targetFolderResolved, `devcontainer-${collectionType}.json`));

if (!isValidFolder) {
throw new Error(`Target folder '${targetFolderResolved}' does not exist`);
}

return {
cliHost,
targetFolder: targetFolderResolved,
registry,
namespace,
gitHubOwner,
gitHubRepo,
output,
disposables,
isSingle
};
}
4 changes: 2 additions & 2 deletions src/spec-node/devContainersSpecCLI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,12 @@ const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,externa
y.command('publish <target>', 'Package and publish Features', featuresPublishOptions, featuresPublishHandler);
y.command('info <mode> <feature>', 'Fetch metadata for a published Feature', featuresInfoOptions, featuresInfoHandler);
y.command('resolve-dependencies', 'Read and resolve dependency graph from a configuration', featuresResolveDependenciesOptions, featuresResolveDependenciesHandler);
y.command('generate-docs', 'Generate documentation', featuresGenerateDocsOptions, featuresGenerateDocsHandler);
y.command('generate-docs <target>', 'Generate documentation', featuresGenerateDocsOptions, featuresGenerateDocsHandler);
});
y.command('templates', 'Templates commands', (y: Argv) => {
y.command('apply', 'Apply a template to the project', templateApplyOptions, templateApplyHandler);
y.command('publish <target>', 'Package and publish templates', templatesPublishOptions, templatesPublishHandler);
y.command('generate-docs', 'Generate documentation', templatesGenerateDocsOptions, templatesGenerateDocsHandler);
y.command('generate-docs <target>', 'Generate documentation', templatesGenerateDocsOptions, templatesGenerateDocsHandler);
});
y.command(restArgs ? ['exec', '*'] : ['exec <cmd> [args..]'], 'Execute a command on a running dev container', execOptions, execHandler);
y.epilog(`devcontainer@${version} ${packageFolder}`);
Expand Down
48 changes: 31 additions & 17 deletions src/spec-node/featuresCLI/generateDocs.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,18 @@
import { Argv } from 'yargs';
import { UnpackArgv } from '../devContainersSpecCLI';
import { generateFeaturesDocumentation } from '../collectionCommonUtils/generateDocsCommandImpl';
import { createLog } from '../devContainers';
import { mapLogLevel } from '../../spec-utils/log';
import { getPackageConfig } from '../../spec-utils/product';
import { isLocalFolder } from '../../spec-utils/pfs';
import { getCLIHost } from '../../spec-common/cliHost';
import { loadNativeModule } from '../../spec-common/commonUtils';
import { GenerateDocsCommandInput, GenerateDocsOptions } from '../collectionCommonUtils/generateDocs';
import { generateDocumentation, prepGenerateDocsCommand } from '../collectionCommonUtils/generateDocsCommandImpl';

const collectionType = 'feature';

// -- 'features generate-docs' command
export function featuresGenerateDocsOptions(y: Argv) {
return y
.options({
'project-folder': { type: 'string', alias: 'p', default: '.', description: 'Path to folder containing \'src\' and \'test\' sub-folders. This is likely the git root of the project.' },
'registry': { type: 'string', alias: 'r', default: 'ghcr.io', description: 'Name of the OCI registry.' },
'namespace': { type: 'string', alias: 'n', require: true, description: `Unique indentifier for the collection of features. Example: <owner>/<repo>` },
'github-owner': { type: 'string', default: '', description: `GitHub owner for docs.` },
'github-repo': { type: 'string', default: '', description: `GitHub repo for docs.` },
'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level.' }
})
.check(_argv => {
return true;
});
return GenerateDocsOptions(y, collectionType);
}

export type FeaturesGenerateDocsArgs = UnpackArgv<ReturnType<typeof featuresGenerateDocsOptions>>;
Expand All @@ -28,7 +22,7 @@ export function featuresGenerateDocsHandler(args: FeaturesGenerateDocsArgs) {
}

export async function featuresGenerateDocs({
'project-folder': collectionFolder,
'target': targetFolder,
'registry': registry,
'namespace': namespace,
'github-owner': gitHubOwner,
Expand All @@ -42,16 +36,36 @@ export async function featuresGenerateDocs({

const pkg = getPackageConfig();

const cwd = process.cwd();
const cliHost = await getCLIHost(cwd, loadNativeModule, true);
const output = createLog({
logLevel: mapLogLevel(inputLogLevel),
logFormat: 'text',
log: (str) => process.stderr.write(str),
terminalDimensions: undefined,
}, pkg, new Date(), disposables);

await generateFeaturesDocumentation(collectionFolder, registry, namespace, gitHubOwner, gitHubRepo, output);
const targetFolderResolved = cliHost.path.resolve(targetFolder);
if (!(await isLocalFolder(targetFolderResolved))) {
throw new Error(`Target folder '${targetFolderResolved}' does not exist`);
}


const args: GenerateDocsCommandInput = {
cliHost,
targetFolder,
registry,
namespace,
gitHubOwner,
gitHubRepo,
output,
disposables,
};

const preparedArgs = await prepGenerateDocsCommand(args, collectionType);

await generateDocumentation(preparedArgs, collectionType);

// Cleanup
await dispose();
process.exit();
}
44 changes: 29 additions & 15 deletions src/spec-node/templatesCLI/generateDocs.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
import { Argv } from 'yargs';
import { UnpackArgv } from '../devContainersSpecCLI';
import { generateTemplatesDocumentation } from '../collectionCommonUtils/generateDocsCommandImpl';
import { generateDocumentation, prepGenerateDocsCommand } from '../collectionCommonUtils/generateDocsCommandImpl';
import { createLog } from '../devContainers';
import { mapLogLevel } from '../../spec-utils/log';
import { getPackageConfig } from '../../spec-utils/product';
import { GenerateDocsCommandInput, GenerateDocsOptions } from '../collectionCommonUtils/generateDocs';
import { getCLIHost } from '../../spec-common/cliHost';
import { loadNativeModule } from '../../spec-common/commonUtils';
import { isLocalFolder } from '../../spec-utils/pfs';

const collectionType = 'template';

// -- 'templates generate-docs' command
export function templatesGenerateDocsOptions(y: Argv) {
return y
.options({
'project-folder': { type: 'string', alias: 'p', default: '.', description: 'Path to folder containing \'src\' and \'test\' sub-folders. This is likely the git root of the project.' },
'github-owner': { type: 'string', default: '', description: `GitHub owner for docs.` },
'github-repo': { type: 'string', default: '', description: `GitHub repo for docs.` },
'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level.' }
})
.check(_argv => {
return true;
});
return GenerateDocsOptions(y, collectionType);
}

export type TemplatesGenerateDocsArgs = UnpackArgv<ReturnType<typeof templatesGenerateDocsOptions>>;
Expand All @@ -26,7 +22,7 @@ export function templatesGenerateDocsHandler(args: TemplatesGenerateDocsArgs) {
}

export async function templatesGenerateDocs({
'project-folder': collectionFolder,
'target': targetFolder,
'github-owner': gitHubOwner,
'github-repo': gitHubRepo,
'log-level': inputLogLevel,
Expand All @@ -38,16 +34,34 @@ export async function templatesGenerateDocs({

const pkg = getPackageConfig();

const cwd = process.cwd();
const cliHost = await getCLIHost(cwd, loadNativeModule, true);
const output = createLog({
logLevel: mapLogLevel(inputLogLevel),
logFormat: 'text',
log: (str) => process.stderr.write(str),
terminalDimensions: undefined,
}, pkg, new Date(), disposables);

await generateTemplatesDocumentation(collectionFolder, gitHubOwner, gitHubRepo, output);
const targetFolderResolved = cliHost.path.resolve(targetFolder);
if (!(await isLocalFolder(targetFolderResolved))) {
throw new Error(`Target folder '${targetFolderResolved}' does not exist`);
}


const args: GenerateDocsCommandInput = {
cliHost,
targetFolder,
gitHubOwner,
gitHubRepo,
output,
disposables,
};

const preparedArgs = await prepGenerateDocsCommand(args, collectionType);

await generateDocumentation(preparedArgs, collectionType);

// Cleanup
await dispose();
process.exit();
}
Loading

0 comments on commit edfba19

Please sign in to comment.