odfjs/scripts/odf/templating/fillOdtElementTemplate.js

822 lines
26 KiB
JavaScript
Raw Normal View History

import {traverse, Node, getAncestors, findCommonAncestor} from "../../DOMUtils.js";
import {closingIfMarker, eachClosingMarker, eachStartMarkerRegex, elseMarker, ifStartMarkerRegex, variableRegex} from './markers.js'
/**
* @typedef TextPlaceToFill
* @property { {expression: string, replacedString:string}[] } expressions
* @property {() => void} fill
*/
class TemplateDOMBranch{
/** @type {Node} */
#startNode
/** @type {Node} */
#leafNode
// ancestors with this.#ancestors[0] === this.#startNode and this.#ancestors.at(-1) === this.#leafNode
/** @type {Node[]} */
#ancestors
/**
*
* @param {Node} startNode
* @param {Node} leafNode
*/
constructor(startNode, leafNode){
this.#startNode = startNode
this.#leafNode = leafNode
this.#ancestors = getAncestors(this.#leafNode, this.#startNode).reverse()
}
/**
*
* @param {number} n
* @returns {Node | undefined}
*/
at(n){
return this.#ancestors.at(n)
}
removeLeafAndEmptyAncestors(){
// it may happen (else marker of if/else/endif) that the leaf was already removed as part of another block
// so before removing anything, let's update #ancestors and #leaf
this.#ancestors.every((ancestor, i) => {
if(!ancestor.parentNode){
// ancestor already removed from tree
this.#ancestors = this.#ancestors.slice(0, i)
this.#leafNode = this.#ancestors.at(-1)
return false;
}
return true // continue
})
//console.log('removeLeafAndEmptyAncestors', this.#startNode.textContent)
let nextLeaf = this.#leafNode.parentNode
//console.log('nextLeaf', !!nextLeaf)
nextLeaf.removeChild(this.#leafNode)
this.#leafNode = nextLeaf
while(this.#leafNode !== this.#startNode &&
this.#leafNode.textContent && this.#leafNode.textContent.trim() === '')
{
nextLeaf = this.#leafNode.parentNode
this.#leafNode.parentNode.removeChild(this.#leafNode)
this.#leafNode = nextLeaf
}
this.#ancestors = getAncestors(this.#startNode, this.#leafNode).reverse()
}
/**
*
* @param {number} [startIndex]
*/
removeRightContent(startIndex = 0){
for(const branchNode of this.#ancestors.slice(startIndex)){
let toRemove = branchNode.nextSibling
while(toRemove){
const toRemoveNext = toRemove.nextSibling
toRemove.parentNode.removeChild(toRemove)
toRemove = toRemoveNext
}
}
}
/**
*
* @param {number} [startIndex]
*/
removeLeftContent(startIndex = 0){
for(const branchNode of this.#ancestors.slice(startIndex)){
let toRemove = branchNode.previousSibling
while(toRemove){
const toRemoveNext = toRemove.previousSibling
toRemove.parentNode.removeChild(toRemove)
toRemove = toRemoveNext
}
}
}
}
class TemplateBlock{
/** @type {Element | Document | DocumentFragment} */
#commonAncestor;
/** @type {TemplateDOMBranch} */
#startBranch;
/** @type {TemplateDOMBranch} */
#endBranch;
/** @type {Node[]} */
#middleContent;
/**
*
* @param {Node} startMarkerNode
* @param {Node} endMarkerNode
*/
constructor(startMarkerNode, endMarkerNode){
// @ts-expect-error xmldom.Node
this.#commonAncestor = findCommonAncestor(startMarkerNode, endMarkerNode)
this.#startBranch = new TemplateDOMBranch(this.#commonAncestor, startMarkerNode)
this.#endBranch = new TemplateDOMBranch(this.#commonAncestor, endMarkerNode)
this.#middleContent = []
let content = this.#startBranch.at(1).nextSibling
while(content && content !== this.#endBranch.at(1)){
this.#middleContent.push(content)
content = content.nextSibling
}
//console.log('TemplateBlock')
//console.log('startBranch', this.#startBranch.at(1).textContent)
//console.log('middleContent', this.#middleContent.map(n => n.textContent).join(''))
//console.log('endBranch', this.#endBranch.at(1).textContent)
}
removeMarkersAndEmptyAncestors(){
//console.log('removeMarkersAndEmptyAncestors startBranch')
this.#startBranch.removeLeafAndEmptyAncestors()
//console.log('removeMarkersAndEmptyAncestors endBranch')
this.#endBranch.removeLeafAndEmptyAncestors()
}
/**
*
* @param {Compartment} compartement
*/
fillBlockContentTemplate(compartement){
const startChild = this.#startBranch.at(1)
if(startChild){
fillOdtElementTemplate(startChild, compartement)
}
for(const content of this.#middleContent){
fillOdtElementTemplate(content, compartement)
}
const endChild = this.#endBranch.at(1)
if(endChild){
fillOdtElementTemplate(endChild, compartement)
}
}
removeContent(){
this.#startBranch.removeRightContent(2)
for(const content of this.#middleContent){
content.parentNode.removeChild(content)
}
this.#endBranch.removeLeftContent(2)
}
}
/**
* @param {string} str
* @param {Compartment} compartment
* @returns {TextPlaceToFill | undefined}
*/
function findPlacesToFillInString(str, compartment) {
const varRexExp = new RegExp(variableRegex.source, 'g');
const matches = str.matchAll(varRexExp)
/** @type {TextPlaceToFill['expressions']} */
const expressions = []
/** @type {(string | ((data:any) => void))[]} */
const parts = []
let remaining = str;
for(const match of matches) {
//console.log('match', match)
const [matched, group1] = match
const replacedString = matched
const expression = group1.trim()
expressions.push({expression, replacedString})
const [fixedPart, newRemaining] = remaining.split(replacedString, 2)
if(fixedPart.length >= 1)
parts.push(fixedPart)
parts.push(() => compartment.evaluate(expression))
remaining = newRemaining
}
if(remaining.length >= 1)
parts.push(remaining)
//console.log('parts', parts)
if(remaining === str) {
// no match found
return undefined
}
else {
return {
expressions,
fill: (data) => {
return parts.map(p => {
if(typeof p === 'string')
return p
else
return p(data)
})
.join('')
}
}
}
}
/**
* Content between blockStartNode and blockEndNode is extracted to a documentFragment
* The original document is modified because nodes are removed from it to be part of the returned documentFragment
*
* startChild and endChild are ancestors of, respectively, blockStartNode and blockEndNode
* and startChild.parentNode === endChild.parentNode
*
* @precondition blockStartNode needs to be before blockEndNode in document order
*
* @param {Node} blockStartNode
* @param {Node} blockEndNode
* @returns {{removeMarkers: () => void, insertContent: (n : Node) => void, content: DocumentFragment}}
*/
function extractBlockContent(blockStartNode, blockEndNode) {
//console.log('[extractBlockContent] blockStartNode', blockStartNode.textContent)
//console.log('[extractBlockContent] blockEndNode', blockEndNode.textContent)
// find common ancestor of blockStartNode and blockEndNode
let commonAncestor
let startAncestor = blockStartNode
let endAncestor = blockEndNode
// ancestries in order of deepest first, closest to root last
const startAncestry = new Set([startAncestor])
const endAncestry = new Set([endAncestor])
while(!startAncestry.has(endAncestor) && !endAncestry.has(startAncestor)) {
if(startAncestor.parentNode) {
startAncestor = startAncestor.parentNode
startAncestry.add(startAncestor)
}
if(endAncestor.parentNode) {
endAncestor = endAncestor.parentNode
endAncestry.add(endAncestor)
}
}
if(startAncestry.has(endAncestor)) {
commonAncestor = endAncestor
}
else {
commonAncestor = startAncestor
}
//console.log('extractBlockContent', commonAncestor.textContent)
const startAncestryToCommonAncestor = [...startAncestry].slice(0, [...startAncestry].indexOf(commonAncestor))
const endAncestryToCommonAncestor = [...endAncestry].slice(0, [...endAncestry].indexOf(commonAncestor))
// direct children of commonAncestor in the branch or blockStartNode and blockEndNode respectively
const startChild = startAncestryToCommonAncestor.at(-1)
const endChild = endAncestryToCommonAncestor.at(-1)
//console.log('[extractBlockContent] startChild', startChild.childNodes.length, startChild.textContent)
//console.log('[extractBlockContent] endChild', endChild.childNodes.length,endChild.textContent)
// Extract DOM content in a documentFragment
/** @type {DocumentFragment} */
const contentFragment = blockStartNode.ownerDocument.createDocumentFragment()
/** @type {Element[]} */
const blockContent = []
// get start branch "right" content
for(const startAncestor of startAncestryToCommonAncestor){
if(startAncestor === startChild)
break;
let sibling = startAncestor.nextSibling
while(sibling) {
blockContent.push(sibling)
sibling = sibling.nextSibling;
}
}
let sibling = startChild.nextSibling
while(sibling !== endChild) {
blockContent.push(sibling)
sibling = sibling.nextSibling;
}
// get end branch "left" content
for(const endAncestor of [...endAncestryToCommonAncestor].reverse()){
if(endAncestor === endChild)
continue; // already taken care of
let sibling = endAncestor.previousSibling
const reversedBlockContentContribution = []
while(sibling) {
reversedBlockContentContribution.push(sibling)
sibling = sibling.previousSibling;
}
const blockContentContribution = reversedBlockContentContribution.reverse()
blockContent.push(...blockContentContribution)
if(endAncestor === blockEndNode)
break;
}
//console.log('blockContent', blockContent.map(n => n.textContent))
for(const sibling of blockContent) {
sibling.parentNode?.removeChild(sibling)
contentFragment.appendChild(sibling)
}
//console.log('extractBlockContent contentFragment', contentFragment.textContent)
let insertionParent;
if(startAncestryToCommonAncestor.length >= endAncestryToCommonAncestor.length){
insertionParent = blockStartNode.parentNode
}
else{
insertionParent = blockEndNode.parentNode
}
let insertionBeforeNodeCandidates
if(blockEndNode.nextSibling){
insertionBeforeNodeCandidates = [blockEndNode.nextSibling]
while(insertionBeforeNodeCandidates.at(-1).nextSibling){
insertionBeforeNodeCandidates.push(insertionBeforeNodeCandidates.at(-1).nextSibling)
}
}
/**
* @param {Node} content
*/
function insertContent(content){
//console.log('insertContent', node.textContent, insertionBeforeNodeCandidates.map(n => `${n.nodeName} - ${n.textContent}`))
let insertionBeforeNode
if(insertionBeforeNodeCandidates){
insertionBeforeNode = insertionBeforeNodeCandidates.find(node => node.parentNode === insertionParent)
}
console.log('insertContent insertionBeforeNode', insertionBeforeNode && insertionBeforeNode.textContent)
if(insertionBeforeNode){
insertionParent.insertBefore(content, insertionBeforeNode)
}
else{
console.log('insertionParent', insertionParent.nodeName)
console.log('insertionParent content before append', insertionParent.textContent)
//console.log('insertionParent owner doc', insertionParent.ownerDocument)
insertionParent.appendChild(content)
console.log('insertionParent content after append', insertionParent.textContent)
}
}
console.log('contentFragment',
contentFragment.childNodes.length,
contentFragment.childNodes[0].nodeName,
contentFragment.textContent
)
return {
removeMarkers(){
for(const marker of [blockStartNode, blockEndNode]){
console.log('removing marker', marker.nodeName, marker.textContent)
try{
marker.parentNode.removeChild(marker)
}
catch(e){}
}
},
insertContent,
content: contentFragment
}
}
/**
*
* @param {Node} ifOpeningMarkerNode
* @param {Node | undefined} ifElseMarkerNode
* @param {Node} ifClosingMarkerNode
* @param {string} ifBlockConditionExpression
* @param {Compartment} compartment
*/
function fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, ifBlockConditionExpression, compartment) {
const conditionValue = compartment.evaluate(ifBlockConditionExpression)
/** @type {TemplateBlock | undefined} */
let thenTemplateBlock
/** @type {TemplateBlock | undefined} */
let elseTemplateBlock
if(ifElseMarkerNode) {
/*console.log('before first extract',
ifOpeningMarkerNode.childNodes.length, ifOpeningMarkerNode.textContent,
ifElseMarkerNode.childNodes.length, ifElseMarkerNode.textContent
)*/
thenTemplateBlock = new TemplateBlock(ifOpeningMarkerNode, ifElseMarkerNode)
elseTemplateBlock = new TemplateBlock(ifElseMarkerNode, ifClosingMarkerNode)
}
else {
thenTemplateBlock = new TemplateBlock(ifOpeningMarkerNode, ifClosingMarkerNode)
}
thenTemplateBlock.removeMarkersAndEmptyAncestors()
if(elseTemplateBlock){
elseTemplateBlock.removeMarkersAndEmptyAncestors()
}
if(conditionValue) {
thenTemplateBlock.fillBlockContentTemplate(compartment)
if(elseTemplateBlock){
elseTemplateBlock.removeContent()
}
}
else{
thenTemplateBlock.removeContent()
if(elseTemplateBlock){
elseTemplateBlock.fillBlockContentTemplate(compartment)
}
}
}
/**
*
* @param {Node} startNode
* @param {string} iterableExpression
* @param {string} itemExpression
* @param {Node} endNode
* @param {Compartment} compartment
*/
function fillEachBlock(startNode, iterableExpression, itemExpression, endNode, compartment) {
//console.log('fillEachBlock', iterableExpression, itemExpression)
const docEl = startNode.ownerDocument.documentElement
const {removeMarkers, insertContent, content: repeatedFragment} = extractBlockContent(startNode, endNode)
// Find the iterable in the data
// PPP eventually, evaluate the expression as a JS expression
let iterable = compartment.evaluate(iterableExpression)
if(!iterable || typeof iterable[Symbol.iterator] !== 'function') {
// when there is no iterable, silently replace with empty array
iterable = []
}
let firstItemFirstChild
let lastItemLastChild
// store before-text in startNodePreviousSiblings
const startNodePreviousSiblings = []
let startNodePreviousSibling = startNode.previousSibling
while(startNodePreviousSibling){
startNodePreviousSiblings.push(startNodePreviousSibling)
startNodePreviousSibling = startNodePreviousSibling.previousSibling
}
// set the array back to tree order
startNodePreviousSiblings.reverse()
// store after-text in endNodeNextSiblings
const endNodeNextSiblings = []
let endNodeNextSibling = endNode.nextSibling
while(endNodeNextSibling){
endNodeNextSiblings.push(endNodeNextSibling)
endNodeNextSibling = endNodeNextSibling.nextSibling
}
// create each loop result
// using a for-of loop to accept all iterable values
for(const item of iterable) {
/** @type {DocumentFragment} */
// @ts-ignore
const itemFragment = repeatedFragment.cloneNode(true)
let insideCompartment = new Compartment({
globals: Object.assign({}, compartment.globalThis, {[itemExpression]: item}),
__options__: true
})
// recursive call to fillTemplatedOdtElement on itemFragment
fillOdtElementTemplate(
itemFragment,
insideCompartment
)
console.log('itemFragment', itemFragment.textContent)
if(!firstItemFirstChild){
firstItemFirstChild = itemFragment.firstChild
}
// eventually, will be set to the last item's last child
lastItemLastChild = itemFragment.lastChild
insertContent(itemFragment)
console.log('doc', docEl.textContent)
}
if(startNodePreviousSiblings.length >= 1){
let firstItemFirstestDescendant = firstItemFirstChild
while(firstItemFirstestDescendant?.firstChild){
firstItemFirstestDescendant = firstItemFirstestDescendant.firstChild
}
for(const beforeFirstNodeElement of startNodePreviousSiblings){
firstItemFirstestDescendant?.parentNode?.insertBefore(beforeFirstNodeElement, firstItemFirstestDescendant)
}
}
console.log('doc after add before-text if any', docEl.textContent)
if(endNodeNextSiblings.length >= 1){
let lastItemLatestDescendant = lastItemLastChild
while(lastItemLatestDescendant?.lastChild){
lastItemLatestDescendant = lastItemLatestDescendant.lastChild
}
for(const afterEndNodeElement of endNodeNextSiblings){
console.log('doc in for-of', docEl.textContent)
console.log('afterEndNodeElement', afterEndNodeElement.textContent)
lastItemLatestDescendant?.parentNode?.appendChild(afterEndNodeElement)
}
}
console.log('doc before removeMarkers', docEl.textContent)
// remove block marker elements
removeMarkers()
console.log('doc after removeMarkers', docEl.textContent)
}
const IF = ifStartMarkerRegex.source
const EACH = eachStartMarkerRegex.source
/**
*
* @param {Element | DocumentFragment | Document} rootElement
* @param {Compartment} compartment
* @returns {void}
*/
export default function fillOdtElementTemplate(rootElement, compartment) {
//console.log('fillTemplatedOdtElement', rootElement.nodeType, rootElement.nodeName, rootElement.textContent)
//console.log('fillTemplatedOdtElement', rootElement.childNodes[0].childNodes.length)
let currentlyOpenBlocks = []
/** @type {Node | undefined} */
let eachOpeningMarkerNode
/** @type {Node | undefined} */
let eachClosingMarkerNode
let eachBlockIterableExpression, eachBlockItemExpression;
/** @type {Node | undefined} */
let ifOpeningMarkerNode
/** @type {Node | undefined} */
let ifElseMarkerNode
/** @type {Node | undefined} */
let ifClosingMarkerNode
let ifBlockConditionExpression
// Traverse "in document order"
// @ts-ignore
traverse(rootElement, currentNode => {
//console.log('currentlyOpenBlocks', currentlyOpenBlocks)
const insideAnOpenBlock = currentlyOpenBlocks.length >= 1
if(currentNode.nodeType === Node.TEXT_NODE) {
const text = currentNode.textContent || ''
/**
* looking for {#each x as y}
*/
const eachStartMatch = text.match(eachStartMarkerRegex);
if(eachStartMatch) {
//console.log('startMatch', startMatch)
currentlyOpenBlocks.push(EACH)
if(insideAnOpenBlock) {
// do nothing
}
else {
let [_, _iterableExpression, _itemExpression] = eachStartMatch
eachBlockIterableExpression = _iterableExpression
eachBlockItemExpression = _itemExpression
eachOpeningMarkerNode = currentNode
}
}
/**
* Looking for {/each}
*/
const isEachClosingBlock = text.includes(eachClosingMarker)
if(isEachClosingBlock) {
//console.log('isEachClosingBlock', isEachClosingBlock)
if(!eachOpeningMarkerNode)
throw new Error(`{/each} found without corresponding opening {#each x as y}`)
if(currentlyOpenBlocks.at(-1) !== EACH)
throw new Error(`{/each} found while the last opened block was not an opening {#each x as y}`)
if(currentlyOpenBlocks.length === 1) {
eachClosingMarkerNode = currentNode
// found an {#each} and its corresponding {/each}
// execute replacement loop
fillEachBlock(eachOpeningMarkerNode, eachBlockIterableExpression, eachBlockItemExpression, eachClosingMarkerNode, compartment)
eachOpeningMarkerNode = undefined
eachBlockIterableExpression = undefined
eachBlockItemExpression = undefined
eachClosingMarkerNode = undefined
}
else {
// ignore because it will be treated as part of the outer {#each}
}
currentlyOpenBlocks.pop()
}
/**
* Looking for {#if ...}
*/
const ifStartMatch = text.match(ifStartMarkerRegex);
if(ifStartMatch) {
currentlyOpenBlocks.push(IF)
if(insideAnOpenBlock) {
// do nothing because the marker is too deep
}
else {
let [_, _ifBlockConditionExpression] = ifStartMatch
ifBlockConditionExpression = _ifBlockConditionExpression
ifOpeningMarkerNode = currentNode
}
}
/**
* Looking for {:else}
*/
const hasElseMarker = text.includes(elseMarker);
if(hasElseMarker) {
if(!insideAnOpenBlock)
throw new Error('{:else} without a corresponding {#if}')
if(currentlyOpenBlocks.length === 1) {
if(currentlyOpenBlocks[0] === IF) {
ifElseMarkerNode = currentNode
}
else
throw new Error('{:else} inside an {#each} but without a corresponding {#if}')
}
else {
// do nothing because the marker is too deep
}
}
/**
* Looking for {/if}
*/
const ifClosingMarker = text.includes(closingIfMarker);
if(ifClosingMarker) {
if(!insideAnOpenBlock)
throw new Error('{/if} without a corresponding {#if}')
if(currentlyOpenBlocks.length === 1) {
if(currentlyOpenBlocks[0] === IF) {
ifClosingMarkerNode = currentNode
// found an {#if} and its corresponding {/if}
// execute replacement loop
fillIfBlock(ifOpeningMarkerNode, ifElseMarkerNode, ifClosingMarkerNode, ifBlockConditionExpression, compartment)
ifOpeningMarkerNode = undefined
ifElseMarkerNode = undefined
ifClosingMarkerNode = undefined
ifBlockConditionExpression = undefined
}
else
throw new Error('{/if} inside an {#each} but without a corresponding {#if}')
}
else {
// do nothing because the marker is too deep
}
currentlyOpenBlocks.pop()
}
/**
* Looking for variables for substitutions
*/
if(!insideAnOpenBlock) {
// @ts-ignore
if(currentNode.data) {
// @ts-ignore
const placesToFill = findPlacesToFillInString(currentNode.data, compartment)
if(placesToFill) {
const newText = placesToFill.fill()
// @ts-ignore
const newTextNode = currentNode.ownerDocument?.createTextNode(newText)
// @ts-ignore
currentNode.parentNode?.replaceChild(newTextNode, currentNode)
}
}
}
else {
// ignore because it will be treated as part of the outer {#each} block
}
}
if(currentNode.nodeType === Node.ATTRIBUTE_NODE) {
// Looking for variables for substitutions
if(!insideAnOpenBlock) {
// @ts-ignore
if(currentNode.value) {
// @ts-ignore
const placesToFill = findPlacesToFillInString(currentNode.value, compartment)
if(placesToFill) {
// @ts-ignore
currentNode.value = placesToFill.fill()
}
}
}
else {
// ignore because it will be treated as part of the {#each} block
}
}
})
}