Skip to content

Commit 67bd2ef

Browse files
kanadguptaerunion
andauthored
feat: add docs migrate command (#1220)
fixes RM-12513 ## 🧰 Changes experimental alpha, not ready for public usage just yet. ## 🧬 QA & Testing i added a tiny bit of test coverage, will eventually want to look into how to test this using plugins. --------- Co-authored-by: Jon Ursenbach <jon@ursenba.ch> Co-authored-by: Jon Ursenbach <erunion@users.noreply.github.com>
1 parent c9f9778 commit 67bd2ef

File tree

8 files changed

+309
-59
lines changed

8 files changed

+309
-59
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`rdme docs migrate > should error out if no path is passed 1`] = `
4+
{
5+
"error": [Error: Missing 1 required arg:
6+
path Path to a local Markdown file or folder of Markdown files.
7+
See more help with --help],
8+
"stderr": "",
9+
"stdout": "",
10+
}
11+
`;
12+
13+
exports[`rdme docs migrate > should error out if no plugins are installed 1`] = `
14+
{
15+
"error": [Error: This command requires a valid migration plugin.],
16+
"stderr": "- 🔍 Looking for Markdown files in the \`__tests__/__fixtures__/docs/new-docs\` directory...
17+
✔ 🔍 Looking for Markdown files in the \`__tests__/__fixtures__/docs/new-docs\` directory... 1 file(s) found!
18+
",
19+
"stdout": "",
20+
}
21+
`;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { beforeAll, describe, expect, it } from 'vitest';
2+
3+
import Command from '../../../src/commands/docs/migrate.js';
4+
import { runCommand, type OclifOutput } from '../../helpers/oclif.js';
5+
6+
describe('rdme docs migrate', () => {
7+
let run: (args?: string[]) => OclifOutput;
8+
9+
beforeAll(() => {
10+
run = runCommand(Command);
11+
});
12+
13+
it('should error out if no path is passed', async () => {
14+
const output = await run();
15+
expect(output).toMatchSnapshot();
16+
});
17+
18+
it('should error out if no plugins are installed', async () => {
19+
const output = await run(['__tests__/__fixtures__/docs/new-docs']);
20+
expect(output).toMatchSnapshot();
21+
});
22+
23+
it.todo('should load plugin and transform docs');
24+
});

src/commands/docs/migrate.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import type { PluginHooks } from '../../lib/hooks/exported.js';
2+
3+
import { Args, Flags, type Hook } from '@oclif/core';
4+
import chalk from 'chalk';
5+
import ora from 'ora';
6+
import { dir } from 'tmp-promise';
7+
8+
import BaseCommand from '../../lib/baseCommand.js';
9+
import { fix, writeFixes } from '../../lib/frontmatter.js';
10+
import isCI from '../../lib/isCI.js';
11+
import { oraOptions } from '../../lib/logger.js';
12+
import promptTerminal from '../../lib/promptWrapper.js';
13+
import { fetchMappings, fetchSchema } from '../../lib/readmeAPIFetch.js';
14+
import { findPages } from '../../lib/readPage.js';
15+
16+
const alphaNotice = 'This command is in an experimental alpha and is likely to change. Use at your own risk!';
17+
18+
export default class DocsMigrateCommand extends BaseCommand<typeof DocsMigrateCommand> {
19+
id = 'docs migrate' as const;
20+
21+
route = 'guides' as const;
22+
23+
static hidden = true;
24+
25+
static summary = `Migrates a directory of pages to the ReadMe Guides format.\n\n${alphaNotice}`;
26+
27+
static description =
28+
"The path can either be a directory or a single Markdown file. The command will transform the Markdown using plugins and validate the frontmatter to conform to ReadMe's standards.";
29+
30+
static args = {
31+
path: Args.string({ description: 'Path to a local Markdown file or folder of Markdown files.', required: true }),
32+
};
33+
34+
static flags = {
35+
out: Flags.string({
36+
summary: 'The directory to write the migration output to. Defaults to a temporary directory.',
37+
}),
38+
'skip-validation': Flags.boolean({
39+
description:
40+
'Skips the validation of the Markdown files. Useful if this command is as part of a chain of commands that includes `docs upload`.',
41+
}),
42+
};
43+
44+
async run() {
45+
const { path: rawPathInput }: { path: string } = this.args;
46+
const { out: rawOutputDir, 'skip-validation': skipValidation } = this.flags;
47+
48+
const outputDir = rawOutputDir || (await dir({ prefix: 'rdme-migration-output' })).path;
49+
50+
let pathInput = rawPathInput;
51+
52+
// todo: fix this type once https://github.com/oclif/core/pull/1359 is merged
53+
const fileScanHookResults: Hook.Result<PluginHooks['pre_markdown_file_scan']['return']> = await this.config.runHook(
54+
'pre_markdown_file_scan',
55+
{ pathInput },
56+
);
57+
58+
fileScanHookResults.successes.forEach(success => {
59+
if (success.result) {
60+
pathInput = success.result;
61+
}
62+
});
63+
64+
fileScanHookResults.failures.forEach(fail => {
65+
if (fail.error && fail.error instanceof Error) {
66+
throw new Error(`Error executing the \`${fail.plugin.name}\` plugin: ${fail.error.message}`);
67+
}
68+
});
69+
70+
let unsortedFiles = await findPages.call(this, pathInput);
71+
72+
let transformedByHooks = false;
73+
74+
// todo: fix this type once https://github.com/oclif/core/pull/1359 is merged
75+
const validationHookResults: Hook.Result<PluginHooks['pre_markdown_validation']['return']> =
76+
await this.config.runHook('pre_markdown_validation', { pages: unsortedFiles });
77+
78+
if (!validationHookResults.successes.length && !validationHookResults.failures.length) {
79+
throw new Error('This command requires a valid migration plugin.');
80+
}
81+
82+
validationHookResults.successes.forEach(success => {
83+
if (success.result && success.result.length) {
84+
transformedByHooks = true;
85+
this.log(`🔌 ${success.result.length} Markdown files updated via the \`${success.plugin.name}\` plugin`);
86+
unsortedFiles = success.result;
87+
}
88+
});
89+
90+
validationHookResults.failures.forEach(fail => {
91+
if (fail.error && fail.error instanceof Error) {
92+
throw new Error(`Error executing the \`${fail.plugin.name}\` plugin: ${fail.error.message}`);
93+
}
94+
});
95+
96+
// todo: either DRY this validation logic up or remove it entirely
97+
if (!skipValidation) {
98+
const validationSpinner = ora({ ...oraOptions() }).start('🔬 Validating frontmatter data...');
99+
100+
const schema = await fetchSchema.call(this);
101+
const mappings = await fetchMappings.call(this);
102+
103+
// validate the files, prompt user to fix if necessary
104+
const validationResults = unsortedFiles.map(file => {
105+
this.debug(`validating frontmatter for ${file.filePath}`);
106+
return fix.call(this, file.data, schema, mappings);
107+
});
108+
109+
const filesWithIssues = validationResults.filter(result => result.hasIssues);
110+
const filesWithFixableIssues = filesWithIssues.filter(result => result.changeCount);
111+
const filesWithUnfixableIssues = filesWithIssues.filter(result => result.unfixableErrors.length);
112+
113+
if (filesWithIssues.length) {
114+
validationSpinner.warn(`${validationSpinner.text} issues found in ${filesWithIssues.length} file(s).`);
115+
if (filesWithFixableIssues.length) {
116+
if (isCI()) {
117+
throw new Error(
118+
`${filesWithIssues.length} file(s) have issues. Please run \`${this.config.bin} ${this.id} ${pathInput} --dry-run\` in a non-CI environment to fix them.`,
119+
);
120+
}
121+
122+
const { confirm } = await promptTerminal([
123+
{
124+
type: 'confirm',
125+
name: 'confirm',
126+
message: `${filesWithFixableIssues.length} file(s) have issues that can be fixed automatically. Would you like to make these changes?`,
127+
},
128+
]);
129+
130+
if (!confirm) {
131+
throw new Error('Aborting fixes due to user input.');
132+
}
133+
134+
const fileUpdateSpinner = ora({ ...oraOptions() }).start(
135+
`📝 Writing file changes to the following directory: ${chalk.underline(outputDir)}...`,
136+
);
137+
138+
const updatedFiles = unsortedFiles.map((file, index) => {
139+
return writeFixes.call(this, file, validationResults[index].updatedData, outputDir);
140+
});
141+
142+
fileUpdateSpinner.succeed(`${fileUpdateSpinner.text} ${updatedFiles.length} file(s) updated!`);
143+
144+
unsortedFiles = updatedFiles;
145+
}
146+
147+
// also inform the user if there are files with issues that can't be fixed
148+
if (filesWithUnfixableIssues.length) {
149+
this.warn(
150+
`${filesWithUnfixableIssues.length} file(s) have issues that cannot be fixed automatically. Please get in touch with us at support@readme.io if you need a hand.`,
151+
);
152+
}
153+
} else if (transformedByHooks) {
154+
validationSpinner.succeed(`${validationSpinner.text} no issues found!`);
155+
156+
const fileUpdateSpinner = ora({ ...oraOptions() }).start(
157+
`📝 Writing the updated files to the following directory: ${chalk.underline(outputDir)}...`,
158+
);
159+
160+
const updatedFiles = unsortedFiles.map((file, index) => {
161+
return writeFixes.call(this, file, validationResults[index].updatedData, outputDir);
162+
});
163+
164+
fileUpdateSpinner.succeed(`${fileUpdateSpinner.text} done!`);
165+
166+
unsortedFiles = updatedFiles;
167+
} else {
168+
validationSpinner.succeed(`${validationSpinner.text} no issues found!`);
169+
}
170+
} else {
171+
this.debug('skipping validation');
172+
if (transformedByHooks) {
173+
const fileUpdateSpinner = ora({ ...oraOptions() }).start(
174+
`📝 Writing the updated files to the following directory: ${chalk.underline(outputDir)}...`,
175+
);
176+
177+
const updatedFiles = unsortedFiles.map(file => {
178+
return writeFixes.call(this, file, file.data, outputDir);
179+
});
180+
181+
fileUpdateSpinner.succeed(`${fileUpdateSpinner.text} done!`);
182+
183+
unsortedFiles = updatedFiles;
184+
}
185+
}
186+
187+
return { outputDir };
188+
}
189+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ValueOf } from 'type-fest';
22

33
import ChangelogsCommand from './commands/changelogs.js';
4+
import DocsMigrateCommand from './commands/docs/migrate.js';
45
import DocsUploadCommand from './commands/docs/upload.js';
56
import LoginCommand from './commands/login.js';
67
import LogoutCommand from './commands/logout.js';
@@ -27,6 +28,7 @@ export { default as prerun } from './lib/hooks/prerun.js';
2728
export const COMMANDS = {
2829
changelogs: ChangelogsCommand,
2930

31+
'docs:migrate': DocsMigrateCommand,
3032
'docs:upload': DocsUploadCommand,
3133

3234
login: LoginCommand,

src/lib/readPage.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import type ChangelogsCommand from '../commands/changelogs.js';
2+
import type DocsMigrateCommand from '../commands/docs/migrate.js';
23
import type DocsUploadCommand from '../commands/docs/upload.js';
34

45
import crypto from 'node:crypto';
56
import fs from 'node:fs';
7+
import fsPromises from 'node:fs/promises';
68
import path from 'node:path';
79

810
import grayMatter from 'gray-matter';
11+
import ora from 'ora';
12+
13+
import { oraOptions } from './logger.js';
14+
import readdirRecursive from './readdirRecursive.js';
915

1016
export interface PageMetadata<T = Record<string, unknown>> {
1117
/**
@@ -35,8 +41,8 @@ export interface PageMetadata<T = Record<string, unknown>> {
3541
/**
3642
* Returns the content, matter and slug of the specified Markdown or HTML file
3743
*/
38-
export default function readPage(
39-
this: ChangelogsCommand | DocsUploadCommand,
44+
export function readPage(
45+
this: ChangelogsCommand | DocsMigrateCommand | DocsUploadCommand,
4046
/**
4147
* path to the HTML/Markdown file
4248
* (file extension must end in `.html`, `.md`., or `.markdown`)
@@ -58,3 +64,58 @@ export default function readPage(
5864
const hash = crypto.createHash('sha1').update(rawFileContents).digest('hex');
5965
return { content, data, filePath, hash, slug };
6066
}
67+
68+
/**
69+
* Takes a path input and finds pages. If the path is a directory, it will recursively search for files with the specified extensions.
70+
* If the path is a file, it will check if the file has a valid extension.
71+
*
72+
* Once the files are found, it reads each file and returns an array of page metadata objects (e.g., the parsed frontmatter data).
73+
*/
74+
export async function findPages(
75+
this: ChangelogsCommand | DocsMigrateCommand | DocsUploadCommand,
76+
pathInput: string,
77+
allowedFileExtensions: string[] = ['.markdown', '.md', '.mdx'],
78+
) {
79+
let files: string[];
80+
81+
const stat = await fsPromises.stat(pathInput).catch(err => {
82+
if (err.code === 'ENOENT') {
83+
throw new Error("Oops! We couldn't locate a file or directory at the path you provided.");
84+
}
85+
throw err;
86+
});
87+
88+
if (stat.isDirectory()) {
89+
const fileScanningSpinner = ora({ ...oraOptions() }).start(
90+
`🔍 Looking for Markdown files in the \`${pathInput}\` directory...`,
91+
);
92+
// Filter out any files that don't match allowedFileExtensions
93+
files = readdirRecursive(pathInput).filter(file =>
94+
allowedFileExtensions.includes(path.extname(file).toLowerCase()),
95+
);
96+
97+
if (!files.length) {
98+
fileScanningSpinner.fail(`${fileScanningSpinner.text} no files found.`);
99+
throw new Error(
100+
`The directory you provided (${pathInput}) doesn't contain any of the following file extensions: ${allowedFileExtensions.join(
101+
', ',
102+
)}.`,
103+
);
104+
}
105+
106+
fileScanningSpinner.succeed(`${fileScanningSpinner.text} ${files.length} file(s) found!`);
107+
} else {
108+
const fileExtension = path.extname(pathInput).toLowerCase();
109+
if (!allowedFileExtensions.includes(fileExtension)) {
110+
throw new Error(
111+
`Invalid file extension (${fileExtension}). Must be one of the following: ${allowedFileExtensions.join(', ')}`,
112+
);
113+
}
114+
115+
files = [pathInput];
116+
}
117+
118+
this.debug(`number of files: ${files.length}`);
119+
120+
return files.map(file => readPage.call(this, file));
121+
}

src/lib/readmeAPIFetch.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { SpecFileType } from './prepareOas.js';
22
import type { CommandClass } from '../index.js';
3-
import type { CommandsThatSyncMarkdown } from './syncPagePath.js';
3+
import type { APIv2PageCommands } from './syncPagePath.js';
44
import type { Hook } from '@oclif/core';
55
import type { SchemaObject } from 'oas/types';
66

@@ -488,7 +488,7 @@ export async function fetchMappings(this: CommandClass['prototype']): Promise<Ma
488488
/**
489489
* Fetches the schema for the current route from the OpenAPI description for ReadMe API v2.
490490
*/
491-
export async function fetchSchema(this: CommandsThatSyncMarkdown) {
491+
export async function fetchSchema(this: APIv2PageCommands) {
492492
const oas = await this.readmeAPIFetch('/openapi.json')
493493
.then(res => {
494494
if (!res.ok) {

src/lib/syncDocsPath.legacy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import toposort from 'toposort';
1010
import { APIv1Error } from './apiError.js';
1111
import readdirRecursive from './readdirRecursive.js';
1212
import { cleanAPIv1Headers, handleAPIv1Res, readmeAPIv1Fetch } from './readmeAPIFetch.js';
13-
import readPage from './readPage.js';
13+
import { readPage } from './readPage.js';
1414

1515
/** API path within ReadMe to update (e.g. `docs`, `changelogs`, etc.) */
1616
type PageType = 'changelogs' | 'custompages' | 'docs';

0 commit comments

Comments
 (0)