Skip to content

Commit

Permalink
Merge pull request #759 from hellodword/patch-1
Browse files Browse the repository at this point in the history
Feat: add generate-docs subcommand
  • Loading branch information
samruddhikhandale authored Mar 11, 2024
2 parents ab79dd6 + 56d83a1 commit 10b0ec5
Show file tree
Hide file tree
Showing 8 changed files with 367 additions and 1 deletion.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ output
*.testMarker
src/test/container-features/configs/temp_lifecycle-hooks-alternative-order
test-secrets-temp.json
src/test/container-*/**/src/**/README.md
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"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
198 changes: 198 additions & 0 deletions src/spec-node/collectionCommonUtils/generateDocsCommandImpl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import * as fs from 'fs';
import * as path from 'path';
import * as jsonc from 'jsonc-parser';
import { Log, LogLevel } from '../../spec-utils/log';

const FEATURES_README_TEMPLATE = `
# #{Name}
#{Description}
## Example Usage
\`\`\`json
"features": {
"#{Registry}/#{Namespace}/#{Id}:#{Version}": {}
}
\`\`\`
#{OptionsTable}
#{Customizations}
#{Notes}
---
_Note: This file was auto-generated from the [devcontainer-feature.json](#{RepoUrl}). Add additional notes to a \`NOTES.md\`._
`;

const TEMPLATE_README_TEMPLATE = `
# #{Name}
#{Description}
#{OptionsTable}
#{Notes}
---
_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);
}

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

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

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

if (!fs.existsSync(jsonPath)) {
output.write(`(!) Warning: ${metadataFile} not found at path '${jsonPath}'. Skipping...`, LogLevel.Warning);
return;
}

let parsedJson: any | undefined = undefined;
try {
parsedJson = jsonc.parse(fs.readFileSync(jsonPath, 'utf8'));
} catch (err) {
output.write(`Failed to parse ${jsonPath}: ${err}`, LogLevel.Error);
return;
}

if (!parsedJson || !parsedJson?.id) {
output.write(`${metadataFile} for '${f}' does not contain an 'id'`, LogLevel.Error);
return;
}

// Add version
let version = 'latest';
const parsedVersion: string = parsedJson?.version;
if (parsedVersion) {
// example - 1.0.0
const splitVersion = parsedVersion.split('.');
version = splitVersion[0];
}

const generateOptionsMarkdown = () => {
const options = parsedJson?.options;
if (!options) {
return '';
}

const keys = Object.keys(options);
const contents = keys
.map(k => {
const val = options[k];

const desc = val.description || '-';
const type = val.type || '-';
const def = val.default !== '' ? val.default : '-';

return `| ${k} | ${desc} | ${type} | ${def} |`;
})
.join('\n');

return '## Options\n\n' + '| Options Id | Description | Type | Default Value |\n' + '|-----|-----|-----|-----|\n' + contents;
};

const generateNotesMarkdown = () => {
const notesPath = path.join(basePath, f, 'NOTES.md');
return fs.existsSync(notesPath) ? fs.readFileSync(path.join(notesPath), 'utf8') : '';
};

let urlToConfig = `${metadataFile}`;
const basePathTrimmed = basePath.startsWith('./') ? basePath.substring(2) : basePath;
if (gitHubOwner !== '' && gitHubRepo !== '') {
urlToConfig = `https://github.com/${gitHubOwner}/${gitHubRepo}/blob/main/${basePathTrimmed}/${f}/${metadataFile}`;
}

let header;
const isDeprecated = parsedJson?.deprecated;
const hasLegacyIds = parsedJson?.legacyIds && parsedJson?.legacyIds.length > 0;

if (isDeprecated || hasLegacyIds) {
header = '### **IMPORTANT NOTE**\n';

if (isDeprecated) {
header += `- **This Feature is deprecated, and will no longer receive any further updates/support.**\n`;
}

if (hasLegacyIds) {
const formattedLegacyIds = parsedJson.legacyIds.map((legacyId: string) => `'${legacyId}'`);
header += `- **Ids used to publish this Feature in the past - ${formattedLegacyIds.join(', ')}**\n`;
}
}

let extensions = '';
if (parsedJson?.customizations?.vscode?.extensions) {
const extensionsList = parsedJson.customizations.vscode.extensions;
if (extensionsList && extensionsList.length > 0) {
extensions =
'\n## Customizations\n\n### VS Code Extensions\n\n' + extensionsList.map((ext: string) => `- \`${ext}\``).join('\n') + '\n';
}
}

let newReadme = readmeTemplate
// Templates & Features
.replace('#{Id}', parsedJson.id)
.replace('#{Name}', parsedJson.name ? `${parsedJson.name} (${parsedJson.id})` : `${parsedJson.id}`)
.replace('#{Description}', parsedJson.description ?? '')
.replace('#{OptionsTable}', generateOptionsMarkdown())
.replace('#{Notes}', generateNotesMarkdown())
.replace('#{RepoUrl}', urlToConfig)
// Features Only
.replace('#{Registry}', ociRegistry)
.replace('#{Namespace}', namespace)
.replace('#{Version}', version)
.replace('#{Customizations}', extensions);

if (header) {
newReadme = header + newReadme;
}

// Remove previous readme
if (fs.existsSync(readmePath)) {
fs.unlinkSync(readmePath);
}

// Write new readme
fs.writeFileSync(readmePath, newReadme);
}
})
);
}
4 changes: 4 additions & 0 deletions src/spec-node/devContainersSpecCLI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import { featuresResolveDependenciesHandler, featuresResolveDependenciesOptions
import { getFeatureIdWithoutVersion } from '../spec-configuration/containerFeaturesOCI';
import { featuresUpgradeHandler, featuresUpgradeOptions } from './upgradeCommand';
import { readFeaturesConfig } from './featureUtils';
import { featuresGenerateDocsHandler, featuresGenerateDocsOptions } from './featuresCLI/generateDocs';
import { templatesGenerateDocsHandler, templatesGenerateDocsOptions } from './templatesCLI/generateDocs';
import { mapNodeOSToGOOS, mapNodeArchitectureToGOARCH } from '../spec-configuration/containerCollectionsOCI';

const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell';
Expand Down Expand Up @@ -78,10 +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('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(restArgs ? ['exec', '*'] : ['exec <cmd> [args..]'], 'Execute a command on a running dev container', execOptions, execHandler);
y.epilog(`devcontainer@${version} ${packageFolder}`);
Expand Down
57 changes: 57 additions & 0 deletions src/spec-node/featuresCLI/generateDocs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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';

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

export type FeaturesGenerateDocsArgs = UnpackArgv<ReturnType<typeof featuresGenerateDocsOptions>>;

export function featuresGenerateDocsHandler(args: FeaturesGenerateDocsArgs) {
(async () => await featuresGenerateDocs(args))().catch(console.error);
}

export async function featuresGenerateDocs({
'project-folder': collectionFolder,
'registry': registry,
'namespace': namespace,
'github-owner': gitHubOwner,
'github-repo': gitHubRepo,
'log-level': inputLogLevel,
}: FeaturesGenerateDocsArgs) {
const disposables: (() => Promise<unknown> | undefined)[] = [];
const dispose = async () => {
await Promise.all(disposables.map(d => d()));
};

const pkg = getPackageConfig();

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

// Cleanup
await dispose();
process.exit();
}
53 changes: 53 additions & 0 deletions src/spec-node/templatesCLI/generateDocs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Argv } from 'yargs';
import { UnpackArgv } from '../devContainersSpecCLI';
import { generateTemplatesDocumentation } from '../collectionCommonUtils/generateDocsCommandImpl';
import { createLog } from '../devContainers';
import { mapLogLevel } from '../../spec-utils/log';
import { getPackageConfig } from '../../spec-utils/product';

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

export type TemplatesGenerateDocsArgs = UnpackArgv<ReturnType<typeof templatesGenerateDocsOptions>>;

export function templatesGenerateDocsHandler(args: TemplatesGenerateDocsArgs) {
(async () => await templatesGenerateDocs(args))().catch(console.error);
}

export async function templatesGenerateDocs({
'project-folder': collectionFolder,
'github-owner': gitHubOwner,
'github-repo': gitHubRepo,
'log-level': inputLogLevel,
}: TemplatesGenerateDocsArgs) {
const disposables: (() => Promise<unknown> | undefined)[] = [];
const dispose = async () => {
await Promise.all(disposables.map(d => d()));
};

const pkg = getPackageConfig();

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

// Cleanup
await dispose();
process.exit();
}
27 changes: 26 additions & 1 deletion src/test/container-features/featuresCLICommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { isLocalFile, readLocalFile } from '../../spec-utils/pfs';
import { ExecResult, shellExec } from '../testUtils';
import { getSemanticTags } from '../../spec-node/collectionCommonUtils/publishCommandImpl';
import { getRef, getPublishedTags, getVersionsStrictSorted } from '../../spec-configuration/containerCollectionsOCI';
import { generateFeaturesDocumentation } from '../../spec-node/collectionCommonUtils/generateDocsCommandImpl';
export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace));

const pkg = require('../../../package.json');
Expand Down Expand Up @@ -482,6 +483,7 @@ describe('CLI features subcommands', async function () {
});

describe('test function getSermanticVersions', () => {

it('should generate correct semantic versions for first publishing', async () => {
let version = '1.0.0';
let publishedTags: string[] = [];
Expand Down Expand Up @@ -675,4 +677,27 @@ describe('test functions getVersionsStrictSorted and getPublishedTags', async ()

});

});
});

describe('tests generateFeaturesDocumentation()', async function () {
this.timeout('120s');

const projectFolder = `${__dirname}/example-v2-features-sets/simple/src`;

after('clean', async () => {
await shellExec(`rm ${projectFolder}/**/README.md`);
});

it('tests generate-docs', async function () {
await generateFeaturesDocumentation(projectFolder, 'ghcr.io', 'devcontainers/cli', 'devcontainers', 'cli', output);

const colorDocsExists = await isLocalFile(`${projectFolder}/color/README.md`);
assert.isTrue(colorDocsExists);

const helloDocsExists = await isLocalFile(`${projectFolder}/hello/README.md`);
assert.isTrue(helloDocsExists);

const invalidDocsExists = await isLocalFile(`${projectFolder}/not-a-feature/README.md`);
assert.isFalse(invalidDocsExists);
});
});
Loading

0 comments on commit 10b0ec5

Please sign in to comment.