feat: as BREAKING CHANGE convert to import based approach

This commit is contained in:
Thomas Allmer 2021-03-15 10:52:24 +01:00 committed by Thomas Allmer
parent 9371bfa857
commit 5d528fd795
9 changed files with 473 additions and 1241 deletions

View file

@ -1,326 +1,3 @@
# remark-extend # Remark Extend
A plugin for remark to extend one markdown file with another. [=> See Source <=](../../docs/docs/node-tools/remark-extend/overview.md)
`remark-extend` is build to be integrated within the [unifiedjs](https://unifiedjs.com/) system.
## Installation
```bash
npm i -D remark-extend
```
```js
const unified = require('unified');
const markdown = require('remark-parse');
const mdStringify = require('remark-html');
const { remarkExtend } = require('remark-extend');
const sourceMd = '# Headline';
const extendMd = 'extending instructions';
const parser = unified().use(markdown).use(remarkExtend, { extendMd }).use(mdStringify);
const result = await parser.process(sourceMd);
```
## Extending Instructions
### Selection
For modifications you will need to provide a starting node.
In order to get this node css like selectors from [unist-util-select](https://github.com/syntax-tree/unist-util-select#support) are supported.
Some examples are:
- `:root` for the top of the markdown file
- `:scope:last-child` for the end of the markdown file
- `heading:has([value=Red])` first heading with a text value of Red (e.g. ### Red)
- `heading[depth=2]` first second level heading (e.g. ## Something)
- `heading[depth=2]:has([value=Red]) ~ heading[depth=2]` following h2 after h2 with "Red" (e.g. ## Red ... ## Something)
### Markdown AST
All adjustments to the markdown file happen via the markdown AST (Abstract Syntax Tree).
You can explore it via the [ASTExplorer](https://astexplorer.net/). (> Markdown > remark)
```md
### Red
red is the fire
```
Resulting AST.
```json
{
"type": "root",
"children": [
{
"type": "heading",
"depth": 3,
"children": [
{
"type": "text",
"value": "Red"
}
]
},
{
"type": "paragraph",
"children": [
{
"type": "text",
"value": "red is the fire"
}
]
}
]
}
```
---
### Replacement
Does adjustments from a start point downwards.
The provided function does get called for every node from the starting point (including the starting point).
#### Replacement Input File
```md
### Red
red is the fire
```
#### Replacement Extending File
Goal is to replace all red with green.
````md
```js ::replaceFrom(':root')
module.exports.replaceSection = node => {
if (node.value) {
node.value = node.value.replace(/red/g, 'green').replace(/Red/g, 'Green');
}
return node;
};
```
````
This function gets called with these nodes in order.
1. root
2. heading
3. text
4. paragraph
5. text
#### Replacement Result
```md
### Green
green is the fire
```
### Replacement Range
Whenever a section or part of the original markdown needs to be adjusted.
The function does get every node from the starting point (including the starting point) till the end point (excluding the end point).
#### Replacement Range Input File
```md
### Red <-- starting point (including)
red is the fire
### More Red <-- end point (excluding)
the sun can get red
```
#### Replacement Range Extending File
````md
```js ::replaceBetween('heading:has([value=Red])', 'heading:has([value=More Red])')
module.exports.replaceSection = node => {
if (node.value) {
node.value = node.value.replace(/red/g, 'green').replace(/Red/g, 'Green');
}
return node;
};
```
````
#### Replacement Range Result
```md
### Green <-- starting point (including)
green is the fire
### More Red <-- end point (excluding)
the sun can get red
```
---
### Add More Markdown Content After
If additional markdown content should be inserted after at a specific location.
#### Add After Input File
```md
### Red
red is the fire
```
#### Add After Extending File
````md
```
::addMdAfter('heading:has([value=Red])')
```
the ocean is blue
````
#### Add After Result
```md
### Red
the ocean is blue
red is the fire
```
More example use cases:
- Add more markdown at the top `::addMdAfter(':root')`
- Add more markdown at the bottom `::addMdAfter(':scope:last-child')`
### Add More Markdown Content Before
If additional markdown content should be inserted before at a specific location.
Useful for adding disclaimers above a headline.
#### Add Before Input File
```md
### Red
red is the fire
### Green <-- staring point
```
#### Add Before Extending File
````md
```
::addMdBefore('heading:has([value=Red])')
```
the ocean is blue
````
#### Add Before Result
```md
### Red
red is the fire
the ocean is blue
### Green <-- staring point
```
More example use cases:
- Add something at the end of a "section": `::addMdBefore('heading:has([value=Red]) ~ heading[depth=3]')`
It works by selecting the headline of your section and add before the next sibling headline with the same depth.
---
### Removal
Does adjustments from a start point downwards.
The provided function does get called for every node from the starting point (including the starting point).
#### Removal Input File
```md
### Red
red is the fire
### More Red // <-- start
the sun can get red
```
#### Removal Extending File
Goal is to remove everything after `### More Red`
````md
```
::removeFrom('heading:has([value=More Red])')
```
````
#### Removal Result
```md
### Red
red is the fire
```
### Removal Range
Whenever a section or part of the original markdown needs to be removed.
#### Removal Range Input File
```md
### Red <-- starting point (including)
red is the fire
### More Red <-- end point (excluding)
the sun can get red
```
#### Removal Range Extending File
Starting from `### Red` until the next headline with depth of 3.
````md
```
::removeBetween('heading:has([value=Red])', 'heading:has([value=Red]) ~ heading[depth=3]')
```
````
#### Removal Range Result
```md
### More Red <-- end point (excluding)
the sun can get red
```
```js script
export default {
title: 'Tools/Remark Extend',
};
```

View file

@ -0,0 +1,3 @@
# Remark Extend
[=> See Source <=](../../../docs/docs/node-tools/remark-extend/overview.md)

View file

@ -18,14 +18,15 @@
"test" "test"
], ],
"scripts": { "scripts": {
"prepublishOnly": "../../scripts/npm-prepublish.js", "publish-docs": "node ../../packages-node/publish-docs/src/cli.js --github-url https://github.com/ing-bank/lion/ --git-root-dir ../../",
"prepublishOnly": "npm run publish-docs",
"test": "npm run test:node", "test": "npm run test:node",
"test:node": "mocha test-node", "test:node": "mocha test-node",
"test:watch": "mocha test-node --watch" "test:watch": "mocha test-node --watch"
}, },
"dependencies": { "dependencies": {
"remark-parse": "^8.0.0", "remark-parse": "^9.0.0",
"unified": "^9.0.0", "unified": "^9.2.0",
"unist-util-is": "^4.0.2", "unist-util-is": "^4.0.2",
"unist-util-select": "^3.0.1", "unist-util-select": "^3.0.1",
"unist-util-visit": "^2.0.2" "unist-util-visit": "^2.0.2"
@ -36,5 +37,8 @@
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"exports": "./index.js" "exports": {
".": "./index.js",
"./docs/": "./docs/"
}
} }

View file

@ -5,143 +5,8 @@ const { select } = require('unist-util-select');
const unified = require('unified'); const unified = require('unified');
const markdown = require('remark-parse'); const markdown = require('remark-parse');
const is = require('unist-util-is'); const is = require('unist-util-is');
const fs = require('fs');
function addTask(file, newAction) { const path = require('path');
if (!file.data.remarkExtend) {
file.data.remarkExtend = [];
}
file.data.remarkExtend.push(newAction);
}
function evaluateAsReplacementTask(node, file) {
if (
node.type === 'code' &&
node.lang === 'js' &&
node.meta &&
node.meta.startsWith('::replaceFrom')
) {
const startSelector = node.meta.substring(node.meta.indexOf("('") + 2, node.meta.indexOf("')"));
addTask(file, {
action: 'replaceFrom',
startSelector,
jsCode: node.value,
});
}
if (
node.type === 'code' &&
node.lang === 'js' &&
node.meta &&
node.meta.startsWith('::replaceBetween')
) {
const startSelector = node.meta.substring(node.meta.indexOf("('") + 2, node.meta.indexOf("',"));
const endSelector = node.meta
.substring(node.meta.indexOf("',") + 4, node.meta.indexOf("')"))
.trim();
addTask(file, {
action: 'replaceBetween',
startSelector,
endSelector,
jsCode: node.value,
});
}
}
function evaluateAsRemoveTask(node, file) {
if (node.type === 'code' && node.value && node.value.startsWith('::removeFrom')) {
const startSelector = node.value.substring(
node.value.indexOf("('") + 2,
node.value.indexOf("')"),
);
addTask(file, {
action: 'removeFrom',
startSelector,
});
}
if (node.type === 'code' && node.value && node.value.startsWith('::removeBetween')) {
const startSelector = node.value.substring(
node.value.indexOf("('") + 2,
node.value.indexOf("',"),
);
const endSelector = node.value
.substring(node.value.indexOf("',") + 4, node.value.indexOf("')"))
.trim();
addTask(file, {
action: 'removeBetween',
startSelector,
endSelector,
});
}
}
function shouldFinishGathering(node) {
if (node.type === 'code' && node.lang === 'js' && node.meta && node.meta.startsWith('::')) {
return true;
}
if (node.value && node.value.startsWith('::')) {
return true;
}
return false;
}
let mdAdditionAddNodes = [];
let mdAdditionGathering = false;
let mdAdditionStartSelector;
let mdAdditionAction = '';
function evaluateAsMdAdditionTask(node, file, parent) {
if (mdAdditionGathering === true && shouldFinishGathering(node)) {
mdAdditionGathering = false;
addTask(file, {
action: mdAdditionAction,
startSelector: mdAdditionStartSelector,
addNodes: mdAdditionAddNodes,
});
mdAdditionAddNodes = [];
}
if (mdAdditionGathering === true) {
if (parent.type === 'root') {
mdAdditionAddNodes.push(node);
}
}
if (node.type === 'code' && node.value && node.value.startsWith('::addMdAfter')) {
mdAdditionStartSelector = node.value.substring(
node.value.indexOf("('") + 2,
node.value.indexOf("')"),
);
mdAdditionGathering = true;
mdAdditionAction = 'addMdAfter';
}
if (node.type === 'code' && node.value && node.value.startsWith('::addMdBefore')) {
mdAdditionStartSelector = node.value.substring(
node.value.indexOf("('") + 2,
node.value.indexOf("')"),
);
mdAdditionGathering = true;
mdAdditionAction = 'addMdBefore';
}
}
function findExtendTasks() {
return (tree, file) => {
visit(tree, (node, index, parent) => {
evaluateAsReplacementTask(node, file);
evaluateAsMdAdditionTask(node, file, parent);
evaluateAsRemoveTask(node, file);
});
// for evaluateAsMdAdditionTask
if (mdAdditionGathering === true) {
mdAdditionGathering = false;
addTask(file, {
action: mdAdditionAction,
startSelector: mdAdditionStartSelector,
addNodes: mdAdditionAddNodes,
});
mdAdditionAddNodes = [];
}
};
}
/** /**
* Allows to execute an actual node module code block. * Allows to execute an actual node module code block.
@ -157,170 +22,195 @@ function findExtendTasks() {
* @param {*} filename * @param {*} filename
*/ */
function requireFromString(src, filename = 'tmp.js') { function requireFromString(src, filename = 'tmp.js') {
const srcWithPath = `const path = require('path');\n${src}`;
const m = new module.constructor(); const m = new module.constructor();
m.paths = module.paths; m.paths = module.paths;
m._compile(src, filename); m._compile(srcWithPath, filename);
return m.exports; return m.exports;
} }
function handleAdditions(tree, action, startIsNode, addNodes) { let toInsertNodes = [];
visit(tree, (node, index, parent) => {
if (is(node, startIsNode)) { function handleImportedFile({
if (action === 'addMdAfter') { startSelector,
if (node.type === 'root') { endSelector,
node.children.splice(0, 0, ...addNodes); userFunction,
} else { filePath,
parent.children.splice(index + 1, 0, ...addNodes); fileImport,
missingEndSelectorMeansUntilEndOfFile = false,
}) {
return tree => {
const start = select(startSelector, tree);
if (!start) {
const msg = `The start selector "${startSelector}" could not find a matching node in "${filePath}".`;
throw new Error(msg);
}
const startIsNode = { ...start };
delete startIsNode.children; // unified is comparison does not support children
let endIsNode;
if (endSelector !== ':end-of-file') {
const end = select(endSelector, tree);
if (!end) {
if (missingEndSelectorMeansUntilEndOfFile === false) {
const msg = `The end selector "${endSelector}" could not find a matching node in "${filePath}".`;
throw new Error(msg);
}
} else {
endIsNode = { ...end };
delete endIsNode.children; // unified is comparison does not support children
}
}
let insertIt = false;
visit(tree, (node, index, parent) => {
if (is(node, startIsNode)) {
insertIt = true;
}
if (endIsNode && is(node, endIsNode)) {
insertIt = false;
}
if (insertIt) {
if (userFunction) {
node = userFunction(node, { index, parent, tree });
} }
} }
if (action === 'addMdBefore') {
if (node.remarkExtendedProcessed === undefined) {
// preventing infinite loops as adding a node before means we visit the target node again and insert again
node.remarkExtendedProcessed = true;
if (node.type === 'root') {
node.children.splice(0, 0, ...addNodes);
} else {
parent.children.splice(index, 0, ...addNodes);
}
}
}
}
});
}
function handleReplacements(tree, action, startIsNode, endIsNode, jsCode) { if (insertIt && parent && parent.type === 'root') {
let doReplacements = false; toInsertNodes.push(node);
let resetAtEnd = false;
let userFunction;
if (action === 'replaceFrom' || action === 'replaceBetween') {
const virtualMod = requireFromString(jsCode);
const keys = Object.keys(virtualMod);
userFunction = virtualMod[keys[0]];
}
visit(tree, (node, index, parent) => {
if (is(node, startIsNode)) {
doReplacements = true;
}
if (action === 'replaceBetween' && is(node, endIsNode)) {
resetAtEnd = true;
}
if (doReplacements) {
node = userFunction(node, { index, parent, tree });
}
if (resetAtEnd === true) {
resetAtEnd = false;
doReplacements = false;
}
});
}
/**
* Needs 2 loops as
* - First to mark nodes for removal
* - Do actual removal in revers order (to not effect the index of the loop)
*
* @param {*} tree
* @param {*} action
* @param {*} startIsNode
* @param {*} endIsNode
*/
function handleRemovals(tree, action, startIsNode, endIsNode) {
let removeIt = false;
visit(tree, (node, index, parent) => {
if (is(node, startIsNode)) {
removeIt = true;
}
if (action === 'removeBetween' && is(node, endIsNode)) {
removeIt = false;
}
if (removeIt && parent.type === 'root') {
// only mark for removal
// removing directly messes with the index which prevents further removals down the line
parent.children[index].__remarkExtendRemove = true;
}
});
visit(
tree,
(node, index, parent) => {
if (node.__remarkExtendRemove) {
parent.children.splice(index, 1);
} }
},
true, if (node.type === 'image') {
); node.url = path.join(path.dirname(fileImport), node.url);
}
});
};
} }
// unified expect direct // unified expect direct
// eslint-disable-next-line consistent-return // eslint-disable-next-line consistent-return
function remarkExtend(options) { function remarkExtend({ rootDir = process.cwd(), page } = {}) {
if (options.extendMd) { return tree => {
const parser = unified() visit(tree, (node, index, parent) => {
.use(markdown) if (
.use(findExtendTasks) node.type === 'code' &&
.use(function plugin() { node.lang === 'js' &&
this.Compiler = () => ''; node.meta &&
}); node.meta.startsWith('::import')
) {
// eslint-disable-next-line prefer-const
let [fileImport, startSelector = ':root', endSelector = ':end-of-file'] = node.meta
.substring(node.meta.indexOf("('") + 2, node.meta.indexOf("')"))
.split("', '")
.map(paramPart => paramPart.trim());
const changes = parser.processSync(options.extendMd); let filePath;
let missingEndSelectorMeansUntilEndOfFile = false;
const extensionTasks = changes.data.remarkExtend; try {
filePath = require.resolve(fileImport);
if (!extensionTasks) { } catch (err) {
return tree => tree; filePath = path.resolve(path.join(rootDir, fileImport));
}
return tree => {
for (const extensionTask of extensionTasks) {
const { action, startSelector, endSelector, jsCode, addNodes } = extensionTask;
const start = select(extensionTask.startSelector, tree);
if (!start) {
const msg = [
`The start selector "${startSelector}" could not find a matching node.`,
options.filePath ? `Markdown File: ${options.filePath}` : '',
options.overrideFilePath ? `Override File: ${options.overrideFilePath}` : '',
]
.filter(Boolean)
.join('\n');
throw new Error(msg);
} }
const startIsNode = { ...start };
delete startIsNode.children; // unified is comparison does not support children
let endIsNode; if (!fs.existsSync(filePath)) {
if (action === 'replaceBetween' || action === 'removeBetween') { const inputPath = page ? page.inputPath : 'no page.inputPath given';
const end = select(endSelector, tree); throw new Error(
if (!end) { `The import "${fileImport}" in "${inputPath}" does not exist. Resolved to "${filePath}".`,
const msg = [ );
`The end selector "${endSelector}" could not find a matching node.`, }
options.filePath ? `Markdown File: ${options.filePath}` : '', const importFileContent = fs.readFileSync(filePath);
options.overrideFilePath ? `Override File: ${options.overrideFilePath}` : '',
] if (
.filter(Boolean) node.meta.startsWith('::importBlock(') ||
.join('\n'); node.meta.startsWith('::importBlockContent(') ||
throw new Error(msg); node.meta.startsWith('::importSmallBlock(') ||
node.meta.startsWith('::importSmallBlockContent(')
) {
missingEndSelectorMeansUntilEndOfFile = true;
const [identifier] = node.meta.split('(');
if (!startSelector.startsWith('#')) {
throw new Error(
`${identifier} only works for headlines like "## My Headline" but "${startSelector}" was given`,
);
}
const [hashes, ...headline] = startSelector.split(' ');
switch (identifier) {
case '::importBlock':
startSelector = `heading[depth=${hashes.length}]:has([value=${headline.join(' ')}])`;
endSelector = `${startSelector} ~ heading[depth=${hashes.length}]`;
break;
case '::importBlockContent':
startSelector = `heading[depth=${hashes.length}]:has([value=${headline.join(
' ',
)}]) ~ *`;
endSelector = `${startSelector} ~ heading[depth=${hashes.length}]`;
break;
case '::importSmallBlock':
startSelector = `heading[depth=${hashes.length}]:has([value=${headline.join(' ')}])`;
endSelector = `${startSelector} ~ heading`;
break;
case '::importSmallBlockContent':
startSelector = `heading[depth=${hashes.length}]:has([value=${headline.join(
' ',
)}]) ~ *`;
endSelector = `${startSelector} ~ heading`;
/* no default */
} }
endIsNode = { ...end };
delete endIsNode.children; // unified is comparison does not support children
} }
switch (action) { let userFunction;
case 'addMdAfter': if (node.value !== '') {
case 'addMdBefore': const inputPath = page ? page.inputPath : 'no page.inputPath given';
handleAdditions(tree, action, startIsNode, addNodes); const resolvedInputPath = path.resolve(inputPath);
break; if (!resolvedInputPath) {
case 'replaceFrom': throw new Error(
case 'replaceBetween': `The page.inputPath "${inputPath}" could not be resolved. Tried to resolve with "${resolvedInputPath}".`,
handleReplacements(tree, action, startIsNode, endIsNode, jsCode); );
break; }
case 'removeFrom': const virtualMod = requireFromString(node.value, resolvedInputPath);
case 'removeBetween': const keys = Object.keys(virtualMod);
handleRemovals(tree, action, startIsNode, endIsNode); userFunction = virtualMod[keys[0]];
break; }
/* no default */
toInsertNodes = [];
const parser = unified()
.use(markdown)
.use(handleImportedFile, {
startSelector,
endSelector,
userFunction,
filePath,
fileImport,
missingEndSelectorMeansUntilEndOfFile,
})
.use(function plugin() {
this.Compiler = () => '';
});
parser.processSync(importFileContent.toString());
if (node.type === 'root') {
node.children.splice(0, 0, ...toInsertNodes);
} else {
parent.children[index].__remarkExtendRemove = true;
parent.children.splice(index + 1, 0, ...toInsertNodes);
} }
} }
}; });
}
// another pass to remove nodes
visit(
tree,
(node, index, parent) => {
if (node.__remarkExtendRemove) {
parent.children.splice(index, 1);
}
},
true,
);
return tree;
};
} }
module.exports = { module.exports = {

View file

@ -0,0 +1 @@
# import me headline

View file

@ -0,0 +1,4 @@
# import me headline
![my image](./my.svg)
[link to](./import-me.md)

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,11 @@
# Red
red is the fire
## More Red
the sun can get red
## Additional Red
the red sea

View file

@ -6,7 +6,13 @@ const mdStringify = require('remark-html');
const { remarkExtend } = require('../src/remarkExtend.js'); const { remarkExtend } = require('../src/remarkExtend.js');
async function expectThrowsAsync(method, errorMessage) { /**
* @param {function} method
* @param {object} options
* @param {string} [options.errorMatch]
* @param {string} [options.errorMessage]
*/
async function expectThrowsAsync(method, { errorMatch, errorMessage } = {}) {
let error = null; let error = null;
try { try {
await method(); await method();
@ -14,682 +20,317 @@ async function expectThrowsAsync(method, errorMessage) {
error = err; error = err;
} }
expect(error).to.be.an('Error', 'No error was thrown'); expect(error).to.be.an('Error', 'No error was thrown');
if (errorMatch) {
expect(error.message).to.match(errorMatch);
}
if (errorMessage) { if (errorMessage) {
expect(error.message).to.equal(errorMessage); expect(error.message).to.equal(errorMessage);
} }
} }
async function execute(input) {
const parser = unified()
//
.use(markdown)
.use(remarkExtend, { rootDir: __dirname, page: { inputPath: 'test-file.md' } })
.use(mdStringify);
const result = await parser.process(input);
return result.contents;
}
describe('remarkExtend', () => { describe('remarkExtend', () => {
it('does no modifications if no action is found', async () => { it('does no modifications if no action is found', async () => {
const input = [ const result = await execute(
'### Red', ['### Red', 'red is the fire', '#### More Red', 'the sun can get red'].join('\n'),
'',
'red is the fire',
'',
'#### More Red',
'',
'the sun can get red',
].join('\n');
const extendMd = '';
const output = [
'<h3>Red</h3>',
'<p>red is the fire</p>',
'<h4>More Red</h4>',
'<p>the sun can get red</p>',
'',
].join('\n');
const parser = unified()
//
.use(markdown)
.use(remarkExtend, { extendMd })
.use(mdStringify);
const result = await parser.process(input);
expect(result.contents).to.equal(output);
});
it('can do replacements on the full file', async () => {
const input = [
'### Red', // <-- start
'',
'red is the fire',
'',
'#### More Red',
'',
'the sun can get red',
].join('\n');
const extendMd = [
//
"```js ::replaceFrom(':root')",
'module.exports.replaceSection = (node) => {',
' if (node.value) {',
" node.value = node.value.replace(/red/g, 'green').replace(/Red/g, 'Green');",
' }',
' return node;',
'};',
'```',
].join('\n');
const output = [
'<h3>Green</h3>', // <-- start
'<p>green is the fire</p>',
'<h4>More Green</h4>',
'<p>the sun can get green</p>',
'',
].join('\n');
const parser = unified()
//
.use(markdown)
.use(remarkExtend, { extendMd })
.use(mdStringify);
const result = await parser.process(input);
expect(result.contents).to.equal(output);
});
it('can replace from a starting point downward', async () => {
const input = [
'### Red',
'',
'red is the fire',
'',
'### More Red', // <-- start
'',
'the sun can get red',
].join('\n');
const extendMd = [
"```js ::replaceFrom('heading[depth=3]:has([value=More Red])')",
'module.exports.replaceSection = (node) => {',
' if (node.value) {',
" node.value = node.value.replace(/red/g, 'green').replace(/Red/g, 'Green');",
' }',
' return node;',
'};',
'```',
].join('\n');
const output = [
'<h3>Red</h3>',
'<p>red is the fire</p>',
'<h3>More Green</h3>', // <-- start
'<p>the sun can get green</p>',
'',
].join('\n');
const parser = unified()
//
.use(markdown)
.use(remarkExtend, { extendMd })
.use(mdStringify);
const result = await parser.process(input);
expect(result.contents).to.equal(output);
});
it('can replace within a range (start point included, endpoint not)', async () => {
const input = [
'### Red',
'',
'red is the fire',
'',
'red is the cross', // <-- start
'',
'red is the flag',
'',
'#### More Red',
'',
'the sun can get red',
].join('\n');
const extendMd = [
"```js ::replaceBetween('heading:has([value=Red]) ~ paragraph:nth-of-type(2)', 'heading:has([value=More Red])')",
'module.exports.replaceSection = (node) => {',
' if (node.value) {',
" node.value = node.value.replace(/red/g, 'green').replace(/Red/g, 'Green');",
' }',
' return node',
'};',
].join('\n');
const output = [
'<h3>Red</h3>',
'<p>red is the fire</p>',
'<p>green is the cross</p>', // <-- start
'<p>green is the flag</p>',
'<h4>More Red</h4>', // <-- end
'<p>the sun can get red</p>',
'',
].join('\n');
const parser = unified()
//
.use(markdown)
.use(remarkExtend, { extendMd })
.use(mdStringify);
const result = await parser.process(input);
expect(result.contents).to.equal(output);
});
it('throws if a start selector is not found', async () => {
const input = [
//
'### Red',
].join('\n');
const extendMd = [
"```js ::replaceFrom('heading:has([value=More Red])')",
'module.exports.replaceSection = (node) => {}',
'```',
].join('\n');
const parser = unified()
//
.use(markdown)
.use(remarkExtend, { extendMd })
.use(mdStringify);
await expectThrowsAsync(
() => parser.process(input),
'The start selector "heading:has([value=More Red])" could not find a matching node.',
); );
});
it('throws with addition info (if provide as filePath, overrideFilePath) if a start selector is not found', async () => { expect(result).to.equal(
const input = [
//
'### Red',
].join('\n');
const extendMd = [
"```js ::replaceFrom('heading:has([value=More Red])')",
'module.exports.replaceSection = (node) => {}',
'```',
].join('\n');
const parser = unified()
//
.use(markdown)
.use(remarkExtend, {
extendMd,
filePath: '/path/to/input.md',
overrideFilePath: '/path/to/override.md',
})
.use(mdStringify);
await expectThrowsAsync(
() => parser.process(input),
[ [
'The start selector "heading:has([value=More Red])" could not find a matching node.', '<h3>Red</h3>',
'Markdown File: /path/to/input.md', '<p>red is the fire</p>',
'Override File: /path/to/override.md', '<h4>More Red</h4>',
'<p>the sun can get red</p>',
'',
].join('\n'), ].join('\n'),
); );
}); });
it('throws if a end selector is not found', async () => { it('can import another file', async () => {
const input = [ const result = await execute(
// [
'### Red', //
].join('\n'); '### Red',
const extendMd = [ "```js ::import('./fixtures/import-me.md')",
"```js ::replaceBetween('heading:has([value=Red])', 'heading:has([value=Red2])')", '```',
'module.exports.replaceSection = (node) => {}', ].join('\n'),
'```',
].join('\n');
const parser = unified().use(markdown).use(remarkExtend, { extendMd }).use(mdStringify);
await expectThrowsAsync(
() => parser.process(input),
'The end selector "heading:has([value=Red2])" could not find a matching node.',
); );
expect(result).to.equal(['<h3>Red</h3>', '<h1>import me headline</h1>', ''].join('\n'));
}); });
it('throws with addition info (if provide as filePath, overrideFilePath) if a end selector is not found', async () => { it('will rewrite image urls/paths but not links', async () => {
const input = [ const result = await execute(
//
'### Red',
].join('\n');
const extendMd = [
"```js ::replaceBetween('heading:has([value=Red])', 'heading:has([value=Red2])')",
'module.exports.replaceSection = (node) => {}',
'```',
].join('\n');
const parser = unified()
.use(markdown)
.use(remarkExtend, {
extendMd,
filePath: '/path/to/input.md',
overrideFilePath: '/path/to/override.md',
})
.use(mdStringify);
await expectThrowsAsync(
() => parser.process(input),
[ [
'The end selector "heading:has([value=Red2])" could not find a matching node.', //
'Markdown File: /path/to/input.md', '### Static Headline',
'Override File: /path/to/override.md', "```js ::import('./fixtures/import-with-image.md')",
'```',
].join('\n'),
);
expect(result).to.equal(
[
'<h3>Static Headline</h3>',
'<h1>import me headline</h1>',
'<p><img src="fixtures/my.svg" alt="my image">',
'<a href="./import-me.md">link to</a></p>',
'',
].join('\n'), ].join('\n'),
); );
}); });
it('replaces a single node if replacing between the start and end of the same node', async () => { it('can import another file from a start point', async () => {
const input = [ const result = await execute(
// [
'### Red', //
'red', '# Static Headline',
'### Red', "```js ::import('./fixtures/three-sections-red.md', 'heading:has([value=More Red])')",
].join('\n'); '```',
const extendMd = [ ].join('\n'),
"```js ::replaceBetween('heading:has([value=Red]) > text', 'heading:has([value=Red]) > text')", );
'module.exports.replaceSection = (node) => {',
' if (node.value) {',
" node.value = node.value.replace(/red/g, 'green').replace(/Red/g, 'Green');",
' }',
' return node',
'};',
'```',
].join('\n');
const output = [
'<h3>Green</h3>', // text node "Green" == start == end
'<p>red</p>',
'<h3>Red</h3>',
'',
].join('\n');
const parser = unified() expect(result).to.equal(
// [
.use(markdown) '<h1>Static Headline</h1>',
.use(remarkExtend, { extendMd }) '<h2>More Red</h2>',
.use(mdStringify); '<p>the sun can get red</p>',
'<h2>Additional Red</h2>',
const result = await parser.process(input); '<p>the red sea</p>',
'',
expect(result.contents).to.equal(output); ].join('\n'),
});
it('can put something after via "::addMdAfter"', async () => {
const input = [
//
'### Red',
'',
'red is the fire',
].join('\n');
const extendMd = [
'```',
"::addMdAfter('heading:has([value=Red])')",
'```',
'',
'the ocean is blue',
].join('\n');
const output = [
//
'<h3>Red</h3>',
'<p>the ocean is blue</p>',
'<p>red is the fire</p>',
'',
].join('\n');
const parser = unified()
//
.use(markdown)
.use(remarkExtend, { extendMd })
.use(mdStringify);
const result = await parser.process(input);
expect(result.contents).to.equal(output);
});
it('adding stops at the next ::[action]', async () => {
const input = [
//
'### Red',
'',
'red is the fire',
'',
'### More Red',
].join('\n');
const extendMd = [
'```',
"::addMdAfter('heading:has([value=Red])')",
'```',
'',
'the ocean is blue',
'',
'```',
"::addMdAfter('heading:has([value=More Red])')",
'```',
'',
'as in the sun is the ultimate red',
'',
"```js ::replaceBetween('heading:has([value=Red])', 'heading:has([value=Red])')",
'module.exports.replaceSection = (node) => {}',
'```',
'content not part of an add so it gets ignored',
].join('\n');
const output = [
//
'<h3>Red</h3>',
'<p>the ocean is blue</p>',
'<p>red is the fire</p>',
'<h3>More Red</h3>',
'<p>as in the sun is the ultimate red</p>',
'',
].join('\n');
const parser = unified()
//
.use(markdown)
.use(remarkExtend, { extendMd })
.use(mdStringify);
const result = await parser.process(input);
expect(result.contents).to.equal(output);
});
it('will throw if trying to immediate run replacements of added content', async () => {
const input = [
//
'### Red',
'',
].join('\n');
const extendMd = [
'```',
"::addMdAfter('heading:has([value=Red])')",
'```',
'',
'## Blue',
'',
"```js ::replaceFrom('heading:has([value=Blue])')",
'module.exports.replaceSection = (node) => {',
' if (node.value) {',
" node.value = node.value.replace(/Blue/g, 'Yellow');",
' }',
' return node',
'}',
'```',
].join('\n');
const parser = unified()
//
.use(markdown)
.use(remarkExtend, { extendMd })
.use(mdStringify);
await expectThrowsAsync(
() => parser.process(input),
'The start selector "heading:has([value=Blue])" could not find a matching node.',
); );
}); });
it(`can put something right at the top via "::addMdAfter(':root')"`, async () => { it('can import another file from a start to an end point', async () => {
const input = [ const result = await execute(
// [
'### Red', //
].join('\n'); '# Red',
const extendMd = [ "```js ::import('./fixtures/three-sections-red.md', 'heading:has([value=More Red])', 'heading:has([value=More Red]) ~ heading[depth=2]')",
// '```',
'```', ].join('\n'),
"::addMdAfter(':root')", );
'```',
'',
'# New Headline',
].join('\n');
const output = [
//
'<h1>New Headline</h1>',
'<h3>Red</h3>',
'',
].join('\n');
const parser = unified()
//
.use(markdown)
.use(remarkExtend, { extendMd })
.use(mdStringify);
const result = await parser.process(input);
expect(result.contents).to.equal(output); expect(result).to.equal(
['<h1>Red</h1>', '<h2>More Red</h2>', '<p>the sun can get red</p>', ''].join('\n'),
);
}); });
it(`can put something right at the bottom via "::addMdAfter(':scope:last-child')"`, async () => { it('can do replacements on imports', async () => {
const input = [ const result = await execute(
// [
'### Red', //
'', '# Red',
'red is the fire', "```js ::import('./fixtures/three-sections-red.md', 'heading:has([value=More Red])', 'heading:has([value=More Red]) ~ heading[depth=2]')",
].join('\n'); 'module.exports.replaceSection = (node) => {',
const extendMd = [ ' if (node.value) {',
// " node.value = node.value.replace(/red/g, 'green').replace(/Red/g, 'Green');",
'```', ' }',
"::addMdAfter(':scope:last-child')", ' return node;',
'```', '};',
'', '```',
'extra text', ].join('\n'),
].join('\n'); );
const output = [
//
'<h3>Red</h3>',
'<p>red is the fire</p>',
'<p>extra text</p>',
'',
].join('\n');
const parser = unified()
//
.use(markdown)
.use(remarkExtend, { extendMd })
.use(mdStringify);
const result = await parser.process(input);
expect(result.contents).to.equal(output); expect(result).to.equal(
['<h1>Red</h1>', '<h2>More Green</h2>', '<p>the sun can get green</p>', ''].join('\n'),
);
}); });
it('can put something before via "::addMdBefore"', async () => { it('throws if an import file does not exist', async () => {
const input = [ await expectThrowsAsync(() => execute("```js ::import('./fixtures/not-available.md')\n```"), {
// errorMatch: /The import "\.\/fixtures\/not-available.md" in "test-file.md" does not exist\. Resolved to ".*"\.$/,
'### Red', });
'',
'red is the fire',
'',
'### Blue',
].join('\n');
const extendMd = [
'```',
"::addMdBefore('heading:has([value=Blue])')",
'```',
'',
'the ocean is blue',
].join('\n');
const output = [
//
'<h3>Red</h3>',
'<p>red is the fire</p>',
'<p>the ocean is blue</p>',
'<h3>Blue</h3>',
'',
].join('\n');
const parser = unified()
//
.use(markdown)
.use(remarkExtend, { extendMd })
.use(mdStringify);
const result = await parser.process(input);
expect(result.contents).to.equal(output);
}); });
it(`can put something at the end of a "section" via "::addMdBefore('heading:has([value=Red]) ~ heading[depth=3]')"`, async () => { it('throws if an start selector can not be found', async () => {
const input = [ const input =
// "```js ::import('./fixtures/three-sections-red.md', 'heading:has([value=Does not exit])')\n```";
'### Red', await expectThrowsAsync(() => execute(input), {
'', errorMatch: /The start selector "heading:has\(\[value=Does not exit\]\)" could not find a matching node in ".*"\.$/,
'red is the fire', });
'',
'### Blue',
].join('\n');
const extendMd = [
'```',
"::addMdBefore('heading:has([value=Red]) ~ heading[depth=3]')",
'```',
'',
'the sun will be red',
].join('\n');
const output = [
//
'<h3>Red</h3>',
'<p>red is the fire</p>',
'<p>the sun will be red</p>',
'<h3>Blue</h3>',
'',
].join('\n');
const parser = unified()
//
.use(markdown)
.use(remarkExtend, { extendMd })
.use(mdStringify);
const result = await parser.process(input);
expect(result.contents).to.equal(output);
}); });
it(`can put something at the top of the file via '::addMdBefore(":root")'`, async () => { it('throws if an end selector can not be found', async () => {
const input = [ const input =
// "```js ::import('./fixtures/three-sections-red.md', 'heading:has([value=More Red])', 'heading:has([value=Does not exit])')\n```";
'### Red', await expectThrowsAsync(() => execute(input), {
'', errorMatch: /The end selector "heading:has\(\[value=Does not exit\]\)" could not find a matching node in ".*"\./,
'red is the fire', });
'',
'### Blue',
].join('\n');
const extendMd = [
//
'```',
"::addMdBefore(':root')",
'```',
'',
'the ocean is blue',
].join('\n');
const output = [
//
'<p>the ocean is blue</p>',
'<h3>Red</h3>',
'<p>red is the fire</p>',
'<h3>Blue</h3>',
'',
].join('\n');
const parser = unified()
//
.use(markdown)
.use(remarkExtend, { extendMd })
.use(mdStringify);
const result = await parser.process(input);
expect(result.contents).to.equal(output);
}); });
it('can remove from a starting point downward', async () => { it('can import a block (from start headline to next headline same level)', async () => {
const input = [ const result = await execute(
'### Red', [
'', //
'red is the fire', '### Static Headline',
'', "```js ::importBlock('./fixtures/three-sections-red.md', '## More Red')",
'### More Red', // <-- start '```',
'', ].join('\n'),
'the sun can get red', );
].join('\n');
const extendMd = [
//
'```',
"::removeFrom('heading:has([value=More Red])')",
'```',
].join('\n');
const output = [
//
'<h3>Red</h3>',
'<p>red is the fire</p>',
'',
].join('\n');
const parser = unified() expect(result).to.equal(
// [
.use(markdown) //
.use(remarkExtend, { extendMd }) '<h3>Static Headline</h3>',
.use(mdStringify); '<h2>More Red</h2>',
const result = await parser.process(input); '<p>the sun can get red</p>',
'',
expect(result.contents).to.equal(output); ].join('\n'),
);
}); });
it('can remove a range (start point included, endpoint not)', async () => { it('can import the last block (from start headline to end of file)', async () => {
const input = [ const result = await execute(
'### Red', // <-- start [
'', //
'red is the fire', '### Static Headline',
'', "```js ::importBlock('./fixtures/three-sections-red.md', '## Additional Red')",
'### More Red', // <-- end '```',
'', ].join('\n'),
'the sun can get red', );
].join('\n');
const extendMd = [
'```',
`::removeBetween('heading:has([value=Red])', 'heading:has([value=Red]) ~ heading[depth=3]')`,
'```',
].join('\n');
const output = [
'<h3>More Red</h3>', // <-- end
'<p>the sun can get red</p>',
'',
].join('\n');
const parser = unified()
//
.use(markdown)
.use(remarkExtend, { extendMd })
.use(mdStringify);
const result = await parser.process(input);
expect(result.contents).to.equal(output); expect(result).to.equal(
[
//
'<h3>Static Headline</h3>',
'<h2>Additional Red</h2>',
'<p>the red sea</p>',
'',
].join('\n'),
);
}); });
it('does replacements in order of extendMd', async () => { it('can import a block content (from start headline (excluding) to next headline same level)', async () => {
const input = [ const result = await execute(
'### Red', // <-- start [
'', //
'red is the fire', '### Static Headline',
'### More', "```js ::importBlockContent('./fixtures/three-sections-red.md', '## More Red')",
].join('\n'); '```',
const extendMd = [ ].join('\n'),
'```', );
"::removeBetween('heading:has([value=Red]) + *', 'heading:has([value=Red]) ~ heading')",
'```',
"```js ::replaceFrom(':root')",
'module.exports.replaceSection = (node) => {',
' if (node.value) {',
" node.value = node.value.replace(/red/g, 'green').replace(/Red/g, 'Green');",
' }',
' return node;',
'};',
'```',
"```js ::replaceFrom(':root')",
'module.exports.replaceSection = (node) => {',
' if (node.value) {',
" node.value = node.value.replace(/green/g, 'yellow').replace(/Green/g, 'Yellow');",
' }',
' return node;',
'};',
'```',
'```',
"::addMdAfter('heading:has([value=Yellow])')",
'```',
'This is added',
].join('\n');
const output = [
'<h3>Yellow</h3>', // <-- start
'<p>This is added</p>',
'<h3>More</h3>',
'',
].join('\n');
const parser = unified() expect(result).to.equal(
// [
.use(markdown) //
.use(remarkExtend, { extendMd }) '<h3>Static Headline</h3>',
.use(mdStringify); '<p>the sun can get red</p>',
const result = await parser.process(input); '',
].join('\n'),
);
});
expect(result.contents).to.equal(output); it('can import a small block (from start headline to next headline of any level)', async () => {
const result = await execute(
[
//
'### Static Headline',
"```js ::importSmallBlock('./fixtures/three-sections-red.md', '# Red')",
'```',
].join('\n'),
);
expect(result).to.equal(
[
//
'<h3>Static Headline</h3>',
'<h1>Red</h1>',
'<p>red is the fire</p>',
'',
].join('\n'),
);
});
it('can import a small block content (from start headline (excluding) to next headline of any level)', async () => {
const result = await execute(
[
//
'### Static Headline',
"```js ::importSmallBlockContent('./fixtures/three-sections-red.md', '# Red')",
'```',
].join('\n'),
);
expect(result).to.equal(
[
//
'<h3>Static Headline</h3>',
'<p>red is the fire</p>',
'',
].join('\n'),
);
});
it('resolves imports via node resolution', async () => {
// NOTE: this test can easily break as it reads content from a 3rd. party package
// If that content changes the test should be adjusted
const result = await execute(
[
//
'### Static Headline',
"```js ::importBlock('unified/readme.md', '## Install')",
'```',
].join('\n'),
);
expect(result).to.equal(
[
'<h3>Static Headline</h3>',
'<h2>Install</h2>',
'<p>[npm][]:</p>',
'<pre><code class="language-sh">npm install unified',
'</code></pre>',
'<p>This package comes with types.',
'If youre using TypeScript, make sure to also install',
'[<code>@types/unist</code>][ts-unist].</p>',
'',
].join('\n'),
);
});
it('throws if importBlock is used not with headlines', async () => {
const input =
"```js ::importBlock('./fixtures/three-sections-red.md', 'heading:has([value=More Red])')\n```";
await expectThrowsAsync(() => execute(input), {
errorMessage:
'::importBlock only works for headlines like "## My Headline" but "heading:has([value=More Red])" was given',
});
});
it('can import multiple files', async () => {
const result = await execute(
[
//
'### Static Headline',
"```js ::importBlock('./fixtures/three-sections-red.md', '## More Red')",
'```',
'### Another Static Headline',
"```js ::import('./fixtures/import-me.md')",
'```',
].join('\n'),
);
expect(result).to.equal(
[
//
'<h3>Static Headline</h3>',
'<h2>More Red</h2>',
'<p>the sun can get red</p>',
'<h3>Another Static Headline</h3>',
'<h1>import me headline</h1>',
'',
].join('\n'),
);
}); });
}); });