Skip to content

Commit 6812a54

Browse files
authored
refactor: consolidate doc frontmatter validation logic (#1233)
## 🧰 Changes i'm about to open up another PR for RM-12535 that adds a new `-confirm-autofixes` flag, and as part of that i got fed up and totally DRY'd up our duplicated validation logic so it's now basically the same between `docs:upload` and `docs:migrate`. i also added a tiny enhancement to our frontmatter autofixing logic that allows the `hidden` attribute to accept flags like `'true'` and `'false'`. ## 🧬 QA & Testing `docs:migrate` is still in dire need of more test coverage so i've been testing it locally and confirmed that it's still working as expected. `docs:upload` has great test coverage so if those tests pass we should be in good shape.
1 parent 422de31 commit 6812a54

File tree

5 files changed

+157
-160
lines changed

5 files changed

+157
-160
lines changed

__tests__/lib/frontmatter.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,25 @@ describe('#fix', () => {
245245
`);
246246
});
247247

248+
it('should fix privacy (public, `hidden` is a string)', () => {
249+
const data = {
250+
title: 'Hello, world!',
251+
hidden: 'false',
252+
};
253+
254+
const result = fix.call(command, data, schema, emptyMappings);
255+
256+
expect(result.hasIssues).toBe(true);
257+
expect(result.updatedData).toMatchInlineSnapshot(`
258+
{
259+
"privacy": {
260+
"view": "public",
261+
},
262+
"title": "Hello, world!",
263+
}
264+
`);
265+
});
266+
248267
it('should fix privacy (anyone_with_link)', () => {
249268
const data = {
250269
title: 'Hello, world!',
@@ -264,6 +283,25 @@ describe('#fix', () => {
264283
`);
265284
});
266285

286+
it('should fix privacy (anyone_with_link, `hidden` is a string)', () => {
287+
const data = {
288+
title: 'Hello, world!',
289+
hidden: 'true',
290+
};
291+
292+
const result = fix.call(command, data, schema, emptyMappings);
293+
294+
expect(result.hasIssues).toBe(true);
295+
expect(result.updatedData).toMatchInlineSnapshot(`
296+
{
297+
"privacy": {
298+
"view": "anyone_with_link",
299+
},
300+
"title": "Hello, world!",
301+
}
302+
`);
303+
});
304+
267305
it.todo('should fix metadata object');
268306

269307
it.todo('should fix content.link object');

src/commands/docs/migrate.ts

Lines changed: 22 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,11 @@ import ora from 'ora';
66
import { dir } from 'tmp-promise';
77

88
import BaseCommand from '../../lib/baseCommand.js';
9-
import { fix, writeFixes } from '../../lib/frontmatter.js';
10-
import isCI from '../../lib/isCI.js';
9+
import { writeFixes } from '../../lib/frontmatter.js';
1110
import { oraOptions } from '../../lib/logger.js';
12-
import promptTerminal from '../../lib/promptWrapper.js';
13-
import { fetchMappings, fetchSchema } from '../../lib/readmeAPIFetch.js';
1411
import { findPages } from '../../lib/readPage.js';
1512
import { attemptUnzip } from '../../lib/unzip.js';
13+
import { validateFrontmatter } from '../../lib/validateFrontmatter.js';
1614

1715
const alphaNotice = 'This command is in an experimental alpha and is likely to change. Use at your own risk!';
1816

@@ -113,96 +111,31 @@ export default class DocsMigrateCommand extends BaseCommand<typeof DocsMigrateCo
113111
}
114112
});
115113

116-
// todo: either DRY this validation logic up or remove it entirely
117-
if (!skipValidation) {
118-
const validationSpinner = ora({ ...oraOptions() }).start('🔬 Validating frontmatter data...');
114+
if (skipValidation) {
115+
this.debug('skipping validation');
116+
} else {
117+
unsortedFiles = await validateFrontmatter.call(
118+
this,
119+
unsortedFiles,
120+
'Would you like to make these changes?',
121+
outputDir,
122+
);
123+
}
119124

120-
const schema = await fetchSchema.call(this);
121-
const mappings = await fetchMappings.call(this);
125+
if (transformedByHooks) {
126+
const fileUpdateSpinner = ora({ ...oraOptions() }).start(
127+
`📝 Writing the updated files to the following directory: ${chalk.underline(outputDir)}...`,
128+
);
122129

123-
// validate the files, prompt user to fix if necessary
124-
const validationResults = unsortedFiles.map(file => {
125-
this.debug(`validating frontmatter for ${file.filePath}`);
126-
return fix.call(this, file.data, schema, mappings);
130+
const updatedFiles = unsortedFiles.map(file => {
131+
// TODO: I think that this `writeFixes` call is redundant if `validateFrontmatter` above also writes to the file,
132+
// but it's not even close to being a bottleneck so I'm not going to worry about it for now
133+
return writeFixes.call(this, file, file.data, outputDir);
127134
});
128135

129-
const filesWithIssues = validationResults.filter(result => result.hasIssues);
130-
this.debug(`found ${filesWithIssues.length} files with issues: ${JSON.stringify(filesWithIssues)}`);
131-
const filesWithFixableIssues = filesWithIssues.filter(result => result.changeCount);
132-
const filesWithUnfixableIssues = filesWithIssues.filter(result => result.unfixableErrors.length);
133-
134-
if (filesWithIssues.length) {
135-
validationSpinner.warn(`${validationSpinner.text} issues found in ${filesWithIssues.length} file(s).`);
136-
if (filesWithFixableIssues.length) {
137-
if (isCI()) {
138-
throw new Error(
139-
`${filesWithIssues.length} file(s) have issues. Please run \`${this.config.bin} ${this.id} ${pathInput} --dry-run\` in a non-CI environment to fix them.`,
140-
);
141-
}
142-
143-
const { confirm } = await promptTerminal([
144-
{
145-
type: 'confirm',
146-
name: 'confirm',
147-
message: `${filesWithFixableIssues.length} file(s) have issues that can be fixed automatically. Would you like to make these changes?`,
148-
},
149-
]);
150-
151-
if (!confirm) {
152-
throw new Error('Aborting fixes due to user input.');
153-
}
154-
155-
const fileUpdateSpinner = ora({ ...oraOptions() }).start(
156-
`📝 Writing file changes to the following directory: ${chalk.underline(outputDir)}...`,
157-
);
158-
159-
const updatedFiles = unsortedFiles.map((file, index) => {
160-
return writeFixes.call(this, file, validationResults[index].updatedData, outputDir);
161-
});
162-
163-
fileUpdateSpinner.succeed(`${fileUpdateSpinner.text} ${updatedFiles.length} file(s) updated!`);
164-
165-
unsortedFiles = updatedFiles;
166-
}
167-
168-
// also inform the user if there are files with issues that can't be fixed
169-
if (filesWithUnfixableIssues.length) {
170-
this.warn(
171-
`${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.`,
172-
);
173-
}
174-
} else if (transformedByHooks) {
175-
validationSpinner.succeed(`${validationSpinner.text} no issues found!`);
176-
177-
const fileUpdateSpinner = ora({ ...oraOptions() }).start(
178-
`📝 Writing the updated files to the following directory: ${chalk.underline(outputDir)}...`,
179-
);
180-
181-
const updatedFiles = unsortedFiles.map((file, index) => {
182-
return writeFixes.call(this, file, validationResults[index].updatedData, outputDir);
183-
});
184-
185-
fileUpdateSpinner.succeed(`${fileUpdateSpinner.text} done!`);
186-
187-
unsortedFiles = updatedFiles;
188-
} else {
189-
validationSpinner.succeed(`${validationSpinner.text} no issues found!`);
190-
}
191-
} else {
192-
this.debug('skipping validation');
193-
if (transformedByHooks) {
194-
const fileUpdateSpinner = ora({ ...oraOptions() }).start(
195-
`📝 Writing the updated files to the following directory: ${chalk.underline(outputDir)}...`,
196-
);
197-
198-
const updatedFiles = unsortedFiles.map(file => {
199-
return writeFixes.call(this, file, file.data, outputDir);
200-
});
136+
fileUpdateSpinner.succeed(`${fileUpdateSpinner.text} done!`);
201137

202-
fileUpdateSpinner.succeed(`${fileUpdateSpinner.text} done!`);
203-
204-
unsortedFiles = updatedFiles;
205-
}
138+
unsortedFiles = updatedFiles;
206139
}
207140

208141
return { outputDir, stats };

src/lib/frontmatter.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,15 @@ export function fix(
2929
*/
3030
mappings: Mappings,
3131
): {
32-
changeCount: number;
32+
fixableErrorCount: number;
3333
errors: ErrorObject[];
3434
hasIssues: boolean;
3535
unfixableErrors: ErrorObject[];
3636
updatedData: PageMetadata['data'];
3737
} {
3838
if (!Object.keys(data).length) {
3939
this.debug('no frontmatter attributes found, skipping validation');
40-
return { changeCount: 0, errors: [], hasIssues: false, unfixableErrors: [], updatedData: data };
40+
return { fixableErrorCount: 0, errors: [], hasIssues: false, unfixableErrors: [], updatedData: data };
4141
}
4242

4343
const ajv = new Ajv({ allErrors: true, strictTypes: false, strictTuples: false });
@@ -76,12 +76,12 @@ export function fix(
7676
}
7777
}
7878

79-
let changeCount = 0;
79+
let fixableErrorCount = 0;
8080
const unfixableErrors: ErrorObject[] = [];
8181
const updatedData = structuredClone(data);
8282

8383
if (typeof errors === 'undefined' || !errors.length) {
84-
return { changeCount, errors: [], hasIssues: false, unfixableErrors, updatedData };
84+
return { errors: [], fixableErrorCount, hasIssues: false, unfixableErrors, updatedData };
8585
}
8686

8787
errors.forEach(error => {
@@ -90,14 +90,14 @@ export function fix(
9090
updatedData.category = {
9191
uri: uri || `uri-that-does-not-map-to-${data.category}`,
9292
};
93-
changeCount += 1;
93+
fixableErrorCount += 1;
9494
} else if (error.keyword === 'additionalProperties') {
9595
const badKey = error.params.additionalProperty as string;
9696
const extractedValue = data[badKey];
9797
if (error.schemaPath === '#/additionalProperties') {
9898
// if the bad property is at the root level, delete it
9999
delete updatedData[badKey];
100-
changeCount += 1;
100+
fixableErrorCount += 1;
101101
if (badKey === 'excerpt') {
102102
// if the `content` object exists, add to it. otherwise, create it
103103
if (typeof updatedData.content === 'object' && updatedData.content) {
@@ -123,7 +123,8 @@ export function fix(
123123
uri: extractedValue,
124124
};
125125
} else if (badKey === 'hidden') {
126-
updatedData.privacy = { view: extractedValue ? 'anyone_with_link' : 'public' };
126+
const hidden = typeof extractedValue === 'boolean' ? extractedValue : extractedValue === 'true';
127+
updatedData.privacy = { view: hidden ? 'anyone_with_link' : 'public' };
127128
} else if (badKey === 'order') {
128129
updatedData.position = extractedValue;
129130
}
@@ -135,7 +136,7 @@ export function fix(
135136
}
136137
});
137138

138-
return { errors, changeCount, hasIssues: true, unfixableErrors, updatedData };
139+
return { errors, fixableErrorCount, hasIssues: true, unfixableErrors, updatedData };
139140
}
140141

141142
export function writeFixes(

src/lib/syncPagePath.ts

Lines changed: 10 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,10 @@ import ora from 'ora';
77
import toposort from 'toposort';
88

99
import { APIv2Error } from './apiError.js';
10-
import { fix, writeFixes } from './frontmatter.js';
11-
import isCI from './isCI.js';
1210
import { oraOptions } from './logger.js';
13-
import promptTerminal from './promptWrapper.js';
14-
import { fetchMappings, fetchSchema } from './readmeAPIFetch.js';
1511
import { findPages, type PageMetadata } from './readPage.js';
1612
import { categoryUriRegexPattern, parentUriRegexPattern } from './types/index.js';
13+
import { validateFrontmatter } from './validateFrontmatter.js';
1714

1815
/**
1916
* Commands that leverage the APIv2 representations for pages (e.g., Guides, API Reference, etc.)
@@ -220,7 +217,7 @@ function sortFiles(files: PageMetadata<PageRepresentation>[]): PageMetadata<Page
220217
* @returns An array of objects with the results
221218
*/
222219
export default async function syncPagePath(this: DocsUploadCommand) {
223-
const { path: pathInput }: { path: string } = this.args;
220+
const { path: pathInput } = this.args;
224221
const { key, 'dry-run': dryRun, 'max-errors': maxErrors, 'skip-validation': skipValidation } = this.flags;
225222

226223
// check whether or not the project has bidirection syncing enabled
@@ -240,6 +237,8 @@ export default async function syncPagePath(this: DocsUploadCommand) {
240237
);
241238
}
242239

240+
let unsortedFiles = await findPages.call(this, pathInput);
241+
243242
if (skipValidation) {
244243
if (biDiConnection) {
245244
this.warn(
@@ -248,64 +247,12 @@ export default async function syncPagePath(this: DocsUploadCommand) {
248247
} else {
249248
this.warn('Skipping pre-upload validation of the Markdown file(s). This is not recommended.');
250249
}
251-
}
252-
253-
let unsortedFiles = await findPages.call(this, pathInput);
254-
255-
if (!skipValidation) {
256-
const validationSpinner = ora({ ...oraOptions() }).start('🔬 Validating frontmatter data...');
257-
258-
const schema = await fetchSchema.call(this);
259-
const mappings = await fetchMappings.call(this);
260-
261-
// validate the files, prompt user to fix if necessary
262-
const validationResults = unsortedFiles.map(file => {
263-
this.debug(`validating frontmatter for ${file.filePath}`);
264-
return fix.call(this, file.data, schema, mappings);
265-
});
266-
267-
const filesWithIssues = validationResults.filter(result => result.hasIssues);
268-
this.debug(`found ${filesWithIssues.length} files with issues: ${JSON.stringify(filesWithIssues)}`);
269-
const filesWithFixableIssues = filesWithIssues.filter(result => result.changeCount);
270-
const filesWithUnfixableIssues = filesWithIssues.filter(result => result.unfixableErrors.length);
271-
272-
if (filesWithIssues.length) {
273-
validationSpinner.warn(`${validationSpinner.text} issues found in ${filesWithIssues.length} file(s).`);
274-
if (filesWithFixableIssues.length) {
275-
if (isCI()) {
276-
throw new Error(
277-
`${filesWithIssues.length} file(s) have issues that should be fixed before uploading to ReadMe. Please run \`${this.config.bin} ${this.id} ${pathInput} --dry-run\` in a non-CI environment to fix them.`,
278-
);
279-
}
280-
281-
const { confirm } = await promptTerminal([
282-
{
283-
type: 'confirm',
284-
name: 'confirm',
285-
message: `${filesWithFixableIssues.length} file(s) have issues that can be fixed automatically. Would you like to make these changes and continue with the upload to ReadMe?`,
286-
},
287-
]);
288-
289-
if (!confirm) {
290-
throw new Error('Aborting upload due to user input.');
291-
}
292-
293-
const updatedFiles = unsortedFiles.map((file, index) => {
294-
return writeFixes.call(this, file, validationResults[index].updatedData);
295-
});
296-
297-
unsortedFiles = updatedFiles;
298-
}
299-
300-
// also inform the user if there are files with issues that can't be fixed
301-
if (filesWithUnfixableIssues.length) {
302-
this.warn(
303-
`${filesWithUnfixableIssues.length} file(s) have issues that cannot be fixed automatically. The upload will proceed but we recommend addressing these issues. Please get in touch with us at support@readme.io if you need a hand.`,
304-
);
305-
}
306-
} else {
307-
validationSpinner.succeed(`${validationSpinner.text} no issues found!`);
308-
}
250+
} else {
251+
unsortedFiles = await validateFrontmatter.call(
252+
this,
253+
unsortedFiles,
254+
'Would you like to make these changes and continue with the upload to ReadMe?',
255+
);
309256
}
310257

311258
const uploadSpinner = ora({ ...oraOptions() }).start(

0 commit comments

Comments
 (0)