/* * md ast - pluggable markdown parser */ const getSecondsFromTimeCode = timeCode => { let times = timeCode.split(':').map(x => +x).filter(x => !isNaN(x)) return times.length === 2 ? times[0]*60 + times[1] : (times.length === 3 ? times[0]*60*60 + times[1]*60 + times[2] : NaN ) } const getTimeCodeFromSeconds = seconds => { let hours = Math.floor(seconds / 3600) let minutes = Math.floor((seconds % 3600) / 60) let secs = Math.floor((seconds % 3600) % 60) return `${hours}:${minutes}:${secs}` } const getTimeCodes = ast =>{ let timeCodes = {} const walker = node => ((node.tag && node.tag.startsWith('heading') && (timeCodes[node.startMatch[4] || node.startMatch[3]] = node.startMatch[3])), (node.children instanceof Array && node.children.forEach(walker))) walker(ast) delete timeCodes.undefined let result = {} for (let timeCode in timeCodes){ result[getSecondsFromTimeCode(timeCode)] = timeCodes[timeCode] } return result; } const syntax = { img:{ paired: true, recursive: false, startRegexp: /!(\S*)\[(.*)\]\(/, endRegexp: /\)/, content: { start: { point: 'end', offset: 0 }, end: { point: 'start', offset: 0 } }, begin: 0, forward: { point: 'end', //start, startEnd, end, endEnd offset: 1 }, title: { index: 2, recursive: false, }, onbuild(md, mdTags, buildAST){ //this = {tag: } } }, a:{ paired: true, recursive: false, startRegexp: /\[(.*)\]\(/, endRegexp: /\)/, content: { start: { point: 'end', offset: 0 }, end: { point: 'start', offset: 0 } }, begin: 0, forward: { point: 'end', //start, startEnd, end, endEnd offset: 1 }, title: { //index: 1, recursive: true, }, onbuild(md, mdTags, buildAST){ //this = {tag: } } }, strike: { paired: true, recursive: true, startRegexp: /\~\~\S.*/, endRegexp: /\~\~\W/, content: { start: { point: 'start', offset: 2 }, end: { point: 'start', offset: 0 } }, begin: 0, forward: { point: 'endEnd', //start, startEnd, end, endEnd offset: -1 } }, bold1: { paired: true, recursive: true, startRegexp: /\*\*\S.*/, endRegexp: /\*\*\W/, content: { start: { point: 'start', offset: 2 }, end: { point: 'start', offset: 0 } }, begin: 0, forward: { point: 'endEnd', //start, startEnd, end, endEnd offset: -1 } }, bold2: { paired: true, recursive: true, startRegexp: /\s__\S.*/, endRegexp: /__\W/, content: { start: { point: 'start', offset: 3 }, end: { point: 'start', offset: 0 } }, begin: 1, forward: { point: 'endEnd', //start, startEnd, end, endEnd offset: -1 } }, italic1: { paired: true, recursive: true, startRegexp: /\*\S.*/, endRegexp: /\S\*[^*]/, content: { start: { point: 'start', offset: 1 }, end: { point: 'start', offset: 1 } }, begin: 0, forward: { point: 'endEnd', //start, startEnd, end, endEnd offset: -1 } }, italic2: { paired: true, recursive: true, startRegexp: /\s_\S.*/, endRegexp: /\S_\W/, content: { start: { point: 'start', offset: 2 }, end: { point: 'start', offset: 1 } }, begin: 1, forward: { point: 'endEnd', //start, startEnd, end, endEnd offset: -1 } }, root: { paired: true, recursive: true, startRegexp: /^/, endRegexp: /$/, content: { start: { point: 'start', offset: 1 }, end: { point: 'end', offset: 0 } }, begin: 0, forward: { point: 'endEnd', //start, startEnd, end, endEnd offset: -1 } }, heading6: { paired: true, recursive: true, startRegexp: /\n######[ \t]*([^\[\n]*)(\[#(\S*)[ \t]*(.*)\])?\n/, endRegexp: /\n#+/, content: { start: { point: 'end', offset: -1 }, end: { point: 'start', offset: 0 } }, begin: 0, forward: { point: 'end', //start, startEnd, end, endEnd offset: 0 }, title: { //index: 1, recursive: true, }, onbuild(md, mdTags, buildAST){ //this = {tag: } } }, heading5: { paired: true, recursive: true, startRegexp: /\n#####[ \t]*([^\[\n]*)(\[#(\S*)[ \t]*(.*)\])?\n/, endRegexp: /\n#{1,5}\s/, content: { start: { point: 'end', offset: -1 }, end: { point: 'start', offset: 0 } }, begin: 0, forward: { point: 'end', //start, startEnd, end, endEnd offset: 0 }, title: { //index: 1, recursive: true, }, onbuild(md, mdTags, buildAST){ //this = {tag: } } }, heading4: { paired: true, recursive: true, startRegexp: /\n####[ \t]*([^\[\n]*)(\[#(\S*)[ \t]*(.*)\])?\n/, endRegexp: /\n#{1,4}\s/, content: { start: { point: 'end', offset: -1 }, end: { point: 'start', offset: 0 } }, begin: 0, forward: { point: 'end', //start, startEnd, end, endEnd offset: 0 }, title: { //index: 1, recursive: true, }, onbuild(md, mdTags, buildAST){ //this = {tag: } } }, heading3: { paired: true, recursive: true, startRegexp: /\n###[ \t]*([^\[\n]*)(\[#(\S*)[ \t]*(.*)\])?\n/, endRegexp: /\n#{1,3}\s/, content: { start: { point: 'end', offset: -1 }, end: { point: 'start', offset: 0 } }, begin: 0, forward: { point: 'end', //start, startEnd, end, endEnd offset: 0 }, title: { //index: 1, recursive: true, }, onbuild(md, mdTags, buildAST){ //this = {tag: } } }, heading2: { paired: true, recursive: true, startRegexp: /\n##[ \t]*([^\[\n]*)(\[#(\S*)[ \t]*(.*)\])?\n/, endRegexp: /\n#{1,2}\s/, content: { start: { point: 'end', offset: -1 }, end: { point: 'start', offset: 0 } }, begin: 0, forward: { point: 'end', //start, startEnd, end, endEnd offset: 0 }, title: { //index: 1, recursive: true, }, onbuild(md, mdTags, buildAST){ //this = {tag: } } }, heading1: { paired: true, recursive: true, startRegexp: /\n#[ \t]*([^\[\n]*)(\[#(\S*)[ \t]*(.*)\])?\n/, endRegexp: /\n#\s/, content: { start: { point: 'end', offset: -1 }, end: { point: 'start', offset: 0 } }, begin: 0, forward: { point: 'end', //start, startEnd, end, endEnd offset: 0 }, title: { //index: 1, recursive: true, }, onbuild(md, mdTags, buildAST){ //this = {tag: } } }, code: { paired: true, recursive: false, startRegexp: /`/, endRegexp: /`/, content: { start: { point: 'start', offset: 1 }, end: { point: 'start', offset: 0 } }, begin: 0, forward: { point: 'end', //start, startEnd, end, endEnd offset: 1 } }, codeMultiLine: { paired: true, recursive: false, startRegexp: /\n```\s*\n/, endRegexp: /\n```[ \t]*\n/, content:{ start:{ point: 'end', offset: 0 }, end:{ point: 'start', offset: 0 } }, begin: 1, forward: { point: 'endEnd', offset: -1 }, }, codeLanguage: { paired: true, recursive: false, startRegexp: /\n```(\w+)\s*\n/, endRegexp: /\n```[ \t]*\n/, title: { recursive: false }, content:{ start:{ point: 'end', offset: 0 }, end:{ point: 'start', offset: 0 } }, begin: 1, forward: { point: 'endEnd', offset: -1 }, }, unOrderedList: { indent: true, childName: 'unOrderedListItem', //paired: true, recursive: true, regexp: /-\s*\S/, content:{ start:{ point: 'end', offset: -1 }, end:{ point: 'start', offset: 0 } }, begin: 1, forward: { point: 'end', offset: 0 } }, orderedList: { indent: true, childName: 'orderedListItem', //paired: true, recursive: true, regexp: /\d+\.\s+\S/, content:{ start:{ point: 'end', offset: -1 }, end:{ point: 'start', offset: 0 } }, begin: 1, forward: { point: 'end', offset: 0 } }, p:{ paired: true, recursive: true, startRegexp: /\n\s*\S/, endRegexp: /\n/, content: { start: { point: 'start', offset: 1 }, end: { point: 'start', offset: 0 } }, begin: 1, forward: { point: 'end', //start, startEnd, end, endEnd offset: 0 }, }, } const indentRegexp = (regexp,count) => new RegExp(`\\n([ \\t]${count === undefined ? '*' : `{${count}}` })` + regexp.toString().slice(1,-1)) const indentEndRegexp = (count) => new RegExp(`\\n([ \\t]${count === undefined ? '*' : `{0,${count}}` })\\S`) function findNearest(md, mdTags, offset=0){ let nearest, nearestMatch = {index: Infinity}; for (let [mdTag, {paired, startRegexp, regexp, indent}] of Object.entries(mdTags)) { if (mdTag === 'root') continue; regexp = startRegexp || regexp regexp = indent ? indentRegexp(regexp) : regexp let match = md.offsetMatch(offset, regexp) if (match && match.index < nearestMatch.index){ nearestMatch = match nearest = mdTag } } return [nearest, nearestMatch] } //node: //{ // tag: 'keyFromSyntax', // children: [String, Node] // parent: node //} // String.prototype.offsetMatch = function(offset, ...params){ return this.slice(offset).match(...params) } Array.prototype.last = function(amount=-1){ return this[this.length +amount] } String.prototype.cutIndent = function(indent){ let lines = this.split('\n').map(line => line.slice(0, indent).match(/^\s*$/) ? line.slice(indent) : line) return lines.join('\n') } function buildAST(md, mdTags=syntax, offset=0, tree={tag: 'root'}, stack=[]){ let currentNode = stack.last() || tree if (currentNode.tag === 'root') md = '\n' + md + '\n' currentNode.children = currentNode.children || [] const { children } = currentNode let {indent, childName, title, recursive, regexp, endRegexp, content: {end: {offset: offsetEnd, point} }, forward } = mdTags[currentNode.tag] if (indent){ if (currentNode.parent.tag !== currentNode.tag){ let { parent: {children: siblings} } = currentNode //current node are ol/ul if (siblings.length > 1 && siblings.last(-2).tag === currentNode.tag){ //two ol/ul one after another should be concated as li siblings.pop() //so remove last ol/ul currentNode = siblings.last() //make previous ol/ul currentNode to add new LI into previous ol/li } const { children } = currentNode const indentLength = currentNode.startMatch[1].length console.log(indentLength) currentNode.indentLength = indentLength endRegexp = indentEndRegexp(indentLength) let endMatch = md.offsetMatch(offset, endRegexp) || {index: md.length +1, 0: 'zzz'} let listMD = md.slice(offset, endMatch.index + offset).cutIndent(currentNode.startMatch[0].length -2) const newNode = {tag: childName, startOffset: offset, parent: currentNode, startMatch: currentNode.startMatch} children.push(newNode) //create LI and add it into children of ol/ul newNode.children = buildAST(listMD, mdTags).children newNode.children.forEach(item => ((typeof item === 'object') && (item.parent = currentNode))) offset = newNode.endOffset = currentNode.endOffset = endMatch.index + offset } } if (title){ const {index=1, recursive} = title const {[index]: titleContent } = currentNode.startMatch if (titleContent && recursive){ currentNode.title = buildAST(titleContent, mdTags).children currentNode.title.forEach(item => ((typeof item === 'object') && (item.parent = currentNode))) } else { currentNode.title = [titleContent] } } while(offset < md.length){ const [nearest, nearestMatch] = findNearest(md, mdTags, offset) let endMatch = md.offsetMatch(offset, endRegexp) if (!recursive || endMatch) { //if we (should) find closing tag if (!recursive || !nearest || endMatch.index <= nearestMatch.index ){ //if closing tag closer than new nested tag endMatch = endMatch || {index: md.length - offset, 0: "zzz"} currentNode.endContent = offset + endMatch.index + offsetEnd + (point === 'end' ? endMatch[0].length : 0) offset !== currentNode.endContent && children.push(md.slice(offset, currentNode.endContent)) offset += endMatch.index + forward.offset + (forward.point === 'endEnd' ? endMatch[0].length : 0) currentNode.endOffset = offset currentNode.endMatch = endMatch return currentNode } } if (nearest){ //new nested tag const {begin,content: {start}} = mdTags[nearest] if (nearestMatch.index){ //if just text before nested tag nearestMatch.index + begin > 0 && children.push(md.slice(offset, offset + nearestMatch.index + begin)) offset += nearestMatch.index } else { //if new tag right under cursor (offset) let newNode = {tag: nearest, startOffset: offset, parent: currentNode, startMatch: nearestMatch} children.push(newNode) newNode = buildAST(md, mdTags, offset + start.offset + (start.point === 'end' ? nearestMatch[0].length : 0), tree, [...stack, newNode]) offset = newNode.endOffset } } else { //no nearest - rest of line to children as text children.push(md.slice(offset)) offset = md.length } } return currentNode } const Heading = ({react:React, children, title, node: {tag, startMatch}}) => { const level = +tag.slice(-1) const _ = React.createElement.bind(React) const [m, m1,m2, id, rest] = startMatch if (isNaN(level)) throw new SyntaxError('wrong heading name') return _(React.Fragment, null, _(`h${level}`, id && {id}, ...title), _(`div`, null, ...children) ) } const A = ({react:React, children, title}) =>{ const _ = React.createElement.bind(React) return _("a", {children: title, href: children}) } const Img = ({react:React, children, title, node}) =>{ const _ = React.createElement.bind(React) return _("img", {alt: title, src: children, className: node.startMatch[1] || undefined}) } const defaultMapMDToComponents = { heading1: Heading, heading2: Heading, heading3: Heading, heading4: Heading, heading5: Heading, heading6: Heading, strike: "strike", bold1: "strong", bold2: "strong", p: "p", a: A, img: Img, italic1: "i", italic2: "i", unOrderedList: 'ul', orderedList: 'ol', unOrderedListItem: 'li', orderedListItem: 'li', code: 'code', codeMultiLine: 'pre', codeLanguage: 'pre', root: "" } function toReact(ast, React, mapMDToComponents=defaultMapMDToComponents){ mapMDToComponents = {...defaultMapMDToComponents, ...mapMDToComponents} const gC = (tag, c) => (c = mapMDToComponents[tag]) ? c : (c === "" ? React.Fragment : "span") const RenderComponent = gC(ast.tag) const _ = React.createElement.bind(React) const childToReact = child => typeof child === 'string' ? child : toReact(child, React, mapMDToComponents) const props = {key: Math.random(), children: ast.children && ast.children.map(childToReact)} if (typeof RenderComponent !== 'string' && RenderComponent !== React.Fragment) Object.assign(props,{node: ast, title: ast.title && ast.title.map(childToReact), react: React}) return _(RenderComponent, props) } export {buildAST, toReact, getSecondsFromTimeCode, getTimeCodes, defaultMapMDToComponents , getTimeCodeFromSeconds}