With empty input return an empty node to skip the full block
[packbundle] / Twig / PackTokenParser.php
1 <?php
2
3 namespace Rapsys\PackBundle\Twig;
4
5 use Symfony\Component\HttpKernel\Config\FileLocator;
6 use Symfony\Component\Asset\PackageInterface;
7 use Twig\Error\Error;
8 use Twig\Node\Expression\AssignNameExpression;
9 use Twig\Node\Node;
10 use Twig\Node\SetNode;
11 use Twig\Node\TextNode;
12 use Twig\Token;
13 use Twig\TokenParser\AbstractTokenParser;
14
15 class PackTokenParser extends AbstractTokenParser {
16 //The tag name
17 private $tag;
18
19 /**
20 * Constructor
21 *
22 * @param class $locator The FileLocator instance
23 * @param class $package The Assets Package instance
24 * @param string $config The config path
25 * @param string $tag The tag name
26 * @param string $output The default output string
27 * @param array $filters The default filters array
28 */
29 public function __construct(FileLocator $locator, PackageInterface $package, $config, $tag, $output, $filters) {
30 //Save locator
31 $this->locator = $locator;
32
33 //Save assets package
34 $this->package = $package;
35
36 //Set name
37 $this->name = $config['name'];
38
39 //Set scheme
40 $this->scheme = $config['scheme'];
41
42 //Set timeout
43 $this->timeout = $config['timeout'];
44
45 //Set agent
46 $this->agent = $config['agent'];
47
48 //Set redirect
49 $this->redirect = $config['redirect'];
50
51 //Set tag
52 $this->tag = $tag;
53
54 //Set output
55 $this->output = $output;
56
57 //Set filters
58 $this->filters = $filters;
59 }
60
61 /**
62 * Get the tag name
63 */
64 public function getTag() {
65 return $this->tag;
66 }
67
68 /**
69 * Parse the token
70 *
71 * @param class $token The \Twig\Token instance
72 *
73 * @return class The PackNode
74 *
75 * @todo see if we can't add a debug mode behaviour
76 *
77 * If twig.debug or env=dev (or rapsys_pack.config.debug?) is set, it should be possible to loop on each input
78 * and process the captured body without applying requested filter.
79 *
80 * @todo list:
81 * - detect debug mode
82 * - retrieve fixe link from input s%@(Name)Bundle/Resources/public(/somewhere/file.ext)%/bundles/\L\1\E\2%
83 * - for each inputs:
84 * - generate a set asset_url=x
85 * - generate a body
86 */
87 public function parse(Token $token) {
88 $parser = $this->parser;
89 $stream = $this->parser->getStream();
90
91 $inputs = [];
92 $name = $this->name;
93 $output = $this->output;
94 $filters = $this->filters;
95
96 $content = '';
97
98 //Process the token block until end
99 while (!$stream->test(Token::BLOCK_END_TYPE)) {
100 //The files to process
101 if ($stream->test(Token::STRING_TYPE)) {
102 //'somewhere/somefile.(css,img,js)' 'somewhere/*' '@jquery'
103 $inputs[] = $stream->next()->getValue();
104 //The filters token
105 } elseif ($stream->test(Token::NAME_TYPE, 'filters')) {
106 //filter='yui_js'
107 $stream->next();
108 $stream->expect(Token::OPERATOR_TYPE, '=');
109 $filters = array_merge($filters, array_filter(array_map('trim', explode(',', $stream->expect(Token::STRING_TYPE)->getValue()))));
110 //The output token
111 } elseif ($stream->test(Token::NAME_TYPE, 'output')) {
112 //output='js/packed/*.js' OR output='js/core.js'
113 $stream->next();
114 $stream->expect(Token::OPERATOR_TYPE, '=');
115 $output = $stream->expect(Token::STRING_TYPE)->getValue();
116 //The name token
117 } elseif ($stream->test(Token::NAME_TYPE, 'name')) {
118 //name='core_js'
119 $stream->next();
120 $stream->expect(Token::OPERATOR_TYPE, '=');
121 $name = $stream->expect(Token::STRING_TYPE)->getValue();
122 //Unexpected token
123 } else {
124 $token = $stream->getCurrent();
125 throw new Error(sprintf('Unexpected token "%s" of value "%s"', Token::typeToEnglish($token->getType()), $token->getValue()), $token->getLine(), $stream->getSourceContext());
126 }
127 }
128
129 //Process end block
130 $stream->expect(Token::BLOCK_END_TYPE);
131
132 //Process body
133 $body = $this->parser->subparse([$this, 'testEndTag'], true);
134
135 //Process end block
136 $stream->expect(Token::BLOCK_END_TYPE);
137
138 //TODO: debug mode should be inserted here before the output variable is rewritten
139
140 //Replace star with sha1
141 if (($pos = strpos($output, '*')) !== false) {
142 //XXX: assetic use substr(sha1(serialize($inputs).serialize($filters).serialize($options)), 0, 7)
143 $output = substr($output, 0, $pos).sha1(serialize($inputs).serialize($filters)).substr($output, $pos + 1);
144 }
145
146 //Process inputs
147 for($k = 0; $k < count($inputs); $k++) {
148 //Deal with generic url
149 if (strpos($inputs[$k], '//') === 0) {
150 //Fix url
151 $inputs[$k] = $this->scheme.substr($inputs[$k], 2);
152 //Deal with non url path
153 } elseif (strpos($inputs[$k], '://') === false) {
154 //Check if we have a bundle path
155 if ($inputs[$k][0] == '@') {
156 //Check that we have a / separator between bundle name and path
157 if (($pos = strpos($inputs[$k], '/')) === false) {
158 //TODO: add a @jquery magic feature ?
159 /*
160 header('Content-Type: text/plain');
161 var_dump($inputs);
162 if ($inputs[0] == '@jquery') {
163 exit;
164 }
165 */
166 throw new Error(sprintf('Invalid input path "%s"', $inputs[$k]), $token->getLine(), $stream->getSourceContext());
167 }
168 //Resolve bundle prefix
169 $inputs[$k] = $this->locator->locate(substr($inputs[$k], 0, $pos)).substr($inputs[$k], $pos + 1);
170 }
171 //Deal with globs
172 if (strpos($inputs[$k], '*') !== false || (($a = strpos($inputs[$k], '{')) !== false && ($b = strpos($inputs[$k], ',', $a)) !== false && strpos($inputs[$k], '}', $b) !== false)) {
173 //Get replacement
174 $replacement = glob($inputs[$k], GLOB_NOSORT|GLOB_BRACE);
175 //Check that these are working files
176 foreach($replacement as $input) {
177 //Check that it's a file
178 if (!is_file($input)) {
179 throw new Error(sprintf('Input path "%s" from "%s" is not a file', $input, $inputs[$k]), $token->getLine(), $stream->getSourceContext());
180 }
181 }
182 //Replace with glob path
183 array_splice($inputs, $k, 1, $replacement);
184 //Fix current key
185 $k += count($replacement) - 1;
186 //Check that it's a file
187 } elseif (!is_file($inputs[$k])) {
188 throw new Error(sprintf('Input path "%s" is not a file', $inputs[$k]), $token->getLine(), $stream->getSourceContext());
189 }
190 }
191 }
192
193 //Init context
194 $ctx = stream_context_create(
195 [
196 'http' => [
197 'timeout' => $this->timeout,
198 'user_agent' => $this->agent,
199 'redirect' => $this->redirect,
200 ]
201 ]
202 );
203
204 //Check inputs
205 if (!empty($inputs)) {
206 //Retrieve files content
207 foreach($inputs as $input) {
208 //Try to retrieve content
209 if (($data = file_get_contents($input, false, $ctx)) === false) {
210 throw new Error(sprintf('Unable to retrieve input path "%s"', $input), $token->getLine(), $stream->getSourceContext());
211 }
212 //Append content
213 $content .= $data;
214 }
215 } else {
216 //Trigger error about empty inputs ?
217 //XXX: There may be a legitimate case where we want an empty file or an error, feel free to contact the author in such case
218 #throw new Error('Empty inputs token', $token->getLine(), $stream->getSourceContext());
219
220 //Send an empty node without inputs
221 return new Node();
222 }
223
224 //Check filters
225 if (!empty($filters)) {
226 //Apply all filters
227 foreach($filters as $filter) {
228 //Init args
229 $args = [$stream->getSourceContext(), $token->getLine()];
230 //Check if args is available
231 if (!empty($filter['args'])) {
232 //Append args if provided
233 $args += $filter['args'];
234 }
235 //Init reflection
236 $reflection = new \ReflectionClass($filter['class']);
237 //Set instance args
238 $tool = $reflection->newInstanceArgs($args);
239 //Process content
240 $content = $tool->process($content);
241 //Remove object
242 unset($tool, $reflection);
243 }
244 } else {
245 //Trigger error about empty filters ?
246 //XXX: There may be a legitimate case where we want only a merged file or an error, feel free to contact the author in such case
247 #throw new Error('Empty filters token', $token->getLine(), $stream->getSourceContext());
248 }
249
250 //Retrieve asset uri
251 //XXX: this path is the merge of services.assets.path_package.arguments[0] and rapsys_pack.output.(css,img,js)
252 if (($outputUrl = $this->package->getUrl($output)) === false) {
253 throw new Error(sprintf('Unable to get url for asset: %s', $output), $token->getLine(), $stream->getSourceContext());
254 }
255
256 //Check if we have a bundle path
257 if ($output[0] == '@') {
258 //Check that we have a / separator between bundle name and path
259 if (($pos = strpos($output, '/')) === false) {
260 throw new Error(sprintf('Invalid output path "%s"', $output), $token->getLine(), $stream->getSourceContext());
261 }
262 //Resolve bundle prefix
263 $output = $this->locator->locate(substr($output, 0, $pos)).substr($output, $pos + 1);
264 }
265
266 //Create output dir if not present
267 if (!is_dir($dir = dirname($output))) {
268 try {
269 //XXX: set as 0777, symfony umask (0022) will reduce rights (0755)
270 if (mkdir($dir, 0777, true) === false) {
271 throw new \Exception();
272 }
273 } catch (\Exception $e) {
274 throw new Error(sprintf('Output directory "%s" do not exists and unable to create it', $dir), $token->getLine(), $stream->getSourceContext(), $e);
275 }
276 }
277
278 //Send file content
279 //XXX: to avoid partial content in reverse cache we use atomic rotation write, unlink and move
280 try {
281 if (file_put_contents($output.'.new', $content) === false) {
282 throw new \Exception();
283 }
284 } catch(\Exception $e) {
285 throw new Error(sprintf('Unable to write to: %s', $output.'.new'), $token->getLine(), $stream->getSourceContext(), $e);
286 }
287
288 //Remove old file
289 if (is_file($output)) {
290 try {
291 if (unlink($output) === false) {
292 throw new \Exception();
293 }
294 } catch (\Exception $e) {
295 throw new Error(sprintf('Unable to unlink: %s', $output), $token->getLine(), $stream->getSourceContext(), $e);
296 }
297 }
298
299 //Rename it
300 try {
301 if (rename($output.'.new', $output) === false) {
302 throw new \Exception();
303 }
304 } catch (\Exception $e) {
305 throw new Error(sprintf('Unable to rename: %s to %s', $output.'.new', $output), $token->getLine(), $stream->getSourceContext(), $e);
306 }
307
308 //Set name in context key
309 $ref = new AssignNameExpression($name, $token->getLine());
310
311 //Set output in context value
312 $value = new TextNode($outputUrl, $token->getLine());
313
314 //Send body with context set
315 return new Node([
316 //This define name in twig template by prepending $context['<name>'] = '<output>';
317 new SetNode(true, $ref, $value, $token->getLine(), $this->getTag()),
318 //The tag captured body
319 $body
320 ]);
321 }
322
323 /**
324 * Test for tag end
325 *
326 * @param class $token The \Twig\Token instance
327 *
328 * @return bool
329 */
330 public function testEndTag(Token $token) {
331 return $token->test(['end'.$this->getTag()]);
332 }
333 }