diff --git a/.editorconfig b/.editorconfig index 59cb414..717912f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,3 +6,4 @@ indent_size = 2 insert_final_newline = true trim_trailing_whitespace = true charset = "utf-8" +end_of_line = lf diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..7cac555 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "arrowParens": "avoid", + "trailingComma": "all" +} diff --git a/README.md b/README.md index 3e2c4e6..a108dab 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,6 @@ Adds meta data about a Markdown file to a Markdown file, formatted as [Front Matter](https://jekyllrb.com/docs/frontmatter/). -The following meta data is added: - -- `lastModifiedAt` using one of the following heuristics: - 1. the `vFile` has the property `data.lastModifiedAt` defined - 1. if [`git`](https://git-scm.com/) exists, the commit time of the file - 1. the `mtime` reported by Node's `stat` method. - ## Installation ```sh @@ -47,11 +40,38 @@ var example = vfile.readSync('example.md'); remark() .use(frontmatter) - .use(metadata, { git: true }) + .use(metadata, { + gitExcludeCommit: 'chore:', + metadata: { + // string + tag: 'remark-metadata', + // constant + gitCreated: metadata.GIT_CREATED_TIME, + gitUpdated: metadata.GIT_LAST_MODIFIED_TIME, + updated: metadata.LAST_MODIFIED_TIME, + // function + duration({ gitCreatedTime, modifiedTime }) { + return ( + new Date(modifiedTime).getTime() - new Date(gitCreatedTime).getTime() + ); + }, + // object + title: { + value({ vFile }) { + return vFile.basename; + }, + shouldUpdate(newValue, oldValue) { + if (oldValue === 'Example') { + return true; + } + return false; + }, + }, + }, + }) .process(example, function (err, file) { if (err) throw err; - console.log(String(file)) - }) + console.log(String(file)); }); ``` @@ -61,7 +81,6 @@ This will output the following Markdown: --- title: Example lastModifiedDate: 'Tue, 28 Nov 2017 02:44:25 GMT' - --- # Example @@ -75,4 +94,26 @@ If a file has no Front Matter, it will be added by this plugin. The plugin has the following options: -- `git`: Enables determining modification dates using git (defaults: `true`) +- `gitExcludeCommit`: A regexp string to exclude commits when get the last modified time through git. This is useful to exclude commits of chore. +- `metadata`: An object describe each metadata. +- `metadata[key]`: A string value, or a constant, or a function that will be called with an object parameter and using the string it returns as the value, or an object contain options below. + +```js +// the object parameter of function: +{ + gitModifiedTime, + gitCreatedTime, + modifiedTime, + vFile, + oldFrontMatter, +} +``` + +- `metadata[key].value`: A string value, or a constant, or a function, like `metadata[key]`. +- `metadata[key].shouldUpdate`: A function will be called with old and new value of this metadata. The metadata will update, if this function return truthy. + +## Constants + +- `GIT_CREATED_TIME`: A value of metadata to set the created time of a markdown file. Will use the first commit time of Git. +- `GIT_LAST_MODIFIED_TIME`: A value of metadata to set the last modified time of a markdown file. Will use the last commit time of Git. +- `LAST_MODIFIED_TIME`: A value of metadata to set the last modified time of a markdown file. Will use the mtime of file. diff --git a/package.json b/package.json index 0c4f494..f975a09 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,10 @@ "vfile": "^2.2.0" }, "scripts": { - "lint": "$(npm bin)/eslint src", + "lint": "eslint src", "teardown": "rm -rf test/runtime/*", - "test": "npm run teardown && $(npm bin)/jest", - "test:coverage": "npm run teardown && $(npm bin)/jest --coverage --coverageReporters text text-summary", + "test": "npm run teardown && jest", + "test:coverage": "npm run teardown && jest --coverage --coverageReporters text text-summary", "preversion": "npm run lint && npm run test", "version": "version-changelog CHANGELOG.md && changelog-verify CHANGELOG.md && git add CHANGELOG.md" }, diff --git a/src/index.js b/src/index.js index 10191da..ad7dd15 100644 --- a/src/index.js +++ b/src/index.js @@ -43,8 +43,9 @@ function getMatter(ast) { * * @param {Object} frontmatterNode a MDAST-like node for Frontmatter. * @param {Object} meta + * @param {Object} metadataConfig */ -function writeMatter(frontmatterNode, meta) { +function writeMatter(frontmatterNode, meta, metadataConfig) { const fm = {}; // parse any existing frontmatter @@ -53,57 +54,157 @@ function writeMatter(frontmatterNode, meta) { } // merge in meta - Object.assign(fm, meta); + Object.entries(meta).forEach(([name, value]) => { + const { shouldUpdate } = metadataConfig[name]; + + if (typeof shouldUpdate !== 'function' || shouldUpdate(value, fm[name])) { + fm[name] = value; + } + }); // stringify frontmatterNode.value = jsYaml.safeDump(fm).trim(); // eslint-disable-line no-param-reassign } /** - * Given the vFile, returns an object containing possible meta data: + * Get the modified time of a vFile. + * + * If git option is true return the last commit time of this vFile, + * otherwise return the mtime. + * + * @param {vFile} vFile + * @param {Object} options {git, gitLogGrep} + * @return {string} + */ +function getModifiedTime(vFile, { git, gitLogGrep }) { + if (git) { + const cmd = `git log -1 --format="%ad" ${gitLogGrep} -- "${vFile.path}"`; + let modified; + try { + modified = execSync(cmd, { encoding: 'utf-8' }).trim(); + } catch (error) { + vFile.message(error, null, PLUGIN_NAME); + } + + // New files that aren't committed yet will return nothing + if (modified) { + return new Date(modified).toUTCString(); + } + + return ''; + } + + try { + const stats = fs.statSync(vFile.path); + return new Date(stats.mtime).toUTCString(); + } catch (error) /* istanbul ignore next */ { + vFile.message(error, null, PLUGIN_NAME); + } + + return ''; +} + +/** + * Get the created (first commit) time of a vFile. + * + * @param {vFile} vFile + * @return {string} + */ +function getCreatedTime(vFile) { + const cmd = `git log --reverse --format="%ad" -- "${vFile.path}"`; + let created; + try { + [created] = execSync(cmd, { encoding: 'utf-8' }).trim().split('\n'); + } catch (error) { + vFile.message(error, null, PLUGIN_NAME); + } + + // New files that aren't committed yet will return nothing + if (created) { + return new Date(created).toUTCString(); + } + + return ''; +} + +/** + * Get the parameter of value function of metadata. * - * - lastModifiedAt + * @param {Object} data {vFile, oldFrontMatter, gitLogGrep} + * @return {Object} + */ +function createValueFunctionParam({ vFile, oldFrontMatter, gitLogGrep }) { + const param = { + vFile, + oldFrontMatter, + }; + + Object.defineProperties(param, { + gitModifiedTime: { + get() { + return getModifiedTime(vFile, { git: true, gitLogGrep }); + }, + }, + modifiedTime: { + get() { + return getModifiedTime(vFile, { git: false, gitLogGrep }); + }, + }, + gitCreatedTime: { + get() { + return getCreatedTime(vFile); + }, + }, + }); + + return param; +} + +/** + * Given the vFile, metadata returns an object containing possible meta data * - * @todo extract this out, there may be other metadata to add and we don't want - * a mega function to handle it all. * @param {vFile} vFile - * @param {boolean} hasGit + * @param {Object} options {gitLogGrep, oldFrontMatter} + * @param {Object} metadata * @return {Object} */ -function getMetadata(vFile, hasGit) { +function getMetadata(vFile, { gitLogGrep, oldFrontMatter }, metadataConfig) { + /* eslint-disable no-use-before-define */ const meta = {}; - let isSet = false; - // Does the vFile already contain a date? - if (vFile.data && vFile.data.lastModifiedAt) { - meta.lastModifiedAt = vFile.data.lastModifiedAt; - isSet = true; - } + Object.entries(metadataConfig).forEach(([name, config]) => { + const value = + typeof config === 'string' || typeof config === 'function' + ? config + : config.value; - // Do we have Git? We can get it way? - if (!isSet && hasGit) { - const cmd = `git log -1 --format="%ad" -- ${vFile.path}`; - const modified = execSync(cmd, { encoding: 'utf-8' }).trim(); + if (value === metadata.GIT_LAST_MODIFIED_TIME) { + meta[name] = getModifiedTime(vFile, { git: true, gitLogGrep }); + return; + } - // New files that aren't committed yet will return nothing - if (modified) { - meta.lastModifiedAt = new Date(modified).toUTCString(); - isSet = true; + if (value === metadata.LAST_MODIFIED_TIME) { + meta[name] = getModifiedTime(vFile, { git: false, gitLogGrep }); + return; } - } - // Otherwise fallback to using the file's `mtime`. - if (!isSet) { - try { - const stats = fs.statSync(vFile.path); - meta.lastModifiedAt = new Date(stats.mtime).toUTCString(); - isSet = true; - } catch (error) { - vFile.message(error, null, PLUGIN_NAME); + if (value === metadata.GIT_CREATED_TIME) { + meta[name] = getCreatedTime(vFile); + return; + } + + if (typeof value === 'string') { + meta[name] = value; + return; } - } + + if (typeof value === 'function') { + meta[name] = value(createValueFunctionParam({ vFile, oldFrontMatter, gitLogGrep })); + } + }); return meta; + /* eslint-enable no-use-before-define */ } /** @@ -115,7 +216,11 @@ function getMetadata(vFile, hasGit) { * @return {function} */ function metadata(options = {}) { - const hasGit = options.git !== undefined ? options.git : true; + const gitLogGrep = + typeof options.gitExcludeCommit === 'string' + ? ` --grep="${options.gitExcludeCommit}" --invert-grep ` + : ''; + const metadataConfig = options.metadata || {}; /** * @param {object} ast MDAST @@ -128,10 +233,17 @@ function metadata(options = {}) { const frontmatterNode = getMatter(ast); // Get metadata - const meta = getMetadata(vFile, hasGit); + const meta = getMetadata( + vFile, + { + gitLogGrep, + oldFrontMatter: jsYaml.safeLoad(frontmatterNode.value), + }, + metadataConfig, + ); // Write metadata (by reference) - writeMatter(frontmatterNode, meta); + writeMatter(frontmatterNode, meta, metadataConfig); // If we don't have a Matter node in the AST, put it in. if (!getMatterNode(ast)) { @@ -146,4 +258,8 @@ function metadata(options = {}) { }; } +metadata.GIT_LAST_MODIFIED_TIME = 'REMARK_METADATA_GIT_LAST_MODIFIED_TIME'; +metadata.GIT_CREATED_TIME = 'REMARK_METADATA_GIT_CREATED_TIME'; +metadata.LAST_MODIFIED_TIME = 'REMARK_METADATA_LAST_MODIFIED_TIME'; + module.exports = metadata; diff --git a/test/fixtures/existing.md b/test/fixtures/existing.md index 44deaac..8976733 100644 --- a/test/fixtures/existing.md +++ b/test/fixtures/existing.md @@ -1,6 +1,7 @@ --- title: Lorem ipsum author: Joe Blogs +lastModifiedAt: 'Thu, 22 Oct 2020 06:47:56 GMT' --- # front matter diff --git a/test/fixtures/file name with spaces.md b/test/fixtures/file name with spaces.md new file mode 100644 index 0000000..e69de29 diff --git a/test/index.test.js b/test/index.test.js index 86d5cd6..e885708 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -8,10 +8,12 @@ const metadata = require('../src/'); const fixturesDir = path.join(__dirname, '/fixtures'); const runtimeDir = path.join(__dirname, '/runtime'); -const remark = unified().use(parse).use(stringify).use(frontmatter).freeze(); +const remark = unified().use(parse).use(stringify).use(frontmatter) + .freeze(); -// Utility function to add metdata to a vFile. +// Utility function to add metadata to a vFile. function addMetadata(vFile, destinationFilePath) { + // eslint-disable-next-line no-param-reassign vFile.data = { destinationFilePath, destinationDir: path.dirname(destinationFilePath), @@ -19,44 +21,182 @@ function addMetadata(vFile, destinationFilePath) { } describe('remark-metadata', () => { - it('adds metadata to existing front matter', () => { - const srcFile = `${fixturesDir}/existing.md`; - const destFile = `${runtimeDir}/existing.md`; - const vfile = toVFile.readSync(srcFile); - addMetadata(vfile, destFile); + describe('src file name with spaces should work fine', () => { + const srcFile = `${fixturesDir}/file name with spaces.md`; + const destFile = `${runtimeDir}/file name with spaces.md`; + let vfile; + + beforeEach(() => { + vfile = toVFile.readSync(srcFile); + addMetadata(vfile, destFile); + }); + + it('when using git', () => { + const result = remark() + .use(frontmatter) + .use(metadata, { + metadata: { + created: metadata.GIT_CREATED_TIME, + updated: metadata.GIT_LAST_MODIFIED_TIME, + }, + }) + .processSync(vfile) + .toString(); + + // should be "created: 'DATE'" + expect(result).not.toContain("created: ''"); + + // should be "updated: 'DATE'" + expect(result).not.toContain("updated: ''"); + }); - const result = remark().use(metadata).processSync(vfile).toString(); - expect(result).toContain('lastModifiedAt:'); + it('when using stat', () => { + const result = remark() + .use(metadata, { + metadata: { + updated: metadata.LAST_MODIFIED_TIME, + }, + }) + .processSync(vfile) + .toString(); + + // should be "created: 'DATE'" + expect(result).not.toContain("created: ''"); + + // should be "updated: 'DATE'" + expect(result).not.toContain("updated: ''"); + }); }); - it('adds metadata using stat', () => { + describe('adds metadata to existing front matter', () => { const srcFile = `${fixturesDir}/existing.md`; const destFile = `${runtimeDir}/existing.md`; - const vfile = toVFile.readSync(srcFile); - addMetadata(vfile, destFile); + let vfile; + + beforeEach(() => { + vfile = toVFile.readSync(srcFile); + addMetadata(vfile, destFile); + }); + + it('when provide shouldUpdate option', () => { + const mockFn = jest.fn(); + const result = remark() + .use(metadata, { + metadata: { + lastModifiedAt: { + value: metadata.LAST_MODIFIED_TIME, + shouldUpdate(newValue, oldValue) { + mockFn(); + expect(newValue).not.toBeFalsy(); + expect(oldValue).toBe('Thu, 22 Oct 2020 06:47:56 GMT'); + + return false; + }, + }, + }, + }) + .processSync(vfile) + .toString(); - const result = remark().use(metadata, { git: false }).processSync(vfile).toString(); - expect(result).toContain('lastModifiedAt:'); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(result).toContain("lastModifiedAt: 'Thu, 22 Oct 2020 06:47:56 GMT'"); + }); + + it('when does not provide shouldUpdate option', () => { + const result = remark() + .use(metadata, { + metadata: { + lastModifiedAt: { + value: metadata.LAST_MODIFIED_TIME, + }, + }, + }) + .processSync(vfile) + .toString(); + + expect(result).not.toContain('lastModifiedAt: Thu, 22 Oct 2020 06:47:56 GMT'); + }); + + it('when provide gitExcludeCommit option', () => { + const result = remark() + .use(metadata, { + gitExcludeCommit: '', + metadata: { + lastModifiedAt: metadata.GIT_LAST_MODIFIED_TIME, + }, + }) + .processSync(vfile) + .toString(); + + expect(result).toContain("lastModifiedAt: ''"); + }); + + it('when provide string metadata', () => { + const result = remark() + .use(metadata, { + metadata: { + foo: 'bar', + }, + }) + .processSync(vfile) + .toString(); + + expect(result).toContain('foo: bar'); + }); + + it('when provide function metadata', () => { + const mockFn = jest.fn(); + const result = remark() + .use(metadata, { + metadata: { + foo({ + gitModifiedTime, + gitCreatedTime, + modifiedTime, + vFile, + oldFrontMatter, + }) { + mockFn(); + expect(gitModifiedTime).toBeTruthy(); + expect(gitCreatedTime).toBeTruthy(); + expect(modifiedTime).toBeTruthy(); + expect(vFile).toBeTruthy(); + expect(oldFrontMatter).toBeTruthy(); + return 'bar'; + }, + }, + }) + .processSync(vfile) + .toString(); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(result).toContain('foo: bar'); + }); }); - it('adds metadata as new front matter', () => { + describe('adds metadata as new front matter', () => { const srcFile = `${fixturesDir}/no.md`; const destFile = `${runtimeDir}/no.md`; - const vfile = toVFile.readSync(srcFile); - addMetadata(vfile, destFile); + let vfile; - const result = remark().use(metadata).processSync(vfile).toString(); - expect(result).toContain('lastModifiedAt:'); - }); + beforeEach(() => { + vfile = toVFile.readSync(srcFile); + addMetadata(vfile, destFile); + }); - it('reuses modified date from vFile data', () => { - const srcFile = `${fixturesDir}/existing.md`; - const destFile = `${runtimeDir}/existing.md`; - const vfile = toVFile.readSync(srcFile); - addMetadata(vfile, destFile); - vfile.data.lastModifiedAt = 'Tue, 28 Nov 2017 02:44:25 GMT'; + it('when provide metadata', () => { + const result = remark() + .use(metadata, { + metadata: { lastModifiedAt: metadata.LAST_MODIFIED_TIME }, + }) + .processSync(vfile) + .toString(); + expect(result).toContain('lastModifiedAt:'); + }); - const result = remark().use(metadata).processSync(vfile).toString(); - expect(result).toContain('lastModifiedAt:'); + it('when does not provide metadata', () => { + const result = remark().use(metadata).processSync(vfile).toString(); + expect(result).toContain('{}'); + }); }); });