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(docs): adds ability to delete docs from ReadMe if they're no longer in local folder #581

Merged
merged 30 commits into from
Sep 12, 2022
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0873fef
feat(openapi): Add `updateSingleSpec` option to automatically update …
shaiarmis Aug 23, 2022
8d44028
feat(docs): Updated docs
shaiarmis Aug 23, 2022
f0da91b
Fix eslint errors
shaiarmis Aug 23, 2022
60e1513
Throw error if using --updateSingleSpec when there are multiple spec …
shaiarmis Aug 23, 2022
8e83532
Show a warning when passing both `--updateSingleSpec` and `--id`
shaiarmis Aug 23, 2022
e1e9f92
Update tests snapshot
shaiarmis Aug 23, 2022
b68e0ed
docs: copy edits, rename flag to `--update`
kanadgupta Aug 23, 2022
bba9448
fix: add logic for if create and update flags passed together
kanadgupta Aug 23, 2022
95a8148
ci: attempt to skip failing step if API key isn't present
kanadgupta Aug 23, 2022
8ca1596
Revert "ci: attempt to skip failing step if API key isn't present"
kanadgupta Aug 23, 2022
1a2658b
Merge branch 'main' into pr/579
kanadgupta Aug 23, 2022
0f9730f
Merge branch 'readmeio:main' into main
shaiarmis Aug 24, 2022
3f76bca
Add --deleteMissing option to `rdme docs` command to delete from Read…
shaiarmis Aug 24, 2022
0292a09
Take into account only "guide" categories
shaiarmis Aug 24, 2022
daa9c17
chore: markdown formatting
kanadgupta Aug 24, 2022
276f6f0
Add warning about what the new option does
shaiarmis Aug 25, 2022
082d4c1
Improved typing of functions
shaiarmis Aug 25, 2022
2b31260
Move "if" to a more proper location
shaiarmis Aug 25, 2022
8b1ba91
Refactor "getSlug" to "readDoc" and reuse code
shaiarmis Aug 25, 2022
e174386
Removed unused code
shaiarmis Aug 25, 2022
4145c99
Simplify code
shaiarmis Aug 25, 2022
e5c6eac
Refactored tests to use a folder with a document that should not be d…
shaiarmis Aug 25, 2022
27d3fa0
Merge remote-tracking branch 'origin/add-delete-missing-option' into …
shaiarmis Aug 25, 2022
637a7af
Added a confirmation prompt when folder is empty
shaiarmis Aug 25, 2022
771ccf2
Rename --deleteMissing to --cleanup
shaiarmis Aug 30, 2022
d679349
Merge branch 'main' into add-delete-missing-option
shaiarmis Sep 7, 2022
bd51962
CR
shaiarmis Sep 8, 2022
ac79f09
Merge branch 'main' into add-delete-missing-option
shaiarmis Sep 8, 2022
5af81ee
Undo accidental commit
shaiarmis Sep 8, 2022
46e2323
Update index.ts
kanadgupta Sep 12, 2022
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ rdme docs path-to-markdown-files --version={project-version}

This command also has a dry run mode, which can be useful for initial setup and debugging. You can read more about dry run mode [in our docs](https://docs.readme.com/docs/rdme#dry-run-mode).

#### Cleanup

If you wish to delete documents from ReadMe that are no longer present in the directory, pass the `--cleanup` option to the command.
erunion marked this conversation as resolved.
Show resolved Hide resolved

#### Edit a Single ReadMe Doc on Your Local Machine

```sh
Expand Down
4 changes: 4 additions & 0 deletions __tests__/__fixtures__/docs/delete-docs/some-doc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
category: 5ae122e10fdf4e39bb34db6f
title: This is the document title
---
95 changes: 95 additions & 0 deletions __tests__/cmds/docs/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';

import chalk from 'chalk';
import frontMatter from 'gray-matter';
import nock from 'nock';
import prompts from 'prompts';

import DocsCommand from '../../../src/cmds/docs';
import APIError from '../../../src/lib/apiError';
Expand Down Expand Up @@ -374,6 +376,99 @@ describe('rdme docs', () => {
});
});

describe('cleanup docs', () => {
kanadgupta marked this conversation as resolved.
Show resolved Hide resolved
const folder = `./__tests__/${fixturesBaseDir}/delete-docs`;
const someDocContent = fs.readFileSync(path.join(folder, 'some-doc.md'));
const lastUpdatedHash = crypto.createHash('sha1').update(someDocContent).digest('hex');

it('should delete doc if file is missing and --cleanup option is used', async () => {
const versionMock = getAPIMock()
.get(`/api/v1/version/${version}`)
.basicAuth({ user: key })
.reply(200, { version });

const apiMocks = getAPIMockWithVersionHeader(version)
.get('/api/v1/categories?perPage=20&page=1')
.basicAuth({ user: key })
.reply(200, [{ slug: 'category1', type: 'guide' }], { 'x-total-count': '1' })
.get('/api/v1/categories/category1/docs')
.basicAuth({ user: key })
.reply(200, [{ slug: 'this-doc-should-be-missing-in-folder' }, { slug: 'some-doc' }])
.delete('/api/v1/docs/this-doc-should-be-missing-in-folder')
.basicAuth({ user: key })
.reply(204, '')
.get('/api/v1/docs/some-doc')
.basicAuth({ user: key })
.reply(200, { lastUpdatedHash });

await expect(
docs.run({
folder,
key,
version,
cleanup: true,
})
).resolves.toBe(
'successfully deleted `this-doc-should-be-missing-in-folder`.\n' +
'`some-doc` was not updated because there were no changes.'
);

apiMocks.done();
versionMock.done();
});

it('should return doc delete info for dry run', async () => {
const versionMock = getAPIMock()
.get(`/api/v1/version/${version}`)
.basicAuth({ user: key })
.reply(200, { version });
const apiMocks = getAPIMockWithVersionHeader(version)
.get('/api/v1/categories?perPage=20&page=1')
.basicAuth({ user: key })
.reply(200, [{ slug: 'category1', type: 'guide' }], { 'x-total-count': '1' })
.get('/api/v1/categories/category1/docs')
.basicAuth({ user: key })
.reply(200, [{ slug: 'this-doc-should-be-missing-in-folder' }])
.get('/api/v1/docs/some-doc')
.basicAuth({ user: key })
.reply(200, { lastUpdatedHash });
await expect(
docs.run({
folder,
key,
version,
cleanup: true,
dryRun: true,
})
).resolves.toBe(
'🎭 dry run! This will delete `this-doc-should-be-missing-in-folder`.\n' +
'🎭 dry run! `some-doc` will not be updated because there were no changes.'
);
apiMocks.done();
versionMock.done();
});

it('should do nothing if using --cleanup but thr folder is empty and the user aborted', async () => {
erunion marked this conversation as resolved.
Show resolved Hide resolved
prompts.inject([false]);

const versionMock = getAPIMock()
.get(`/api/v1/version/${version}`)
.basicAuth({ user: key })
.reply(200, { version });

await expect(
docs.run({
folder: './__tests__/__fixtures__/ref-oas',
key,
version,
cleanup: true,
})
).rejects.toStrictEqual(new Error('Aborting, no changes were made.'));

versionMock.done();
});
});

describe('slug metadata', () => {
it('should use provided slug', async () => {
const slug = 'new-doc-slug';
Expand Down
42 changes: 38 additions & 4 deletions src/cmds/docs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,26 @@ import chalk from 'chalk';
import config from 'config';

import Command, { CommandCategories } from '../../lib/baseCommand';
import deleteDoc from '../../lib/deleteDoc';
import getDocs from '../../lib/getDocs';
import * as promptHandler from '../../lib/prompts';
import promptTerminal from '../../lib/promptWrapper';
import pushDoc from '../../lib/pushDoc';
import readdirRecursive from '../../lib/readdirRecursive';
import readDoc from '../../lib/readDoc';
import { getProjectVersion } from '../../lib/versionSelect';

export type Options = {
dryRun?: boolean;
folder?: string;
cleanup?: boolean;
};

function getSlug(filename: string): string {
const { slug } = readDoc(filename);
return slug;
}

export default class DocsCommand extends Command {
constructor() {
super();
Expand Down Expand Up @@ -41,13 +52,18 @@ export default class DocsCommand extends Command {
type: Boolean,
description: 'Runs the command without creating/updating any docs in ReadMe. Useful for debugging.',
},
{
name: 'cleanup',
type: Boolean,
description: "Delete a doc from ReadMe if its slug can't be found in the target folder.",
},
];
}

async run(opts: CommandOptions<Options>) {
super.run(opts);

const { dryRun, folder, key, version } = opts;
const { dryRun, folder, key, version, cleanup } = opts;

if (!folder) {
return Promise.reject(new Error(`No folder provided. Usage \`${config.get('cli')} ${this.usage}\`.`));
Expand All @@ -67,7 +83,25 @@ export default class DocsCommand extends Command {

Command.debug(`number of files: ${files.length}`);

if (!files.length) {
const changes: string[] = [];
if (cleanup) {
if (!files.length) {
const { deleteAll } = await promptTerminal(promptHandler.deleteDocsPrompt(selectedVersion));
if (!deleteAll) {
return Promise.reject(new Error('Aborting, no changes were made.'));
}
}

Command.warn(`We're going to delete from ReadMe any document that isn't found in ${folder}.`);
const docs = await getDocs(key, selectedVersion);
kanadgupta marked this conversation as resolved.
Show resolved Hide resolved
const docSlugs = docs.map(({ slug }: { slug: string }) => slug);
const fileSlugs = new Set(files.map(getSlug));
const slugsToDelete = docSlugs.filter((slug: string) => !fileSlugs.has(slug));
const deletedDocs = await Promise.all(
slugsToDelete.map((slug: string) => deleteDoc(key, selectedVersion, dryRun, slug, this.cmdCategory))
);
changes.push(...deletedDocs);
} else if (!files.length) {
return Promise.reject(new Error(`We were unable to locate Markdown files in ${folder}.`));
}

Expand All @@ -76,7 +110,7 @@ export default class DocsCommand extends Command {
return pushDoc(key, selectedVersion, dryRun, filename, this.cmdCategory);
})
);

return chalk.green(updatedDocs.join('\n'));
changes.push(...updatedDocs);
return chalk.green(changes.join('\n'));
}
}
40 changes: 40 additions & 0 deletions src/lib/deleteDoc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { CommandCategories } from './baseCommand';

import config from 'config';
import { Headers } from 'node-fetch';

import fetch, { cleanHeaders, handleRes } from './fetch';

/**
* Delete a document from ReadMe
*
* @param {String} key the project API key
* @param {String} selectedVersion the project version
* @param {Boolean} dryRun boolean indicating dry run mode
* @param {String} slug The slug of the document to delete
* @param {String} type module within ReadMe to update (e.g. docs, changelogs, etc.)
* @returns {Promise<String>} a string containing the result
erunion marked this conversation as resolved.
Show resolved Hide resolved
*/
export default async function deleteDoc(
key: string,
selectedVersion: string,
dryRun: boolean,
slug: string,
type: CommandCategories
): Promise<string> {
if (dryRun) {
return Promise.resolve(`🎭 dry run! This will delete \`${slug}\`.`);
}
return fetch(`${config.get('host')}/api/v1/${type}/${slug}`, {
method: 'delete',
headers: cleanHeaders(
key,
new Headers({
'x-readme-version': selectedVersion,
'Content-Type': 'application/json',
})
),
})
.then(handleRes)
.then(() => `successfully deleted \`${slug}\`.`);
erunion marked this conversation as resolved.
Show resolved Hide resolved
}
5 changes: 5 additions & 0 deletions src/lib/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import APIError from './apiError';
import isGHA from './isGitHub';
import { debug } from './logger';

const SUCCESS_NO_CONTENT = 204;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DELETE /api/v1/docs/{slug} endpoint returns 204 with an empty body if the deletion was successful.


/**
* Getter function for a string to be used in the user-agent header based on the current
* environment.
Expand Down Expand Up @@ -71,6 +73,9 @@ async function handleRes(res: Response) {
}
return body;
}
if (res.status === SUCCESS_NO_CONTENT) {
erunion marked this conversation as resolved.
Show resolved Hide resolved
return {};
}
// If we receive a non-JSON response, it's likely an error.
// Let's debug the raw response body and throw it.
const body = await res.text();
Expand Down
46 changes: 46 additions & 0 deletions src/lib/getDocs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import config from 'config';
import { Headers } from 'node-fetch';

import fetch, { cleanHeaders, handleRes } from './fetch';
import getCategories from './getCategories';

type Document = {
_id: string;
title: string;
slug: string;
order: number;
hidden: boolean;
children: Document[];
};

function flatten(data: Document[][]): Document[] {
return [].concat(...data);
}

async function getCategoryDocs(key: string, selectedVersion: string, category: string): Promise<Document[]> {
return fetch(`${config.get('host')}/api/v1/categories/${category}/docs`, {
method: 'get',
headers: cleanHeaders(
key,
new Headers({
'x-readme-version': selectedVersion,
'Content-Type': 'application/json',
})
),
}).then(handleRes);
}

/**
* Retrieve the docs under all categories or under a specific one
*
* @param {String} key the project API key
* @param {String} selectedVersion the project version
* @returns {Promise<Array<Document>>} an array containing the docs
*/
export default async function getDocs(key: string, selectedVersion: string): Promise<Document[]> {
return getCategories(key, selectedVersion)
.then(categories => categories.filter(({ type }: { type: string }) => type === 'guide'))
.then(categories => categories.map(({ slug }: { slug: string }) => getCategoryDocs(key, selectedVersion, slug)))
.then(categoryDocsPromises => Promise.all(categoryDocsPromises))
.then(flatten);
}
8 changes: 8 additions & 0 deletions src/lib/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,11 @@ export function createVersionPrompt(
},
];
}

export function deleteDocsPrompt(version: string): PromptObject {
return {
type: 'confirm',
name: 'deleteAll',
message: `This action will delete all docs under version ${version} from ReadMe, are you sure you wish to continue?`,
erunion marked this conversation as resolved.
Show resolved Hide resolved
};
}
14 changes: 3 additions & 11 deletions src/lib/pushDoc.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';

import chalk from 'chalk';
import config from 'config';
import grayMatter from 'gray-matter';
import { Headers } from 'node-fetch';

import APIError from './apiError';
import { CommandCategories } from './baseCommand';
import fetch, { cleanHeaders, handleRes } from './fetch';
import { debug } from './logger';
import readDoc from './readDoc';

/**
* Reads the contents of the specified Markdown or HTML file
Expand All @@ -31,14 +29,8 @@ export default async function pushDoc(
filepath: string,
type: CommandCategories
) {
debug(`reading file ${filepath}`);
const file = fs.readFileSync(filepath, 'utf8');
const matter = grayMatter(file);
debug(`frontmatter for ${filepath}: ${JSON.stringify(matter)}`);

// Stripping the subdirectories and markdown extension from the filename and lowercasing to get the default slug.
const slug = matter.data.slug || path.basename(filepath).replace(path.extname(filepath), '').toLowerCase();
const hash = crypto.createHash('sha1').update(file).digest('hex');
const { content, matter, slug } = readDoc(filepath);
const hash = crypto.createHash('sha1').update(content).digest('hex');

let data: {
body?: string;
Expand Down
32 changes: 32 additions & 0 deletions src/lib/readDoc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type matter from 'gray-matter';

import fs from 'fs';
import path from 'path';

import grayMatter from 'gray-matter';

import { debug } from './logger';

type DocMetadata = {
content: string;
matter: matter.GrayMatterFile<string>;
slug: string;
};

/**
* Returns the content, matter and slug of the specified Markdown or HTML file
*
* @param {String} filepath path to the HTML/Markdown file
* (file extension must end in `.html`, `.md`., or `.markdown`)
* @returns {DocMetadata} an object containing the file's content, matter, and slug
*/
export default function readDoc(filepath: string): DocMetadata {
debug(`reading file ${filepath}`);
const content = fs.readFileSync(filepath, 'utf8');
const matter = grayMatter(content);
debug(`frontmatter for ${filepath}: ${JSON.stringify(matter)}`);

// Stripping the subdirectories and markdown extension from the filename and lowercasing to get the default slug.
const slug = matter.data.slug || path.basename(filepath).replace(path.extname(filepath), '').toLowerCase();
return { content, matter, slug };
}