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.
`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',
};
```
[=> See Source <=](../../docs/docs/node-tools/remark-extend/overview.md)

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"
],
"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:node": "mocha test-node",
"test:watch": "mocha test-node --watch"
},
"dependencies": {
"remark-parse": "^8.0.0",
"unified": "^9.0.0",
"remark-parse": "^9.0.0",
"unified": "^9.2.0",
"unist-util-is": "^4.0.2",
"unist-util-select": "^3.0.1",
"unist-util-visit": "^2.0.2"
@ -36,5 +37,8 @@
"publishConfig": {
"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 markdown = require('remark-parse');
const is = require('unist-util-is');
function addTask(file, newAction) {
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 = [];
}
};
}
const fs = require('fs');
const path = require('path');
/**
* Allows to execute an actual node module code block.
@ -157,88 +22,184 @@ function findExtendTasks() {
* @param {*} filename
*/
function requireFromString(src, filename = 'tmp.js') {
const srcWithPath = `const path = require('path');\n${src}`;
const m = new module.constructor();
m.paths = module.paths;
m._compile(src, filename);
m._compile(srcWithPath, filename);
return m.exports;
}
function handleAdditions(tree, action, startIsNode, addNodes) {
let toInsertNodes = [];
function handleImportedFile({
startSelector,
endSelector,
userFunction,
filePath,
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)) {
if (action === 'addMdAfter') {
if (node.type === 'root') {
node.children.splice(0, 0, ...addNodes);
} else {
parent.children.splice(index + 1, 0, ...addNodes);
}
}
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);
insertIt = true;
}
if (endIsNode && is(node, endIsNode)) {
insertIt = false;
}
if (insertIt) {
if (userFunction) {
node = userFunction(node, { index, parent, tree });
}
}
if (insertIt && parent && parent.type === 'root') {
toInsertNodes.push(node);
}
if (node.type === 'image') {
node.url = path.join(path.dirname(fileImport), node.url);
}
});
};
}
function handleReplacements(tree, action, startIsNode, endIsNode, jsCode) {
let doReplacements = false;
let resetAtEnd = false;
// unified expect direct
// eslint-disable-next-line consistent-return
function remarkExtend({ rootDir = process.cwd(), page } = {}) {
return tree => {
visit(tree, (node, index, parent) => {
if (
node.type === 'code' &&
node.lang === 'js' &&
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());
let filePath;
let missingEndSelectorMeansUntilEndOfFile = false;
try {
filePath = require.resolve(fileImport);
} catch (err) {
filePath = path.resolve(path.join(rootDir, fileImport));
}
if (!fs.existsSync(filePath)) {
const inputPath = page ? page.inputPath : 'no page.inputPath given';
throw new Error(
`The import "${fileImport}" in "${inputPath}" does not exist. Resolved to "${filePath}".`,
);
}
const importFileContent = fs.readFileSync(filePath);
if (
node.meta.startsWith('::importBlock(') ||
node.meta.startsWith('::importBlockContent(') ||
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 */
}
}
let userFunction;
if (action === 'replaceFrom' || action === 'replaceBetween') {
const virtualMod = requireFromString(jsCode);
if (node.value !== '') {
const inputPath = page ? page.inputPath : 'no page.inputPath given';
const resolvedInputPath = path.resolve(inputPath);
if (!resolvedInputPath) {
throw new Error(
`The page.inputPath "${inputPath}" could not be resolved. Tried to resolve with "${resolvedInputPath}".`,
);
}
const virtualMod = requireFromString(node.value, resolvedInputPath);
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
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) => {
@ -248,79 +209,8 @@ function handleRemovals(tree, action, startIsNode, endIsNode) {
},
true,
);
}
// unified expect direct
// eslint-disable-next-line consistent-return
function remarkExtend(options) {
if (options.extendMd) {
const parser = unified()
.use(markdown)
.use(findExtendTasks)
.use(function plugin() {
this.Compiler = () => '';
});
const changes = parser.processSync(options.extendMd);
const extensionTasks = changes.data.remarkExtend;
if (!extensionTasks) {
return tree => tree;
}
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 (action === 'replaceBetween' || action === 'removeBetween') {
const end = select(endSelector, tree);
if (!end) {
const msg = [
`The end selector "${endSelector}" 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);
}
endIsNode = { ...end };
delete endIsNode.children; // unified is comparison does not support children
}
switch (action) {
case 'addMdAfter':
case 'addMdBefore':
handleAdditions(tree, action, startIsNode, addNodes);
break;
case 'replaceFrom':
case 'replaceBetween':
handleReplacements(tree, action, startIsNode, endIsNode, jsCode);
break;
case 'removeFrom':
case 'removeBetween':
handleRemovals(tree, action, startIsNode, endIsNode);
break;
/* no default */
}
}
return tree;
};
}
}
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');
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;
try {
await method();
@ -14,648 +20,118 @@ async function expectThrowsAsync(method, errorMessage) {
error = err;
}
expect(error).to.be.an('Error', 'No error was thrown');
if (errorMatch) {
expect(error.message).to.match(errorMatch);
}
if (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', () => {
it('does no modifications if no action is found', async () => {
const input = [
'### Red',
'',
'red is the fire',
'',
'#### More Red',
'',
'the sun can get red',
].join('\n');
const extendMd = '';
const output = [
const result = await execute(
['### Red', 'red is the fire', '#### More Red', 'the sun can get red'].join('\n'),
);
expect(result).to.equal(
[
'<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 () => {
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.',
'Markdown File: /path/to/input.md',
'Override File: /path/to/override.md',
].join('\n'),
);
});
it('throws if a end selector is not found', async () => {
const input = [
it('can import another file', async () => {
const result = await execute(
[
//
'### Red',
].join('\n');
const extendMd = [
"```js ::replaceBetween('heading:has([value=Red])', 'heading:has([value=Red2])')",
'module.exports.replaceSection = (node) => {}',
"```js ::import('./fixtures/import-me.md')",
'```',
].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.',
].join('\n'),
);
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 () => {
const input = [
//
'### 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),
it('will rewrite image urls/paths but not links', async () => {
const result = await execute(
[
'The end selector "heading:has([value=Red2])" could not find a matching node.',
'Markdown File: /path/to/input.md',
'Override File: /path/to/override.md',
//
'### Static Headline',
"```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'),
);
});
it('replaces a single node if replacing between the start and end of the same node', async () => {
const input = [
it('can import another file from a start point', async () => {
const result = await execute(
[
//
'### Red',
'red',
'### Red',
].join('\n');
const extendMd = [
"```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',
'};',
'# Static Headline',
"```js ::import('./fixtures/three-sections-red.md', 'heading:has([value=More Red])')",
'```',
].join('\n');
const output = [
'<h3>Green</h3>', // text node "Green" == start == end
'<p>red</p>',
'<h3>Red</h3>',
'',
].join('\n');
].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 after via "::addMdAfter"', async () => {
const input = [
//
'### Red',
expect(result).to.equal(
[
'<h1>Static Headline</h1>',
'<h2>More Red</h2>',
'<p>the sun can get red</p>',
'<h2>Additional Red</h2>',
'<p>the red sea</p>',
'',
'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.',
].join('\n'),
);
});
it(`can put something right at the top via "::addMdAfter(':root')"`, async () => {
const input = [
//
'### Red',
].join('\n');
const extendMd = [
it('can import another file from a start to an end point', async () => {
const result = await execute(
[
//
'# Red',
"```js ::import('./fixtures/three-sections-red.md', 'heading:has([value=More Red])', 'heading:has([value=More Red]) ~ heading[depth=2]')",
'```',
"::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);
].join('\n'),
);
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 () => {
const input = [
it('can do replacements on imports', async () => {
const result = await execute(
[
//
'### Red',
'',
'red is the fire',
].join('\n');
const extendMd = [
//
'```',
"::addMdAfter(':scope:last-child')",
'```',
'',
'extra text',
].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);
});
it('can put something before via "::addMdBefore"', async () => {
const input = [
//
'### 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 () => {
const input = [
//
'### Red',
'',
'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 () => {
const input = [
//
'### Red',
'',
'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 () => {
const input = [
'### Red',
'',
'red is the fire',
'',
'### More Red', // <-- start
'',
'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()
//
.use(markdown)
.use(remarkExtend, { extendMd })
.use(mdStringify);
const result = await parser.process(input);
expect(result.contents).to.equal(output);
});
it('can remove a range (start point included, endpoint not)', async () => {
const input = [
'### Red', // <-- start
'',
'red is the fire',
'',
'### More Red', // <-- end
'',
'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);
});
it('does replacements in order of extendMd', async () => {
const input = [
'### Red', // <-- start
'',
'red is the fire',
'### More',
].join('\n');
const extendMd = [
'```',
"::removeBetween('heading:has([value=Red]) + *', 'heading:has([value=Red]) ~ heading')",
'```',
"```js ::replaceFrom(':root')",
'# Red',
"```js ::import('./fixtures/three-sections-red.md', 'heading:has([value=More Red])', 'heading:has([value=More Red]) ~ heading[depth=2]')",
'module.exports.replaceSection = (node) => {',
' if (node.value) {',
" node.value = node.value.replace(/red/g, 'green').replace(/Red/g, 'Green');",
@ -663,33 +139,198 @@ describe('remarkExtend', () => {
' 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');
].join('\n'),
);
const parser = unified()
expect(result).to.equal(
['<h1>Red</h1>', '<h2>More Green</h2>', '<p>the sun can get green</p>', ''].join('\n'),
);
});
it('throws if an import file does not exist', async () => {
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 ".*"\.$/,
});
});
it('throws if an start selector can not be found', async () => {
const input =
"```js ::import('./fixtures/three-sections-red.md', 'heading:has([value=Does not exit])')\n```";
await expectThrowsAsync(() => execute(input), {
errorMatch: /The start selector "heading:has\(\[value=Does not exit\]\)" could not find a matching node in ".*"\.$/,
});
});
it('throws if an end selector can not be found', async () => {
const input =
"```js ::import('./fixtures/three-sections-red.md', 'heading:has([value=More Red])', 'heading:has([value=Does not exit])')\n```";
await expectThrowsAsync(() => execute(input), {
errorMatch: /The end selector "heading:has\(\[value=Does not exit\]\)" could not find a matching node in ".*"\./,
});
});
it('can import a block (from start headline to next headline same level)', async () => {
const result = await execute(
[
//
.use(markdown)
.use(remarkExtend, { extendMd })
.use(mdStringify);
const result = await parser.process(input);
'### Static Headline',
"```js ::importBlock('./fixtures/three-sections-red.md', '## More Red')",
'```',
].join('\n'),
);
expect(result.contents).to.equal(output);
expect(result).to.equal(
[
//
'<h3>Static Headline</h3>',
'<h2>More Red</h2>',
'<p>the sun can get red</p>',
'',
].join('\n'),
);
});
it('can import the last block (from start headline to end of file)', async () => {
const result = await execute(
[
//
'### Static Headline',
"```js ::importBlock('./fixtures/three-sections-red.md', '## Additional Red')",
'```',
].join('\n'),
);
expect(result).to.equal(
[
//
'<h3>Static Headline</h3>',
'<h2>Additional Red</h2>',
'<p>the red sea</p>',
'',
].join('\n'),
);
});
it('can import a block content (from start headline (excluding) to next headline same level)', async () => {
const result = await execute(
[
//
'### Static Headline',
"```js ::importBlockContent('./fixtures/three-sections-red.md', '## More Red')",
'```',
].join('\n'),
);
expect(result).to.equal(
[
//
'<h3>Static Headline</h3>',
'<p>the sun can get red</p>',
'',
].join('\n'),
);
});
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'),
);
});
});