node. A second optional argument can be passed to be used * as its @select node-set. * * @param string $template Original template * @param string $regexp Regexp for matching parts that need replacement * @param callable $fn Callback used to get the replacement * @return string Processed template */ public static function replaceTokens($template, $regexp, $fn) { $dom = TemplateLoader::load($template); $xpath = new DOMXPath($dom); foreach ($xpath->query('//@*') as $attribute) { self::replaceTokensInAttribute($attribute, $regexp, $fn); } foreach ($xpath->query('//text()') as $node) { self::replaceTokensInText($node, $regexp, $fn); } return TemplateLoader::save($dom); } /** * Create a node that implements given replacement strategy * * @param DOMDocument $dom * @param array $replacement * @return DOMNode */ protected static function createReplacementNode(DOMDocument $dom, array $replacement) { if ($replacement[0] === 'expression') { $newNode = $dom->createElementNS(self::XMLNS_XSL, 'xsl:value-of'); $newNode->setAttribute('select', $replacement[1]); } elseif ($replacement[0] === 'passthrough') { $newNode = $dom->createElementNS(self::XMLNS_XSL, 'xsl:apply-templates'); if (isset($replacement[1])) { $newNode->setAttribute('select', $replacement[1]); } } else { $newNode = $dom->createTextNode($replacement[1]); } return $newNode; } /** * Replace parts of an attribute that match given regexp * * @param DOMAttr $attribute Attribute * @param string $regexp Regexp for matching parts that need replacement * @param callable $fn Callback used to get the replacement * @return void */ protected static function replaceTokensInAttribute(DOMAttr $attribute, $regexp, $fn) { $attrValue = preg_replace_callback( $regexp, function ($m) use ($fn, $attribute) { $replacement = $fn($m, $attribute); if ($replacement[0] === 'expression' || $replacement[0] === 'passthrough') { // Use the node's text content as the default expression $replacement[] = '.'; return '{' . $replacement[1] . '}'; } else { return $replacement[1]; } }, $attribute->value ); $attribute->value = htmlspecialchars($attrValue, ENT_COMPAT, 'UTF-8'); } /** * Replace parts of a text node that match given regexp * * @param DOMText $node Text node * @param string $regexp Regexp for matching parts that need replacement * @param callable $fn Callback used to get the replacement * @return void */ protected static function replaceTokensInText(DOMText $node, $regexp, $fn) { // Grab the node's parent so that we can rebuild the text with added variables right // before the node, using DOM's insertBefore(). Technically, it would make more sense // to create a document fragment, append nodes then replace the node with the fragment // but it leads to namespace redeclarations, which looks ugly $parentNode = $node->parentNode; $dom = $node->ownerDocument; preg_match_all($regexp, $node->textContent, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); $lastPos = 0; foreach ($matches as $m) { $pos = $m[0][1]; // Catch-up to current position $text = substr($node->textContent, $lastPos, $pos - $lastPos); $parentNode->insertBefore($dom->createTextNode($text), $node); $lastPos = $pos + strlen($m[0][0]); // Get the replacement for this token $replacement = $fn(array_column($m, 0), $node); $newNode = self::createReplacementNode($dom, $replacement); $parentNode->insertBefore($newNode, $node); } // Append the rest of the text $text = substr($node->textContent, $lastPos); $parentNode->insertBefore($dom->createTextNode($text), $node); // Now remove the old text node $parentNode->removeChild($node); } }