import {traverse, Node} from "../../DOMUtils.js"; import {closingIfMarker, eachClosingMarker, eachStartMarkerRegex, elseMarker, ifStartMarkerRegex, variableRegex} from './markers.js' /** * * @param {string} text * @param {string | RegExp} pattern * @returns {{marker: string, index: number}[]} */ function findAllMatches(text, pattern) { const results = []; let match; if(typeof pattern === 'string') { // For string markers like elseMarker and closingIfMarker let index = 0; while((index = text.indexOf(pattern, index)) !== -1) { results.push({ marker: pattern, index: index }); index += pattern.length; } } else { // For regex patterns pattern = new RegExp(pattern.source, 'g'); while((match = pattern.exec(text)) !== null) { results.push({ marker: match[0], index: match.index }); } } return results; } /** * * @param {Node} node1 * @param {Node} node2 * @returns {Node | undefined} */ function findCommonAncestor(node1, node2) { const ancestors1 = getAncestors(node1); const ancestors2 = new Set(getAncestors(node2)); for(const ancestor of ancestors1) { if(ancestors2.has(ancestor)) { return ancestor; } } return undefined; } /** * * @param {Node} node * @returns {Node[]} */ function getAncestors(node) { const ancestors = []; let current = node; while(current) { ancestors.push(current); current = current.parentNode; } return ancestors; } /** * text position of a node relative to a text nodes within a container * * @param {Text} node * @param {Text[]} containerTextNodes * @returns {number} */ function getNodeTextPosition(node, containerTextNodes) { let position = 0; for(const currentTextNode of containerTextNodes) { if(currentTextNode === node) { return position } else { position += (currentTextNode.textContent || '').length; } } throw new Error(`[${getNodeTextPosition.name}] None of containerTextNodes elements is equal to node`) } /** @typedef {Node[]} DOMPath */ /** * remove nodes between startNode and endNode * including startNode and endNode * * @param {Node} startNode * @param {Node} endNode * @param {string} text * @returns {void} */ function replaceBetweenNodesWithText(startNode, endNode, text) { // find both ancestry branch const startNodeAncestors = new Set(getAncestors(startNode)) const endNodeAncestors = new Set(getAncestors(endNode)) // find common ancestor const commonAncestor = findCommonAncestor(startNode, endNode) let remove = false let toRemove = [] let commonAncestorChild = commonAncestor.firstChild let commonAncestorInsertionChild while(commonAncestorChild){ if(startNodeAncestors.has(commonAncestorChild)){ remove = true } if(remove){ toRemove.push(commonAncestorChild) if(endNodeAncestors.has(commonAncestorChild)){ commonAncestorInsertionChild = commonAncestorChild.nextSibling break; } } commonAncestorChild = commonAncestorChild.nextSibling } for(const node of toRemove){ commonAncestor.removeChild(node) } //console.log('replaceBetweenNodesWithText startNode', startNode.textContent) const newTextNode = commonAncestor.ownerDocument.createTextNode(text) if(commonAncestorInsertionChild){ commonAncestor.insertBefore(newTextNode, commonAncestorInsertionChild) } else{ commonAncestor.appendChild(newTextNode) } } /** * Consolidate markers which are split among several Text nodes * * @param {Document} document */ function consolidateMarkers(document){ // Perform a first pass to detect templating markers with formatting to remove it const potentialMarkersContainers = [ ...Array.from(document.getElementsByTagName('text:p')), ...Array.from(document.getElementsByTagName('text:h')) ] for(const potentialMarkersContainer of potentialMarkersContainers) { const consolidatedMarkers = [] /** @type {Text[]} */ let containerTextNodesInTreeOrder = []; function refreshContainerTextNodes(){ containerTextNodesInTreeOrder = [] traverse(potentialMarkersContainer, node => { if(node.nodeType === Node.TEXT_NODE) { containerTextNodesInTreeOrder.push(/** @type {Text} */(node)) } }) } refreshContainerTextNodes() let fullText = '' for(const node of containerTextNodesInTreeOrder){ fullText = fullText + node.textContent } // Check for each template marker const positionedMarkers = [ ...findAllMatches(fullText, ifStartMarkerRegex), ...findAllMatches(fullText, elseMarker), ...findAllMatches(fullText, closingIfMarker), ...findAllMatches(fullText, eachStartMarkerRegex), ...findAllMatches(fullText, eachClosingMarker), ...findAllMatches(fullText, variableRegex) ]; //if(positionedMarkers.length >= 1) // console.log('positionedMarkers', positionedMarkers) while(consolidatedMarkers.length < positionedMarkers.length) { refreshContainerTextNodes() // For each marker, check if it's contained within a single text node for(const positionedMarker of positionedMarkers.slice(consolidatedMarkers.length)) { //console.log('positionedMarker', positionedMarker) let currentPos = 0; let startNode; let endNode; // Find which text node(s) contain this marker for(const textNode of containerTextNodesInTreeOrder) { const nodeStart = currentPos; const nodeEnd = nodeStart + textNode.textContent.length; // If start of marker is in this node if(!startNode && positionedMarker.index >= nodeStart && positionedMarker.index < nodeEnd) { startNode = textNode; } // If end of marker is in this node if(startNode && positionedMarker.index + positionedMarker.marker.length > nodeStart && positionedMarker.index + positionedMarker.marker.length <= nodeEnd) { endNode = textNode; break; } currentPos = nodeEnd; } if(!startNode){ throw new Error(`Could not find startNode for marker '${positionedMarker.marker}'`) } if(!endNode){ throw new Error(`Could not find endNode for marker '${positionedMarker.marker}'`) } // Check if marker spans multiple nodes if(startNode !== endNode) { const commonAncestor = findCommonAncestor(startNode, endNode) let commonAncestorStartChild = startNode while(commonAncestorStartChild.parentNode !== commonAncestor){ commonAncestorStartChild = commonAncestorStartChild.parentNode } let commonAncestorEndChild = endNode while(commonAncestorEndChild.parentNode !== commonAncestor){ commonAncestorEndChild = commonAncestorEndChild.parentNode } // Calculate relative positions within the nodes let startNodeTextContent = startNode.textContent || ''; let endNodeTextContent = endNode.textContent || ''; // Calculate the position within the start node let posInStartNode = positionedMarker.index - getNodeTextPosition(startNode, containerTextNodesInTreeOrder); // Calculate the position within the end node let posInEndNode = (positionedMarker.index + positionedMarker.marker.length) - getNodeTextPosition(endNode, containerTextNodesInTreeOrder); let newStartNode = startNode // if there is before-text, split if(posInStartNode > 0) { // Text exists before the marker - preserve it // set newStartNode to a Text node containing only the marker beginning newStartNode = startNode.splitText(posInStartNode) // startNode/beforeStartNode now contains only non-marker text // then, by definition of .splitText(posInStartNode): posInStartNode = 0 // move the marker beginning part to become a child of commonAncestor newStartNode.parentNode?.removeChild(newStartNode) commonAncestor.insertBefore(newStartNode, commonAncestorStartChild.nextSibling) } // if there is after-text, split if(posInEndNode < endNodeTextContent.length) { // Text exists after the marker - preserve it endNode.splitText(posInEndNode); // endNode now contains only the end of marker text // then, by definition of .splitText(posInEndNode): posInEndNode = endNodeTextContent.length // move the marker ending part to become a child of commonAncestor if(endNode !== commonAncestorEndChild){ endNode.parentNode?.removeChild(endNode) commonAncestor.insertBefore(endNode, commonAncestorEndChild) } } // then, replace all nodes between (new)startNode and (new)endNode with a single textNode in commonAncestor replaceBetweenNodesWithText(newStartNode, endNode, positionedMarker.marker) // After consolidation, break as the DOM structure has changed // and containerTextNodesInTreeOrder needs to be refreshed consolidatedMarkers.push(positionedMarker) break; } consolidatedMarkers.push(positionedMarker) } } } } /** * isolate markers which are in Text nodes with other texts * * @param {Document} document */ function isolateMarkers(document){ traverse(document, currentNode => { if(currentNode.nodeType === Node.TEXT_NODE) { // find all marker starts and ends and split textNode let remainingText = currentNode.textContent || '' while(remainingText.length >= 1) { let matchText; let matchIndex; // looking for a block marker for(const marker of [ifStartMarkerRegex, elseMarker, closingIfMarker, eachStartMarkerRegex, eachClosingMarker]) { if(typeof marker === 'string') { const index = remainingText.indexOf(marker) if(index !== -1) { matchText = marker matchIndex = index // found the first match break; // get out of loop } } else { // marker is a RegExp const match = remainingText.match(marker) if(match) { matchText = match[0] matchIndex = match.index // found the first match break; // get out of loop } } } if(matchText) { // split 3-way : before-match, match and after-match if(matchText.length < remainingText.length) { // @ts-ignore let afterMatchTextNode = currentNode.splitText(matchIndex + matchText.length) if(afterMatchTextNode.textContent && afterMatchTextNode.textContent.length >= 1) { remainingText = afterMatchTextNode.textContent } else { remainingText = '' } // per spec, currentNode now contains before-match and match text // @ts-ignore if(matchIndex > 0) { // @ts-ignore currentNode.splitText(matchIndex) } if(afterMatchTextNode) { currentNode = afterMatchTextNode } } else { remainingText = '' } } else { remainingText = '' } } } else { // skip } }) } /** * This function prepares the template DOM tree in a way that makes it easily processed by the template execution * Specifically, after the call to this function, the document is altered to respect the following property: * * each template marker ({#each ... as ...}, {/if}, etc.) placed within a single Text node * * If the template marker was partially formatted in the original document, the formatting is removed so the * marker can be within a single Text node * * If the template marker was in a Text node with other text, the Text node is split in a way to isolate the marker * from the rest of the text * * @param {Document} document */ export default function prepareTemplateDOMTree(document){ consolidateMarkers(document) isolateMarkers(document) }