md-to-adf
Version:
Translate Markdown (Github) into Atlassian Document Format (ADF)
325 lines (269 loc) • 12.3 kB
JavaScript
/**********************************************************************************************************************
*
* Markdown handling
*
* @author bruno.morel@b-yond.com
*---------------------------------------------------------------------------------------------------------------------
*
* This build an intermediate representation tree and manage the hierarchy and priorities of the markdown expressions
* each branch will contain an object with the follow properties:
*
**********************************************************************************************************************/
const translateMarkdownLineToIR = require( __dirname + '/markdownParsing' )
/**
* @typedef {Object} IRTreeNode
* @property {IRElement} node - intermediate representation of the markdown element
* @property {IRElement[]} children - the list of children attach to that node
* @property {Number} indexOfList - the index in the list of expression
*/
/**
* Implement markdown greediness and collapsing of subnode, generate the final node tree representing
* the IRElement topology
*
* @param rawTextMarkdown {String[]} array of expression to parse and handle
*
* @returns {IRElement[]} an array of IRElement
*/
function buildTreeFromMarkdown( rawTextMarkdown ){
//code block are the most greedy expression in markdown
const cleanedCodeBlock = collapseCodeBloc( rawTextMarkdown )
//block quote collapse paragraphs, so we have to to them first
const blockquotedNodes = collapseBlockquote( cleanedCodeBlock )
//paragraph themselves collapse when they are not separated by
// two consecutive empty lines
const breakedLineNodes = collapseParagraph( blockquotedNodes )
//lists accumulate elements of the same level unless separated by
// two consecutive empty lines
const accumulatedNodes = accumulateLevelFromList( breakedLineNodes )
//we build the array of textPosition for each level
const levelsPosition = createLevelList( accumulatedNodes )
//map each element to a level, in order
const elementMap = mapIRToLevels( accumulatedNodes, levelsPosition )
return buildTreeFromLevelMap( elementMap )
}
/**
* CodeBlock swallow all text until they are closed, so we collapsed all paragraph into them
* When a code block is open, it should be closed by a triple caret, everything in between is code
*
* @param rawIROfMarkdown {Array} the array of IRElement to look into collapsing
*
* @returns {IRElement[]} an array of IRElement
*/
function collapseCodeBloc( rawIROfMarkdown ){
///MARDKOWN logic - closing code blocks
//
const { codeBlockHandled } = rawIROfMarkdown.split( /\r\n|\r|\n/ ).reduce( ( { codeBlockHandled, indexCurrentCodeBloc }, currentLine ) => {
const lineTranslation = translateMarkdownLineToIR( currentLine )
if( typeof indexCurrentCodeBloc === "undefined"
&& ( lineTranslation.adfType === 'codeBlock'
|| lineTranslation.nodeAttached ) ){
codeBlockHandled.push( lineTranslation )
if( lineTranslation.nodeAttached ){
codeBlockHandled.push( lineTranslation.nodeAttached )
}
return { codeBlockHandled, indexCurrentCodeBloc: codeBlockHandled.length - 1 }
}
if( typeof indexCurrentCodeBloc !== "undefined"
&& ( lineTranslation.adfType !== 'codeBlock'
|| typeof lineTranslation.typeParam === "undefined"
|| lineTranslation.typeParam !== '' ) ) {
const textToAdd = lineTranslation.textPosition >= codeBlockHandled[ indexCurrentCodeBloc ].textPosition
? currentLine.slice( codeBlockHandled[ indexCurrentCodeBloc ].textPosition )
: currentLine
codeBlockHandled[ indexCurrentCodeBloc ].textToEmphasis = codeBlockHandled[ indexCurrentCodeBloc ].textToEmphasis
+ ( codeBlockHandled[ indexCurrentCodeBloc ].textToEmphasis === ''
? textToAdd
: '\n' + textToAdd )
return { codeBlockHandled, indexCurrentCodeBloc }
}
if( typeof indexCurrentCodeBloc !== "undefined"
&& lineTranslation.adfType === 'codeBlock'
&& typeof lineTranslation.typeParam !== "undefined"
&& lineTranslation.typeParam === '' ){
return { codeBlockHandled }
}
codeBlockHandled.push( lineTranslation )
return { codeBlockHandled }
}, { codeBlockHandled: [] } )
//handling of unfinished empty codeBlock
const cleanedCodeBlock = codeBlockHandled.filter( ( currentNode ) => {
if( currentNode.adfType !== 'codeBlock' )
return currentNode
if( currentNode.textToEmphasis !== '' )
return currentNode
} )
return cleanedCodeBlock
}
/**
* Blockquote start with each line identify with a caret. Any interruption (line break) create a new blockquote
*
* @param rawIROfMarkdown {Array} the array of IRElement to look into collapsing
*
* @returns {IRElement[]} an array of IRElement
*/
function collapseBlockquote( rawIROfMarkdown ){
const { blockquotedNodes } = rawIROfMarkdown.reduce( ( { blockquotedNodes, currentLastThatWasBlockQuote }, currentLineNode ) => {
if( !currentLastThatWasBlockQuote
&& currentLineNode.adfType === 'blockQuote' ){
blockquotedNodes.push( currentLineNode )
return { blockquotedNodes, currentLastThatWasBlockQuote: currentLineNode }
}
//this is a non-empty paragraph, if we are already filling up a paragraph, let's add the text inside
if( currentLastThatWasBlockQuote
&& currentLineNode.adfType === 'blockQuote' ){
currentLastThatWasBlockQuote.textToEmphasis = currentLastThatWasBlockQuote.textToEmphasis +
' ' + currentLineNode.textToEmphasis
return { blockquotedNodes, currentLastThatWasBlockQuote }
}
//this is non-blockquote node, we add it to the list
blockquotedNodes.push( currentLineNode )
return { blockquotedNodes }
}, { blockquotedNodes: [ ] } )
return blockquotedNodes
}
/**
* Heading is an exception, otherwise non-empty line aggregate in the parent element
* For all other type, following a markdown with any paragraph of text is considered a continuation, so we aggregate
* all subsequent text into the same parent element (paragraph, list item, ...)
*
* @param rawIROfMarkdown {Array} the array of IRElement to look into collapsing
*
* @returns {IRElement[]} an array of IRElement
*/
function collapseParagraph( rawIROfMarkdown ){
const { breakedLineNodes } = rawIROfMarkdown.reduce( ( { breakedLineNodes, currentParent, lastWasAlsoAParagraph }, currentLineNode ) => {
if( currentLineNode.adfType === 'heading'
|| currentLineNode.adfType === 'divider'
|| currentLineNode.adfType === 'codeBlock' ){
breakedLineNodes.push( currentLineNode )
return { breakedLineNodes }
}
if( currentLineNode.adfType !== 'paragraph' ){
breakedLineNodes.push( currentLineNode )
return { breakedLineNodes, currentParent: currentLineNode }
}
if( !lastWasAlsoAParagraph
&& /^(?:[\s]*)$/.test( currentLineNode.textToEmphasis ) ) {
//we're breaking into a new paragraph
return { breakedLineNodes, lastWasAlsoAParagraph: true }
}
if( lastWasAlsoAParagraph
&& /^(?:[\s]*)$/.test( currentLineNode.textToEmphasis ) ) {
//we've double break, we add a paragraph
breakedLineNodes.push( currentLineNode )
return { breakedLineNodes }
}
//this is a non-empty paragraph, if we are already filling up a paragraph, let's add the text inside
if( currentParent ){
const textToAdd = currentLineNode.textPosition >= currentParent.textPosition
? currentLineNode.textToEmphasis.slice( currentParent.textPosition )
: currentLineNode.textToEmphasis
currentParent.textToEmphasis = currentParent.textToEmphasis + ( currentLineNode.textToEmphasis.charAt( 0 ) !== ' '
? ' ' + textToAdd
: textToAdd )
return { breakedLineNodes, currentParent }
}
//this is a lone new paragraph, we add it to the list
breakedLineNodes.push( currentLineNode )
return { breakedLineNodes, currentParent: currentLineNode }
}, { breakedLineNodes: [ ] } )
return breakedLineNodes
}
/**
* Realign children nodes to orderedList and bulletList
*
* @param rawIROfMarkdown {Array} the array of IRElement to look into collapsing
*
* @returns {IRElement[]} an array of IRElement
*/
function accumulateLevelFromList( rawIROfMarkdown ){
const { accumulatedNodes } = rawIROfMarkdown.reduce( ( { accumulatedNodes, indexCurrentList }, currentLineNode ) => {
if( currentLineNode.adfType !== 'heading'
&& currentLineNode.adfType !== 'divider'
&& currentLineNode.adfType !== 'orderedList'
&& currentLineNode.adfType !== 'bulletList'
&& indexCurrentList
&& currentLineNode.textPosition < accumulatedNodes[ indexCurrentList ].textPosition + 2 ){
currentLineNode.textPosition = accumulatedNodes[ indexCurrentList ].textPosition + 2
}
accumulatedNodes.push( currentLineNode )
if( currentLineNode.adfType === 'heading'
|| currentLineNode.adfType === 'divider' )
return { accumulatedNodes }
if( currentLineNode.adfType === 'bulletList' || currentLineNode.adfType === 'orderedList' ){
return { accumulatedNodes, indexCurrentList: accumulatedNodes.length - 1 }
}
return { accumulatedNodes, indexCurrentList }
}, { accumulatedNodes: [ ] } )
return accumulatedNodes
}
/**
* Build an array of all the different level (defined by the lists) we have to manage
* and their corresponding textPosition
*
* @param rawIROfMarkdown {Array} the array of IRElement to look into collapsing
*
* @returns {Number[]} an array of the textPosition for each level
*/
function createLevelList( rawIROfMarkdown ){
return rawIROfMarkdown.reduce( ( currentLevelList, currentNode ) => {
if( currentNode.adfType !== 'orderedList'
&& currentNode.adfType !== 'bulletList' )
return currentLevelList
return ( currentLevelList.includes( currentNode.textPosition + 2 ) || currentLevelList.includes( currentNode.textPosition + 3 ) )
? currentLevelList
: currentNode.textPosition + 2 > ( currentLevelList[ currentLevelList.length - 1 ] + 1 )
? [ ...currentLevelList, currentNode.textPosition + 2 ]
: currentLevelList
}, [ 0 ] )
}
/**
* Map all element to their level in an array of level
*
* @param rawIROfMarkdown {Array} the array of IRElement to look into mapping
* @param levelsPosition {Array} the list of level's textPosition to use
*
* @returns {IRTreeNode[]} an array of IRTreeNode
*/
function mapIRToLevels( rawIROfMarkdown, levelsPosition ){
return levelsPosition.map( ( currentLevelPosition, currentIndex ) => {
return rawIROfMarkdown.filter( currentList => ( currentList.textPosition >= currentLevelPosition
&& ( currentIndex === levelsPosition.length - 1 //this is the last level
|| currentList.textPosition < levelsPosition[ currentIndex + 1 ] ) ) )
.map( currentList => ( {
indexOfList: rawIROfMarkdown.indexOf( currentList ),
children: [],
node: currentList } ) )
} )
}
/**
* Map all element to their level in an array of level
*
* @param levelsMap {Array} the level array of array of IRElement
*
* @returns {IRTreeNode[]} tree of IRElements and their children
*/
function buildTreeFromLevelMap( levelsMap ){
const treeOfNode = levelsMap.reduce( ( currentTree, currentArrayOfListIndexes, currentIndexInTheArrayOfListIndexes ) => {
const stepAtTree = currentArrayOfListIndexes.reduce( ( currentTreeValues, currentListValues ) => {
if( currentIndexInTheArrayOfListIndexes <= 0 )
return [ ...currentTreeValues, currentListValues ]
const parentList = levelsMap[ currentIndexInTheArrayOfListIndexes - 1 ]
const lastParentWithIndexBelow = parentList.findIndex( currentParentListIndex => {
return currentParentListIndex.indexOfList > currentListValues.indexOfList
} )
const parentIndexToUse = lastParentWithIndexBelow === -1
? parentList.length - 1
: lastParentWithIndexBelow === 0
? 0
: lastParentWithIndexBelow - 1
if( parentIndexToUse < 0 )
throw 'Parent list of node is empty!'
parentList[ parentIndexToUse ].children.push( currentListValues )
return currentTreeValues
}, currentTree )
return stepAtTree
}, [] )
return treeOfNode
}
module.exports = buildTreeFromMarkdown