Completed
Push — master ( 4fa9cb...c3dd31 )
by Nathan
02:40
created

HtmlElement::alreadyResolved()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
eloc 2
nc 1
nop 1
1
<?php
2
3
namespace NatePage\EasyHtmlElement;
4
5
use NatePage\EasyHtmlElement\Exception\InvalidArgumentsNumberException;
6
use NatePage\EasyHtmlElement\Exception\InvalidElementException;
7
use NatePage\EasyHtmlElement\Exception\UndefinedElementException;
8
9
class HtmlElement implements HtmlElementInterface
10
{
11
    /** @var array */
12
    private $map;
13
14
    /** @var EscaperInterface */
15
    private $escaper;
16
17
    /** @var BranchValidatorInterface */
18
    private $branchValidator;
19
20
    /** @var array The already resolved elements */
21
    private $resolved = array();
22
23
    /** @var array The default values of element options */
24
    private $defaults = array(
25
        'parent' => null,
26
        'children' => array(),
27
        'extends' => array(),
28
        'attr' => array(),
29
        'text' => null,
30
        'type' => null,
31
        'class' => Element::class
32
    );
33
34
    /** @var array The mergeable attributes */
35
    private $mergeableAttributes = array('class', 'style');
36
37
    /**
38
     * HtmlElement constructor.
39
     *
40
     * @param array                         $map             The elements map
41
     * @param BranchValidatorInterface|null $branchValidator The branch validator
42
     * @param EscaperInterface|null         $escaper         The escaper, by default ZendFramework/Escaper is used
43
     * @param string                        $encoding        The encoding used for escaping, by default utf-8 is used
44
     */
45
    public function __construct(
46
        array $map = array(),
47
        BranchValidatorInterface $branchValidator = null,
48
        EscaperInterface $escaper = null,
49
        $encoding = 'utf-8')
50
    {
51
        $this->map = $map;
52
        $this->branchValidator = null !== $branchValidator ? $branchValidator : new BranchValidator($this);
53
        $this->escaper = null !== $escaper ? $escaper : new Escaper($encoding);
54
    }
55
56
    /**
57
     * Load element on dynamic calls.
58
     *
59
     * @param string $name      The element name
60
     * @param array  $arguments The arguments array to set:
61
     *                          [0] = text (string|null)
62
     *                          [1] = attributes (array)
63
     *                          [2] = parameters (array)
64
     *                          [3] = extras (array)
65
     *                          [4] = children (array)
66
     *
67
     * @return ElementInterface
68
     *
69
     * @throws InvalidArgumentsNumberException If the arguments length is more than 3
70
     */
71
    public function __call($name, $arguments)
72
    {
73
        array_unshift($arguments, $name);
74
75
        return $this->load(...$arguments);
76
    }
77
78
    /**
79
     * {@inheritdoc}
80
     */
81
    public function getMap(): array
82
    {
83
        return $this->map;
84
    }
85
86
    /**
87
     * {@inheritdoc}
88
     */
89
    public function setMap(array $map): HtmlElementInterface
90
    {
91
        $this->map = $map;
92
93
        return $this;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $this; (NatePage\EasyHtmlElement\HtmlElement) is incompatible with the return type declared by the interface NatePage\EasyHtmlElement...lementInterface::setMap of type self.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
94
    }
95
96
    /**
97
     * {@inheritdoc}
98
     */
99
    public function addManyToMap(array $elements): HtmlElementInterface
100
    {
101
        foreach ($elements as $name => $element) {
102
            $this->addOneToMap($name, $element);
103
        }
104
105
        return $this;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $this; (NatePage\EasyHtmlElement\HtmlElement) is incompatible with the return type declared by the interface NatePage\EasyHtmlElement...Interface::addManyToMap of type self.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
106
    }
107
108
    /**
109
     * {@inheritdoc}
110
     */
111
    public function addOneToMap(string $name, array $element): HtmlElementInterface
112
    {
113
        $this->map[$name] = $element;
114
115
        return $this;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $this; (NatePage\EasyHtmlElement\HtmlElement) is incompatible with the return type declared by the interface NatePage\EasyHtmlElement...tInterface::addOneToMap of type self.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
116
    }
117
118
    /**
119
     * {@inheritdoc}
120
     */
121
    public function getBranchValidator(): BranchValidatorInterface
122
    {
123
        return $this->branchValidator;
124
    }
125
126
    /**
127
     * {@inheritdoc}
128
     */
129
    public function setBranchValidator(BranchValidatorInterface $branchValidator): HtmlElementInterface
130
    {
131
        $this->branchValidator = $branchValidator;
132
133
        return $this;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $this; (NatePage\EasyHtmlElement\HtmlElement) is incompatible with the return type declared by the interface NatePage\EasyHtmlElement...ace::setBranchValidator of type self.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
134
    }
135
136
    /**
137
     * {@inheritdoc}
138
     */
139
    public function getEscaper(): EscaperInterface
140
    {
141
        return $this->escaper;
142
    }
143
144
    /**
145
     * {@inheritdoc}
146
     */
147
    public function setEscaper(EscaperInterface $escaper): HtmlElementInterface
148
    {
149
        $this->escaper = $escaper;
150
151
        return $this;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return $this; (NatePage\EasyHtmlElement\HtmlElement) is incompatible with the return type declared by the interface NatePage\EasyHtmlElement...ntInterface::setEscaper of type self.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
152
    }
153
154
    /**
155
     * {@inheritdoc}
156
     */
157
    public function load(
158
        $name,
159
        $text = null,
160
        array $attributes = array(),
161
        array $parameters = array(),
162
        array $extras = array(),
163
        array $children = array()
164
    ): ElementInterface
165
    {
166
        $element = $this->getInstance($name, $text, $extras, $parameters, true);
167
168
        $element->addAttributes($this->escaper->escapeAttributes($attributes));
169
170
        foreach ($children as $child) {
171
            $element->addChild($this->escaper->escape($child->getRoot()));
172
        }
173
174
        return $element;
175
    }
176
177
    /**
178
     * Get the element instance.
179
     *
180
     * @param string|array $name       The element name
181
     * @param string|null  $text       The element text
182
     * @param array        $extras     The element extras
183
     * @param array        $parameters The parameters to replace in element
184
     * @param bool         $mainCall   Determine if it's the main(first) call of the method
185
     *
186
     * @return ElementInterface
187
     *
188
     * @throws InvalidElementException If the current instance doesn't implement ElementInterface
189
     */
190
    private function getInstance(
191
        $name, $text,
192
        array $extras,
193
        array $parameters,
194
        bool $mainCall = false
195
    ): ElementInterface
196
    {
197
        $element = $this->resolveElement($name, $parameters, $mainCall);
198
199
        $class = $element['class'];
200
        $type = $element['type'];
201
        $text = $text !== null ? $text : $element['text'];
202
        $attributes = $element['attr'];
203
204
        $children = array();
205
        foreach ((array) $element['children'] as $child) {
206
            $children[] = $this->getInstance($child, null, array(), $parameters);
207
        }
208
209
        $instance = new $class($type, $text, $attributes, $extras, $children);
210
211
        if (!$instance instanceof ElementInterface) {
212
            throw new InvalidElementException(sprintf(
213
                'The element "%s" does not implement the %s',
214
                get_class($instance),
215
                ElementInterface::class
216
            ));
217
        }
218
219
        if (null !== $element['parent']) {
220
            $parent = $this->getInstance($element['parent'], null, array(), $parameters);
221
222
            $parent->addChild($instance->getRoot());
223
        }
224
225
        return $this->escaper->escape($instance);
226
    }
227
228
    /**
229
     * Get the resolved element representation.
230
     *
231
     * @param string|array $name       The current element name
232
     * @param array        $parameters The parameters to replace in element
233
     * @param bool         $mainCall   Determine if it's the main(first) call of the method
234
     *
235
     * @return array
236
     */
237
    private function resolveElement($name, array $parameters, bool $mainCall = false): array
238
    {
239
        $current = $this->getCurrentElement($name);
240
241
        $name = $current['name'];
242
243
        if ($this->alreadyResolved($name)) {
244
            return $this->replaceParameters($this->resolved[$name], $parameters);
245
        }
246
247
        if ($mainCall) {
248
            $this->branchValidator->validateBranch($name);
249
        }
250
251
        foreach ($this->defaults as $default => $value) {
252
            if (!isset($current[$default])) {
253
                $current[$default] = $value;
254
            }
255
        }
256
257
        foreach ((array) $current['extends'] as $extend) {
258
            $extend = $this->resolveElement($extend, $parameters);
259
            $current = $this->extendElement($extend, $current);
260
        }
261
262
        $this->resolved[$name] = $current;
263
264
        $current = $this->replaceParameters($current, $parameters);
265
266
        return $current;
267
    }
268
269
    /**
270
     * Check if an element has been already resolved.
271
     *
272
     * @param string $name
273
     *
274
     * @return bool
275
     */
276
    private function alreadyResolved(string $name): bool
277
    {
278
        return array_key_exists($name, $this->resolved);
279
    }
280
281
    /**
282
     * {@inheritdoc}
283
     */
284
    public function exists(string $name): bool
285
    {
286
        return array_key_exists(lcfirst($name), $this->map);
287
    }
288
289
    /**
290
     * Get the current element representation.
291
     *
292
     * @param string|array $name The element name
293
     *
294
     * @return array
295
     *
296
     * @throws InvalidElementException   If the current element is defined dynamically and doesn't define a name
297
     * @throws UndefinedElementException If the current element doesn't exist
298
     */
299
    public function getCurrentElement($name): array
300
    {
301
        if (is_array($name)) {
302
            if (!isset($name['name'])) {
303
                throw new InvalidElementException(sprintf(
304
                    'Elements defined dynamically in parent or children must define a name.'
305
                ));
306
            }
307
308
            return $name;
309
        }
310
311
        if (!$this->exists($name)) {
312
            throw new UndefinedElementException(sprintf('The element with name "%s" does not exist.', $name));
313
        }
314
315
        $current = $this->map[lcfirst($name)];
316
        $current['name'] = $name;
317
318
        return $current;
319
    }
320
321
    /**
322
     * Replace parameters in text and attr.
323
     *
324
     * @param array $element    The element with the parameters to replace
325
     * @param array $parameters The array of parameters values
326
     *
327
     * @return array
328
     */
329
    private function replaceParameters(array $element, array $parameters): array
330
    {
331
        foreach ($parameters as $parameter => $replace) {
332
            $element['text'] = str_replace('%'.$parameter.'%', $replace, (string) $element['text']);
333
        }
334
335
        $element['attr'] = $this->replaceParametersInAttributes($element['attr'], $parameters);
336
337
        return $element;
338
    }
339
340
    /**
341
     * Replace parameters in attr.
342
     *
343
     * @param array $attributes The attributes
344
     * @param array $parameters The parameters
345
     *
346
     * @return array
347
     */
348
    private function replaceParametersInAttributes(array $attributes, array $parameters): array
349
    {
350
        foreach ($attributes as $key => $value) {
351
            if (is_array($value)) {
352
                $attributes[$key] = $this->replaceParametersInAttributes($value, $parameters);
353
            }
354
355
            foreach ($parameters as $parameter => $replace) {
356
                if (in_array($key, $this->escaper->getUrlsAttributes()) && $this->escaper->isEscapeUrl()) {
357
                    $replace = $this->escaper->escapeUrlParameter($replace);
358
                }
359
360
                $value = str_replace('%'.$parameter.'%', $replace, (string) $value);
361
            }
362
363
            $attributes[$key] = $value;
364
        }
365
366
        return $attributes;
367
    }
368
369
    /**
370
     * Extend element from another one.
371
     *
372
     * @param array $extend  The array of the element to extend
373
     * @param array $current The current element which extends
374
     *
375
     * @return array
376
     */
377
    private function extendElement(array $extend, array $current): array
378
    {
379
        foreach ($this->defaults as $default => $value) {
380
            if (!in_array($default, array('attr', 'children')) && $current[$default] === $value) {
381
                $current[$default] = $extend[$default];
382
            }
383
        }
384
385
        $current['attr'] = $this->extendAttributes($extend['attr'], $current['attr']);
386
387
        foreach ($extend['children'] as $child) {
388
            $current['children'][] = $child;
389
        }
390
391
        return $current;
392
    }
393
394
    /**
395
     * Extend attributes from another element.
396
     *
397
     * @param array $from The array of attributes to extend
398
     * @param array $to   The array of attributes which extends
399
     *
400
     * @return array
401
     */
402
    private function extendAttributes(array $from, array $to): array
403
    {
404
        foreach ($from as $key => $value) {
405
            if (in_array($key, $this->mergeableAttributes) && isset($to[$key])) {
406
                $to[$key] = $this->extendMergeableAttributes($value, $to[$key], $key);
407
            } elseif (!isset($to[$key])) {
408
                $to[$key] = $value;
409
            } elseif (is_array($value)) {
410
                $to[$key] = $this->extendAttributes($value, $to[$key]);
411
            }
412
        }
413
414
        return $to;
415
    }
416
417
    /**
418
     * Extend mergeable attributes from another element.
419
     *
420
     * @param string|array $from The attribute to extend
421
     * @param string|array $to   The attribute which extends
422
     * @param string       $attr The attribute name
423
     *
424
     * @return string
425
     */
426
    private function extendMergeableAttributes($from, $to, string $attr): string
427
    {
428
        $value = array_merge((array) $to, (array) $from);
429
430
        switch ($attr) {
431
            case 'class':
432
                return implode(' ', $value);
433
            case 'style':
434
                return implode('; ', $value);
435
        }
436
    }
437
}
438