Skip to content

Commit e174142

Browse files
committed
Feat: add gen-docs subcommand
1 parent 2a6ab1a commit e174142

File tree

8 files changed

+336
-1
lines changed

8 files changed

+336
-1
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ output
1414
*.testMarker
1515
src/test/container-features/configs/temp_lifecycle-hooks-alternative-order
1616
test-secrets-temp.json
17+
src/test/container-*/**/src/**/README.md

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"test": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit src/test/*.test.ts",
4141
"test-matrix": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit",
4242
"test-container-features": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit src/test/container-features/*.test.ts",
43+
"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",
4344
"test-container-templates": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit src/test/container-templates/*.test.ts"
4445
},
4546
"files": [
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import * as jsonc from 'jsonc-parser';
4+
import { Log, LogLevel } from '../../spec-utils/log';
5+
6+
const FEATURES_README_TEMPLATE = `
7+
# #{Name}
8+
9+
#{Description}
10+
11+
## Example Usage
12+
13+
\`\`\`json
14+
"features": {
15+
"#{Registry}/#{Namespace}/#{Id}:#{Version}": {}
16+
}
17+
\`\`\`
18+
19+
#{OptionsTable}
20+
#{Customizations}
21+
#{Notes}
22+
23+
---
24+
25+
_Note: This file was auto-generated from the [devcontainer-feature.json](#{RepoUrl}). Add additional notes to a \`NOTES.md\`._
26+
`;
27+
28+
const TEMPLATE_README_TEMPLATE = `
29+
# #{Name}
30+
31+
#{Description}
32+
33+
#{OptionsTable}
34+
35+
#{Notes}
36+
37+
---
38+
39+
_Note: This file was auto-generated from the [devcontainer-template.json](#{RepoUrl}). Add additional notes to a \`NOTES.md\`._
40+
`;
41+
42+
export async function generateFeaturesDocumentation(basePath: string, ociRegistry: string, namespace: string, output: Log) {
43+
await _generateDocumentation(basePath, FEATURES_README_TEMPLATE, 'devcontainer-feature.json', ociRegistry, namespace, output);
44+
}
45+
46+
export async function generateTemplatesDocumentation(basePath: string, output: Log) {
47+
await _generateDocumentation(basePath, TEMPLATE_README_TEMPLATE, 'devcontainer-template.json', '', '', output);
48+
}
49+
50+
async function _generateDocumentation(
51+
basePath: string,
52+
readmeTemplate: string,
53+
metadataFile: string,
54+
ociRegistry: string = '',
55+
namespace: string = '',
56+
output: Log
57+
) {
58+
const directories = fs.readdirSync(basePath);
59+
60+
await Promise.all(
61+
directories.map(async (f: string) => {
62+
if (!f.startsWith('.')) {
63+
const readmePath = path.join(basePath, f, 'README.md');
64+
output.write(`Generating ${readmePath}...`, LogLevel.Info);
65+
66+
const jsonPath = path.join(basePath, f, metadataFile);
67+
68+
if (!fs.existsSync(jsonPath)) {
69+
output.write(`(!) Warning: ${metadataFile} not found at path '${jsonPath}'. Skipping...`, LogLevel.Warning);
70+
return;
71+
}
72+
73+
let parsedJson: any | undefined = undefined;
74+
try {
75+
parsedJson = jsonc.parse(fs.readFileSync(jsonPath, 'utf8'));
76+
} catch (err) {
77+
output.write(`Failed to parse ${jsonPath}: ${err}`, LogLevel.Error);
78+
return;
79+
}
80+
81+
if (!parsedJson || !parsedJson?.id) {
82+
output.write(`${metadataFile} for '${f}' does not contain an 'id'`, LogLevel.Error);
83+
return;
84+
}
85+
86+
// Add version
87+
let version = 'latest';
88+
const parsedVersion: string = parsedJson?.version;
89+
if (parsedVersion) {
90+
// example - 1.0.0
91+
const splitVersion = parsedVersion.split('.');
92+
version = splitVersion[0];
93+
}
94+
95+
const generateOptionsMarkdown = () => {
96+
const options = parsedJson?.options;
97+
if (!options) {
98+
return '';
99+
}
100+
101+
const keys = Object.keys(options);
102+
const contents = keys
103+
.map(k => {
104+
const val = options[k];
105+
106+
const desc = val.description || '-';
107+
const type = val.type || '-';
108+
const def = val.default !== '' ? val.default : '-';
109+
110+
return `| ${k} | ${desc} | ${type} | ${def} |`;
111+
})
112+
.join('\n');
113+
114+
return '## Options\n\n' + '| Options Id | Description | Type | Default Value |\n' + '|-----|-----|-----|-----|\n' + contents;
115+
};
116+
117+
const generateNotesMarkdown = () => {
118+
const notesPath = path.join(basePath, f, 'NOTES.md');
119+
return fs.existsSync(notesPath) ? fs.readFileSync(path.join(notesPath), 'utf8') : '';
120+
};
121+
122+
let header;
123+
const isDeprecated = parsedJson?.deprecated;
124+
const hasLegacyIds = parsedJson?.legacyIds && parsedJson?.legacyIds.length > 0;
125+
126+
if (isDeprecated || hasLegacyIds) {
127+
header = '### **IMPORTANT NOTE**\n';
128+
129+
if (isDeprecated) {
130+
header += `- **This Feature is deprecated, and will no longer receive any further updates/support.**\n`;
131+
}
132+
133+
if (hasLegacyIds) {
134+
const formattedLegacyIds = parsedJson.legacyIds.map((legacyId: string) => `'${legacyId}'`);
135+
header += `- **Ids used to publish this Feature in the past - ${formattedLegacyIds.join(', ')}**\n`;
136+
}
137+
}
138+
139+
let extensions = '';
140+
if (parsedJson?.customizations?.vscode?.extensions) {
141+
const extensionsList = parsedJson.customizations.vscode.extensions;
142+
if (extensionsList && extensionsList.length > 0) {
143+
extensions =
144+
'\n## Customizations\n\n### VS Code Extensions\n\n' + extensionsList.map((ext: string) => `- \`${ext}\``).join('\n') + '\n';
145+
}
146+
}
147+
148+
let newReadme = readmeTemplate
149+
// Templates & Features
150+
.replace('#{Id}', parsedJson.id)
151+
.replace('#{Name}', parsedJson.name ? `${parsedJson.name} (${parsedJson.id})` : `${parsedJson.id}`)
152+
.replace('#{Description}', parsedJson.description ?? '')
153+
.replace('#{OptionsTable}', generateOptionsMarkdown())
154+
.replace('#{Notes}', generateNotesMarkdown())
155+
// Features Only
156+
.replace('#{Registry}', ociRegistry)
157+
.replace('#{Namespace}', namespace)
158+
.replace('#{Version}', version)
159+
.replace('#{Customizations}', extensions);
160+
161+
if (header) {
162+
newReadme = header + newReadme;
163+
}
164+
165+
// Remove previous readme
166+
if (fs.existsSync(readmePath)) {
167+
fs.unlinkSync(readmePath);
168+
}
169+
170+
// Write new readme
171+
fs.writeFileSync(readmePath, newReadme);
172+
}
173+
})
174+
);
175+
}

src/spec-node/devContainersSpecCLI.ts

+4
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import { featuresResolveDependenciesHandler, featuresResolveDependenciesOptions
4141
import { getFeatureIdWithoutVersion } from '../spec-configuration/containerFeaturesOCI';
4242
import { featuresUpgradeHandler, featuresUpgradeOptions } from './upgradeCommand';
4343
import { readFeaturesConfig } from './featureUtils';
44+
import { featuresGenDocsHandler, featuresGenDocsOptions } from './featuresCLI/genDocs';
45+
import { templatesGenDocsHandler, templatesGenDocsOptions } from './templatesCLI/genDocs';
4446

4547
const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell';
4648

@@ -77,10 +79,12 @@ const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,externa
7779
y.command('publish <target>', 'Package and publish Features', featuresPublishOptions, featuresPublishHandler);
7880
y.command('info <mode> <feature>', 'Fetch metadata for a published Feature', featuresInfoOptions, featuresInfoHandler);
7981
y.command('resolve-dependencies', 'Read and resolve dependency graph from a configuration', featuresResolveDependenciesOptions, featuresResolveDependenciesHandler);
82+
y.command('gen-docs', 'Generate documentation', featuresGenDocsOptions, featuresGenDocsHandler);
8083
});
8184
y.command('templates', 'Templates commands', (y: Argv) => {
8285
y.command('apply', 'Apply a template to the project', templateApplyOptions, templateApplyHandler);
8386
y.command('publish <target>', 'Package and publish templates', templatesPublishOptions, templatesPublishHandler);
87+
y.command('gen-docs', 'Generate documentation', templatesGenDocsOptions, templatesGenDocsHandler);
8488
});
8589
y.command(restArgs ? ['exec', '*'] : ['exec <cmd> [args..]'], 'Execute a command on a running dev container', execOptions, execHandler);
8690
y.epilog(`devcontainer@${version} ${packageFolder}`);

src/spec-node/featuresCLI/genDocs.ts

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { Argv } from 'yargs';
2+
import { UnpackArgv } from '../devContainersSpecCLI';
3+
import { generateFeaturesDocumentation } from '../collectionCommonUtils/genDocsCommandImpl';
4+
import { createLog } from '../devContainers';
5+
import { mapLogLevel } from '../../spec-utils/log';
6+
import { getPackageConfig } from '../../spec-utils/product';
7+
8+
// -- 'features gen-docs' command
9+
export function featuresGenDocsOptions(y: Argv) {
10+
return y
11+
.options({
12+
'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.' },
13+
'registry': { type: 'string', alias: 'r', default: 'ghcr.io', description: 'Name of the OCI registry.' },
14+
'namespace': { type: 'string', alias: 'n', require: true, description: `Unique indentifier for the collection of features. Example: <owner>/<repo>` },
15+
'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level.' }
16+
})
17+
.check(_argv => {
18+
return true;
19+
});
20+
}
21+
22+
export type FeaturesGenDocsArgs = UnpackArgv<ReturnType<typeof featuresGenDocsOptions>>;
23+
24+
export function featuresGenDocsHandler(args: FeaturesGenDocsArgs) {
25+
(async () => await featuresGenDocs(args))().catch(console.error);
26+
}
27+
28+
export async function featuresGenDocs({
29+
'project-folder': collectionFolder,
30+
'log-level': inputLogLevel,
31+
'registry': registry,
32+
'namespace': namespace
33+
}: FeaturesGenDocsArgs) {
34+
const disposables: (() => Promise<unknown> | undefined)[] = [];
35+
const dispose = async () => {
36+
await Promise.all(disposables.map(d => d()));
37+
};
38+
39+
const pkg = getPackageConfig();
40+
41+
const output = createLog({
42+
logLevel: mapLogLevel(inputLogLevel),
43+
logFormat: 'text',
44+
log: (str) => process.stderr.write(str),
45+
terminalDimensions: undefined,
46+
}, pkg, new Date(), disposables);
47+
48+
await generateFeaturesDocumentation(collectionFolder, registry, namespace, output);
49+
50+
// Cleanup
51+
await dispose();
52+
process.exit();
53+
}

src/spec-node/templatesCLI/genDocs.ts

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Argv } from 'yargs';
2+
import { UnpackArgv } from '../devContainersSpecCLI';
3+
import { generateTemplatesDocumentation } from '../collectionCommonUtils/genDocsCommandImpl';
4+
import { createLog } from '../devContainers';
5+
import { mapLogLevel } from '../../spec-utils/log';
6+
import { getPackageConfig } from '../../spec-utils/product';
7+
8+
// -- 'templates gen-docs' command
9+
export function templatesGenDocsOptions(y: Argv) {
10+
return y
11+
.options({
12+
'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.' },
13+
'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level.' }
14+
})
15+
.check(_argv => {
16+
return true;
17+
});
18+
}
19+
20+
export type TemplatesGenDocsArgs = UnpackArgv<ReturnType<typeof templatesGenDocsOptions>>;
21+
22+
export function templatesGenDocsHandler(args: TemplatesGenDocsArgs) {
23+
(async () => await templatesGenDocs(args))().catch(console.error);
24+
}
25+
26+
export async function templatesGenDocs({
27+
'project-folder': collectionFolder,
28+
'log-level': inputLogLevel,
29+
}: TemplatesGenDocsArgs) {
30+
const disposables: (() => Promise<unknown> | undefined)[] = [];
31+
const dispose = async () => {
32+
await Promise.all(disposables.map(d => d()));
33+
};
34+
35+
const pkg = getPackageConfig();
36+
37+
const output = createLog({
38+
logLevel: mapLogLevel(inputLogLevel),
39+
logFormat: 'text',
40+
log: (str) => process.stderr.write(str),
41+
terminalDimensions: undefined,
42+
}, pkg, new Date(), disposables);
43+
44+
await generateTemplatesDocumentation(collectionFolder, output);
45+
46+
// Cleanup
47+
await dispose();
48+
process.exit();
49+
}

src/test/container-features/featuresCLICommands.test.ts

+26-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { isLocalFile, readLocalFile } from '../../spec-utils/pfs';
55
import { ExecResult, shellExec } from '../testUtils';
66
import { getSemanticTags } from '../../spec-node/collectionCommonUtils/publishCommandImpl';
77
import { getRef, getPublishedTags, getVersionsStrictSorted } from '../../spec-configuration/containerCollectionsOCI';
8+
import { generateFeaturesDocumentation } from '../../spec-node/collectionCommonUtils/genDocsCommandImpl';
89
export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace));
910

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

484485
describe('test function getSermanticVersions', () => {
486+
485487
it('should generate correct semantic versions for first publishing', async () => {
486488
let version = '1.0.0';
487489
let publishedTags: string[] = [];
@@ -675,4 +677,27 @@ describe('test functions getVersionsStrictSorted and getPublishedTags', async ()
675677

676678
});
677679

678-
});
680+
});
681+
682+
describe('tests generateFeaturesDocumentation()', async function () {
683+
this.timeout('120s');
684+
685+
const projectFolder = `${__dirname}/example-v2-features-sets/simple/src`;
686+
687+
after('clean', async () => {
688+
await shellExec(`rm ${projectFolder}/**/README.md`);
689+
});
690+
691+
it('tests gen-docs', async function () {
692+
await generateFeaturesDocumentation(projectFolder, 'ghcr.io', 'devcontainers/cli', output);
693+
694+
const colorDocsExists = await isLocalFile(`${projectFolder}/color/README.md`);
695+
assert.isTrue(colorDocsExists);
696+
697+
const helloDocsExists = await isLocalFile(`${projectFolder}/hello/README.md`);
698+
assert.isTrue(helloDocsExists);
699+
700+
const invalidDocsExists = await isLocalFile(`${projectFolder}/not-a-feature/README.md`);
701+
assert.isFalse(invalidDocsExists);
702+
});
703+
});

src/test/container-templates/templatesCLICommands.test.ts

+27
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Template } from '../../spec-configuration/containerTemplatesConfigurati
88
import { PackageCommandInput } from '../../spec-node/collectionCommonUtils/package';
99
import { getCLIHost } from '../../spec-common/cliHost';
1010
import { loadNativeModule } from '../../spec-common/commonUtils';
11+
import { generateTemplatesDocumentation } from '../../spec-node/collectionCommonUtils/genDocsCommandImpl';
1112

1213
export const output = makeLog(createPlainLog(text => process.stderr.write(text), () => LogLevel.Trace));
1314

@@ -170,3 +171,29 @@ describe('tests packageTemplates()', async function () {
170171
assert.equal(alpineProperties?.fileCount, 2);
171172
});
172173
});
174+
175+
describe('tests generateTemplateDocumentation()', async function () {
176+
this.timeout('120s');
177+
178+
const projectFolder = `${__dirname}/example-templates-sets/simple/src`;
179+
180+
after('clean', async () => {
181+
await shellExec(`rm ${projectFolder}/**/README.md`);
182+
});
183+
184+
it('tests gen-docs', async function () {
185+
await generateTemplatesDocumentation(projectFolder, output);
186+
187+
const alpineDocsExists = await isLocalFile(`${projectFolder}/alpine/README.md`);
188+
assert.isTrue(alpineDocsExists);
189+
190+
const cppDocsExists = await isLocalFile(`${projectFolder}/cpp/README.md`);
191+
assert.isTrue(cppDocsExists);
192+
193+
const nodeMongoDocsExists = await isLocalFile(`${projectFolder}/node-mongo/README.md`);
194+
assert.isTrue(nodeMongoDocsExists);
195+
196+
const invalidDocsExists = await isLocalFile(`${projectFolder}/not-a-template/README.md`);
197+
assert.isFalse(invalidDocsExists);
198+
});
199+
});

0 commit comments

Comments
 (0)