Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: add generate-docs subcommand #759

Merged
merged 2 commits into from
Mar 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading