Skip to content

Commit 10b0ec5

Browse files
Merge pull request #759 from hellodword/patch-1
Feat: add generate-docs subcommand
2 parents ab79dd6 + 56d83a1 commit 10b0ec5

File tree

8 files changed

+367
-1
lines changed

8 files changed

+367
-1
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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": [
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
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(
43+
basePath: string,
44+
ociRegistry: string,
45+
namespace: string,
46+
gitHubOwner: string,
47+
gitHubRepo: string,
48+
output: Log
49+
) {
50+
await _generateDocumentation(output, basePath, FEATURES_README_TEMPLATE,
51+
'devcontainer-feature.json', ociRegistry, namespace, gitHubOwner, gitHubRepo);
52+
}
53+
54+
export async function generateTemplatesDocumentation(
55+
basePath: string,
56+
gitHubOwner: string,
57+
gitHubRepo: string,
58+
output: Log
59+
) {
60+
await _generateDocumentation(output, basePath, TEMPLATE_README_TEMPLATE,
61+
'devcontainer-template.json', '', '', gitHubOwner, gitHubRepo);
62+
}
63+
64+
async function _generateDocumentation(
65+
output: Log,
66+
basePath: string,
67+
readmeTemplate: string,
68+
metadataFile: string,
69+
ociRegistry: string = '',
70+
namespace: string = '',
71+
gitHubOwner: string = '',
72+
gitHubRepo: string = ''
73+
) {
74+
const directories = fs.readdirSync(basePath);
75+
76+
await Promise.all(
77+
directories.map(async (f: string) => {
78+
if (!f.startsWith('.')) {
79+
const readmePath = path.join(basePath, f, 'README.md');
80+
output.write(`Generating ${readmePath}...`, LogLevel.Info);
81+
82+
const jsonPath = path.join(basePath, f, metadataFile);
83+
84+
if (!fs.existsSync(jsonPath)) {
85+
output.write(`(!) Warning: ${metadataFile} not found at path '${jsonPath}'. Skipping...`, LogLevel.Warning);
86+
return;
87+
}
88+
89+
let parsedJson: any | undefined = undefined;
90+
try {
91+
parsedJson = jsonc.parse(fs.readFileSync(jsonPath, 'utf8'));
92+
} catch (err) {
93+
output.write(`Failed to parse ${jsonPath}: ${err}`, LogLevel.Error);
94+
return;
95+
}
96+
97+
if (!parsedJson || !parsedJson?.id) {
98+
output.write(`${metadataFile} for '${f}' does not contain an 'id'`, LogLevel.Error);
99+
return;
100+
}
101+
102+
// Add version
103+
let version = 'latest';
104+
const parsedVersion: string = parsedJson?.version;
105+
if (parsedVersion) {
106+
// example - 1.0.0
107+
const splitVersion = parsedVersion.split('.');
108+
version = splitVersion[0];
109+
}
110+
111+
const generateOptionsMarkdown = () => {
112+
const options = parsedJson?.options;
113+
if (!options) {
114+
return '';
115+
}
116+
117+
const keys = Object.keys(options);
118+
const contents = keys
119+
.map(k => {
120+
const val = options[k];
121+
122+
const desc = val.description || '-';
123+
const type = val.type || '-';
124+
const def = val.default !== '' ? val.default : '-';
125+
126+
return `| ${k} | ${desc} | ${type} | ${def} |`;
127+
})
128+
.join('\n');
129+
130+
return '## Options\n\n' + '| Options Id | Description | Type | Default Value |\n' + '|-----|-----|-----|-----|\n' + contents;
131+
};
132+
133+
const generateNotesMarkdown = () => {
134+
const notesPath = path.join(basePath, f, 'NOTES.md');
135+
return fs.existsSync(notesPath) ? fs.readFileSync(path.join(notesPath), 'utf8') : '';
136+
};
137+
138+
let urlToConfig = `${metadataFile}`;
139+
const basePathTrimmed = basePath.startsWith('./') ? basePath.substring(2) : basePath;
140+
if (gitHubOwner !== '' && gitHubRepo !== '') {
141+
urlToConfig = `https://github.com/${gitHubOwner}/${gitHubRepo}/blob/main/${basePathTrimmed}/${f}/${metadataFile}`;
142+
}
143+
144+
let header;
145+
const isDeprecated = parsedJson?.deprecated;
146+
const hasLegacyIds = parsedJson?.legacyIds && parsedJson?.legacyIds.length > 0;
147+
148+
if (isDeprecated || hasLegacyIds) {
149+
header = '### **IMPORTANT NOTE**\n';
150+
151+
if (isDeprecated) {
152+
header += `- **This Feature is deprecated, and will no longer receive any further updates/support.**\n`;
153+
}
154+
155+
if (hasLegacyIds) {
156+
const formattedLegacyIds = parsedJson.legacyIds.map((legacyId: string) => `'${legacyId}'`);
157+
header += `- **Ids used to publish this Feature in the past - ${formattedLegacyIds.join(', ')}**\n`;
158+
}
159+
}
160+
161+
let extensions = '';
162+
if (parsedJson?.customizations?.vscode?.extensions) {
163+
const extensionsList = parsedJson.customizations.vscode.extensions;
164+
if (extensionsList && extensionsList.length > 0) {
165+
extensions =
166+
'\n## Customizations\n\n### VS Code Extensions\n\n' + extensionsList.map((ext: string) => `- \`${ext}\``).join('\n') + '\n';
167+
}
168+
}
169+
170+
let newReadme = readmeTemplate
171+
// Templates & Features
172+
.replace('#{Id}', parsedJson.id)
173+
.replace('#{Name}', parsedJson.name ? `${parsedJson.name} (${parsedJson.id})` : `${parsedJson.id}`)
174+
.replace('#{Description}', parsedJson.description ?? '')
175+
.replace('#{OptionsTable}', generateOptionsMarkdown())
176+
.replace('#{Notes}', generateNotesMarkdown())
177+
.replace('#{RepoUrl}', urlToConfig)
178+
// Features Only
179+
.replace('#{Registry}', ociRegistry)
180+
.replace('#{Namespace}', namespace)
181+
.replace('#{Version}', version)
182+
.replace('#{Customizations}', extensions);
183+
184+
if (header) {
185+
newReadme = header + newReadme;
186+
}
187+
188+
// Remove previous readme
189+
if (fs.existsSync(readmePath)) {
190+
fs.unlinkSync(readmePath);
191+
}
192+
193+
// Write new readme
194+
fs.writeFileSync(readmePath, newReadme);
195+
}
196+
})
197+
);
198+
}

src/spec-node/devContainersSpecCLI.ts

Lines changed: 4 additions & 0 deletions
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 { featuresGenerateDocsHandler, featuresGenerateDocsOptions } from './featuresCLI/generateDocs';
45+
import { templatesGenerateDocsHandler, templatesGenerateDocsOptions } from './templatesCLI/generateDocs';
4446
import { mapNodeOSToGOOS, mapNodeArchitectureToGOARCH } from '../spec-configuration/containerCollectionsOCI';
4547

4648
const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell';
@@ -78,10 +80,12 @@ const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,externa
7880
y.command('publish <target>', 'Package and publish Features', featuresPublishOptions, featuresPublishHandler);
7981
y.command('info <mode> <feature>', 'Fetch metadata for a published Feature', featuresInfoOptions, featuresInfoHandler);
8082
y.command('resolve-dependencies', 'Read and resolve dependency graph from a configuration', featuresResolveDependenciesOptions, featuresResolveDependenciesHandler);
83+
y.command('generate-docs', 'Generate documentation', featuresGenerateDocsOptions, featuresGenerateDocsHandler);
8184
});
8285
y.command('templates', 'Templates commands', (y: Argv) => {
8386
y.command('apply', 'Apply a template to the project', templateApplyOptions, templateApplyHandler);
8487
y.command('publish <target>', 'Package and publish templates', templatesPublishOptions, templatesPublishHandler);
88+
y.command('generate-docs', 'Generate documentation', templatesGenerateDocsOptions, templatesGenerateDocsHandler);
8589
});
8690
y.command(restArgs ? ['exec', '*'] : ['exec <cmd> [args..]'], 'Execute a command on a running dev container', execOptions, execHandler);
8791
y.epilog(`devcontainer@${version} ${packageFolder}`);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Argv } from 'yargs';
2+
import { UnpackArgv } from '../devContainersSpecCLI';
3+
import { generateFeaturesDocumentation } from '../collectionCommonUtils/generateDocsCommandImpl';
4+
import { createLog } from '../devContainers';
5+
import { mapLogLevel } from '../../spec-utils/log';
6+
import { getPackageConfig } from '../../spec-utils/product';
7+
8+
// -- 'features generate-docs' command
9+
export function featuresGenerateDocsOptions(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+
'github-owner': { type: 'string', default: '', description: `GitHub owner for docs.` },
16+
'github-repo': { type: 'string', default: '', description: `GitHub repo for docs.` },
17+
'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level.' }
18+
})
19+
.check(_argv => {
20+
return true;
21+
});
22+
}
23+
24+
export type FeaturesGenerateDocsArgs = UnpackArgv<ReturnType<typeof featuresGenerateDocsOptions>>;
25+
26+
export function featuresGenerateDocsHandler(args: FeaturesGenerateDocsArgs) {
27+
(async () => await featuresGenerateDocs(args))().catch(console.error);
28+
}
29+
30+
export async function featuresGenerateDocs({
31+
'project-folder': collectionFolder,
32+
'registry': registry,
33+
'namespace': namespace,
34+
'github-owner': gitHubOwner,
35+
'github-repo': gitHubRepo,
36+
'log-level': inputLogLevel,
37+
}: FeaturesGenerateDocsArgs) {
38+
const disposables: (() => Promise<unknown> | undefined)[] = [];
39+
const dispose = async () => {
40+
await Promise.all(disposables.map(d => d()));
41+
};
42+
43+
const pkg = getPackageConfig();
44+
45+
const output = createLog({
46+
logLevel: mapLogLevel(inputLogLevel),
47+
logFormat: 'text',
48+
log: (str) => process.stderr.write(str),
49+
terminalDimensions: undefined,
50+
}, pkg, new Date(), disposables);
51+
52+
await generateFeaturesDocumentation(collectionFolder, registry, namespace, gitHubOwner, gitHubRepo, output);
53+
54+
// Cleanup
55+
await dispose();
56+
process.exit();
57+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { Argv } from 'yargs';
2+
import { UnpackArgv } from '../devContainersSpecCLI';
3+
import { generateTemplatesDocumentation } from '../collectionCommonUtils/generateDocsCommandImpl';
4+
import { createLog } from '../devContainers';
5+
import { mapLogLevel } from '../../spec-utils/log';
6+
import { getPackageConfig } from '../../spec-utils/product';
7+
8+
// -- 'templates generate-docs' command
9+
export function templatesGenerateDocsOptions(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+
'github-owner': { type: 'string', default: '', description: `GitHub owner for docs.` },
14+
'github-repo': { type: 'string', default: '', description: `GitHub repo for docs.` },
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 TemplatesGenerateDocsArgs = UnpackArgv<ReturnType<typeof templatesGenerateDocsOptions>>;
23+
24+
export function templatesGenerateDocsHandler(args: TemplatesGenerateDocsArgs) {
25+
(async () => await templatesGenerateDocs(args))().catch(console.error);
26+
}
27+
28+
export async function templatesGenerateDocs({
29+
'project-folder': collectionFolder,
30+
'github-owner': gitHubOwner,
31+
'github-repo': gitHubRepo,
32+
'log-level': inputLogLevel,
33+
}: TemplatesGenerateDocsArgs) {
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 generateTemplatesDocumentation(collectionFolder, gitHubOwner, gitHubRepo, output);
49+
50+
// Cleanup
51+
await dispose();
52+
process.exit();
53+
}

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

Lines changed: 26 additions & 1 deletion
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/generateDocsCommandImpl';
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 generate-docs', async function () {
692+
await generateFeaturesDocumentation(projectFolder, 'ghcr.io', 'devcontainers/cli', '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+
});

0 commit comments

Comments
 (0)