1 | <?php |
||
2 | /** |
||
3 | * Template Comments plugin for Craft CMS |
||
4 | * |
||
5 | * Adds a HTML comment to demarcate each Twig template that is included or extended. |
||
6 | * |
||
7 | * @link https://nystudio107.com/ |
||
0 ignored issues
–
show
Coding Style
introduced
by
Loading history...
|
|||
8 | * @copyright Copyright (c) nystudio107 |
||
0 ignored issues
–
show
|
|||
9 | */ |
||
0 ignored issues
–
show
|
|||
10 | |||
11 | /** |
||
12 | * This is a wholesale copy pasta of the Twig Parser class; with one modification so that it |
||
13 | * does not throw a "A block definition cannot be nested under non-capturing nodes" SyntaxError exception |
||
14 | * |
||
15 | * This exception is explained in detail here: https://github.com/twigphp/Twig/issues/3926 |
||
16 | * |
||
17 | * So that Template Comments can add HTML comments that indicate any {% include %} or {% extends %} templates |
||
18 | * with execution timing, it uses its own CommentTemplateLoader which wraps the loaded template |
||
19 | * with {% comments 'templateName' %}<loaded-template>{% endcomments %} |
||
20 | * |
||
21 | * There is a corresponding CommentsTokenParser that takes care of parsing the {% comments %} tags |
||
22 | * |
||
23 | * This worked in Twig 1.x and 2.x, but they added a check for block definitions nested under non-capturing |
||
24 | * blocks in Twig 3.x, which causes it to throw an exception in that case. So if you end up with something like: |
||
25 | * |
||
26 | * {% comments 'index' %} |
||
27 | * {% block conent %} |
||
28 | * {% endblock %} |
||
29 | * {% encomments %} |
||
30 | * |
||
31 | * ...the SyntaxError exception will be thrown. |
||
32 | * |
||
33 | * I tried adding implements NodeCaptureInterface to the CommentsNode but that resulted in the Parser returning |
||
34 | * the node, causing duplicate rendering for every {% include %} or {% extends %} that was wrapped in a |
||
35 | * {% comments %} tag |
||
36 | * |
||
37 | * We can't just subclass the Parser class, because the properties and methods are private |
||
38 | * |
||
39 | * So here we are. |
||
40 | * |
||
41 | * Don't judge me |
||
42 | */ |
||
43 | |||
44 | namespace nystudio107\templatecomments\web\twig; |
||
45 | |||
46 | use Twig\Environment; |
||
47 | use Twig\Error\SyntaxError; |
||
48 | use Twig\ExpressionParser; |
||
49 | use Twig\Node\BlockNode; |
||
50 | use Twig\Node\BlockReferenceNode; |
||
51 | use Twig\Node\BodyNode; |
||
52 | use Twig\Node\Expression\AbstractExpression; |
||
53 | use Twig\Node\MacroNode; |
||
54 | use Twig\Node\ModuleNode; |
||
55 | use Twig\Node\Node; |
||
56 | use Twig\Node\NodeCaptureInterface; |
||
57 | use Twig\Node\NodeOutputInterface; |
||
58 | use Twig\Node\PrintNode; |
||
59 | use Twig\Node\TextNode; |
||
60 | use Twig\NodeTraverser; |
||
61 | use Twig\Parser; |
||
62 | use Twig\Token; |
||
63 | use Twig\TokenParser\TokenParserInterface; |
||
64 | use Twig\TokenStream; |
||
65 | use function chr; |
||
66 | use function count; |
||
67 | use function get_class; |
||
68 | use function is_array; |
||
69 | |||
70 | /** |
||
0 ignored issues
–
show
|
|||
71 | * @author Fabien Potencier <[email protected]> |
||
72 | */ |
||
0 ignored issues
–
show
|
|||
73 | class TemplateCommentsParser extends Parser |
||
74 | { |
||
75 | private $stack = []; |
||
0 ignored issues
–
show
|
|||
76 | private $stream; |
||
0 ignored issues
–
show
|
|||
77 | private $parent; |
||
0 ignored issues
–
show
|
|||
78 | private $visitors; |
||
0 ignored issues
–
show
|
|||
79 | private $expressionParser; |
||
0 ignored issues
–
show
|
|||
80 | private $blocks; |
||
0 ignored issues
–
show
|
|||
81 | private $blockStack; |
||
0 ignored issues
–
show
|
|||
82 | private $macros; |
||
0 ignored issues
–
show
|
|||
83 | private $env; |
||
0 ignored issues
–
show
|
|||
84 | private $importedSymbols; |
||
0 ignored issues
–
show
|
|||
85 | private $traits; |
||
0 ignored issues
–
show
|
|||
86 | private $embeddedTemplates = []; |
||
0 ignored issues
–
show
|
|||
87 | private $varNameSalt = 0; |
||
0 ignored issues
–
show
|
|||
88 | |||
89 | public function __construct(Environment $env) |
||
0 ignored issues
–
show
|
|||
90 | { |
||
91 | $this->env = $env; |
||
92 | } |
||
93 | |||
94 | public function getVarName(): string |
||
0 ignored issues
–
show
|
|||
95 | { |
||
96 | return sprintf('__internal_parse_%d', $this->varNameSalt++); |
||
97 | } |
||
98 | |||
99 | public function parse(TokenStream $stream, $test = null, bool $dropNeedle = false): ModuleNode |
||
0 ignored issues
–
show
|
|||
100 | { |
||
101 | $vars = get_object_vars($this); |
||
102 | unset($vars['stack'], $vars['env'], $vars['handlers'], $vars['visitors'], $vars['expressionParser'], $vars['reservedMacroNames'], $vars['varNameSalt']); |
||
103 | $this->stack[] = $vars; |
||
104 | |||
105 | // node visitors |
||
106 | if (null === $this->visitors) { |
||
107 | $this->visitors = $this->env->getNodeVisitors(); |
||
108 | } |
||
109 | |||
110 | if (null === $this->expressionParser) { |
||
111 | $this->expressionParser = new ExpressionParser($this, $this->env); |
||
112 | } |
||
113 | |||
114 | $this->stream = $stream; |
||
115 | $this->parent = null; |
||
116 | $this->blocks = []; |
||
117 | $this->macros = []; |
||
118 | $this->traits = []; |
||
119 | $this->blockStack = []; |
||
120 | $this->importedSymbols = [[]]; |
||
121 | $this->embeddedTemplates = []; |
||
122 | |||
123 | try { |
||
124 | $body = $this->subparse($test, $dropNeedle); |
||
125 | |||
126 | if (null !== $this->parent && null === $body = $this->filterBodyNodes($body)) { |
||
127 | $body = new Node(); |
||
128 | } |
||
129 | } catch (SyntaxError $e) { |
||
130 | if (!$e->getSourceContext()) { |
||
131 | $e->setSourceContext($this->stream->getSourceContext()); |
||
132 | } |
||
133 | |||
134 | if (!$e->getTemplateLine()) { |
||
135 | $e->setTemplateLine($this->stream->getCurrent()->getLine()); |
||
136 | } |
||
137 | |||
138 | throw $e; |
||
139 | } |
||
140 | |||
141 | $node = new ModuleNode(new BodyNode([$body]), $this->parent, new Node($this->blocks), new Node($this->macros), new Node($this->traits), $this->embeddedTemplates, $stream->getSourceContext()); |
||
142 | |||
143 | $traverser = new NodeTraverser($this->env, $this->visitors); |
||
144 | |||
145 | /** @var ModuleNode $node */ |
||
0 ignored issues
–
show
|
|||
146 | $node = $traverser->traverse($node); |
||
147 | |||
148 | // restore previous stack so previous parse() call can resume working |
||
149 | foreach (array_pop($this->stack) as $key => $val) { |
||
150 | $this->$key = $val; |
||
151 | } |
||
152 | |||
153 | return $node; |
||
154 | } |
||
155 | |||
156 | public function subparse($test, bool $dropNeedle = false): Node |
||
0 ignored issues
–
show
|
|||
157 | { |
||
158 | $lineno = $this->getCurrentToken()->getLine(); |
||
159 | $rv = []; |
||
160 | while (!$this->stream->isEOF()) { |
||
161 | switch ($this->getCurrentToken()->getType()) { |
||
162 | case /* Token::TEXT_TYPE */ 0: |
||
0 ignored issues
–
show
|
|||
163 | $token = $this->stream->next(); |
||
164 | $rv[] = new TextNode($token->getValue(), $token->getLine()); |
||
165 | break; |
||
166 | |||
167 | case /* Token::VAR_START_TYPE */ 2: |
||
0 ignored issues
–
show
|
|||
168 | $token = $this->stream->next(); |
||
169 | $expr = $this->expressionParser->parseExpression(); |
||
170 | $this->stream->expect(/* Token::VAR_END_TYPE */ 4); |
||
171 | $rv[] = new PrintNode($expr, $token->getLine()); |
||
172 | break; |
||
173 | |||
174 | case /* Token::BLOCK_START_TYPE */ 1: |
||
0 ignored issues
–
show
|
|||
175 | $this->stream->next(); |
||
176 | $token = $this->getCurrentToken(); |
||
177 | |||
178 | if (/* Token::NAME_TYPE */ 5 !== $token->getType()) { |
||
0 ignored issues
–
show
|
|||
179 | throw new SyntaxError('A block must start with a tag name.', $token->getLine(), $this->stream->getSourceContext()); |
||
180 | } |
||
0 ignored issues
–
show
|
|||
181 | |||
182 | if (null !== $test && $test($token)) { |
||
0 ignored issues
–
show
|
|||
183 | if ($dropNeedle) { |
||
0 ignored issues
–
show
|
|||
184 | $this->stream->next(); |
||
185 | } |
||
0 ignored issues
–
show
|
|||
186 | |||
187 | if (1 === count($rv)) { |
||
0 ignored issues
–
show
|
|||
188 | return $rv[0]; |
||
189 | } |
||
0 ignored issues
–
show
|
|||
190 | |||
191 | return new Node($rv, [], $lineno); |
||
192 | } |
||
0 ignored issues
–
show
|
|||
193 | |||
194 | if (!$subparser = $this->env->getTokenParser($token->getValue())) { |
||
0 ignored issues
–
show
|
|||
195 | if (null !== $test) { |
||
0 ignored issues
–
show
|
|||
196 | $e = new SyntaxError(sprintf('Unexpected "%s" tag', $token->getValue()), $token->getLine(), $this->stream->getSourceContext()); |
||
197 | |||
198 | if (is_array($test) && isset($test[0]) && $test[0] instanceof TokenParserInterface) { |
||
0 ignored issues
–
show
|
|||
199 | $e->appendMessage(sprintf(' (expecting closing tag for the "%s" tag defined near line %s).', $test[0]->getTag(), $lineno)); |
||
200 | } |
||
0 ignored issues
–
show
|
|||
201 | } else { |
||
0 ignored issues
–
show
|
|||
202 | $e = new SyntaxError(sprintf('Unknown "%s" tag.', $token->getValue()), $token->getLine(), $this->stream->getSourceContext()); |
||
203 | $e->addSuggestions($token->getValue(), array_keys($this->env->getTokenParsers())); |
||
204 | } |
||
0 ignored issues
–
show
|
|||
205 | |||
206 | throw $e; |
||
207 | } |
||
0 ignored issues
–
show
|
|||
208 | |||
209 | $this->stream->next(); |
||
210 | |||
211 | $subparser->setParser($this); |
||
212 | $node = $subparser->parse($token); |
||
213 | if (null !== $node) { |
||
0 ignored issues
–
show
|
|||
214 | $rv[] = $node; |
||
215 | } |
||
0 ignored issues
–
show
|
|||
216 | break; |
||
217 | |||
218 | default: |
||
0 ignored issues
–
show
|
|||
219 | throw new SyntaxError('Lexer or parser ended up in unsupported state.', $this->getCurrentToken()->getLine(), $this->stream->getSourceContext()); |
||
220 | } |
||
221 | } |
||
222 | |||
223 | if (1 === count($rv)) { |
||
224 | return $rv[0]; |
||
225 | } |
||
226 | |||
227 | return new Node($rv, [], $lineno); |
||
228 | } |
||
229 | |||
230 | public function getBlockStack(): array |
||
0 ignored issues
–
show
|
|||
231 | { |
||
232 | return $this->blockStack; |
||
233 | } |
||
234 | |||
235 | public function peekBlockStack() |
||
0 ignored issues
–
show
|
|||
236 | { |
||
237 | return $this->blockStack[count($this->blockStack) - 1] ?? null; |
||
238 | } |
||
239 | |||
240 | public function popBlockStack(): void |
||
0 ignored issues
–
show
|
|||
241 | { |
||
242 | array_pop($this->blockStack); |
||
243 | } |
||
244 | |||
245 | public function pushBlockStack($name): void |
||
0 ignored issues
–
show
|
|||
246 | { |
||
247 | $this->blockStack[] = $name; |
||
248 | } |
||
249 | |||
250 | public function hasBlock(string $name): bool |
||
0 ignored issues
–
show
|
|||
251 | { |
||
252 | return isset($this->blocks[$name]); |
||
253 | } |
||
254 | |||
255 | public function getBlock(string $name): Node |
||
0 ignored issues
–
show
|
|||
256 | { |
||
257 | return $this->blocks[$name]; |
||
258 | } |
||
259 | |||
260 | public function setBlock(string $name, BlockNode $value): void |
||
0 ignored issues
–
show
|
|||
261 | { |
||
262 | $this->blocks[$name] = new BodyNode([$value], [], $value->getTemplateLine()); |
||
263 | } |
||
264 | |||
265 | public function hasMacro(string $name): bool |
||
0 ignored issues
–
show
|
|||
266 | { |
||
267 | return isset($this->macros[$name]); |
||
268 | } |
||
269 | |||
270 | public function setMacro(string $name, MacroNode $node): void |
||
0 ignored issues
–
show
|
|||
271 | { |
||
272 | $this->macros[$name] = $node; |
||
273 | } |
||
274 | |||
275 | public function addTrait($trait): void |
||
0 ignored issues
–
show
|
|||
276 | { |
||
277 | $this->traits[] = $trait; |
||
278 | } |
||
279 | |||
280 | public function hasTraits(): bool |
||
0 ignored issues
–
show
|
|||
281 | { |
||
282 | return count($this->traits) > 0; |
||
283 | } |
||
284 | |||
285 | public function embedTemplate(ModuleNode $template) |
||
0 ignored issues
–
show
|
|||
286 | { |
||
287 | $template->setIndex(mt_rand()); |
||
288 | |||
289 | $this->embeddedTemplates[] = $template; |
||
290 | } |
||
291 | |||
292 | public function addImportedSymbol(string $type, string $alias, string $name = null, AbstractExpression $node = null): void |
||
0 ignored issues
–
show
|
|||
293 | { |
||
294 | $this->importedSymbols[0][$type][$alias] = ['name' => $name, 'node' => $node]; |
||
295 | } |
||
296 | |||
297 | public function getImportedSymbol(string $type, string $alias) |
||
0 ignored issues
–
show
|
|||
298 | { |
||
299 | // if the symbol does not exist in the current scope (0), try in the main/global scope (last index) |
||
300 | return $this->importedSymbols[0][$type][$alias] ?? ($this->importedSymbols[count($this->importedSymbols) - 1][$type][$alias] ?? null); |
||
301 | } |
||
302 | |||
303 | public function isMainScope(): bool |
||
0 ignored issues
–
show
|
|||
304 | { |
||
305 | return 1 === count($this->importedSymbols); |
||
306 | } |
||
307 | |||
308 | public function pushLocalScope(): void |
||
0 ignored issues
–
show
|
|||
309 | { |
||
310 | array_unshift($this->importedSymbols, []); |
||
311 | } |
||
312 | |||
313 | public function popLocalScope(): void |
||
0 ignored issues
–
show
|
|||
314 | { |
||
315 | array_shift($this->importedSymbols); |
||
316 | } |
||
317 | |||
318 | public function getExpressionParser(): ExpressionParser |
||
0 ignored issues
–
show
|
|||
319 | { |
||
320 | return $this->expressionParser; |
||
321 | } |
||
322 | |||
323 | public function getParent(): ?Node |
||
0 ignored issues
–
show
|
|||
324 | { |
||
325 | return $this->parent; |
||
326 | } |
||
327 | |||
328 | public function setParent(?Node $parent): void |
||
0 ignored issues
–
show
|
|||
329 | { |
||
330 | $this->parent = $parent; |
||
331 | } |
||
332 | |||
333 | public function getStream(): TokenStream |
||
0 ignored issues
–
show
|
|||
334 | { |
||
335 | return $this->stream; |
||
336 | } |
||
337 | |||
338 | public function getCurrentToken(): Token |
||
0 ignored issues
–
show
|
|||
339 | { |
||
340 | return $this->stream->getCurrent(); |
||
341 | } |
||
342 | |||
343 | private function filterBodyNodes(Node $node, bool $nested = false): ?Node |
||
0 ignored issues
–
show
|
|||
344 | { |
||
345 | // check that the body does not contain non-empty output nodes |
||
346 | if ( |
||
0 ignored issues
–
show
|
|||
347 | ($node instanceof TextNode && !ctype_space($node->getAttribute('data'))) |
||
0 ignored issues
–
show
|
|||
348 | || (!$node instanceof TextNode && !$node instanceof BlockReferenceNode && $node instanceof NodeOutputInterface) |
||
349 | ) { |
||
350 | if (str_contains((string)$node, chr(0xEF) . chr(0xBB) . chr(0xBF))) { |
||
351 | $t = substr($node->getAttribute('data'), 3); |
||
352 | if ('' === $t || ctype_space($t)) { |
||
353 | // bypass empty nodes starting with a BOM |
||
354 | return null; |
||
355 | } |
||
356 | } |
||
357 | |||
358 | throw new SyntaxError('A template that extends another one cannot include content outside Twig blocks. Did you forget to put the content inside a {% block %} tag?', $node->getTemplateLine(), $this->stream->getSourceContext()); |
||
359 | } |
||
360 | |||
361 | // bypass nodes that "capture" the output |
||
362 | if ($node instanceof NodeCaptureInterface) { |
||
363 | // a "block" tag in such a node will serve as a block definition AND be displayed in place as well |
||
364 | return $node; |
||
365 | } |
||
366 | |||
367 | /** |
||
368 | * We intentionally skip this check to avoid throwing an exception, so our {% comments %} tag can |
||
369 | * render correctly |
||
370 | * // "block" tags that are not captured (see above) are only used for defining |
||
371 | * // the content of the block. In such a case, nesting it does not work as |
||
372 | * // expected as the definition is not part of the default template code flow. |
||
373 | * if ($nested && $node instanceof BlockReferenceNode) { |
||
374 | * throw new SyntaxError('A block definition cannot be nested under non-capturing nodes.', $node->getTemplateLine(), $this->stream->getSourceContext()); |
||
375 | * } |
||
376 | */ |
||
377 | if ($node instanceof NodeOutputInterface) { |
||
378 | return null; |
||
379 | } |
||
380 | |||
381 | // here, $nested means "being at the root level of a child template" |
||
382 | // we need to discard the wrapping "Node" for the "body" node |
||
383 | $nested = $nested || Node::class !== get_class($node); |
||
384 | foreach ($node as $k => $n) { |
||
385 | if (null !== $n && null === $this->filterBodyNodes($n, $nested)) { |
||
386 | $node->removeNode($k); |
||
387 | } |
||
388 | } |
||
389 | |||
390 | return $node; |
||
391 | } |
||
392 | } |
||
393 |