From af61f7e82b53c78fdb31eb65338851f083e4d5d1 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Thu, 28 Jul 2022 16:06:21 -0500 Subject: [PATCH] feat: custom pages support (#547) * chore: create custompages cmd category * test: fixtures * 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 * test: initial pass at tests * docs: update README usage * test: remove unused version params, stricter error checks * 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 * 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 Co-authored-by: Ryan Park --- README.md | 20 + .../existing-docs/not-a-markdown-file | 0 .../custompages/existing-docs/simple-doc.md | 4 + .../existing-docs/subdir/another-doc.md | 4 + .../custompages/failure-docs/fail-doc.md | 1 + .../custompages/failure-docs/new-doc.md | 6 + .../custompages/new-docs-html/new-doc.html | 5 + .../custompages/new-docs/new-doc.md | 6 + .../custompages/slug-docs/new-doc-slug.md | 7 + __tests__/cmds/changelogs.test.js | 22 +- __tests__/cmds/custompages.test.js | 710 ++++++++++++++++++ __tests__/cmds/docs.test.js | 14 +- src/cmds/changelogs/index.js | 20 +- src/cmds/changelogs/single.js | 4 +- src/cmds/custompages/index.js | 72 ++ src/cmds/custompages/single.js | 62 ++ src/cmds/docs/index.js | 20 +- src/cmds/docs/single.js | 4 +- src/lib/commands.js | 4 + src/lib/pushDoc.js | 44 +- 20 files changed, 972 insertions(+), 57 deletions(-) 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 create mode 100644 __tests__/cmds/custompages.test.js create mode 100644 src/cmds/custompages/index.js create mode 100644 src/cmds/custompages/single.js 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 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 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 new file mode 100644 index 000000000..f638a6f35 --- /dev/null +++ b/__tests__/cmds/custompages.test.js @@ -0,0 +1,710 @@ +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.toStrictEqual( + new Error('No project API key provided. Please use `--key`.') + ); + }); + + it('should error if no folder provided', () => { + return expect(custompages.run({ key })).rejects.toStrictEqual( + new Error('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, folder: '.github/workflows' })).rejects.toStrictEqual( + new Error('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.toStrictEqual( + new Error('No project API key provided. Please use `--key`.') + ); + }); + + it('should error if no file path provided', () => { + 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, filePath: 'not-a-markdown-file' })).rejects.toStrictEqual( + new Error('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(); + }); + }); + }); +}); 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..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,21 +47,10 @@ 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.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 new file mode 100644 index 000000000..36fe4f27f --- /dev/null +++ b/src/cmds/custompages/index.js @@ -0,0 +1,72 @@ +const chalk = require('chalk'); +const config = require('config'); + +const { debug } = require('../../lib/logger'); +const pushDoc = require('../../lib/pushDoc'); +const { readdirRecursive } = 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}\`.`)); + } + + // Strip out non-markdown files + const files = readdirRecursive(folder).filter( + file => + file.toLowerCase().endsWith('.html') || + file.toLowerCase().endsWith('.md') || + file.toLowerCase().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..39392054f --- /dev/null +++ b/src/cmds/custompages/single.js @@ -0,0 +1,62 @@ +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.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); + + return chalk.green(createdDoc); + } +}; diff --git a/src/cmds/docs/index.js b/src/cmds/docs/index.js index 46710adc4..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,21 +60,10 @@ 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.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? 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: [], diff --git a/src/lib/pushDoc.js b/src/lib/pushDoc.js index d64f5cd07..5c973a444 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, }) ), }) @@ -111,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]; +};