Completed
Push — master ( 06920f...7e343b )
by Josh
18:41
created

Configurator   B

Complexity

Total Complexity 31

Size/Duplication

Total Lines 365
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 18

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
wmc 31
lcom 1
cbo 18
dl 0
loc 365
ccs 110
cts 110
cp 1
rs 7.1866
c 0
b 0
f 0

7 Methods

Rating   Name   Duplication   Size   Complexity  
B setUp() 0 42 3
A asConfig() 0 13 2
D add() 0 98 10
C addAttribute() 0 42 7
B addScrapes() 0 49 5
A appendFilter() 0 9 2
A normalizeId() 0 11 2
1
<?php
2
3
/**
4
* @package   s9e\TextFormatter
5
* @copyright Copyright (c) 2010-2017 The s9e Authors
6
* @license   http://www.opensource.org/licenses/mit-license.php The MIT License
7
*/
8
namespace s9e\TextFormatter\Plugins\MediaEmbed;
9
10
use InvalidArgumentException;
11
use RuntimeException;
12
use s9e\TextFormatter\Configurator\Helpers\RegexpBuilder;
13
use s9e\TextFormatter\Configurator\Items\Attribute;
14
use s9e\TextFormatter\Configurator\Items\AttributePreprocessor;
15
use s9e\TextFormatter\Configurator\Items\Tag;
16
use s9e\TextFormatter\Plugins\ConfiguratorBase;
17
use s9e\TextFormatter\Plugins\MediaEmbed\Configurator\Collections\CachedDefinitionCollection;
18
use s9e\TextFormatter\Plugins\MediaEmbed\Configurator\Collections\SiteCollection;
19
use s9e\TextFormatter\Plugins\MediaEmbed\Configurator\TemplateBuilder;
20
21
class Configurator extends ConfiguratorBase
22
{
23
	/**
24
	* @var array List of filters that are explicitly allowed in attribute definitions
25
	*/
26
	public $allowedFilters = [
27
		'hexdec',
28
		'stripslashes',
29
		'urldecode'
30
	];
31
32
	/**
33
	* @var SiteCollection Site collection
34
	*/
35
	protected $collection;
36
37
	/**
38
	* @var bool Whether to create the MEDIA BBCode
39
	*/
40
	protected $createMediaBBCode = true;
41
42
	/**
43
	* @var Configurator\Collections\SiteDefinitionCollection Default sites
44
	*/
45
	public $defaultSites;
46
47
	/**
48
	* {@inheritdoc}
49
	*/
50
	protected $quickMatch = '://';
51
52
	/**
53
	* {@inheritdoc}
54
	*/
55
	protected $regexp = '/\\bhttps?:\\/\\/[^["\'\\s]+/Si';
56
57
	/**
58
	* @var string Name of the tag used to handle embeddable URLs
59
	*/
60
	protected $tagName = 'MEDIA';
61
62
	/**
63
	* @var TemplateBuilder
64
	*/
65
	protected $templateBuilder;
66
67
	/**
68
	* {@inheritdoc}
69
	*/
70 41
	protected function setUp()
71
	{
72
		// Create a collection to store the configured sites
73 41
		$this->collection = new SiteCollection;
74
75
		// Register the collection as a variable to be used during parsing
76 41
		$this->configurator->registeredVars['mediasites'] = $this->collection;
77
78
		// Create a MEDIA tag
79 41
		$tag = $this->configurator->tags->add($this->tagName);
80
81
		// This tag should not need to be closed and should not contain itself
82 41
		$tag->rules->autoClose();
83 41
		$tag->rules->denyChild($this->tagName);
84
85
		// Empty this tag's filter chain and add our tag filter
86 41
		$tag->filterChain->clear();
87 41
		$tag->filterChain
88 41
		    ->append([__NAMESPACE__ . '\\Parser', 'filterTag'])
89 41
		    ->addParameterByName('parser')
90 41
		    ->addParameterByName('mediasites')
91 41
		    ->setJS(file_get_contents(__DIR__ . '/Parser/tagFilter.js'));
92
93
		// Create a [MEDIA] BBCode if applicable
94 41
		if ($this->createMediaBBCode)
95
		{
96 40
			$this->configurator->BBCodes->set(
97 40
				$this->tagName,
98
				[
99 40
					'contentAttributes' => ['url'],
100
					'defaultAttribute'  => 'site'
101
				]
102
			);
103
		}
104
105 41
		if (!isset($this->defaultSites))
106
		{
107 41
			$this->defaultSites = new CachedDefinitionCollection;
108
		}
109
110 41
		$this->templateBuilder = new TemplateBuilder;
111 41
	}
112
113
	/**
114
	* {@inheritdoc}
115
	*/
116 4
	public function asConfig()
117
	{
118 4
		if (!count($this->collection))
119
		{
120 1
			return;
121
		}
122
123
		return [
124 3
			'quickMatch' => $this->quickMatch,
125 3
			'regexp'     => $this->regexp,
126 3
			'tagName'    => $this->tagName
127
		];
128
	}
129
130
	//==========================================================================
131
	// Public API
132
	//==========================================================================
133
134
	/**
135
	* Add a media site
136
	*
137
	* @param  string $siteId     Site's ID
138
	* @param  array  $siteConfig Site's config
139
	* @return Tag                Tag created for this site
140
	*/
141 37
	public function add($siteId, array $siteConfig = null)
142
	{
143
		// Normalize the site ID
144 37
		$siteId = $this->normalizeId($siteId);
145
146
		// Normalize or retrieve the site definition
147 36
		$siteConfig = (isset($siteConfig)) ? $this->defaultSites->normalizeValue($siteConfig) : $this->defaultSites->get($siteId);
148
149
		// Add this site to the list
150 35
		$this->collection[$siteId] = $siteConfig;
151
152
		// Create the tag for this site
153 35
		$tag = new Tag;
154
155
		// This tag should not need to be closed and should not contain itself or the MEDIA tag.
156
		// We allow URL as a child to be used as fallback
157 35
		$tag->rules->allowChild('URL');
158 35
		$tag->rules->autoClose();
159 35
		$tag->rules->denyChild($siteId);
160 35
		$tag->rules->denyChild($this->tagName);
161
162
		// Store attributes' configuration, starting with a default "url" attribute to store the
163
		// original URL if applicable
164
		$attributes = [
165 35
			'url' => ['type' => 'url']
166
		];
167
168
		// Process the "scrape" directives
169 35
		$attributes += $this->addScrapes($tag, $siteConfig['scrape']);
170
171
		// Add each "extract" as an attribute preprocessor
172 35
		foreach ($siteConfig['extract'] as $regexp)
173
		{
174
			// Get the attributes filled by this regexp
175 27
			$attrRegexps = $tag->attributePreprocessors->add('url', $regexp)->getAttributes();
176
177
			// For each named subpattern in the regexp, ensure that an attribute exists and
178
			// create it otherwise, using the subpattern as regexp filter
179 27
			foreach ($attrRegexps as $attrName => $attrRegexp)
180
			{
181 27
				$attributes[$attrName]['regexp'] = $attrRegexp;
182
			}
183
		}
184
185
		// Overwrite attribute declarations
186 35
		if (isset($siteConfig['attributes']))
187
		{
188 9
			foreach ($siteConfig['attributes'] as $attrName => $attrConfig)
189
			{
190 9
				foreach ($attrConfig as $configName => $configValue)
191
				{
192 9
					$attributes[$attrName][$configName] = $configValue;
193
				}
194
			}
195
		}
196
197
		// Create the attributes
198 35
		$hasRequiredAttribute = false;
199 35
		foreach ($attributes as $attrName => $attrConfig)
200
		{
201 35
			$attribute = $this->addAttribute($tag, $attrName, $attrConfig);
202 35
			$hasRequiredAttribute |= $attribute->required;
203
		}
204
205
		// If there is an attribute named "id" we'll append its regexp to the list of attribute
206
		// preprocessors in order to support both forms [site]<url>[/site] and [site]<id>[/site]
207 33
		if (isset($attributes['id']['regexp']))
208
		{
209
			// Add a named capture around the whole match
210 27
			$attrRegexp = preg_replace('(\\^(.*)\\$)s', "^(?'id'$1)$", $attributes['id']['regexp']);
211
212 27
			$tag->attributePreprocessors->add('url', $attrRegexp);
213
		}
214
215
		// If the tag definition does not have a required attribute, we use a filter to invalidate
216
		// the tag at parsing time if it does not have a non-default attribute. In other words, if
217
		// no attribute value is extracted, the tag is invalidated
218 33
		if (!$hasRequiredAttribute)
219
		{
220 10
			$tag->filterChain
221 10
				->append([__NAMESPACE__ . '\\Parser', 'hasNonDefaultAttribute'])
222 10
				->setJS(file_get_contents(__DIR__ . '/Parser/hasNonDefaultAttribute.js'));
223
		}
224
225
		// Create a template for this media site based on the preferred rendering method
226 33
		$tag->template = $this->templateBuilder->build($siteId, $siteConfig);
227
228
		// Normalize the tag's template
229 33
		$this->configurator->templateNormalizer->normalizeTag($tag);
230
231
		// Check the tag's safety
232 33
		$this->configurator->templateChecker->checkTag($tag);
233
234
		// Now add the tag to the list
235 32
		$this->configurator->tags->add($siteId, $tag);
236
237 32
		return $tag;
238
	}
239
240
	//==========================================================================
241
	// Internal methods
242
	//==========================================================================
243
244
	/**
245
	* Add an attribute to given tag
246
	*
247
	* @param  Tag       $tag
248
	* @param  string    $attrName
249
	* @param  array     $attrConfig
250
	* @return Attribute
251
	*/
252 35
	protected function addAttribute(Tag $tag, $attrName, array $attrConfig)
253
	{
254 35
		$attribute = $tag->attributes->add($attrName);
255 35
		if (isset($attrConfig['preFilter']))
256
		{
257 2
			$this->appendFilter($attribute, $attrConfig['preFilter']);
258
		}
259
260
		// Add a filter depending on the attribute's type or regexp
261 35
		if (isset($attrConfig['type']))
262
		{
263
			// If "type" is "url", get the "#url" filter
264 35
			$filter = $this->configurator->attributeFilters['#' . $attrConfig['type']];
265 35
			$attribute->filterChain->append($filter);
266
		}
267 31
		elseif (isset($attrConfig['regexp']))
268
		{
269 31
			$attribute->filterChain->append('#regexp')->setRegexp($attrConfig['regexp']);
270
		}
271
272 35
		if (isset($attrConfig['required']))
273
		{
274 6
			$attribute->required = $attrConfig['required'];
275
		}
276
		else
277
		{
278
			// Non-id attributes are marked as optional
279 35
			$attribute->required = ($attrName === 'id');
280
		}
281
282 35
		if (isset($attrConfig['postFilter']))
283
		{
284 2
			$this->appendFilter($attribute, $attrConfig['postFilter']);
285
		}
286
287 35
		if (isset($attrConfig['defaultValue']))
288
		{
289 1
			$attribute->defaultValue = $attrConfig['defaultValue'];
290
		}
291
292 35
		return $attribute;
293
	}
294
295
	/**
296
	* Add the defined scrapes to given tag
297
	*
298
	* @param  array $scrapes Scraping definitions
299
	* @return array          Attributes created from scraped data
300
	*/
301 35
	protected function addScrapes(Tag $tag, array $scrapes)
302
	{
303 35
		$attributes   = [];
304 35
		$scrapeConfig = [];
305 35
		foreach ($scrapes as $scrape)
306
		{
307
			// Collect the names of the attributes filled by this scrape. At runtime, we will
308
			// not scrape the content of the link if all of the attributes already have a value
309 9
			$attrNames = [];
310 9
			foreach ($scrape['extract'] as $extractRegexp)
311
			{
312
				// Use an attribute preprocessor so we can reuse its routines
313 9
				$attributePreprocessor = new AttributePreprocessor($extractRegexp);
314
315 9
				foreach ($attributePreprocessor->getAttributes() as $attrName => $attrRegexp)
316
				{
317 9
					$attrNames[] = $attrName;
318 9
					$attributes[$attrName]['regexp'] = $attrRegexp;
319
				}
320
			}
321
322
			// Deduplicate and sort the attribute names so that they look tidy
323 9
			$attrNames = array_unique($attrNames);
324 9
			sort($attrNames);
325
326
			// Prepare the scrape config and add the URL if applicable
327 9
			$entry = [$scrape['match'], $scrape['extract'], $attrNames];
328 9
			if (isset($scrape['url']))
329
			{
330 1
				$entry[] = $scrape['url'];
331
			}
332
333
			// Add this scrape to the config
334 9
			$scrapeConfig[] = $entry;
335
		}
336
337
		// Add the scrape filter to this tag, execute it right before attributes are filtered,
338
		// which should be after attribute preprocessors are run. The offset is hardcoded here
339
		// for convenience (and because we know the filterChain is in its default state) and
340
		// since scraping is impossible in JavaScript without a PHP proxy, we just make it
341
		// return true in order to keep the tag valid
342 35
		$tag->filterChain->insert(1, __NAMESPACE__ . '\\Parser::scrape')
343 35
		                 ->addParameterByName('scrapeConfig')
344 35
		                 ->addParameterByName('cacheDir')
345 35
		                 ->setVar('scrapeConfig', $scrapeConfig)
346 35
		                 ->setJS('returnTrue');
347
348 35
		return $attributes;
349
	}
350
351
	/**
352
	* Append a filter to an attribute's filterChain
353
	*
354
	* @param  Attribute $attribute Target attribute
355
	* @param  string    $filter    Filter's name
356
	* @return void
357
	*/
358 4
	protected function appendFilter(Attribute $attribute, $filter)
359
	{
360 4
		if (!in_array($filter, $this->allowedFilters, true))
361
		{
362 2
			throw new RuntimeException("Filter '" . $filter . "' is not allowed");
363
		}
364
365 2
		$attribute->filterChain->append($this->configurator->attributeFilters[$filter]);
366 2
	}
367
368
	/**
369
	* Validate and normalize a site ID
370
	*
371
	* @param  string $siteId
372
	* @return string
373
	*/
374 37
	protected function normalizeId($siteId)
375
	{
376 37
		$siteId = strtolower($siteId);
377
378 37
		if (!preg_match('(^[a-z0-9]+$)', $siteId))
379
		{
380 1
			throw new InvalidArgumentException('Invalid site ID');
381
		}
382
383 36
		return $siteId;
384
	}
385
}