Skip to content

Commit

Permalink
feat(remark-table-of-content): don't overwrite existing property
Browse files Browse the repository at this point in the history
  • Loading branch information
wdavidw committed Jan 15, 2024
1 parent c4ebf45 commit b978491
Show file tree
Hide file tree
Showing 9 changed files with 198 additions and 44 deletions.
24 changes: 11 additions & 13 deletions remark/table-of-content/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ It supports CommonJS and ES modules.

## Simple usage

This is a Remark plugin. As such, place the plugin after `remark-parse` and before `remark-rehype`.
This is a Remark plugin. As such, place the plugin after `remark-parse` and before `remark-rehype`. Here is how to [return a `toc` property](./samples/simple-usage.js) with the table of content.

```js
import assert from 'node:assert'
Expand All @@ -27,30 +27,26 @@ import remark2rehype from 'remark-rehype'
import html from 'rehype-stringify'
import pluginToc from '../lib/index.js'

// Create a toc property
const { toc } = await unified()
.use(parseMarkdown)
.use(pluginToc, {property: ['data', 'toc']})
.use(pluginToc, {property: 'toc'})
.use(remark2rehype)
.use(html)
.process(dedent`
---
description: Using with frontmatter
---
# Heading 1
## Heading 2
`)
assert.deepEqual(toc, {
description: 'Using with frontmatter',
toc: [
{ title: 'Heading 1', depth: 1, anchor: 'heading-1' },
{ title: 'Heading 2', depth: 2, anchor: 'heading-2' }
]
})
// Validation
assert.deepEqual(toc, [
{ title: 'Heading 1', depth: 1, anchor: 'heading-1' },
{ title: 'Heading 2', depth: 2, anchor: 'heading-2' }
])
```

## Using the `property` option

The resulting array is returned with the `toc` property by default or any property of you like. For example, when used conjointly with the `remark-read-frontmatter` plugin, setting the `property` option to `['data', 'toc']` enriches the frontmatter `data` property.
The resulting array is returned with the `toc` property by default or any property of you like. For example, when used conjointly with the `remark-read-frontmatter` plugin, setting the `property` option to `['data', 'toc']` [enriches the frontmatter `data` property](./samples/with-extract-frontmatter.js).


```js
Expand Down Expand Up @@ -86,3 +82,5 @@ assert.deepEqual(data, {
]
})
```

A value is preserved if the property is already exists in the vfile, for example in the frontmatter,
99 changes: 79 additions & 20 deletions remark/table-of-content/lib/index.js
Original file line number Diff line number Diff line change
@@ -1,49 +1,108 @@
import { visit } from 'unist-util-visit'
import slugify from '@sindresorhus/slugify'
import { is_object_literal } from 'mixme'

const get = (obj, keys, strict = false) => {
for (const key of keys) {
if (obj.hasOwnProperty(key)) {
obj = obj[key]
} else if (!strict) {
return undefined
} else {
throw Error('REMARK_TABLE_OF_CONTENT: property does not exists in strict mode.')
}
}
return obj
}

const set = (obj, keys, value, overwrite = false) => {
if( obj === null || typeof obj !== 'object' ) {
throw Error('REMARK_TABLE_OF_CONTENT: argument is not an object.')
}
for (let i = 0; i < keys.length; i++) {
const last = i === keys.length - 1
const key = keys[i]
if (!last) {
if (obj.hasOwnProperty(key)) {
// Move into the child
if (is_object_literal(obj[key])) {
obj = obj[key]
} else {
// Never overwrite a parent
throw Error(
'REMARK_TABLE_OF_CONTENT: cannot overwrite parent property.'
)
}
} else {
// Create the parent property
obj[key] = {}
obj = obj[key]
}
} else {
if (obj.hasOwnProperty(key)) {
// Move into the child
if (overwrite) {
obj[key] = value
} else {
// do nothing and preserve the origin value
}
} else {
// Create the parent property
obj[key] = value
}
}
}
}

export default function remarkToc({
depth_min = 1,
depth_max = 3,
property = ['toc'],
} = {}) {
if (typeof property === 'string'){
if (typeof property === 'string') {
property = [property]
}
return function (tree, vfile) {
// if(get(vfile.data, property) === false) {
// return
// }
const toc = []
visit(tree, 'heading', function (node) {
if (node.depth < depth_min || node.depth > depth_max) return
const title = node.children
.filter((child) => child.type === 'text' || child.type === 'strong' || child.type === 'emphasis' || child.type === 'inlineCode')
.map((child) => {
.filter(
(child) =>
child.type === 'text' ||
child.type === 'strong' ||
child.type === 'emphasis' ||
child.type === 'inlineCode'
)
.map((child) => {
// when the text is bold or italic, the node.children[0] is not a text node but a strong or emphasis node
// so we need to check the type of the node.children[0] to get the text value
if (child.type === 'strong' || child.type === 'emphasis') {
// case strong AND emphasis (***italic bold***) : the node.children[0] as an embedded node with the type strong or emphasis
if (child.children[0].type === 'strong' || child.children[0].type === 'emphasis') {
return child.children[0].children[0].value;
if (
child.children[0].type === 'strong' ||
child.children[0].type === 'emphasis'
) {
return child.children[0].children[0].value
}
return child.children[0].value;
}
return child.children[0].value
}
// but for inlineCode and text node, the value is directly in the child.value
return child.value;
return child.value
})
.join('');
if (!title) return;
.join('')
if (!title) return
toc.push({
title: title,
depth: node.depth,
anchor: slugify(title),
});
})
})
let mount = vfile
for(let i = 0; i < property.length - 1; i++){
const prop = property[i];
if(!mount[prop]){
mount[prop] = {}
}
mount = mount[prop]
}
mount[property[property.length - 1]] = toc
set(vfile, property, toc, false)
}
}

export { get, set }
1 change: 1 addition & 0 deletions remark/table-of-content/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@rollup/plugin-commonjs": "^25.0.3",
"@rollup/plugin-node-resolve": "^15.1.0",
"dedent": "^0.7.0",
"each": "^2.6.0",
"rollup": "^3.26.3"
},
"exports": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,18 @@ import remark2rehype from 'remark-rehype'
import html from 'rehype-stringify'
import pluginToc from '../lib/index.js'

// Create a toc property
const { toc } = await unified()
.use(parseMarkdown)
.use(pluginToc, {property: ['data', 'toc']})
.use(pluginToc, {property: 'toc'})
.use(remark2rehype)
.use(html)
.process(dedent`
---
description: Using with frontmatter
---
# Heading 1
## Heading 2
`)
assert.deepEqual(toc, {
description: 'Using with frontmatter',
toc: [
{ title: 'Heading 1', depth: 1, anchor: 'heading-1' },
{ title: 'Heading 2', depth: 2, anchor: 'heading-2' }
]
})
// Validation
assert.deepEqual(toc, [
{ title: 'Heading 1', depth: 1, anchor: 'heading-1' },
{ title: 'Heading 2', depth: 2, anchor: 'heading-2' }
])
27 changes: 27 additions & 0 deletions remark/table-of-content/test/get.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { get } from '../lib/index.js'

describe('Utils `get`', () => {
// Note, get is not yet used by the library

it('valid path - level 1', async () => {
get({ a: 'value' }, ['a']).should.eql('value')
})

it('valid path - level 3 - value string', async () => {
get({ a: { b: { c: 'value' } } }, ['a', 'b', 'c']).should.eql('value')
})

it('valid path - level 3 - value `false`', async () => {
get({ a: { b: { c: false } } }, ['a', 'b', 'c']).should.eql(false)
})

it('get missing path - level 3 - relax', async () => {
should(get({}, ['a', 'b', 'c'])).eql(undefined)
})

it('get missing path - level 3 - strict', async () => {
;(() => get({}, ['a', 'b', 'c'], true)).should.throw(
'REMARK_TABLE_OF_CONTENT: property does not exists in strict mode.'
)
})
})
19 changes: 19 additions & 0 deletions remark/table-of-content/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,25 @@ describe('Extract table of content', () => {
})
})

it('preserve value if `false`', async () => {
const { data } = await unified()
.use(parseMarkdown)
.use(extractFrontmatter, ['yaml'])
.use(pluginReadFrontmatter)
.use(pluginToc, {property: ['data', 'toc']})
.use(remark2rehype)
.use(html).process(dedent`
---
toc: false
---
# Heading 1
## Heading 2
`)
data.should.eql({
toc: false
})
})

})

})
19 changes: 19 additions & 0 deletions remark/table-of-content/test/samples.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import each from 'each'
import fs from 'fs'
import path from 'path'
import { exec } from 'child_process'

const __dirname = new URL('.', import.meta.url).pathname
const dir = path.resolve(__dirname, '../samples')
const samples = fs.readdirSync(dir)

describe('Samples', () => {

each(samples, true, (sample) => {
if (!/\.js$/.test(sample)) return
it(`Sample ${sample}`, (callback) => {
exec(`node ${path.resolve(dir, sample)}`, (err) => callback(err))
})
})

})
35 changes: 35 additions & 0 deletions remark/table-of-content/test/set.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { set } from '../lib/index.js'

describe('Utils `set`', () => {
it('valid path - property not yet defined - level 1', async () => {
const input = {}
set(input, ['a'], 'value')
input.should.eql({ a: 'value' })
})

it('valid path - property not yet defined - level 3', async () => {
const input = {}
set(input, ['a', 'b', 'c'], 'value')
input.should.eql({ a: { b: { c: 'value' } } })
})

it('fail to overwrite parent path', async () => {
const input = {'a': { 'b': true }}
;( () =>
set(input, ['a', 'b', 'c'], 'value')
).should.throw('REMARK_TABLE_OF_CONTENT: cannot overwrite parent property.')
})

it('value exists - level 3 - overwrite false', async () => {
const input = {'a': { 'b': { 'c': true} }}
set(input, ['a', 'b', 'c'], 'value')
input.should.eql({ a: { b: { c: true } } })
})

it('value exists - level 3 - overwrite false', async () => {
const input = {'a': { 'b': { 'c': true} }}
set(input, ['a', 'b', 'c'], 'value', { overwrite: true })
input.should.eql({ a: { b: { c: 'value' } } })
})

})

0 comments on commit b978491

Please sign in to comment.