Skip to content

Commit

Permalink
Feature: Add support for simple wildcards, get 100% test coverage
Browse files Browse the repository at this point in the history
  • Loading branch information
webketje committed May 24, 2022
1 parent 8a14fe4 commit 9c53cfe
Show file tree
Hide file tree
Showing 5 changed files with 255 additions and 15 deletions.
38 changes: 33 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 <strong>markdown-enabled</strong> <em>description</em>\n",
"nested": {
"data": "<h1 id=\"metalsmith\">metalsmith</h1>\n"
}
},
"faq": [
{ "q": "<p><strong>Question1?</strong></p>\n", "a": "<p><em>answer1</em></p>\n"},
{ "q": "<p><strong>Question2?</strong></p>\n", "a": "<p><em>answer2</em></p>\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')
Expand Down
66 changes: 66 additions & 0 deletions lib/expand-wildcard-keypath.js
Original file line number Diff line number Diff line change
@@ -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<string|number>[]} keypaths
* @param {string} [char='*']
* @returns {Array<string|number>[]}
*/
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
43 changes: 33 additions & 10 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand All @@ -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
Expand Down
20 changes: 20 additions & 0 deletions test/fixtures/array-index-keys/src/index.md
Original file line number Diff line number Diff line change
@@ -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
---
103 changes: 103 additions & 0 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand All @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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 = ['<p><em>one</em></p>\n', '<p><em>two</em></p>\n', '<p><em>three</em></p>\n']
const expected = [
{ prop: '<p><em>one</em></p>\n' },
{ prop: '<p><em>two</em></p>\n' },
{ prop: '<p><strong>three</strong></p>\n' }
]
const expectedWildcards = {
faq: [
{ q: '<p><strong>Question1?</strong></p>\n', a: '<p><em>answer1</em></p>\n' },
{ q: '<p><strong>Question2?</strong></p>\n', a: '<p><em>answer2</em></p>\n' }
],
titles: {
first: '<h1 id="first">first</h1>\n',
second: '<h2 id="second">second</h2>\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()
})
})
})

0 comments on commit 9c53cfe

Please sign in to comment.