Completed
Push — master ( 389cfb...bc3e86 )
by Michal
07:54
created

AssetMacro::resolveManifest()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 21
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 4.0119

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 21
ccs 10
cts 11
cp 0.9091
rs 9.0534
cc 4
eloc 11
nc 4
nop 4
crap 4.0119
1
<?php
2
3
namespace Webrouse\AssetMacro;
4
5
use Latte;
6
use Latte\Macros\MacroSet;
7
use Nette\Utils\Json;
8
use Nette\Utils\Strings;
9
use Nette\Utils\Validators;
10
use Webrouse\AssetMacro\Exceptions\AssetNotFoundException;
11
use Webrouse\AssetMacro\Exceptions\AssetVersionNotFound;
12
use Webrouse\AssetMacro\Exceptions\DirNotFoundException;
13
use Webrouse\AssetMacro\Exceptions\InvalidPathException;
14
use Webrouse\AssetMacro\Exceptions\InvalidVariableException;
15
use Webrouse\AssetMacro\Exceptions\ManifestNotFoundException;
16
17
18 1
class AssetMacro extends MacroSet
19
{
20
21
	const CONFIG_PROVIDER = 'assetMacroConfig';
22
23
24
	/**
25
	 * @param Latte\Compiler $compiler
26
	 */
27 1
	public static function install(Latte\Compiler $compiler)
28
	{
29 1
		$me = new self($compiler);
30 1
		$me->addMacro('asset', [$me, 'macroAsset']);
31 1
	}
32
33
34
	/**
35
	 * @param Latte\MacroNode $node
36
	 * @param Latte\PhpWriter $writer
37
	 * @return string
38
	 * @throws Latte\CompileException
39
	 */
40 1
	public function macroAsset(Latte\MacroNode $node, Latte\PhpWriter $writer)
41
	{
42 1
		$args = trim($node->args);
43
44
		// Validate arguments count
45 1
		$argsCount = $args === '' ? 0 : (substr_count($args, ',') + 1);
46 1
		if ($argsCount === 0) {
47 1
			throw new Latte\CompileException("Asset macro requires at least one argument.");
48
		}
49 1
		if ($argsCount > 3) {
50 1
			throw new Latte\CompileException("Asset macro must have no more than 3 arguments.");
51
		}
52
53 1
		return $writer->write(
54
			'echo %escape(' . self::class . '::resolveAssetPath(' .
55 1
			'%node.word, %node.array, $basePath, $template->global->' . self::CONFIG_PROVIDER . '))');
56
	}
57
58
    /**
59
     * @param string $asset     Asset relative path
60
     * @param array $args       Other macro arguments
61
     * @param string $basePath  Base path
62
     * @param array $config     Macro configuration
63
     * @return string
64
     */
65 1
	public static function resolveAssetPath($asset, array $args, $basePath, array $config) {
66 1
        list($path, $format, $need) = self::processArguments($asset, $args);
67 1
	    $wwwDir = self::normalizePath($config['wwwDir']);
68 1
        $manifest = self::resolveManifest($path, $need, $wwwDir, $config);
69 1
        $revision = $manifest === NULL ? NULL : self::resolveRevision($manifest, $path, $need, $config);
70
71
        // Is revision only version (query parameter) or full path to asset?
72 1
        $isVersion = $revision === NULL || strspn($revision,"./") === 0;
73
74
        // Check if asset exists
75 1
        if ($config['existCheck'] === TRUE) {
76 1
            $ds = DIRECTORY_SEPARATOR;
77 1
            $filePath = $isVersion ? ($wwwDir . $ds . $path) : ($wwwDir . $ds . self::normalizePath($revision));
78 1
            if (! file_exists($filePath)) {
79 1
                $msg = sprintf("Asset '%s' not found.", $filePath);
80 1
                self::throwError(new AssetNotFoundException($msg), $config['missingAsset'], $need);
81 1
                return '';
82
            }
83
        }
84
85
        // Format output
86 1
        return self::formatOutput($format, $basePath, $path, $revision, $isVersion);
87
    }
88
89
    /**
90
     * @param string $format         Output format
91
     * @param string $basePath       Base path
92
     * @param string $path           Asset relative path
93
     * @param string|null $revision  Asset revision (version or path to file)
94
     * @param bool   $isVersion      Is revision only version or full path?
95
     * @return string
96
     */
97
    private static function formatOutput($format, $basePath, $path, $revision, $isVersion)
98
    {
99 1
        $revision = $revision ?: 'unknown';
100 1
        $path = $isVersion ?
101 1
            sprintf("%s?v=%s", $path, $revision) :
102 1
            $revision;
103
104 1
        return Strings::replace($format,
105 1
            '/%([^%]+)%/',
106 1
            function ($matches) use ($basePath, $format, $path, $revision) {
107 1
                switch ($matches[1]) {
108 1
                    case 'raw':
109
                        return $revision;
110 1
                    case 'basePath':
111 1
                        return $basePath;
112 1
                    case 'path':
113 1
                        return $path;
114 1
                    case 'url':
115 1
                        return sprintf("%s/%s", $basePath, $path);
116
                    default:
117 1
                        $msg = sprintf(
118
                            "Asset macro: Invalid variable '%s' in format '%s'. " .
119 1
                            "Use one of allowed variables: %%raw%%, %%basePath%%, %%path%%, %%url%%.",
120 1
                            $matches[1],
121 1
                            $format
122
                        );
123 1
                        throw new InvalidVariableException($msg);
124
                }
125 1
            });
126
    }
127
128
    /**
129
     * @param string $asset  Asset path specified in macro
130
     * @param array $args    Macro arguments
131
     * @return array
132
     */
133 1
    private static function processArguments($asset, array $args)
134
    {
135 1
        $format = isset($args['format']) ? $args['format'] : (isset($args[0]) ? $args[0] : '%url%');
136 1
        $need = isset($args['need']) ? $args['need'] : (isset($args[1]) ? $args[1] : TRUE);
137
138 1
        Validators::assert($asset, 'string', 'path');
139 1
        Validators::assert($format, 'string', 'format');
140 1
        Validators::assert($need, 'bool', 'need');
141
142 1
        $path = self::normalizePath($asset);
143
144 1
        return [$path, $format, $need];
145
    }
146
147
    /**
148
     * @param string $asset   Asset path specified in macro
149
     * @param bool $need      Fail if manifest doesn't exist?
150
     * @param string $wwwDir  Public www dir
151
     * @param array $config   Macro configuration
152
     * @return null|array
153
     */
154 1
    private static function resolveManifest($asset, $need, $wwwDir, array $config) {
155 1
        $manifest = $config['manifest'];
156
157
	    // Asset revisions specified directly in configuration
158 1
        if (is_array($manifest)) {
159
            return $manifest;
160
        }
161
162
        // Path to JSON manifest
163 1
        if (is_string($manifest)) {
164 1
            if ( ! file_exists($manifest)) {
165 1
                $msg = sprintf("Manifest file not found: '%s'.", $manifest);
166 1
                self::throwError(new ManifestNotFoundException($msg), $config['missingManifest'], $need);
167 1
                return NULL;
168
            }
169 1
            return Json::decode(file_get_contents($manifest), Json::FORCE_ARRAY);
170
        }
171
172
        // Autodetect manifest path
173 1
        return self::autodetectManifest($asset, $wwwDir, $need, $config);
174
    }
175
176
    /**
177
     * @param string $asset   Asset path specified in macro
178
     * @param string $wwwDir  Public www dir
179
     * @param bool $need      Fail if asset/manifest doesn't exist?
180
     * @param array $config   Macro configuration
181
     * @return null|array
182
     */
183 1
    private static function autodetectManifest($asset, $wwwDir, $need, array $config) {
184
        // Get asset dir, there begins search manifest
185 1
        $dir = realpath($wwwDir . DIRECTORY_SEPARATOR . dirname($asset));
186 1
        if ($dir === FALSE) {
187
            $msg = sprintf("Parent dir of asset '%s' not found.", $asset);
188
            self::throwError(new DirNotFoundException($msg), $config['missingAsset'], $need);
189
            return NULL;
190
        }
191
192
        // Autodetect manifest
193 1
        $autodetectPaths = $config['autodetect'];
194 1
        while (Strings::startsWith($dir, $wwwDir)) {
195 1
            foreach ($autodetectPaths as $path) {
196 1
                $path = $dir . DIRECTORY_SEPARATOR . $path;
197 1
                if (file_exists($path)) {
198 1
                    return Json::decode(file_get_contents($path), Json::FORCE_ARRAY);
199
                }
200
            }
201
202 1
            $dir = dirname($dir); // go up
203
        }
204
205 1
        $msg = sprintf("Manifest not found in: %s.", implode(', ', $autodetectPaths));
206 1
        self::throwError(new ManifestNotFoundException($msg), $config['missingManifest'], $need);
207
        return NULL;
208
    }
209
210
    /**
211
     * @param null|array $manifest  Array of revisions
212
     * @param string $path          Asset path
213
     * @param bool $need            Fail if revision doesn't exist?
214
     * @param array $config         Macro configuration
215
     * @return null|string
216
     */
217 1
    private static function resolveRevision($manifest, $path, $need, array $config) {
218 1
        $revision = isset($manifest[$path]) ? $manifest[$path] : NULL;
219
        
220 1
        if ($revision === NULL) {
221 1
            $msg = sprintf("Revision for asset '%s' not found in manifest.", $path);
222 1
            self::throwError(new AssetVersionNotFound($msg), $config['missingRevision'], $need);
223
        }
224
        
225 1
        return $revision;
226
    }
227
228
229
	/**
230
	 * @param \Exception $e
231
	 * @param string $action
232
	 * @param bool $need
233
	 * @throws \Exception
234
	 */
235 1
	private static function throwError(\Exception $e, $action = 'exception', $need = TRUE)
236
	{
237 1
		if ($need) {
238 1
			if ($action === 'exception') {
239 1
				throw $e;
240
241 1
			} elseif ($action === 'notice') {
242 1
				trigger_error($e->getMessage(), E_USER_NOTICE);
243
			}
244
		}
245 1
	}
246
247 1
	private static function normalizePath($path, $separator = '\\/')
248
    {
249
        // Remove any kind of unicode whitespace
250 1
        $normalized = preg_replace('#\p{C}+|^\./#u', '', $path);
251
252
        // Path remove self referring paths ("/./").
253 1
        $normalized = preg_replace('#/\.(?=/)|^\./|\./$#', '', $normalized);
254
255
        // Regex for resolving relative paths
256 1
        $regex = '#\/*[^/\.]+/\.\.#Uu';
257
258 1
        while (preg_match($regex, $normalized)) {
259
            $normalized = preg_replace($regex, '', $normalized);
260
        }
261
262 1
        if (preg_match('#/\.{2}|\.{2}/#', $normalized)) {
263
            throw new InvalidPathException(
264
                sprintf("Path is outside of the defined root, path: '%s', resolved: '%s'.", $path, $normalized)
265
            );
266
        }
267
268 1
        return rtrim($normalized, $separator);
269
    }
270
271
}
272