1<?php
2/**
3 * Zend Framework (http://framework.zend.com/)
4 *
5 * @link      http://github.com/zendframework/zf2 for the canonical source repository
6 * @copyright Copyright (c) 2005-2015 Zend Technologies USA Inc. (http://www.zend.com)
7 * @license   http://framework.zend.com/license/new-bsd New BSD License
8 */
9
10namespace Zend\View\Helper;
11
12use stdClass;
13use Zend\View;
14use Zend\View\Exception;
15
16/**
17 * Helper for setting and retrieving script elements for HTML head section
18 *
19 * Allows the following method calls:
20 * @method HeadScript appendFile($src, $type = 'text/javascript', $attrs = array())
21 * @method HeadScript offsetSetFile($index, $src, $type = 'text/javascript', $attrs = array())
22 * @method HeadScript prependFile($src, $type = 'text/javascript', $attrs = array())
23 * @method HeadScript setFile($src, $type = 'text/javascript', $attrs = array())
24 * @method HeadScript appendScript($script, $type = 'text/javascript', $attrs = array())
25 * @method HeadScript offsetSetScript($index, $src, $type = 'text/javascript', $attrs = array())
26 * @method HeadScript prependScript($script, $type = 'text/javascript', $attrs = array())
27 * @method HeadScript setScript($script, $type = 'text/javascript', $attrs = array())
28 */
29class HeadScript extends Placeholder\Container\AbstractStandalone
30{
31    /**
32     * Script type constants
33     *
34     * @const string
35     */
36    const FILE   = 'FILE';
37    const SCRIPT = 'SCRIPT';
38
39    /**
40     * Registry key for placeholder
41     *
42     * @var string
43     */
44    protected $regKey = 'Zend_View_Helper_HeadScript';
45
46    /**
47     * Are arbitrary attributes allowed?
48     *
49     * @var bool
50     */
51    protected $arbitraryAttributes = false;
52
53    /**
54     * Is capture lock?
55     *
56     * @var bool
57     */
58    protected $captureLock;
59
60    /**
61     * Capture type
62     *
63     * @var string
64     */
65    protected $captureScriptType;
66
67    /**
68     * Capture attributes
69     *
70     * @var null|array
71     */
72    protected $captureScriptAttrs = null;
73
74    /**
75     * Capture type (append, prepend, set)
76     *
77     * @var string
78     */
79    protected $captureType;
80
81    /**
82     * Optional allowed attributes for script tag
83     *
84     * @var array
85     */
86    protected $optionalAttributes = array(
87        'charset',
88        'crossorigin',
89        'defer',
90        'language',
91        'src',
92    );
93
94    /**
95     * Required attributes for script tag
96     *
97     * @var string
98     */
99    protected $requiredAttributes = array('type');
100
101    /**
102     * Whether or not to format scripts using CDATA; used only if doctype
103     * helper is not accessible
104     *
105     * @var bool
106     */
107    public $useCdata = false;
108
109    /**
110     * Constructor
111     *
112     * Set separator to PHP_EOL.
113     */
114    public function __construct()
115    {
116        parent::__construct();
117
118        $this->setSeparator(PHP_EOL);
119    }
120
121    /**
122     * Return headScript object
123     *
124     * Returns headScript helper object; optionally, allows specifying a script
125     * or script file to include.
126     *
127     * @param  string $mode      Script or file
128     * @param  string $spec      Script/url
129     * @param  string $placement Append, prepend, or set
130     * @param  array  $attrs     Array of script attributes
131     * @param  string $type      Script type and/or array of script attributes
132     * @return HeadScript
133     */
134    public function __invoke(
135        $mode = self::FILE,
136        $spec = null,
137        $placement = 'APPEND',
138        array $attrs = array(),
139        $type = 'text/javascript'
140    ) {
141        if ((null !== $spec) && is_string($spec)) {
142            $action    = ucfirst(strtolower($mode));
143            $placement = strtolower($placement);
144            switch ($placement) {
145                case 'set':
146                case 'prepend':
147                case 'append':
148                    $action = $placement . $action;
149                    break;
150                default:
151                    $action = 'append' . $action;
152                    break;
153            }
154            $this->$action($spec, $type, $attrs);
155        }
156
157        return $this;
158    }
159
160    /**
161     * Overload method access
162     *
163     * @param  string $method Method to call
164     * @param  array  $args   Arguments of method
165     * @throws Exception\BadMethodCallException if too few arguments or invalid method
166     * @return HeadScript
167     */
168    public function __call($method, $args)
169    {
170        if (preg_match('/^(?P<action>set|(ap|pre)pend|offsetSet)(?P<mode>File|Script)$/', $method, $matches)) {
171            if (1 > count($args)) {
172                throw new Exception\BadMethodCallException(sprintf(
173                    'Method "%s" requires at least one argument',
174                    $method
175                ));
176            }
177
178            $action  = $matches['action'];
179            $mode    = strtolower($matches['mode']);
180            $type    = 'text/javascript';
181            $attrs   = array();
182
183            if ('offsetSet' == $action) {
184                $index = array_shift($args);
185                if (1 > count($args)) {
186                    throw new Exception\BadMethodCallException(sprintf(
187                        'Method "%s" requires at least two arguments, an index and source',
188                        $method
189                    ));
190                }
191            }
192
193            $content = $args[0];
194
195            if (isset($args[1])) {
196                $type = (string) $args[1];
197            }
198            if (isset($args[2])) {
199                $attrs = (array) $args[2];
200            }
201
202            switch ($mode) {
203                case 'script':
204                    $item = $this->createData($type, $attrs, $content);
205                    if ('offsetSet' == $action) {
206                        $this->offsetSet($index, $item);
207                    } else {
208                        $this->$action($item);
209                    }
210                    break;
211                case 'file':
212                default:
213                    if (!$this->isDuplicate($content)) {
214                        $attrs['src'] = $content;
215                        $item = $this->createData($type, $attrs);
216                        if ('offsetSet' == $action) {
217                            $this->offsetSet($index, $item);
218                        } else {
219                            $this->$action($item);
220                        }
221                    }
222                    break;
223            }
224
225            return $this;
226        }
227
228        return parent::__call($method, $args);
229    }
230
231    /**
232     * Retrieve string representation
233     *
234     * @param  string|int $indent Amount of whitespaces or string to use for indention
235     * @return string
236     */
237    public function toString($indent = null)
238    {
239        $indent = (null !== $indent)
240            ? $this->getWhitespace($indent)
241            : $this->getIndent();
242
243        if ($this->view) {
244            $useCdata = $this->view->plugin('doctype')->isXhtml();
245        } else {
246            $useCdata = $this->useCdata;
247        }
248
249        $escapeStart = ($useCdata) ? '//<![CDATA[' : '//<!--';
250        $escapeEnd   = ($useCdata) ? '//]]>' : '//-->';
251
252        $items = array();
253        $this->getContainer()->ksort();
254        foreach ($this as $item) {
255            if (!$this->isValid($item)) {
256                continue;
257            }
258
259            $items[] = $this->itemToString($item, $indent, $escapeStart, $escapeEnd);
260        }
261
262        return implode($this->getSeparator(), $items);
263    }
264
265    /**
266     * Start capture action
267     *
268     * @param  mixed  $captureType Type of capture
269     * @param  string $type        Type of script
270     * @param  array  $attrs       Attributes of capture
271     * @throws Exception\RuntimeException
272     * @return void
273     */
274    public function captureStart(
275        $captureType = Placeholder\Container\AbstractContainer::APPEND,
276        $type = 'text/javascript',
277        $attrs = array()
278    ) {
279        if ($this->captureLock) {
280            throw new Exception\RuntimeException('Cannot nest headScript captures');
281        }
282
283        $this->captureLock        = true;
284        $this->captureType        = $captureType;
285        $this->captureScriptType  = $type;
286        $this->captureScriptAttrs = $attrs;
287        ob_start();
288    }
289
290    /**
291     * End capture action and store
292     *
293     * @return void
294     */
295    public function captureEnd()
296    {
297        $content                  = ob_get_clean();
298        $type                     = $this->captureScriptType;
299        $attrs                    = $this->captureScriptAttrs;
300        $this->captureScriptType  = null;
301        $this->captureScriptAttrs = null;
302        $this->captureLock        = false;
303
304        switch ($this->captureType) {
305            case Placeholder\Container\AbstractContainer::SET:
306            case Placeholder\Container\AbstractContainer::PREPEND:
307            case Placeholder\Container\AbstractContainer::APPEND:
308                $action = strtolower($this->captureType) . 'Script';
309                break;
310            default:
311                $action = 'appendScript';
312                break;
313        }
314
315        $this->$action($content, $type, $attrs);
316    }
317
318    /**
319     * Create data item containing all necessary components of script
320     *
321     * @param  string $type       Type of data
322     * @param  array  $attributes Attributes of data
323     * @param  string $content    Content of data
324     * @return stdClass
325     */
326    public function createData($type, array $attributes, $content = null)
327    {
328        $data             = new stdClass();
329        $data->type       = $type;
330        $data->attributes = $attributes;
331        $data->source     = $content;
332
333        return $data;
334    }
335
336    /**
337     * Is the file specified a duplicate?
338     *
339     * @param  string $file Name of file to check
340     * @return bool
341     */
342    protected function isDuplicate($file)
343    {
344        foreach ($this->getContainer() as $item) {
345            if (($item->source === null)
346                && array_key_exists('src', $item->attributes)
347                && ($file == $item->attributes['src'])
348            ) {
349                return true;
350            }
351        }
352
353        return false;
354    }
355
356    /**
357     * Is the script provided valid?
358     *
359     * @param  mixed  $value  Is the given script valid?
360     * @return bool
361     */
362    protected function isValid($value)
363    {
364        if ((!$value instanceof stdClass)
365            || !isset($value->type)
366            || (!isset($value->source)
367                && !isset($value->attributes))
368        ) {
369            return false;
370        }
371
372        return true;
373    }
374
375    /**
376     * Create script HTML
377     *
378     * @param  mixed  $item        Item to convert
379     * @param  string $indent      String to add before the item
380     * @param  string $escapeStart Starting sequence
381     * @param  string $escapeEnd   Ending sequence
382     * @return string
383     */
384    public function itemToString($item, $indent, $escapeStart, $escapeEnd)
385    {
386        $attrString = '';
387        if (!empty($item->attributes)) {
388            foreach ($item->attributes as $key => $value) {
389                if ((!$this->arbitraryAttributesAllowed() && !in_array($key, $this->optionalAttributes))
390                    || in_array($key, array('conditional', 'noescape'))) {
391                    continue;
392                }
393                if ('defer' == $key) {
394                    $value = 'defer';
395                }
396                $attrString .= sprintf(' %s="%s"', $key, ($this->autoEscape) ? $this->escape($value) : $value);
397            }
398        }
399
400        $addScriptEscape = !(isset($item->attributes['noescape'])
401            && filter_var($item->attributes['noescape'], FILTER_VALIDATE_BOOLEAN));
402
403        $type = ($this->autoEscape) ? $this->escape($item->type) : $item->type;
404        $html = '<script type="' . $type . '"' . $attrString . '>';
405        if (!empty($item->source)) {
406            $html .= PHP_EOL;
407
408            if ($addScriptEscape) {
409                $html .= $indent . '    ' . $escapeStart . PHP_EOL;
410            }
411
412            $html .= $indent . '    ' . $item->source;
413
414            if ($addScriptEscape) {
415                $html .= PHP_EOL . $indent . '    ' . $escapeEnd;
416            }
417
418            $html .= PHP_EOL . $indent;
419        }
420        $html .= '</script>';
421
422        if (isset($item->attributes['conditional'])
423            && !empty($item->attributes['conditional'])
424            && is_string($item->attributes['conditional'])
425        ) {
426            // inner wrap with comment end and start if !IE
427            if (str_replace(' ', '', $item->attributes['conditional']) === '!IE') {
428                $html = '<!-->' . $html . '<!--';
429            }
430            $html = $indent . '<!--[if ' . $item->attributes['conditional'] . ']>' . $html . '<![endif]-->';
431        } else {
432            $html = $indent . $html;
433        }
434
435        return $html;
436    }
437
438    /**
439     * Override append
440     *
441     * @param  string $value Append script or file
442     * @throws Exception\InvalidArgumentException
443     * @return void
444     */
445    public function append($value)
446    {
447        if (!$this->isValid($value)) {
448            throw new Exception\InvalidArgumentException(
449                'Invalid argument passed to append(); '
450                . 'please use one of the helper methods, appendScript() or appendFile()'
451            );
452        }
453
454        return $this->getContainer()->append($value);
455    }
456
457    /**
458     * Override prepend
459     *
460     * @param  string $value Prepend script or file
461     * @throws Exception\InvalidArgumentException
462     * @return void
463     */
464    public function prepend($value)
465    {
466        if (!$this->isValid($value)) {
467            throw new Exception\InvalidArgumentException(
468                'Invalid argument passed to prepend(); '
469                . 'please use one of the helper methods, prependScript() or prependFile()'
470            );
471        }
472
473        return $this->getContainer()->prepend($value);
474    }
475
476    /**
477     * Override set
478     *
479     * @param  string $value Set script or file
480     * @throws Exception\InvalidArgumentException
481     * @return void
482     */
483    public function set($value)
484    {
485        if (!$this->isValid($value)) {
486            throw new Exception\InvalidArgumentException(
487                'Invalid argument passed to set(); please use one of the helper methods, setScript() or setFile()'
488            );
489        }
490
491        return $this->getContainer()->set($value);
492    }
493
494    /**
495     * Override offsetSet
496     *
497     * @param  string|int $index Set script of file offset
498     * @param  mixed      $value
499     * @throws Exception\InvalidArgumentException
500     * @return void
501     */
502    public function offsetSet($index, $value)
503    {
504        if (!$this->isValid($value)) {
505            throw new Exception\InvalidArgumentException(
506                'Invalid argument passed to offsetSet(); '
507                . 'please use one of the helper methods, offsetSetScript() or offsetSetFile()'
508            );
509        }
510
511        return $this->getContainer()->offsetSet($index, $value);
512    }
513
514    /**
515     * Set flag indicating if arbitrary attributes are allowed
516     *
517     * @param  bool $flag Set flag
518     * @return HeadScript
519     */
520    public function setAllowArbitraryAttributes($flag)
521    {
522        $this->arbitraryAttributes = (bool) $flag;
523        return $this;
524    }
525
526    /**
527     * Are arbitrary attributes allowed?
528     *
529     * @return bool
530     */
531    public function arbitraryAttributesAllowed()
532    {
533        return $this->arbitraryAttributes;
534    }
535}
536