Last active
March 16, 2020 18:34
-
-
Save dinocarl/70c7d0539699076a078a570195f67f20 to your computer and use it in GitHub Desktop.
Adding kramdown-style attributes to remark
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} | |
[{: height=100 width=100 }](/){: .rspnsv-img data-track="logo click" } | |
[{: 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