Completed
Push — master ( 2f74e5...c5dbe4 )
by Josh
17:21
created

TemplateModifier::createReplacementNode()   B

Complexity

Conditions 4
Paths 4

Size

Total Lines 22
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 4

Importance

Changes 0
Metric Value
dl 0
loc 22
rs 8.9197
c 0
b 0
f 0
ccs 10
cts 10
cp 1
cc 4
eloc 11
nc 4
nop 2
crap 4
1
<?php
2
3
/**
4
* @package   s9e\TextFormatter
5
* @copyright Copyright (c) 2010-2018 The s9e Authors
6
* @license   http://www.opensource.org/licenses/mit-license.php The MIT License
7
*/
8
namespace s9e\TextFormatter\Configurator\Helpers;
9
10
use DOMAttr;
11
use DOMDocument;
12
use DOMText;
13
use DOMXPath;
14
15
abstract class TemplateModifier
16
{
17
	/**
18
	* XSL namespace
19
	*/
20
	const XMLNS_XSL = 'http://www.w3.org/1999/XSL/Transform';
21
22
	/**
23
	* Replace parts of a template that match given regexp
24
	*
25
	* Treats attribute values as plain text. Replacements within XPath expression is unsupported.
26
	* The callback must return an array with two elements. The first must be either of 'expression',
27
	* 'literal' or 'passthrough', and the second element depends on the first.
28
	*
29
	*  - 'expression' indicates that the replacement must be treated as an XPath expression such as
30
	*    '@foo', which must be passed as the second element.
31
	*
32
	*  - 'literal' indicates a literal (plain text) replacement, passed as its second element.
33
	*
34
	*  - 'passthrough' indicates that the replacement should the tag's content. It works differently
35
	*    whether it is inside an attribute's value or a text node. Within an attribute's value, the
36
	*    replacement will be the text content of the tag. Within a text node, the replacement
37
	*    becomes an <xsl:apply-templates/> node. A second optional argument can be passed to be used
38
	*    as its @select node-set.
39
	*
40
	* @param  string   $template Original template
41
	* @param  string   $regexp   Regexp for matching parts that need replacement
42
	* @param  callback $fn       Callback used to get the replacement
43
	* @return string             Processed template
44
	*/
45 15
	public static function replaceTokens($template, $regexp, $fn)
46
	{
47 15
		$dom   = TemplateLoader::load($template);
48 15
		$xpath = new DOMXPath($dom);
49 15
		foreach ($xpath->query('//@*') as $attribute)
50
		{
51 5
			self::replaceTokensInAttribute($attribute, $regexp, $fn);
52
		}
53 15
		foreach ($xpath->query('//text()') as $node)
54
		{
55 9
			self::replaceTokensInText($node, $regexp, $fn);
56
		}
57
58 15
		return TemplateLoader::save($dom);
59
	}
60
61
	/**
62
	* Create a node that implements given replacement strategy
63
	*
64
	* @param  DOMDocument $dom
65
	* @param  array       $replacement
66
	* @return DOMNode
67
	*/
68 9
	protected static function createReplacementNode(DOMDocument $dom, array $replacement)
69
	{
70 9
		if ($replacement[0] === 'expression')
71
		{
72 1
			$newNode = $dom->createElementNS(self::XMLNS_XSL, 'xsl:value-of');
73 1
			$newNode->setAttribute('select', $replacement[1]);
74
		}
75 8
		elseif ($replacement[0] === 'passthrough')
76
		{
77 2
			$newNode = $dom->createElementNS(self::XMLNS_XSL, 'xsl:apply-templates');
78 2
			if (isset($replacement[1]))
79
			{
80 2
				$newNode->setAttribute('select', $replacement[1]);
81
			}
82
		}
83
		else
84
		{
85 6
			$newNode = $dom->createTextNode($replacement[1]);
86
		}
87
88 9
		return $newNode;
89
	}
90
91
	/**
92
	* Replace parts of an attribute that match given regexp
93
	*
94
	* @param  DOMAttr  $attribute Attribute
95
	* @param  string   $regexp    Regexp for matching parts that need replacement
96
	* @param  callback $fn        Callback used to get the replacement
97
	* @return void
98
	*/
99 5
	protected static function replaceTokensInAttribute(DOMAttr $attribute, $regexp, $fn)
100
	{
101 5
		$attrValue = preg_replace_callback(
102 5
			$regexp,
103 5
			function ($m) use ($fn, $attribute)
104
			{
105 5
				$replacement = $fn($m, $attribute);
106 5
				if ($replacement[0] === 'expression' || $replacement[0] === 'passthrough')
107
				{
108
					// Use the node's text content as the default expression
109 3
					$replacement[] = '.';
110
111 3
					return '{' . $replacement[1] . '}';
112
				}
113
				else
114
				{
115 2
					return $replacement[1];
116
				}
117 5
			},
118 5
			$attribute->value
119
		);
120 5
		$attribute->value = htmlspecialchars($attrValue, ENT_COMPAT, 'UTF-8');
121 5
	}
122
123
	/**
124
	* Replace parts of a text node that match given regexp
125
	*
126
	* @param  DOMText  $node   Text node
127
	* @param  string   $regexp Regexp for matching parts that need replacement
128
	* @param  callback $fn     Callback used to get the replacement
129
	* @return void
130
	*/
131 9
	protected static function replaceTokensInText(DOMText $node, $regexp, $fn)
132
	{
133
		// Grab the node's parent so that we can rebuild the text with added variables right
134
		// before the node, using DOM's insertBefore(). Technically, it would make more sense
135
		// to create a document fragment, append nodes then replace the node with the fragment
136
		// but it leads to namespace redeclarations, which looks ugly
137 9
		$parentNode = $node->parentNode;
138 9
		$dom        = $node->ownerDocument;
139
140 9
		preg_match_all($regexp, $node->textContent, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
141 9
		$lastPos = 0;
142 9
		foreach ($matches as $m)
143
		{
144 9
			$pos = $m[0][1];
145
146
			// Catch-up to current position
147 9
			$text = substr($node->textContent, $lastPos, $pos - $lastPos);
148 9
			$parentNode->insertBefore($dom->createTextNode($text), $node);
149 9
			$lastPos = $pos + strlen($m[0][0]);
150
151
			// Get the replacement for this token
152 9
			$replacement = $fn(array_column($m, 0), $node);
153 9
			$newNode     = self::createReplacementNode($dom, $replacement);
154 9
			$parentNode->insertBefore($newNode, $node);
155
		}
156
157
		// Append the rest of the text
158 9
		$text = substr($node->textContent, $lastPos);
159 9
		$parentNode->insertBefore($dom->createTextNode($text), $node);
160
161
		// Now remove the old text node
162 9
		$parentNode->removeChild($node);
163
	}
164
}