Skip to content

Instantly share code, notes, and snippets.

@dinocarl
Last active March 16, 2020 18:34
Show Gist options
  • Save dinocarl/70c7d0539699076a078a570195f67f20 to your computer and use it in GitHub Desktop.
Save dinocarl/70c7d0539699076a078a570195f67f20 to your computer and use it in GitHub Desktop.
Adding kramdown-style attributes to remark
const remark = require('remark');
const html = require('remark-html');
const visit = require('unist-util-visit');
const { log } = console;
const inputMD = `
[A Link](#){: data-track="footer-link" .xclass onclick=alert() } with text that has *some*{: .emphclass } emphasis and something **strong**{: .strngclss}
[![github logo](https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png){: height=100 width=100 }](/){: .rspnsv-img data-track="logo click" }
[![github logo](https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png){: height=100 width=100 }](/)
{: .paraclass }
# title **text**{: .emphclass } with classes on mutliple elements
{: .paraclass }
`;
// some utility functions
const compose = (...fnList) => data => fnList.reduceRight((val, fn) => fn(val), data);
const match = (re) => (str) => str.match(re);
const testFor = (re) => (str) => re.test(str);
const replacesWith = (replaceVal) => (findRE) => (str) => str.replace(findRE, replaceVal);
const removeFromStr = replacesWith('');
const stripQuotes = removeFromStr(/['"]+/g);
const split = (sep) => (str) => str.split(sep);
const map = (fn) => (list) => list.map(fn);
const filter = (fn) => (list) => list.filter(fn);
const join = (glue) => (list) => list.join(glue);
const nth = (pos) => (arr) => arr[pos];
const last = (arr) => nth(arr.length - 1)(arr);
// works for strings and arrays
const includes = (needle) => (haystack) => (haystack.indexOf(needle) > -1);
const includesAtPos = (needle) => (position) => (haystack) => (haystack.indexOf(needle) === position);
// reduce doesn't work with Object.assign. Use apply syntax for this special case
const reduceAssign = (arr) => Object.assign.apply(null, [{}].concat(arr));
const fromPairs = ([a, b]) => ({[a]: stripQuotes(b)});
const and = (...fnList) => (data) => fnList.every((fn) => fn(data));
// domain-specific fns
const concatClassOrIdStrs = compose(
join(' '),
map((str) => str.slice(1))
);
const attributeStrToObj = compose(
fromPairs,
split('=')
);
const indicatesID = includesAtPos('#')(0);
const indicatesClass = includesAtPos('.')(0);
const indicatesSafeAttr = and(
includes('='),
testFor(/^(?!on)/)
);
// parseAttrStr :: String -> Object
// eg {: #id .class1 .class2 key="some val" } => {id: 'id', className: 'class1 class2', key: 'some val'}
const parseAttrStr = (openDelimiter) => (closeDelimiter) => (str) => {
// get attributes by selecting for
// text with whitespace around it,
// but remove the delimiters first
let attrs = compose(
match(/([^" ]+("[^"]*"))|([^" ]+|("[^"]*"))/g),
removeFromStr(closeDelimiter),
removeFromStr(openDelimiter),
)(str);
let ids = filter(indicatesID)(attrs);
let classes = filter(indicatesClass)(attrs);
let tagAttrs = filter(indicatesSafeAttr)(attrs);
let idObj = ids.length > 0
? { id: concatClassOrIdStrs(ids) }
: {};
let classesObj = classes.length > 0
? { className: concatClassOrIdStrs(classes) }
: {};
let attrsObj = reduceAssign(map(attributeStrToObj)(tagAttrs));
return reduceAssign([idObj, classesObj, attrsObj]);
};
// update as needed. Full list available at
// https://github.com/rexxars/react-markdown#node-types
const permittedNodes = [
'heading',
'link',
'paragraph',
'image',
'emphasis',
'strong',
'list',
'listItem',
];
const kramdownAttrs = (delimiters) => (tree) => {
visit(tree, permittedNodes, (node, idx, parentArr) => {
let {
openDelimiter = '{:',
closeDelimiter = '}'
} = delimiters || {};
if (node && node.children) {
node.children.forEach(
(child, childIdx, childrenArr) => {
if (child.type === 'text') {
let initValue = child.value;
let firstPassValue = child.value;
// get attributes string
let attributeRegEx = `${openDelimiter}([^]+?)${closeDelimiter}`;
let attributeSimpleSelector = new RegExp(attributeRegEx, 'g');
let attributeNewLineSelector = new RegExp(`(\r|\n)${attributeRegEx}`);
let newLineMatch = match(attributeNewLineSelector)(initValue);
// deal with newline matches first
// this is for when there is an attribute string
// that immediately follows another string or node.
// the intent there is to modify the preceding node
if (newLineMatch) {
let [fullMatch] = newLineMatch;
let dataObj = parseAttrStr('\n' + openDelimiter)(closeDelimiter)(fullMatch);
if (Object.keys(dataObj).length > 0) {
// update the value of the node that had the attributes
// with that attributes string excised
firstPassValue = removeFromStr(fullMatch)(initValue);
child.value = firstPassValue;
// add the newly acquired attributes to `node`
// but only when it's in the permittedNodes list
if (includes(node.type)(permittedNodes)) {
if (!node.data) {
node.data = {};
}
if (!node.data.hProperties) {
node.data.hProperties = {};
}
Object.assign(node.data, dataObj);
Object.assign(node.data.hProperties, dataObj);
}
}
}
let matched = match(attributeSimpleSelector)(firstPassValue);
// then deal with the conventional case
if (matched) {
let updatedStringValue = firstPassValue;
matched.forEach((fullMatch) => {
let dataObj = parseAttrStr(openDelimiter)(closeDelimiter)(fullMatch);
// update the appropriate node with all the converted values in dataObj
if (Object.keys(dataObj).length > 0) {
// figure out who to update
let prevChild = nth(childIdx - 1)(childrenArr);
let targetNode = prevChild || node;
// update the value of the node that had the attributes
// with that attributes string excised
updatedStringValue = replacesWith(' ')(fullMatch)(updatedStringValue);
child.value = updatedStringValue;
// attribute strings can be on their own lines when
// they're intended to modify block tags, like h tags.
// if they are, remark creates paragraph containers for them.
// their content just got emptied out above so this will
// generate an empty p tag. modify the parent node to nullify rendering.
if (updatedStringValue === ' ' && node.type === 'paragraph' && node.children.length === 1) {
node.type = 'text';
node.value = ' ';
node.children = undefined;
// the targetNode also needs to be updated
// unless it happens to be the very first thing
if (idx > 0) {
targetNode = parentArr.children[idx - 1];
}
}
// add the newly acquired attributes to
// correctly identified target node
// but only when it's in the permittedNodes list
if (includes(targetNode.type)(permittedNodes)) {
if (!targetNode.data) {
targetNode.data = {};
}
if (!targetNode.data.hProperties) {
targetNode.data.hProperties = {};
}
Object.assign(targetNode.data, dataObj);
Object.assign(targetNode.data.hProperties, dataObj);
}
}
});
}
}
}
);
}
return node;
});
return tree;
};
remark()
//.use(kramdownAttrs, { openDelimiter: '{:' })
.use(kramdownAttrs)
.use(html)
.process(inputMD, (err, file) => {
if (err) throw err
log(String(file))
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment