Completed
Push — master ( b7ee53...d27157 )
by Josh
14:21
created

Configurator::appendTemplate()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

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