From 9c53cfe9707daab783c3fc54ddd6ceb9c5eddf9c Mon Sep 17 00:00:00 2001 From: Kevin Van Lierde Date: Fri, 20 May 2022 02:40:20 +0200 Subject: [PATCH] Feature: Add support for simple wildcards, get 100% test coverage --- README.md | 38 +++++++- lib/expand-wildcard-keypath.js | 66 +++++++++++++ lib/index.js | 43 ++++++-- test/fixtures/array-index-keys/src/index.md | 20 ++++ test/index.js | 103 ++++++++++++++++++++ 5 files changed, 255 insertions(+), 15 deletions(-) create mode 100644 lib/expand-wildcard-keypath.js create mode 100644 test/fixtures/array-index-keys/src/index.md diff --git a/README.md b/README.md index 16d8b88..1bdd0b7 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,8 @@ metalsmith.use( `@metalsmith/markdown` is powered by [Marked](https://github.com/markedjs/marked), and you can pass any of the [Marked options](https://marked.js.org/using_advanced#options) to it, including the ['pro' options](https://marked.js.org/using_pro#extensions): `renderer`, `tokenizer`, `walkTokens` and `extensions`. -Additionally, you can render markdown to HTML in file metadata keys by specifying the `keys` option: +You can render markdown to HTML in file metadata keys by specifying the `keys` option. +The `keys` option also supports dot-delimited key-paths. ```js metalsmith.use( @@ -58,29 +59,56 @@ metalsmith.use( ) ``` -A file `article.md` with front-matter: +You can even render all keys at a certain path by setting the `wildcard` option and using a globstar `*` in the keypaths. +This is especially useful for arrays like the `faq` below: + +```js +metalsmith.use( + markdown({ + wildcard: true, + keys: ['html_desc', 'nested.data', 'faq.*.*'] + }) +) +``` + +A file `page.md` with front-matter: ```md --- html_desc: A **markdown-enabled** _description_ nested: data: '#metalsmith' +faq: + - q: '**Question1?**' + a: _answer1_ + - q: '**Question2?**' + a: _answer2_ --- ``` -would transform `html_desc` to +would be transformed into: ```json { "html_desc": "A markdown-enabled description\n", "nested": { "data": "

metalsmith

\n" - } + }, + "faq": [ + { "q": "

Question1?

\n", "a": "

answer1

\n"}, + { "q": "

Question2?

\n", "a": "

answer2

\n"} + ], ``` +**Notes about the wildcard** + +- It acts like the single bash globstar. If you specify `*` this would only match the properties at the first level of the metadata. +- If a wildcard keypath matches a key whose value is not a string, it will be ignored. +- It is set to `false` by default because it can incur some overhead if it is applied too broadly. + ### Custom markdown rendering -You can use a custom renderer by of `marked.Renderer()` +You can use a custom renderer by using `marked.Renderer()` ```js const markdown = require('@metalsmith/markdown') diff --git a/lib/expand-wildcard-keypath.js b/lib/expand-wildcard-keypath.js new file mode 100644 index 0000000..7a5d84b --- /dev/null +++ b/lib/expand-wildcard-keypath.js @@ -0,0 +1,66 @@ +const get = require('dlv') + +function error(name, msg) { + const err = new Error(msg) + err.name = name + return err +} + +function isArray(arg) { + return Array.isArray(arg) +} +function isString(arg) { + return typeof arg === 'string' +} +function isObject(arg) { + return typeof arg === 'object' && arg !== null +} + +/** + * Expand wildcard `char` for `keypaths` in `root`. The results can be used by a utility function like lodash.get or dlv. For example: + * ```js + * let keypaths = [ + * ['arr.*'], + * ['arr.*.*'] + * ] + * expand(keypaths, { arr: ['a','b','c']}) // => [['arr', 0], ['arr', 1], ['arr', 2]] + * expand(keypaths, { arr: ['a','b','c']}) // => [['arr', 0], ['arr', 1], ['arr', 2]] + * ``` + * @param {Object|Array} root + * @param {Array[]} keypaths + * @param {string} [char='*'] + * @returns {Array[]} + */ +function expandWildcardKeypath(root, keypaths, char) { + if (!isObject(root)) { + throw error('EINVALID_ARGUMENT', 'root must be an object or array') + } + if (!isArray(keypaths) || keypaths.filter((keypath) => !isString(keypath) && !isArray(keypath)).length) { + throw error('EINVALID_ARGUMENT', 'keypaths must be strings or arrays of strings') + } + + const expanded = keypaths.reduce((result, keypath) => { + if (isString(keypath)) keypath = keypath.split('.') + const wildcard = keypath.indexOf(char) + if (wildcard > -1) { + const pre = keypath.slice(0, wildcard) + const wildcardRoot = get(root, pre) + const looped = isArray(wildcardRoot) ? wildcardRoot : Object.keys(wildcardRoot) + + looped.forEach((entry, index) => { + const pp = Array.from(keypath) + pp.splice(wildcard, 1, isArray(wildcardRoot) ? index : entry) + result.push(pp) + }) + } else { + result.push(keypath) + } + return result + }, []) + if (expanded.find((entry) => entry.indexOf(char) > -1)) { + return expandWildcardKeypath(root, expanded, char) + } + return expanded +} + +module.exports = expandWildcardKeypath diff --git a/lib/index.js b/lib/index.js index e60eb62..a67f1c7 100644 --- a/lib/index.js +++ b/lib/index.js @@ -3,6 +3,7 @@ const debug = require('debug')('@metalsmith/markdown') const get = require('dlv') const set = require('dset').dset const { marked } = require('marked') +const expandWildcardKeypaths = require('./expand-wildcard-keypath') /** * Check if a `file` is markdown @@ -13,25 +14,46 @@ function isMarkdown(filePath) { return /\.md$|\.markdown$/.test(extname(filePath)) } +function render(data, key, options) { + const value = get(data, key) + if (typeof value === 'string') { + set(data, key, marked(value, options)) + debug('rendered "%s"', key.join ? key.join('.') : key) + } +} + /** * @typedef Options - * @property {String[]} keys - Key names of file metadata to render to HTML + * @property {string[]} [keys] - Key names of file metadata to render to HTML - can be nested + * @property {boolean} [wildcard=false] - Expand `*` wildcards in keypaths **/ +const defaultOptions = { + keys: [], + wildcard: false +} + /** * A Metalsmith plugin to render markdown files to HTML * @param {Options} [options] * @return {import('metalsmith').Plugin} */ -function initMarkdown(options) { - options = options || {} - const keys = options.keys || [] +function initMarkdown(options = defaultOptions) { + if (options === true) { + options = defaultOptions + } else { + options = Object.assign({}, defaultOptions, options) + } return function markdown(files, metalsmith, done) { setImmediate(done) + Object.keys(files).forEach(function (file) { debug('checking file: %s', file) - if (!isMarkdown(file)) return + if (!isMarkdown(file)) { + return + } + const data = files[file] const dir = dirname(file) let html = basename(file, extname(file)) + '.html' @@ -40,11 +62,12 @@ function initMarkdown(options) { debug('converting file: %s', file) const str = marked(data.contents.toString(), options) data.contents = Buffer.from(str) - keys.forEach(function (key) { - if (get(data, key)) { - set(data, key, marked(get(data, key).toString(), options)) - } - }) + + let keys = options.keys + if (options.wildcard) { + keys = expandWildcardKeypaths(data, options.keys, '*') + } + keys.forEach((k) => render(data, k, options)) delete files[file] files[html] = data diff --git a/test/fixtures/array-index-keys/src/index.md b/test/fixtures/array-index-keys/src/index.md new file mode 100644 index 0000000..5d41472 --- /dev/null +++ b/test/fixtures/array-index-keys/src/index.md @@ -0,0 +1,20 @@ +--- +arr: + - _one_ + - _two_ + - _three_ +objarr: + - prop: _one_ + - prop: _two_ + - prop: "**three**" +wildcard: + faq: + - q: "**Question1?**" + a: _answer1_ + - q: "**Question2?**" + a: _answer2_ + titles: + first: "# first" + second: "## second" + third: null +--- \ No newline at end of file diff --git a/test/index.js b/test/index.js index 226c8f1..058cbd2 100644 --- a/test/index.js +++ b/test/index.js @@ -4,6 +4,7 @@ const Metalsmith = require('metalsmith') const { describe, it } = require('mocha') const { name } = require('../package.json') const markdown = require('..') +const expandWildcardKeypath = require('../lib/expand-wildcard-keypath') describe('@metalsmith/markdown', function () { it('should export a named plugin function matching package.json name', function () { @@ -25,6 +26,47 @@ describe('@metalsmith/markdown', function () { }) }) + it('should treat "true" option as default', function (done) { + function getFiles() { + return { + 'subfolder/index.md': { + contents: Buffer.from('"hello"') + } + } + } + + Promise.all([ + new Promise((resolve) => { + const files = getFiles() + markdown(true)(files, {}, () => { + resolve(files) + }) + }), + new Promise((resolve) => { + const files = getFiles() + markdown()(files, {}, () => { + resolve(files) + }) + }), + new Promise((resolve) => { + const files = getFiles() + markdown({ smartypants: true })(files, {}, () => { + resolve(files) + }) + }) + ]).then(([defaultsTrue, defaults, smartypants]) => { + assert.strictEqual( + defaults['subfolder/index.html'].contents.toString(), + defaultsTrue['subfolder/index.html'].contents.toString() + ) + assert.notDeepStrictEqual( + defaultsTrue['subfolder/index.html'].contents.toString(), + smartypants['subfolder/index.html'].contents.toString() + ) + done() + }) + }) + it('should convert markdown files', function (done) { Metalsmith('test/fixtures/basic') .use( @@ -39,6 +81,14 @@ describe('@metalsmith/markdown', function () { }) }) + it('should skip non-markdown files', function (done) { + const files = { 'index.css': {} } + markdown(true)(files, {}, () => { + assert.deepStrictEqual(files, { 'index.css': {} }) + done() + }) + }) + it('should allow a "keys" option', function (done) { Metalsmith('test/fixtures/keys') .use( @@ -68,4 +118,57 @@ describe('@metalsmith/markdown', function () { done() }) }) + + it('expandWildCardKeyPath should throw if root is not an object', function () { + try { + expandWildcardKeypath(null, [], '*') + } catch (err) { + assert.strictEqual(err.name, 'EINVALID_ARGUMENT') + assert.strictEqual(err.message, 'root must be an object or array') + } + }) + + it('expandWildCardKeyPath should throw if keypaths is not an array of arrays or strings', function () { + try { + expandWildcardKeypath({}, [false], '*') + } catch (err) { + assert.strictEqual(err.name, 'EINVALID_ARGUMENT') + assert.strictEqual(err.message, 'keypaths must be strings or arrays of strings') + } + }) + + it('should recognize a keys option loop placeholder', function (done) { + Metalsmith('test/fixtures/array-index-keys') + .use( + markdown({ + keys: ['arr.*', 'objarr.*.prop', 'wildcard.faq.*.*', 'wildcard.titles.*'], + wildcard: '*', + smartypants: true + }) + ) + .build(function (err, files) { + if (err) return done(err) + const expectedFlat = ['

one

\n', '

two

\n', '

three

\n'] + const expected = [ + { prop: '

one

\n' }, + { prop: '

two

\n' }, + { prop: '

three

\n' } + ] + const expectedWildcards = { + faq: [ + { q: '

Question1?

\n', a: '

answer1

\n' }, + { q: '

Question2?

\n', a: '

answer2

\n' } + ], + titles: { + first: '

first

\n', + second: '

second

\n', + third: null + } + } + assert.deepStrictEqual(files['index.html'].objarr, expected) + assert.deepStrictEqual(files['index.html'].arr, expectedFlat) + assert.deepStrictEqual(files['index.html'].wildcard, expectedWildcards) + done() + }) + }) })