From d46b96ee105c9b3ba672604eeffb8575ad39171b Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Thu, 28 Jul 2022 14:34:48 -0400 Subject: [PATCH 1/8] chore: create custompages cmd category --- src/lib/commands.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/commands.js b/src/lib/commands.js index 8a88b2fda..6f30e6d1f 100644 --- a/src/lib/commands.js +++ b/src/lib/commands.js @@ -80,6 +80,10 @@ exports.getCategories = () => { description: 'Changelog', commands: [], }, + custompages: { + description: 'Custom Pages', + commands: [], + }, categories: { description: 'Categories', commands: [], From a6afb20ce4a311074cf87fa9d620ab851cb8923f Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Thu, 28 Jul 2022 14:35:36 -0400 Subject: [PATCH 2/8] test: fixtures --- .../custompages/existing-docs/not-a-markdown-file | 0 .../__fixtures__/custompages/existing-docs/simple-doc.md | 4 ++++ .../custompages/existing-docs/subdir/another-doc.md | 4 ++++ .../__fixtures__/custompages/failure-docs/fail-doc.md | 1 + __tests__/__fixtures__/custompages/failure-docs/new-doc.md | 6 ++++++ .../__fixtures__/custompages/new-docs-html/new-doc.html | 5 +++++ __tests__/__fixtures__/custompages/new-docs/new-doc.md | 6 ++++++ .../__fixtures__/custompages/slug-docs/new-doc-slug.md | 7 +++++++ 8 files changed, 33 insertions(+) create mode 100644 __tests__/__fixtures__/custompages/existing-docs/not-a-markdown-file create mode 100644 __tests__/__fixtures__/custompages/existing-docs/simple-doc.md create mode 100644 __tests__/__fixtures__/custompages/existing-docs/subdir/another-doc.md create mode 100644 __tests__/__fixtures__/custompages/failure-docs/fail-doc.md create mode 100644 __tests__/__fixtures__/custompages/failure-docs/new-doc.md create mode 100644 __tests__/__fixtures__/custompages/new-docs-html/new-doc.html create mode 100644 __tests__/__fixtures__/custompages/new-docs/new-doc.md create mode 100644 __tests__/__fixtures__/custompages/slug-docs/new-doc-slug.md diff --git a/__tests__/__fixtures__/custompages/existing-docs/not-a-markdown-file b/__tests__/__fixtures__/custompages/existing-docs/not-a-markdown-file new file mode 100644 index 000000000..e69de29bb diff --git a/__tests__/__fixtures__/custompages/existing-docs/simple-doc.md b/__tests__/__fixtures__/custompages/existing-docs/simple-doc.md new file mode 100644 index 000000000..2373471a6 --- /dev/null +++ b/__tests__/__fixtures__/custompages/existing-docs/simple-doc.md @@ -0,0 +1,4 @@ +--- +title: This is the custom page title +--- +Body diff --git a/__tests__/__fixtures__/custompages/existing-docs/subdir/another-doc.md b/__tests__/__fixtures__/custompages/existing-docs/subdir/another-doc.md new file mode 100644 index 000000000..f0ccf4091 --- /dev/null +++ b/__tests__/__fixtures__/custompages/existing-docs/subdir/another-doc.md @@ -0,0 +1,4 @@ +--- +title: This is another custom page title +--- +Another body diff --git a/__tests__/__fixtures__/custompages/failure-docs/fail-doc.md b/__tests__/__fixtures__/custompages/failure-docs/fail-doc.md new file mode 100644 index 000000000..e8eba3dc1 --- /dev/null +++ b/__tests__/__fixtures__/custompages/failure-docs/fail-doc.md @@ -0,0 +1 @@ +Body diff --git a/__tests__/__fixtures__/custompages/failure-docs/new-doc.md b/__tests__/__fixtures__/custompages/failure-docs/new-doc.md new file mode 100644 index 000000000..918a1588f --- /dev/null +++ b/__tests__/__fixtures__/custompages/failure-docs/new-doc.md @@ -0,0 +1,6 @@ +--- +htmlmode: true +title: This is the custom page title +--- + +Body diff --git a/__tests__/__fixtures__/custompages/new-docs-html/new-doc.html b/__tests__/__fixtures__/custompages/new-docs-html/new-doc.html new file mode 100644 index 000000000..d8e4f6ad9 --- /dev/null +++ b/__tests__/__fixtures__/custompages/new-docs-html/new-doc.html @@ -0,0 +1,5 @@ +--- +hidden: false +--- + +
diff --git a/__tests__/__fixtures__/custompages/new-docs/new-doc.md b/__tests__/__fixtures__/custompages/new-docs/new-doc.md new file mode 100644 index 000000000..918a1588f --- /dev/null +++ b/__tests__/__fixtures__/custompages/new-docs/new-doc.md @@ -0,0 +1,6 @@ +--- +htmlmode: true +title: This is the custom page title +--- + +Body diff --git a/__tests__/__fixtures__/custompages/slug-docs/new-doc-slug.md b/__tests__/__fixtures__/custompages/slug-docs/new-doc-slug.md new file mode 100644 index 000000000..d1eca05ed --- /dev/null +++ b/__tests__/__fixtures__/custompages/slug-docs/new-doc-slug.md @@ -0,0 +1,7 @@ +--- +htmlmode: true +title: This is the custom page title +slug: marc-actually-wrote-a-test +--- + +Body From ceded1c5d17fba098ce6166600a91803189a09af Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Thu, 28 Jul 2022 15:04:22 -0400 Subject: [PATCH 3/8] feat: initial pass at commands as part of this, we had to slightly refactor the pushDoc function to augment the data to support the HTML file handling required by the custompages API --- src/cmds/custompages/index.js | 83 ++++++++++++++++++++++++++++++++++ src/cmds/custompages/single.js | 56 +++++++++++++++++++++++ src/lib/pushDoc.js | 26 +++++++---- 3 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 src/cmds/custompages/index.js create mode 100644 src/cmds/custompages/single.js diff --git a/src/cmds/custompages/index.js b/src/cmds/custompages/index.js new file mode 100644 index 000000000..a62aa1e7e --- /dev/null +++ b/src/cmds/custompages/index.js @@ -0,0 +1,83 @@ +const chalk = require('chalk'); +const config = require('config'); +const fs = require('fs'); +const path = require('path'); + +const { debug } = require('../../lib/logger'); +const pushDoc = require('../../lib/pushDoc'); + +module.exports = class CustomPagesCommand { + constructor() { + this.command = 'custompages'; + this.usage = 'custompages [options]'; + this.description = 'Sync a folder of Markdown files to your ReadMe project as Custom Pages.'; + this.cmdCategory = 'custompages'; + this.position = 1; + + this.hiddenArgs = ['folder']; + this.args = [ + { + name: 'key', + type: String, + description: 'Project API key', + }, + { + name: 'folder', + type: String, + defaultOption: true, + }, + { + name: 'dryRun', + type: Boolean, + description: 'Runs the command without creating/updating any custom pages in ReadMe. Useful for debugging.', + }, + ]; + } + + async run(opts) { + const { dryRun, folder, key } = opts; + + debug(`command: ${this.command}`); + debug(`opts: ${JSON.stringify(opts)}`); + + if (!key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + + if (!folder) { + return Promise.reject(new Error(`No folder provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); + } + + // Find the files to sync + const readdirRecursive = folderToSearch => { + const filesInFolder = fs.readdirSync(folderToSearch, { withFileTypes: true }); + const files = filesInFolder + .filter(fileHandle => fileHandle.isFile()) + .map(fileHandle => path.join(folderToSearch, fileHandle.name)); + const folders = filesInFolder.filter(fileHandle => fileHandle.isDirectory()); + const subFiles = [].concat( + ...folders.map(fileHandle => readdirRecursive(path.join(folderToSearch, fileHandle.name))) + ); + return [...files, ...subFiles]; + }; + + // Strip out non-markdown files + const files = readdirRecursive(folder).filter( + file => file.endsWith('.html') || file.endsWith('.md') || file.endsWith('.markdown') + ); + + debug(`number of files: ${files.length}`); + + if (!files.length) { + return Promise.reject(new Error(`We were unable to locate Markdown or HTML files in ${folder}.`)); + } + + const updatedDocs = await Promise.all( + files.map(async filename => { + return pushDoc(key, undefined, dryRun, filename, this.cmdCategory); + }) + ); + + return chalk.green(updatedDocs.join('\n')); + } +}; diff --git a/src/cmds/custompages/single.js b/src/cmds/custompages/single.js new file mode 100644 index 000000000..a56054ebb --- /dev/null +++ b/src/cmds/custompages/single.js @@ -0,0 +1,56 @@ +const chalk = require('chalk'); +const config = require('config'); + +const { debug } = require('../../lib/logger'); +const pushDoc = require('../../lib/pushDoc'); + +module.exports = class SingleCustomPageCommand { + constructor() { + this.command = 'custompages:single'; + this.usage = 'custompages:single [options]'; + this.description = 'Sync a single Markdown file to your ReadMe project as a Custom Page.'; + this.cmdCategory = 'custompages'; + this.position = 2; + + this.hiddenArgs = ['filePath']; + this.args = [ + { + name: 'key', + type: String, + description: 'Project API key', + }, + { + name: 'filePath', + type: String, + defaultOption: true, + }, + { + name: 'dryRun', + type: Boolean, + description: 'Runs the command without creating/updating any custom pages in ReadMe. Useful for debugging.', + }, + ]; + } + + async run(opts) { + const { dryRun, filePath, key } = opts; + debug(`command: ${this.command}`); + debug(`opts: ${JSON.stringify(opts)}`); + + if (!key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + + if (!filePath) { + return Promise.reject(new Error(`No file path provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); + } + + if (!(filePath.endsWith('.html') || filePath.endsWith('.md') || filePath.endsWith('.markdown'))) { + return Promise.reject(new Error('The file path specified is not a markdown or HTML file.')); + } + + const createdDoc = await pushDoc(key, undefined, dryRun, filePath, this.cmdCategory); + + return chalk.green(createdDoc); + } +}; diff --git a/src/lib/pushDoc.js b/src/lib/pushDoc.js index d64f5cd07..0d507580e 100644 --- a/src/lib/pushDoc.js +++ b/src/lib/pushDoc.js @@ -11,15 +11,15 @@ const fetch = require('./fetch'); const { debug } = require('./logger'); /** - * Reads the contents of the specified Markdown file - * and creates/updates the doc in ReadMe + * Reads the contents of the specified Markdown or HTML file + * and creates/updates the corresponding doc in ReadMe * * @param {String} key the project API key * @param {String} selectedVersion the project version * @param {Boolean} dryRun boolean indicating dry run mode - * @param {String} filepath path to the Markdown file + * @param {String} filepath path to the HTML/Markdown file + * (file extension must end in `.html`, `.md`., or `.markdown`) * @param {String} type module within ReadMe to update (e.g. docs, changelogs, etc.) - * (file extension must end in `.md` or `.markdown`) * @returns {Promise} a string containing the result */ module.exports = async function pushDoc(key, selectedVersion, dryRun, filepath, type) { @@ -32,6 +32,16 @@ module.exports = async function pushDoc(key, selectedVersion, dryRun, filepath, const slug = matter.data.slug || path.basename(filepath).replace(path.extname(filepath), '').toLowerCase(); const hash = crypto.createHash('sha1').update(file).digest('hex'); + let data = { body: matter.content, ...matter.data, lastUpdatedHash: hash }; + + if (type === 'custompages') { + if (filepath.endsWith('.html')) { + data = { html: matter.content, htmlmode: true, ...matter.data, lastUpdatedHash: hash }; + } else { + data = { body: matter.content, htmlmode: false, ...matter.data, lastUpdatedHash: hash }; + } + } + function createDoc() { if (dryRun) { return `🎭 dry run! This will create '${slug}' with contents from ${filepath} with the following metadata: ${JSON.stringify( @@ -47,9 +57,7 @@ module.exports = async function pushDoc(key, selectedVersion, dryRun, filepath, }), body: JSON.stringify({ slug, - body: matter.content, - ...matter.data, - lastUpdatedHash: hash, + ...data, }), }) .then(res => handleRes(res)) @@ -77,9 +85,7 @@ module.exports = async function pushDoc(key, selectedVersion, dryRun, filepath, }), body: JSON.stringify( Object.assign(existingDoc, { - body: matter.content, - ...matter.data, - lastUpdatedHash: hash, + ...data, }) ), }) From f4cd6febfc07de66d57c705074828384603d788c Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Thu, 28 Jul 2022 15:04:30 -0400 Subject: [PATCH 4/8] test: initial pass at tests --- __tests__/cmds/custompages.test.js | 706 +++++++++++++++++++++++++++++ 1 file changed, 706 insertions(+) create mode 100644 __tests__/cmds/custompages.test.js diff --git a/__tests__/cmds/custompages.test.js b/__tests__/cmds/custompages.test.js new file mode 100644 index 000000000..47e973d46 --- /dev/null +++ b/__tests__/cmds/custompages.test.js @@ -0,0 +1,706 @@ +const nock = require('nock'); +const chalk = require('chalk'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const frontMatter = require('gray-matter'); + +const APIError = require('../../src/lib/apiError'); +const getApiNock = require('../get-api-nock'); + +const CustomPagesCommand = require('../../src/cmds/custompages'); +const SingleCustomPageCommand = require('../../src/cmds/custompages/single'); + +const custompages = new CustomPagesCommand(); +const customPagesSingle = new SingleCustomPageCommand(); + +const fixturesBaseDir = '__fixtures__/custompages'; +const fullFixturesDir = `${__dirname}./../${fixturesBaseDir}`; +const key = 'API_KEY'; + +function hashFileContents(contents) { + return crypto.createHash('sha1').update(contents).digest('hex'); +} + +describe('rdme custompages', () => { + beforeAll(() => nock.disableNetConnect()); + + afterAll(() => nock.cleanAll()); + + it('should error if no api key provided', () => { + return expect(custompages.run({})).rejects.toThrow('No project API key provided. Please use `--key`.'); + }); + + it('should error if no folder provided', () => { + return expect(custompages.run({ key, version: '1.0.0' })).rejects.toThrow( + 'No folder provided. Usage `rdme custompages [options]`.' + ); + }); + + it('should error if the argument is not a folder', () => { + return expect(custompages.run({ key, version: '1.0.0', folder: 'not-a-folder' })).rejects.toThrow( + "ENOENT: no such file or directory, scandir 'not-a-folder'" + ); + }); + + it('should error if the folder contains no markdown nor HTML files', () => { + return expect(custompages.run({ key, version: '1.0.0', folder: '.github/workflows' })).rejects.toThrow( + 'We were unable to locate Markdown or HTML files in .github/workflows.' + ); + }); + + describe('existing custompages', () => { + let simpleDoc; + let anotherDoc; + + beforeEach(() => { + let fileContents = fs.readFileSync(path.join(fullFixturesDir, '/existing-docs/simple-doc.md')); + simpleDoc = { + slug: 'simple-doc', + doc: frontMatter(fileContents), + hash: hashFileContents(fileContents), + }; + + fileContents = fs.readFileSync(path.join(fullFixturesDir, '/existing-docs/subdir/another-doc.md')); + anotherDoc = { + slug: 'another-doc', + doc: frontMatter(fileContents), + hash: hashFileContents(fileContents), + }; + }); + + it('should fetch custom page and merge with what is returned', () => { + expect.assertions(1); + + const getMocks = getApiNock() + .get('/api/v1/custompages/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, htmlmode: false, lastUpdatedHash: 'anOldHash' }) + .get('/api/v1/custompages/another-doc') + .basicAuth({ user: key }) + .reply(200, { slug: anotherDoc.slug, htmlmode: false, lastUpdatedHash: 'anOldHash' }); + + const updateMocks = getApiNock() + .put('/api/v1/custompages/simple-doc', { + slug: simpleDoc.slug, + body: simpleDoc.doc.content, + htmlmode: false, + lastUpdatedHash: simpleDoc.hash, + ...simpleDoc.doc.data, + }) + .basicAuth({ user: key }) + .reply(200, { + slug: simpleDoc.slug, + htmlmode: false, + body: simpleDoc.doc.content, + }) + .put('/api/v1/custompages/another-doc', { + slug: anotherDoc.slug, + body: anotherDoc.doc.content, + htmlmode: false, + lastUpdatedHash: anotherDoc.hash, + ...anotherDoc.doc.data, + }) + .basicAuth({ user: key }) + .reply(200, { slug: anotherDoc.slug, body: anotherDoc.doc.content, htmlmode: false }); + + return custompages.run({ folder: `./__tests__/${fixturesBaseDir}/existing-docs`, key }).then(updatedDocs => { + // All custompages should have been updated because their hashes from the GET request were different from what they + // are currently. + expect(updatedDocs).toBe( + [ + `✏️ successfully updated 'simple-doc' with contents from __tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, + `✏️ successfully updated 'another-doc' with contents from __tests__/${fixturesBaseDir}/existing-docs/subdir/another-doc.md`, + ].join('\n') + ); + + getMocks.done(); + updateMocks.done(); + }); + }); + + it('should return custom page update info for dry run', () => { + expect.assertions(1); + + const getMocks = getApiNock() + .get('/api/v1/custompages/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }) + .get('/api/v1/custompages/another-doc') + .basicAuth({ user: key }) + .reply(200, { slug: anotherDoc.slug, lastUpdatedHash: 'anOldHash' }); + + return custompages + .run({ dryRun: true, folder: `./__tests__/${fixturesBaseDir}/existing-docs`, key }) + .then(updatedDocs => { + // All custompages should have been updated because their hashes from the GET request were different from what they + // are currently. + expect(updatedDocs).toBe( + [ + `🎭 dry run! This will update 'simple-doc' with contents from __tests__/${fixturesBaseDir}/existing-docs/simple-doc.md with the following metadata: ${JSON.stringify( + simpleDoc.doc.data + )}`, + `🎭 dry run! This will update 'another-doc' with contents from __tests__/${fixturesBaseDir}/existing-docs/subdir/another-doc.md with the following metadata: ${JSON.stringify( + anotherDoc.doc.data + )}`, + ].join('\n') + ); + + getMocks.done(); + }); + }); + + it('should not send requests for custompages that have not changed', () => { + expect.assertions(1); + + const getMocks = getApiNock() + .get('/api/v1/custompages/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }) + .get('/api/v1/custompages/another-doc') + .basicAuth({ user: key }) + .reply(200, { slug: anotherDoc.slug, lastUpdatedHash: anotherDoc.hash }); + + return custompages.run({ folder: `./__tests__/${fixturesBaseDir}/existing-docs`, key }).then(skippedDocs => { + expect(skippedDocs).toBe( + [ + '`simple-doc` was not updated because there were no changes.', + '`another-doc` was not updated because there were no changes.', + ].join('\n') + ); + + getMocks.done(); + }); + }); + + it('should adjust "no changes" message if in dry run', () => { + expect.assertions(1); + + const getMocks = getApiNock() + .get('/api/v1/custompages/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }) + .get('/api/v1/custompages/another-doc') + .basicAuth({ user: key }) + .reply(200, { slug: anotherDoc.slug, lastUpdatedHash: anotherDoc.hash }); + + return custompages + .run({ dryRun: true, folder: `./__tests__/${fixturesBaseDir}/existing-docs`, key }) + .then(skippedDocs => { + expect(skippedDocs).toBe( + [ + '🎭 dry run! `simple-doc` will not be updated because there were no changes.', + '🎭 dry run! `another-doc` will not be updated because there were no changes.', + ].join('\n') + ); + + getMocks.done(); + }); + }); + }); + + describe('new custompages', () => { + it('should create new custom page', async () => { + const slug = 'new-doc'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + + const getMock = getApiNock() + .get(`/api/v1/custompages/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CUSTOMPAGE_NOTFOUND', + message: `The custom page with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getApiNock() + .post('/api/v1/custompages', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); + + await expect(custompages.run({ folder: `./__tests__/${fixturesBaseDir}/new-docs`, key })).resolves.toBe( + `🌱 successfully created 'new-doc' with contents from __tests__/${fixturesBaseDir}/new-docs/new-doc.md` + ); + + getMock.done(); + postMock.done(); + }); + + it('should create new HTML custom page', async () => { + const slug = 'new-doc'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs-html/${slug}.html`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs-html/${slug}.html`))); + + const getMock = getApiNock() + .get(`/api/v1/custompages/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CUSTOMPAGE_NOTFOUND', + message: `The custom page with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getApiNock() + .post('/api/v1/custompages', { slug, html: doc.content, htmlmode: true, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug, html: doc.content, htmlmode: true, ...doc.data, lastUpdatedHash: hash }); + + await expect(custompages.run({ folder: `./__tests__/${fixturesBaseDir}/new-docs-html`, key })).resolves.toBe( + `🌱 successfully created 'new-doc' with contents from __tests__/${fixturesBaseDir}/new-docs-html/new-doc.html` + ); + + getMock.done(); + postMock.done(); + }); + + it('should return creation info for dry run', async () => { + const slug = 'new-doc'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + + const getMock = getApiNock() + .get(`/api/v1/custompages/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CUSTOMPAGE_NOTFOUND', + message: `The custom page with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + await expect( + custompages.run({ dryRun: true, folder: `./__tests__/${fixturesBaseDir}/new-docs`, key }) + ).resolves.toBe( + `🎭 dry run! This will create 'new-doc' with contents from __tests__/${fixturesBaseDir}/new-docs/new-doc.md with the following metadata: ${JSON.stringify( + doc.data + )}` + ); + + getMock.done(); + }); + + it('should fail if any custompages are invalid', async () => { + const folder = 'failure-docs'; + const slug = 'fail-doc'; + const slugTwo = 'new-doc'; + + const errorObject = { + error: 'CUSTOMPAGE_INVALID', + message: "We couldn't save this page (Custom page title cannot be blank).", + suggestion: 'Make sure all the data is correct, and the body is valid Markdown or HTML.', + docs: 'fake-metrics-uuid', + help: "If you need help, email support@readme.io and include the following link to your API log: 'fake-metrics-uuid'.", + }; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); + const docTwo = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slugTwo}.md`))); + + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); + const hashTwo = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slugTwo}.md`))); + + const getMocks = getApiNock() + .get(`/api/v1/custompages/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CUSTOMPAGE_NOTFOUND', + message: `The custom page with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }) + .get(`/api/v1/custompages/${slugTwo}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CUSTOMPAGE_NOTFOUND', + message: `The custom page with the slug '${slugTwo}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMocks = getApiNock() + .post('/api/v1/custompages', { + slug: slugTwo, + body: docTwo.content, + htmlmode: false, + ...docTwo.data, + lastUpdatedHash: hashTwo, + }) + .basicAuth({ user: key }) + .reply(201, { + metadata: { image: [], title: '', description: '' }, + title: 'This is the custom page title', + slug: slugTwo, + body: 'Body', + }) + .post('/api/v1/custompages', { slug, body: doc.content, htmlmode: false, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(400, errorObject); + + const fullDirectory = `__tests__/${fixturesBaseDir}/${folder}`; + + const formattedErrorObject = { + ...errorObject, + message: `Error uploading ${chalk.underline(`${fullDirectory}/${slug}.md`)}:\n\n${errorObject.message}`, + }; + + await expect(custompages.run({ folder: `./${fullDirectory}`, key })).rejects.toStrictEqual( + new APIError(formattedErrorObject) + ); + + getMocks.done(); + postMocks.done(); + }); + }); + + describe('slug metadata', () => { + it('should use provided slug', async () => { + const slug = 'new-doc-slug'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); + + const getMock = getApiNock() + .get(`/api/v1/custompages/${doc.data.slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CUSTOMPAGE_NOTFOUND', + message: `The custom page with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getApiNock() + .post('/api/v1/custompages', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug: doc.data.slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); + + await expect(custompages.run({ folder: `./__tests__/${fixturesBaseDir}/slug-docs`, key })).resolves.toBe( + `🌱 successfully created 'marc-actually-wrote-a-test' with contents from __tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md` + ); + + getMock.done(); + postMock.done(); + }); + }); +}); + +describe('rdme custompages:single', () => { + beforeAll(() => nock.disableNetConnect()); + + afterAll(() => nock.cleanAll()); + + it('should error if no api key provided', () => { + return expect(customPagesSingle.run({})).rejects.toThrow('No project API key provided. Please use `--key`.'); + }); + + it('should error if no file path provided', () => { + return expect(customPagesSingle.run({ key, version: '1.0.0' })).rejects.toThrow( + 'No file path provided. Usage `rdme custompages:single [options]`.' + ); + }); + + it('should error if the argument is not a markdown file', async () => { + await expect(customPagesSingle.run({ key, version: '1.0.0', filePath: 'not-a-markdown-file' })).rejects.toThrow( + 'The file path specified is not a markdown or HTML file.' + ); + }); + + it('should error if file path cannot be found', async () => { + await expect( + customPagesSingle.run({ key, version: '1.0.0', filePath: 'non-existent-file.markdown' }) + ).rejects.toThrow('ENOENT: no such file or directory'); + }); + + describe('new custompages', () => { + it('should create new custom page', async () => { + const slug = 'new-doc'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + + const getMock = getApiNock() + .get(`/api/v1/custompages/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CUSTOMPAGE_NOTFOUND', + message: `The custom page with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getApiNock() + .post('/api/v1/custompages', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug, body: doc.content, ...doc.data }); + + await expect( + customPagesSingle.run({ filePath: `./__tests__/${fixturesBaseDir}/new-docs/new-doc.md`, key }) + ).resolves.toBe( + `🌱 successfully created 'new-doc' with contents from ./__tests__/${fixturesBaseDir}/new-docs/new-doc.md` + ); + + getMock.done(); + postMock.done(); + }); + + it('should create new HTML custom page', async () => { + const slug = 'new-doc'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs-html/${slug}.html`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs-html/${slug}.html`))); + + const getMock = getApiNock() + .get(`/api/v1/custompages/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CUSTOMPAGE_NOTFOUND', + message: `The custom page with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getApiNock() + .post('/api/v1/custompages', { slug, html: doc.content, htmlmode: true, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug, html: doc.content, htmlmode: true, ...doc.data }); + + await expect( + customPagesSingle.run({ filePath: `./__tests__/${fixturesBaseDir}/new-docs-html/new-doc.html`, key }) + ).resolves.toBe( + `🌱 successfully created 'new-doc' with contents from ./__tests__/${fixturesBaseDir}/new-docs-html/new-doc.html` + ); + + getMock.done(); + postMock.done(); + }); + + it('should return creation info for dry run', async () => { + const slug = 'new-doc'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + + const getMock = getApiNock() + .get(`/api/v1/custompages/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CUSTOMPAGE_NOTFOUND', + message: `The custom page with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + await expect( + customPagesSingle.run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/new-docs/new-doc.md`, key }) + ).resolves.toBe( + `🎭 dry run! This will create 'new-doc' with contents from ./__tests__/${fixturesBaseDir}/new-docs/new-doc.md with the following metadata: ${JSON.stringify( + doc.data + )}` + ); + + getMock.done(); + }); + + it('should fail if the custom page is invalid', async () => { + const folder = 'failure-docs'; + const slug = 'fail-doc'; + + const errorObject = { + error: 'CUSTOMPAGE_INVALID', + message: "We couldn't save this page (Custom page title cannot be blank).", + suggestion: 'Make sure all the data is correct, and the body is valid Markdown or HTML.', + docs: 'fake-metrics-uuid', + help: "If you need help, email support@readme.io and include the following link to your API log: 'fake-metrics-uuid'.", + }; + + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); + + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); + + const getMock = getApiNock() + .get(`/api/v1/custompages/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CUSTOMPAGE_NOTFOUND', + message: `The custom page with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getApiNock() + .post('/api/v1/custompages', { slug, body: doc.content, htmlmode: false, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(400, errorObject); + + const filePath = `./__tests__/${fixturesBaseDir}/failure-docs/fail-doc.md`; + + const formattedErrorObject = { + ...errorObject, + message: `Error uploading ${chalk.underline(`${filePath}`)}:\n\n${errorObject.message}`, + }; + + await expect(customPagesSingle.run({ filePath: `${filePath}`, key })).rejects.toStrictEqual( + new APIError(formattedErrorObject) + ); + + getMock.done(); + postMock.done(); + }); + + it('should fail if some other error when retrieving page slug', async () => { + const slug = 'fail-doc'; + + const errorObject = { + error: 'INTERNAL_ERROR', + message: 'Unknown error (yikes)', + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }; + + const getMock = getApiNock().get(`/api/v1/custompages/${slug}`).basicAuth({ user: key }).reply(500, errorObject); + + const filePath = `./__tests__/${fixturesBaseDir}/failure-docs/fail-doc.md`; + + const formattedErrorObject = { + ...errorObject, + message: `Error uploading ${chalk.underline(`${filePath}`)}:\n\n${errorObject.message}`, + }; + + await expect(customPagesSingle.run({ filePath: `${filePath}`, key })).rejects.toStrictEqual( + new APIError(formattedErrorObject) + ); + + getMock.done(); + }); + }); + + describe('slug metadata', () => { + it('should use provided slug', async () => { + const slug = 'new-doc-slug'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); + + const getMock = getApiNock() + .get(`/api/v1/custompages/${doc.data.slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CUSTOMPAGE_NOTFOUND', + message: `The custom page with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getApiNock() + .post('/api/v1/custompages', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug: doc.data.slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); + + await expect( + customPagesSingle.run({ filePath: `./__tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md`, key }) + ).resolves.toBe( + `🌱 successfully created 'marc-actually-wrote-a-test' with contents from ./__tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md` + ); + + getMock.done(); + postMock.done(); + }); + }); + + describe('existing custompages', () => { + let simpleDoc; + + beforeEach(() => { + const fileContents = fs.readFileSync(path.join(fullFixturesDir, '/existing-docs/simple-doc.md')); + simpleDoc = { + slug: 'simple-doc', + doc: frontMatter(fileContents), + hash: hashFileContents(fileContents), + }; + }); + + it('should fetch custom page and merge with what is returned', () => { + const getMock = getApiNock() + .get('/api/v1/custompages/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }); + + const updateMock = getApiNock() + .put('/api/v1/custompages/simple-doc', { + slug: simpleDoc.slug, + body: simpleDoc.doc.content, + htmlmode: false, + lastUpdatedHash: simpleDoc.hash, + ...simpleDoc.doc.data, + }) + .basicAuth({ user: key }) + .reply(200, { + slug: simpleDoc.slug, + body: simpleDoc.doc.content, + htmlmode: false, + }); + + return customPagesSingle + .run({ filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) + .then(updatedDocs => { + expect(updatedDocs).toBe( + `✏️ successfully updated 'simple-doc' with contents from ./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md` + ); + + getMock.done(); + updateMock.done(); + }); + }); + + it('should return custom page update info for dry run', () => { + expect.assertions(1); + + const getMock = getApiNock() + .get('/api/v1/custompages/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }); + + return customPagesSingle + .run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) + .then(updatedDocs => { + // All custompages should have been updated because their hashes from the GET request were different from what they + // are currently. + expect(updatedDocs).toBe( + [ + `🎭 dry run! This will update 'simple-doc' with contents from ./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md with the following metadata: ${JSON.stringify( + simpleDoc.doc.data + )}`, + ].join('\n') + ); + + getMock.done(); + }); + }); + + it('should not send requests for custompages that have not changed', () => { + expect.assertions(1); + + const getMock = getApiNock() + .get('/api/v1/custompages/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }); + + return customPagesSingle + .run({ filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) + .then(skippedDocs => { + expect(skippedDocs).toBe('`simple-doc` was not updated because there were no changes.'); + + getMock.done(); + }); + }); + + it('should adjust "no changes" message if in dry run', () => { + const getMock = getApiNock() + .get('/api/v1/custompages/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }); + + return customPagesSingle + .run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) + .then(skippedDocs => { + expect(skippedDocs).toBe('🎭 dry run! `simple-doc` will not be updated because there were no changes.'); + + getMock.done(); + }); + }); + }); +}); From fa051bd3208cc3232c1bc6388868f7ee4f252c0e Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Thu, 28 Jul 2022 15:07:57 -0400 Subject: [PATCH 5/8] docs: update README usage --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index be5dff9fa..595cab0f4 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,26 @@ This command also has a dry run mode, which can be useful for initial setup and rdme changelogs:single path-to-markdown-file ``` +### Custom Pages + +#### Syncing a Folder of Custom Pages to ReadMe + +Custom Pages has support for both Markdown and HTML files. These files will require YAML front matter with certain ReadMe documentation attributes. Check out [our docs](https://docs.readme.com/docs/rdme#markdown-file-setup) for more info on setting up your front matter. + +Passing in a path to a directory will also sync any HTML/Markdown files that are located in subdirectories. + +```sh +rdme custompages path-to-markdown-files +``` + +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). + +#### Syncing a Single Custom Page to ReadMe + +```sh +rdme custompages:single path-to-markdown-file +``` + ### Versions #### Get All Versions Associated With Your Project From 0deefe1118e68796fcb9507eaad01644d82a6ae6 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Thu, 28 Jul 2022 15:28:52 -0400 Subject: [PATCH 6/8] test: remove unused version params, stricter error checks --- __tests__/cmds/custompages.test.js | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/__tests__/cmds/custompages.test.js b/__tests__/cmds/custompages.test.js index 47e973d46..a39e33106 100644 --- a/__tests__/cmds/custompages.test.js +++ b/__tests__/cmds/custompages.test.js @@ -28,12 +28,14 @@ describe('rdme custompages', () => { afterAll(() => nock.cleanAll()); it('should error if no api key provided', () => { - return expect(custompages.run({})).rejects.toThrow('No project API key provided. Please use `--key`.'); + return expect(custompages.run({})).rejects.toStrictEqual( + new Error('No project API key provided. Please use `--key`.') + ); }); it('should error if no folder provided', () => { - return expect(custompages.run({ key, version: '1.0.0' })).rejects.toThrow( - 'No folder provided. Usage `rdme custompages [options]`.' + return expect(custompages.run({ key })).rejects.toStrictEqual( + new Error('No folder provided. Usage `rdme custompages [options]`.') ); }); @@ -44,8 +46,8 @@ describe('rdme custompages', () => { }); it('should error if the folder contains no markdown nor HTML files', () => { - return expect(custompages.run({ key, version: '1.0.0', folder: '.github/workflows' })).rejects.toThrow( - 'We were unable to locate Markdown or HTML files in .github/workflows.' + return expect(custompages.run({ key, folder: '.github/workflows' })).rejects.toStrictEqual( + new Error('We were unable to locate Markdown or HTML files in .github/workflows.') ); }); @@ -389,18 +391,20 @@ describe('rdme custompages:single', () => { afterAll(() => nock.cleanAll()); it('should error if no api key provided', () => { - return expect(customPagesSingle.run({})).rejects.toThrow('No project API key provided. Please use `--key`.'); + return expect(customPagesSingle.run({})).rejects.toStrictEqual( + new Error('No project API key provided. Please use `--key`.') + ); }); it('should error if no file path provided', () => { - return expect(customPagesSingle.run({ key, version: '1.0.0' })).rejects.toThrow( - 'No file path provided. Usage `rdme custompages:single [options]`.' + return expect(customPagesSingle.run({ key })).rejects.toStrictEqual( + new Error('No file path provided. Usage `rdme custompages:single [options]`.') ); }); it('should error if the argument is not a markdown file', async () => { - await expect(customPagesSingle.run({ key, version: '1.0.0', filePath: 'not-a-markdown-file' })).rejects.toThrow( - 'The file path specified is not a markdown or HTML file.' + await expect(customPagesSingle.run({ key, filePath: 'not-a-markdown-file' })).rejects.toStrictEqual( + new Error('The file path specified is not a markdown or HTML file.') ); }); From ce220de4b353353080720f0115aa90a8f8f2ef85 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Thu, 28 Jul 2022 15:57:26 -0500 Subject: [PATCH 7/8] fix(docs/single): support files with `.markdown` extension (#549) * fix: support files with .markdown extension * test: random cleanup fixing typo, removing unused version param, stricter error checks * chore: capitalize Markdown * chore: lowercase all file extension checks --- __tests__/cmds/changelogs.test.js | 22 ++++++++++++++-------- __tests__/cmds/custompages.test.js | 4 ++-- __tests__/cmds/docs.test.js | 14 +++++++++++--- src/cmds/changelogs/index.js | 4 +++- src/cmds/changelogs/single.js | 4 ++-- src/cmds/custompages/index.js | 5 ++++- src/cmds/custompages/single.js | 10 ++++++++-- src/cmds/docs/index.js | 4 +++- src/cmds/docs/single.js | 4 ++-- 9 files changed, 49 insertions(+), 22 deletions(-) diff --git a/__tests__/cmds/changelogs.test.js b/__tests__/cmds/changelogs.test.js index f8ecc0cc1..f146ef69d 100644 --- a/__tests__/cmds/changelogs.test.js +++ b/__tests__/cmds/changelogs.test.js @@ -32,19 +32,19 @@ describe('rdme changelogs', () => { }); it('should error if no folder provided', () => { - return expect(changelogs.run({ key, version: '1.0.0' })).rejects.toThrow( + return expect(changelogs.run({ key })).rejects.toThrow( 'No folder provided. Usage `rdme changelogs [options]`.' ); }); - it('should error if the argument isnt a folder', () => { - return expect(changelogs.run({ key, version: '1.0.0', folder: 'not-a-folder' })).rejects.toThrow( + it('should error if the argument is not a folder', () => { + return expect(changelogs.run({ key, folder: 'not-a-folder' })).rejects.toThrow( "ENOENT: no such file or directory, scandir 'not-a-folder'" ); }); it('should error if the folder contains no markdown files', () => { - return expect(changelogs.run({ key, version: '1.0.0', folder: '.github/workflows' })).rejects.toThrow( + return expect(changelogs.run({ key, folder: '.github/workflows' })).rejects.toThrow( 'We were unable to locate Markdown files in .github/workflows.' ); }); @@ -356,14 +356,20 @@ describe('rdme changelogs:single', () => { }); it('should error if no file path provided', () => { - return expect(changelogsSingle.run({ key, version: '1.0.0' })).rejects.toThrow( + return expect(changelogsSingle.run({ key })).rejects.toThrow( 'No file path provided. Usage `rdme changelogs:single [options]`.' ); }); - it('should error if the argument is not a markdown file', async () => { - await expect(changelogsSingle.run({ key, version: '1.0.0', filePath: 'not-a-markdown-file' })).rejects.toThrow( - 'The file path specified is not a markdown file.' + it('should error if the argument is not a Markdown file', async () => { + await expect(changelogsSingle.run({ key, filePath: 'not-a-markdown-file' })).rejects.toThrow( + 'The file path specified is not a Markdown file.' + ); + }); + + it('should support .markdown files but error if file path cannot be found', async () => { + await expect(changelogsSingle.run({ key, filePath: 'non-existent-file.markdown' })).rejects.toThrow( + 'ENOENT: no such file or directory' ); }); diff --git a/__tests__/cmds/custompages.test.js b/__tests__/cmds/custompages.test.js index a39e33106..f638a6f35 100644 --- a/__tests__/cmds/custompages.test.js +++ b/__tests__/cmds/custompages.test.js @@ -402,9 +402,9 @@ describe('rdme custompages:single', () => { ); }); - it('should error if the argument is not a markdown file', async () => { + it('should error if the argument is not a Markdown file', async () => { await expect(customPagesSingle.run({ key, filePath: 'not-a-markdown-file' })).rejects.toStrictEqual( - new Error('The file path specified is not a markdown or HTML file.') + new Error('The file path specified is not a Markdown or HTML file.') ); }); diff --git a/__tests__/cmds/docs.test.js b/__tests__/cmds/docs.test.js index 71514d3f7..bf2b23d2f 100644 --- a/__tests__/cmds/docs.test.js +++ b/__tests__/cmds/docs.test.js @@ -49,7 +49,7 @@ describe('rdme docs', () => { ); }); - it('should error if the argument isnt a folder', async () => { + it('should error if the argument is not a folder', async () => { const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); await expect(docs.run({ key, version: '1.0.0', folder: 'not-a-folder' })).rejects.toThrow( @@ -564,12 +564,20 @@ describe('rdme docs:single', () => { ); }); - it('should error if the argument is not a markdown file', async () => { + it('should error if the argument is not a Markdown file', async () => { await expect(docsSingle.run({ key, version: '1.0.0', filePath: 'not-a-markdown-file' })).rejects.toThrow( - 'The file path specified is not a markdown file.' + 'The file path specified is not a Markdown file.' ); }); + it('should support .markdown files but error if file path cannot be found', async () => { + const versionMock = getApiNock().get(`/api/v1/version/${version}`).basicAuth({ user: key }).reply(200, { version }); + await expect(docsSingle.run({ key, version: '1.0.0', filePath: 'non-existent-file.markdown' })).rejects.toThrow( + 'ENOENT: no such file or directory' + ); + versionMock.done(); + }); + describe('new docs', () => { it('should create new doc', async () => { const slug = 'new-doc'; diff --git a/src/cmds/changelogs/index.js b/src/cmds/changelogs/index.js index f0637b847..5912ebb55 100644 --- a/src/cmds/changelogs/index.js +++ b/src/cmds/changelogs/index.js @@ -62,7 +62,9 @@ module.exports = class ChangelogsCommand { }; // Strip out non-markdown files - const files = readdirRecursive(folder).filter(file => file.endsWith('.md') || file.endsWith('.markdown')); + const files = readdirRecursive(folder).filter( + file => file.toLowerCase().endsWith('.md') || file.toLowerCase().endsWith('.markdown') + ); debug(`number of files: ${files.length}`); diff --git a/src/cmds/changelogs/single.js b/src/cmds/changelogs/single.js index 104a8e36e..b13f63d9c 100644 --- a/src/cmds/changelogs/single.js +++ b/src/cmds/changelogs/single.js @@ -45,8 +45,8 @@ module.exports = class SingleChangelogCommand { return Promise.reject(new Error(`No file path provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); } - if (filePath.endsWith('.md') === false || !filePath.endsWith('.markdown') === false) { - return Promise.reject(new Error('The file path specified is not a markdown file.')); + if (!(filePath.toLowerCase().endsWith('.md') || filePath.toLowerCase().endsWith('.markdown'))) { + return Promise.reject(new Error('The file path specified is not a Markdown file.')); } const createdDoc = await pushDoc(key, undefined, dryRun, filePath, this.cmdCategory); diff --git a/src/cmds/custompages/index.js b/src/cmds/custompages/index.js index a62aa1e7e..5e9c9e802 100644 --- a/src/cmds/custompages/index.js +++ b/src/cmds/custompages/index.js @@ -63,7 +63,10 @@ module.exports = class CustomPagesCommand { // Strip out non-markdown files const files = readdirRecursive(folder).filter( - file => file.endsWith('.html') || file.endsWith('.md') || file.endsWith('.markdown') + file => + file.toLowerCase().endsWith('.html') || + file.toLowerCase().endsWith('.md') || + file.toLowerCase().endsWith('.markdown') ); debug(`number of files: ${files.length}`); diff --git a/src/cmds/custompages/single.js b/src/cmds/custompages/single.js index a56054ebb..39392054f 100644 --- a/src/cmds/custompages/single.js +++ b/src/cmds/custompages/single.js @@ -45,8 +45,14 @@ module.exports = class SingleCustomPageCommand { return Promise.reject(new Error(`No file path provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); } - if (!(filePath.endsWith('.html') || filePath.endsWith('.md') || filePath.endsWith('.markdown'))) { - return Promise.reject(new Error('The file path specified is not a markdown or HTML file.')); + if ( + !( + filePath.toLowerCase().endsWith('.html') || + filePath.toLowerCase().endsWith('.md') || + filePath.toLowerCase().endsWith('.markdown') + ) + ) { + return Promise.reject(new Error('The file path specified is not a Markdown or HTML file.')); } const createdDoc = await pushDoc(key, undefined, dryRun, filePath, this.cmdCategory); diff --git a/src/cmds/docs/index.js b/src/cmds/docs/index.js index 46710adc4..a06cc1cfe 100644 --- a/src/cmds/docs/index.js +++ b/src/cmds/docs/index.js @@ -75,7 +75,9 @@ module.exports = class DocsCommand { }; // Strip out non-markdown files - const files = readdirRecursive(folder).filter(file => file.endsWith('.md') || file.endsWith('.markdown')); + const files = readdirRecursive(folder).filter( + file => file.toLowerCase().endsWith('.md') || file.toLowerCase().endsWith('.markdown') + ); debug(`number of files: ${files.length}`); diff --git a/src/cmds/docs/single.js b/src/cmds/docs/single.js index 6c12b6c4d..5949bd688 100644 --- a/src/cmds/docs/single.js +++ b/src/cmds/docs/single.js @@ -51,8 +51,8 @@ module.exports = class SingleDocCommand { return Promise.reject(new Error(`No file path provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); } - if (filePath.endsWith('.md') === false || !filePath.endsWith('.markdown') === false) { - return Promise.reject(new Error('The file path specified is not a markdown file.')); + if (!(filePath.toLowerCase().endsWith('.md') || filePath.toLowerCase().endsWith('.markdown'))) { + return Promise.reject(new Error('The file path specified is not a Markdown file.')); } // TODO: should we allow version selection at all here? From b4134e9d5bd4e16808b1fb7c225e44b6390f0283 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Thu, 28 Jul 2022 16:03:34 -0500 Subject: [PATCH 8/8] refactor: DRY some docs logic (#550) * fix: support files with .markdown extension * test: random cleanup fixing typo, removing unused version param, stricter error checks * refactor: DRY some docs logic * feat: add retag-release workflow to add a vX.Y.Z tag for our GitHub Action (#545) * feat: add retag-release workflow to add a vX.Y.Z tag for our GitHub Action * Update .github/workflows/retag-release.yaml Co-authored-by: Kanad Gupta * refactor: move code out of YAML and into bin/ script * fix: lint fixes Co-authored-by: Kanad Gupta Co-authored-by: Ryan Park --- .github/workflows/retag-release.yaml | 20 +++++++++++++ bin/retag-release.js | 42 ++++++++++++++++++++++++++++ src/cmds/changelogs/index.js | 16 +---------- src/cmds/custompages/index.js | 16 +---------- src/cmds/docs/index.js | 16 +---------- src/lib/pushDoc.js | 18 ++++++++++++ 6 files changed, 83 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/retag-release.yaml create mode 100644 bin/retag-release.js diff --git a/.github/workflows/retag-release.yaml b/.github/workflows/retag-release.yaml new file mode 100644 index 000000000..5f4d26c69 --- /dev/null +++ b/.github/workflows/retag-release.yaml @@ -0,0 +1,20 @@ +# This script adds a "vX.Y.Z" tag to every new release. +# +# Our releases are tagged like "1.2.3" but we want people to be able to write +# GitHub Action workflows to say "uses: readmeio/readme@v1.2.3" because that's +# the usual GitHub convention. + +name: retag-release + +on: + release: + types: [created] + +jobs: + retag-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/github-script@v6 + with: + script: require('./bin/retag-release.js')(github, context); diff --git a/bin/retag-release.js b/bin/retag-release.js new file mode 100644 index 000000000..113e6098d --- /dev/null +++ b/bin/retag-release.js @@ -0,0 +1,42 @@ +/* eslint-disable no-console */ +module.exports = async (github, context) => { + const { owner, repo } = context.repo; + const oldTag = context.payload.release.tag_name; + if (!oldTag.match(/^[0-9]+\.[0-9]+\.[0-9]+$/)) { + console.log('Not retagging this release: This script will only retag releases that use'); + console.log(`semantic versioning, like "1.2.3", but this release's tag is "${oldTag}".`); + return {}; + } + const newTag = `v${oldTag}`; + console.log(`Retagging release "${oldTag}" as "${newTag}".`); + + const oldRef = await github.rest.git.getRef({ + owner, + repo, + ref: `tags/${oldTag}`, + }); + if (oldRef.status < 200 || oldRef.status >= 400) { + console.log(oldRef); + throw new Error(`GitHub API call returned HTTP status code ${oldRef.status}`); + } + const sha = oldRef.data.object.sha; + console.log(`Found tag "${oldTag}"; commit hash is ${sha}`); + + console.log(`Creating tag "${newTag}" pointing to commit hash ${sha}...`); + const newRef = await github.rest.git.createRef({ + owner, + repo, + ref: `refs/tags/${newTag}`, + sha, + }); + if (newRef.status < 200 || newRef.status >= 400) { + console.log(newRef); + throw new Error(`GitHub API call returned HTTP status code ${newRef.status}`); + } + console.log('Successfully retagged this release.'); + return { + original_tag: oldTag, + new_tag: newTag, + sha, + }; +}; diff --git a/src/cmds/changelogs/index.js b/src/cmds/changelogs/index.js index 5912ebb55..df61fe01d 100644 --- a/src/cmds/changelogs/index.js +++ b/src/cmds/changelogs/index.js @@ -1,10 +1,9 @@ const chalk = require('chalk'); const config = require('config'); -const fs = require('fs'); -const path = require('path'); const { debug } = require('../../lib/logger'); const pushDoc = require('../../lib/pushDoc'); +const { readdirRecursive } = require('../../lib/pushDoc'); module.exports = class ChangelogsCommand { constructor() { @@ -48,19 +47,6 @@ module.exports = class ChangelogsCommand { return Promise.reject(new Error(`No folder provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); } - // Find the files to sync - const readdirRecursive = folderToSearch => { - const filesInFolder = fs.readdirSync(folderToSearch, { withFileTypes: true }); - const files = filesInFolder - .filter(fileHandle => fileHandle.isFile()) - .map(fileHandle => path.join(folderToSearch, fileHandle.name)); - const folders = filesInFolder.filter(fileHandle => fileHandle.isDirectory()); - const subFiles = [].concat( - ...folders.map(fileHandle => readdirRecursive(path.join(folderToSearch, fileHandle.name))) - ); - return [...files, ...subFiles]; - }; - // Strip out non-markdown files const files = readdirRecursive(folder).filter( file => file.toLowerCase().endsWith('.md') || file.toLowerCase().endsWith('.markdown') diff --git a/src/cmds/custompages/index.js b/src/cmds/custompages/index.js index 5e9c9e802..36fe4f27f 100644 --- a/src/cmds/custompages/index.js +++ b/src/cmds/custompages/index.js @@ -1,10 +1,9 @@ const chalk = require('chalk'); const config = require('config'); -const fs = require('fs'); -const path = require('path'); const { debug } = require('../../lib/logger'); const pushDoc = require('../../lib/pushDoc'); +const { readdirRecursive } = require('../../lib/pushDoc'); module.exports = class CustomPagesCommand { constructor() { @@ -48,19 +47,6 @@ module.exports = class CustomPagesCommand { return Promise.reject(new Error(`No folder provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); } - // Find the files to sync - const readdirRecursive = folderToSearch => { - const filesInFolder = fs.readdirSync(folderToSearch, { withFileTypes: true }); - const files = filesInFolder - .filter(fileHandle => fileHandle.isFile()) - .map(fileHandle => path.join(folderToSearch, fileHandle.name)); - const folders = filesInFolder.filter(fileHandle => fileHandle.isDirectory()); - const subFiles = [].concat( - ...folders.map(fileHandle => readdirRecursive(path.join(folderToSearch, fileHandle.name))) - ); - return [...files, ...subFiles]; - }; - // Strip out non-markdown files const files = readdirRecursive(folder).filter( file => diff --git a/src/cmds/docs/index.js b/src/cmds/docs/index.js index a06cc1cfe..3ddd47d56 100644 --- a/src/cmds/docs/index.js +++ b/src/cmds/docs/index.js @@ -1,11 +1,10 @@ const chalk = require('chalk'); const config = require('config'); -const fs = require('fs'); -const path = require('path'); const { getProjectVersion } = require('../../lib/versionSelect'); const { debug } = require('../../lib/logger'); const pushDoc = require('../../lib/pushDoc'); +const { readdirRecursive } = require('../../lib/pushDoc'); module.exports = class DocsCommand { constructor() { @@ -61,19 +60,6 @@ module.exports = class DocsCommand { debug(`selectedVersion: ${selectedVersion}`); - // Find the files to sync - const readdirRecursive = folderToSearch => { - const filesInFolder = fs.readdirSync(folderToSearch, { withFileTypes: true }); - const files = filesInFolder - .filter(fileHandle => fileHandle.isFile()) - .map(fileHandle => path.join(folderToSearch, fileHandle.name)); - const folders = filesInFolder.filter(fileHandle => fileHandle.isDirectory()); - const subFiles = [].concat( - ...folders.map(fileHandle => readdirRecursive(path.join(folderToSearch, fileHandle.name))) - ); - return [...files, ...subFiles]; - }; - // Strip out non-markdown files const files = readdirRecursive(folder).filter( file => file.toLowerCase().endsWith('.md') || file.toLowerCase().endsWith('.markdown') diff --git a/src/lib/pushDoc.js b/src/lib/pushDoc.js index 0d507580e..5c973a444 100644 --- a/src/lib/pushDoc.js +++ b/src/lib/pushDoc.js @@ -117,3 +117,21 @@ module.exports = async function pushDoc(key, selectedVersion, dryRun, filepath, throw err; }); }; + +/** + * Recursively grabs all files within a given directory + * (including subdirectories) + * @param {String} folderToSearch path to directory + * @returns {String[]} array of files + */ +module.exports.readdirRecursive = function readdirRecursive(folderToSearch) { + const filesInFolder = fs.readdirSync(folderToSearch, { withFileTypes: true }); + const files = filesInFolder + .filter(fileHandle => fileHandle.isFile()) + .map(fileHandle => path.join(folderToSearch, fileHandle.name)); + const folders = filesInFolder.filter(fileHandle => fileHandle.isDirectory()); + const subFiles = [].concat( + ...folders.map(fileHandle => readdirRecursive(path.join(folderToSearch, fileHandle.name))) + ); + return [...files, ...subFiles]; +};