├── phpstan.neon
├── ecs.php
├── src
├── translations
│ └── en
│ │ └── templatecomments.php
├── icon.svg
├── web
│ └── twig
│ │ ├── tokenparsers
│ │ ├── CommentsTokenParser.php
│ │ └── CommentBlockTokenParser.php
│ │ ├── nodes
│ │ ├── CommentsNode.php
│ │ └── CommentBlockNode.php
│ │ ├── CommentTemplateLoader.php
│ │ ├── CommentsTwigExtension.php
│ │ └── TemplateCommentsParser.php
├── helpers
│ └── Reflection.php
├── config.php
├── models
│ └── Settings.php
└── TemplateComments.php
├── Makefile
├── LICENSE.md
├── CHANGELOG.md
├── composer.json
└── README.md
/phpstan.neon:
--------------------------------------------------------------------------------
1 | includes:
2 | - %currentWorkingDirectory%/vendor/craftcms/phpstan/phpstan.neon
3 |
4 | parameters:
5 | level: 5
6 | paths:
7 | - src
8 |
--------------------------------------------------------------------------------
/ecs.php:
--------------------------------------------------------------------------------
1 | paths([
8 | __DIR__ . '/src',
9 | __FILE__,
10 | ]);
11 | $ecsConfig->parallel();
12 | $ecsConfig->sets([SetList::CRAFT_CMS_4]);
13 | };
14 |
--------------------------------------------------------------------------------
/src/translations/en/templatecomments.php:
--------------------------------------------------------------------------------
1 | '{name} plugin loaded',
18 | 'Error rendering `{template}` -> {error}' => 'Error rendering `{template}` -> {error}',
19 | ];
20 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | MAJOR_VERSION?=5
2 | PLUGINDEV_PROJECT_DIR?=/Users/andrew/webdev/sites/plugindev/cms_v${MAJOR_VERSION}/
3 | VENDOR?=nystudio107
4 | PROJECT_PATH?=${VENDOR}/$(shell basename $(CURDIR))
5 |
6 | .PHONY: dev docs release
7 |
8 | # Start up the buildchain dev server
9 | dev:
10 | # Start up the docs dev server
11 | docs:
12 | ${MAKE} -C docs/ dev
13 | # Run code quality tools, tests, and build the buildchain & docs in preparation for a release
14 | release: --code-quality --code-tests --buildchain-clean-build --docs-clean-build
15 | # The internal targets used by the dev & release targets
16 | --buildchain-clean-build:
17 | --code-quality:
18 | ${MAKE} -C ${PLUGINDEV_PROJECT_DIR} -- ecs check vendor/${PROJECT_PATH}/src --fix
19 | ${MAKE} -C ${PLUGINDEV_PROJECT_DIR} -- phpstan analyze -c vendor/${PROJECT_PATH}/phpstan.neon
20 | --code-tests:
21 | --docs-clean-build:
22 | ${MAKE} -C docs/ clean
23 | ${MAKE} -C docs/ image-build
24 | ${MAKE} -C docs/ fix
25 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) nystudio107
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Template Comments Changelog
2 |
3 | ## 5.0.5 - UNRELEASED
4 | ### Changed
5 | * Template Comments now requires `craftcms/cms` `^5.6.0` going forward, because of the addition of breaking changes in the version of Twig that it requires ([#51](https://github.com/nystudio107/craft-templatecomments/issues/51))
6 | * Updated `TemplateCommentsParser.php` to match Twig `3.15.x`'s `Parser` ([#51](https://github.com/nystudio107/craft-templatecomments/issues/51))
7 |
8 | ## 5.0.4 - 2024.11.29
9 | ### Changed
10 | * Defer installing Template Comments until Craft is fully setup, to avoid a "Twig instantiated before Craft is fully initialized" warning ([#50](https://github.com/nystudio107/craft-templatecomments/issues/50))
11 |
12 | ## 5.0.3 - 2024.09.06
13 | ### Changed
14 | * Template Comments now requires `craftcms/cms` `^5.4.0` going forward, because of the addition of breaking changes in the version of Twig that it requires
15 |
16 | ### Fixed
17 | * Fixed an issue with a change in Twig `3.12.0` (which is now used by Craft `5.4.0`) which would cause an exception to be thrown when rendering templates with an empty `{% block %}` tag ([#46](https://github.com/nystudio107/craft-templatecomments/issues/46))
18 | * Fixed an issue with a change in Twig `3.12.0` (which is now used by Craft `5.4.0`) which would cause an exception to be thrown when rendering templates ([#44](https://github.com/nystudio107/craft-templatecomments/issues/44))
19 |
20 | ## 5.0.1 - 2024.08.06
21 | ### Fixed
22 | * Fixed an issue where Template Comments would cause the Craft Closure `^1.0.6` package to not work
23 |
24 | ## 5.0.0 - 2024.04.18
25 | ### Added
26 | * Stable release for Craft CMS 5
27 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nystudio107/craft-templatecomments",
3 | "description": "Adds a HTML comment with performance timings to demarcate `{% block %}`s and each Twig template that is included or extended.",
4 | "type": "craft-plugin",
5 | "version": "5.0.4",
6 | "keywords": [
7 | "craftcms",
8 | "craft-plugin",
9 | "twig",
10 | "comments",
11 | "debugging"
12 | ],
13 | "support": {
14 | "docs": "https://nystudio107.com/docs/template-comments/",
15 | "issues": "https://nystudio107.com/plugins/template-comments/support",
16 | "source": "https://github.com/nystudio107/craft-templatecomments"
17 | },
18 | "license": "MIT",
19 | "authors": [
20 | {
21 | "name": "nystudio107",
22 | "homepage": "https://nystudio107.com/"
23 | }
24 | ],
25 | "require": {
26 | "php": "^8.2",
27 | "craftcms/cms": "^5.6.0"
28 | },
29 | "require-dev": {
30 | "craftcms/ecs": "dev-main",
31 | "craftcms/phpstan": "dev-main",
32 | "craftcms/rector": "dev-main"
33 | },
34 | "scripts": {
35 | "phpstan": "phpstan --ansi --memory-limit=1G",
36 | "check-cs": "ecs check --ansi",
37 | "fix-cs": "ecs check --fix --ansi"
38 | },
39 | "config": {
40 | "allow-plugins": {
41 | "craftcms/plugin-installer": true,
42 | "yiisoft/yii2-composer": true
43 | },
44 | "optimize-autoloader": true,
45 | "sort-packages": true
46 | },
47 | "autoload": {
48 | "psr-4": {
49 | "nystudio107\\templatecomments\\": "src/"
50 | }
51 | },
52 | "extra": {
53 | "class": "nystudio107\\templatecomments\\TemplateComments",
54 | "handle": "templatecomments",
55 | "name": "Template Comments"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
21 |
--------------------------------------------------------------------------------
/src/web/twig/tokenparsers/CommentsTokenParser.php:
--------------------------------------------------------------------------------
1 | getLine();
41 | $stream = $this->parser->getStream();
42 | $nodes = [
43 | 'templateName' => $this->parser->getExpressionParser()->parseExpression(),
44 | ];
45 | $stream->expect(Token::BLOCK_END_TYPE);
46 | $nodes['body'] = $this->parser->subparse([$this, 'decideCommentsEnd'], true);
47 | $stream->expect(Token::BLOCK_END_TYPE);
48 |
49 | return new CommentsNode($nodes, [], $lineno, $this->getTag());
50 | }
51 |
52 |
53 | /**
54 | * @param Token $token
55 | *
56 | * @return bool
57 | */
58 | public function decideCommentsEnd(Token $token): bool
59 | {
60 | return $token->test('endcomments');
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/web/twig/nodes/CommentsNode.php:
--------------------------------------------------------------------------------
1 | addDebugInfo($this)
34 | ->write('$_templateTimer = microtime(true)')
35 | ->raw(";\n")
36 | ->write('$_templateName = ')
37 | ->subcompile($this->getNode('templateName'))
38 | ->raw(";\n")
39 | ->write('echo PHP_EOL."".PHP_EOL')
40 | ->raw(";\n");
41 | // Make sure there is a body node
42 | if (!empty($this->nodes['body'])) {
43 | $compiler
44 | ->indent()
45 | ->subcompile($this->getNode('body'))
46 | ->outdent();
47 | }
48 | $compiler
49 | ->write('echo PHP_EOL."".PHP_EOL')
50 | ->raw(";\n")
51 | ->write("unset(\$_templateName);\n")
52 | ->write("unset(\$_templateTimer);\n");
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/web/twig/CommentTemplateLoader.php:
--------------------------------------------------------------------------------
1 | _resolveTemplate($name);
31 |
32 | if (!is_readable($template)) {
33 | throw new TemplateLoaderException($name, Craft::t('app', 'Tried to read the template at {path}, but could not. Check the permissions.', ['path' => $template]));
34 | }
35 | $escapedName = addslashes($name);
36 | $prefix = "{% comments '{$escapedName}' %}" . PHP_EOL;
37 | $suffix = PHP_EOL . "{% endcomments %}";
38 |
39 | return new Source($prefix . file_get_contents($template) . $suffix, $name, $template);
40 | }
41 |
42 | // Private Methods
43 | // =========================================================================
44 |
45 | /**
46 | * @inheritdoc
47 | */
48 | private function _resolveTemplate(string $name): string
49 | {
50 | $template = $this->view->resolveTemplate($name);
51 |
52 | if ($template !== false) {
53 | return $template;
54 | }
55 |
56 | throw new TemplateLoaderException($name, Craft::t('app', 'Unable to find the template “{template}”.', ['template' => $name]));
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/helpers/Reflection.php:
--------------------------------------------------------------------------------
1 | hasProperty($propertyName)) {
40 | $reflectionProperty = $reflectionObject->getProperty($propertyName);
41 | } else {
42 |
43 | // This is needed for private parent properties only.
44 | $parent = $reflectionObject->getParentClass();
45 | while ($reflectionProperty === null && $parent !== false) {
46 | if ($parent->hasProperty($propertyName)) {
47 | $reflectionProperty = $parent->getProperty($propertyName);
48 | }
49 |
50 | $parent = $parent->getParentClass();
51 | }
52 | }
53 |
54 | if (!$reflectionProperty) {
55 | throw new ReflectionException("Property not found: " . $propertyName);
56 | }
57 |
58 | return $reflectionProperty;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://scrutinizer-ci.com/g/nystudio107/craft-templatecomments/?branch=v5) [](https://scrutinizer-ci.com/g/nystudio107/craft-templatecomments/?branch=v5) [](https://scrutinizer-ci.com/g/nystudio107/craft-templatecomments/build-status/v5) [](https://scrutinizer-ci.com/code-intelligence)
2 |
3 | # Template Comments plugin for Craft CMS 5.x
4 |
5 | Adds a HTML comment with performance timings to demarcate `{% block %}`s and each Twig template that is included or extended.
6 |
7 | 
8 |
9 | ## Requirements
10 |
11 | This plugin requires Craft CMS 5.0.0 or later.
12 |
13 | ## Installation
14 |
15 | To install the plugin, follow these instructions.
16 |
17 | 1. Open your terminal and go to your Craft project:
18 |
19 | cd /path/to/project
20 |
21 | 2. Then tell Composer to load the plugin:
22 |
23 | composer require nystudio107/craft-templatecomments
24 |
25 | 3. Install the plugin via `./craft install/plugin templatecomments` via the CLI, or in the Control Panel, go to Settings → Plugins and click the “Install” button for Template Comments.
26 |
27 | You can also install Template Comments via the **Plugin Store** in the Craft Control Panel.
28 |
29 | ## Documentation
30 |
31 | Click here -> [Template Comments Documentation](https://nystudio107.com/plugins/template-comments/documentation)
32 |
33 | ## Template Comments Roadmap
34 |
35 | Some things to do, and ideas for potential features:
36 |
37 | * Support wrapping macros in comments
38 |
39 | Brought to you by [nystudio107](https://nystudio107.com/)
40 |
--------------------------------------------------------------------------------
/src/config.php:
--------------------------------------------------------------------------------
1 | true,
33 |
34 | /**
35 | * @var bool Whether comments should be generated for Control Panel templates
36 | */
37 | 'cpTemplateComments' => false,
38 |
39 | /**
40 | * @var bool Whether to generate comments only when `devMode` is on
41 | */
42 | 'onlyCommentsInDevMode' => true,
43 |
44 | /**
45 | * @var array Don't add comments to template blocks that contain these strings (case-insensitive)
46 | */
47 | 'excludeBlocksThatContain' => [
48 | 'css',
49 | 'js',
50 | 'javascript',
51 | ],
52 |
53 | /**
54 | * @var bool Whether or not to show comments for templates that are include'd
55 | */
56 | 'templateCommentsEnabled' => true,
57 |
58 | /**
59 | * @var bool Whether or not to show comments for `{% block %}`s
60 | */
61 | 'blockCommentsEnabled' => true,
62 |
63 | /**
64 | * @var array Template file suffixes that Template Comments should be enabled for
65 | */
66 | 'allowedTemplateSuffixes' => [
67 | '',
68 | 'twig',
69 | 'htm',
70 | 'html',
71 | ],
72 | ];
73 |
--------------------------------------------------------------------------------
/src/web/twig/CommentsTwigExtension.php:
--------------------------------------------------------------------------------
1 | true, 'is_safe' => ['all']]),
46 | ];
47 | }
48 |
49 | /**
50 | * @inheritdoc
51 | */
52 | public function getTokenParsers(): array
53 | {
54 | $parsers = [];
55 | if (TemplateComments::$settings->templateCommentsEnabled) {
56 | $parsers[] = new CommentsTokenParser();
57 | }
58 | if (TemplateComments::$settings->blockCommentsEnabled) {
59 | $parsers[] = new CommentBlockTokenParser();
60 | }
61 |
62 | return $parsers;
63 | }
64 |
65 | /**
66 | * Returns a template content without rendering it.
67 | *
68 | * @param Environment $env The Twig environment
69 | * @param string $name The template name
70 | * @param bool $ignoreMissing Whether to ignore missing templates or not
71 | *
72 | * @return string The template source
73 | */
74 | public function originalSource(Environment $env, string $name, bool $ignoreMissing = false): string
75 | {
76 | $loader = TemplateComments::$originalTwigLoader;
77 | try {
78 | return $loader->getSourceContext($name)->getCode();
79 | } catch (LoaderError $e) {
80 | if (!$ignoreMissing) {
81 | throw $e;
82 | }
83 | }
84 |
85 | return '';
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/web/twig/nodes/CommentBlockNode.php:
--------------------------------------------------------------------------------
1 |
24 | */
25 | class CommentBlockNode extends BlockNode
26 | {
27 | private string $blockName;
28 |
29 | private array $excludeBlocks = ['attr'];
30 |
31 | public function __construct(string $name, Node $body, int $lineno)
32 | {
33 | parent::__construct($name, $body, $lineno);
34 | $this->blockName = $name;
35 | }
36 |
37 | public function compile(Compiler $compiler): void
38 | {
39 | $compiler
40 | ->addDebugInfo($this)
41 | ->write(sprintf("public function block_%s(\$context, array \$blocks = array())\n", $this->getAttribute('name')), "{\n")
42 | ->indent()
43 | ->write("\$macros = \$this->macros;\n");
44 | if (!in_array($this->blockName, $this->excludeBlocks, false)) {
45 | $compiler
46 | ->write('$_blockTimer = microtime(true)')
47 | ->raw(";\n")
48 | ->write('$_templateName = ')
49 | ->write("'" . $this->getTemplateName() . "'")
50 | ->raw(";\n")
51 | ->write('$_blockName = ')
52 | ->write("'" . $this->blockName . "'")
53 | ->raw(";\n")
54 | ->write('echo PHP_EOL."".PHP_EOL')
55 | ->raw(";\n");
56 | }
57 |
58 | $compiler
59 | ->subcompile($this->getNode('body'))
60 | ->write("return; yield '';\n") // needed when body doesn't yield anything
61 | ->outdent();
62 | if (!in_array($this->blockName, $this->excludeBlocks, false)) {
63 | $compiler
64 | ->write('echo PHP_EOL."".PHP_EOL')
65 | ->raw(";\n")
66 | ->write("unset(\$_blockTimer);\n")
67 | ->write("unset(\$_blockName);\n")
68 | ->write("unset(\$_templateName);\n");
69 | }
70 |
71 | $compiler
72 | ->write("}\n\n");
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/models/Settings.php:
--------------------------------------------------------------------------------
1 |
30 | * {% block head %}
31 | *
32 | *
{% block title %}{% endblock %} - My Webpage
33 | * {% endblock %}
34 | *
35 | */
36 | final class CommentBlockTokenParser extends AbstractTokenParser
37 | {
38 | public function parse(Token $token): Node
39 | {
40 | $lineno = $token->getLine();
41 | $stream = $this->parser->getStream();
42 | $name = $stream->expect(Token::NAME_TYPE)->getValue();
43 |
44 | // Exclude certain blocks from being CommentBlockNodes
45 | $blockClass = CommentBlockNode::class;
46 | $settings = TemplateComments::$settings;
47 | foreach ($settings->excludeBlocksThatContain as $excludeString) {
48 | if (stripos($name, $excludeString) !== false) {
49 | $blockClass = BlockNode::class;
50 | }
51 | }
52 |
53 | $this->parser->setBlock($name, $block = new $blockClass($name, new Node([]), $lineno));
54 | $this->parser->pushLocalScope();
55 | $this->parser->pushBlockStack($name);
56 |
57 | if ($stream->nextIf(Token::BLOCK_END_TYPE)) {
58 | $body = $this->parser->subparse([$this, 'decideBlockEnd'], true);
59 | if ($token = $stream->nextIf(Token::NAME_TYPE)) {
60 | $value = $token->getValue();
61 |
62 | if ($value != $name) {
63 | throw new SyntaxError(sprintf('Expected endblock for block "%s" (but "%s" given).', $name, $value), $stream->getCurrent()->getLine(), $stream->getSourceContext());
64 | }
65 | }
66 | } else {
67 | $body = new Node([
68 | new PrintNode($this->parser->getExpressionParser()->parseExpression(), $lineno),
69 | ]);
70 | }
71 | $stream->expect(Token::BLOCK_END_TYPE);
72 |
73 | $block->setNode('body', $body);
74 | $this->parser->popBlockStack();
75 | $this->parser->popLocalScope();
76 |
77 | return new BlockReferenceNode($name, $lineno);
78 | }
79 |
80 | public function decideBlockEnd(Token $token): bool
81 | {
82 | return $token->test('endblock');
83 | }
84 |
85 | public function getTag(): string
86 | {
87 | return 'block';
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/TemplateComments.php:
--------------------------------------------------------------------------------
1 | getSettings();
74 | self::$settings = $settings;
75 | // Defer some setup tasks until Craft is fully initialized:
76 | Craft::$app->onInit(function() {
77 | // Add in our Craft components
78 | $this->addComponents();
79 | // Install our global event handlers
80 | $this->installEventListeners();
81 | });
82 |
83 | Craft::info(
84 | Craft::t(
85 | 'templatecomments',
86 | '{name} plugin loaded',
87 | ['name' => $this->name]
88 | ),
89 | __METHOD__
90 | );
91 | }
92 |
93 | // Protected Methods
94 | // =========================================================================
95 |
96 | /**
97 | * Add in our Craft components
98 | */
99 | protected function addComponents(): void
100 | {
101 | $request = Craft::$app->getRequest();
102 | if (!$request->getIsConsoleRequest()) {
103 | // Do nothing at all on AJAX requests
104 | if ($request->getIsAjax()) {
105 | return;
106 | }
107 |
108 | // Install only for site requests
109 | if ($request->getIsSiteRequest()) {
110 | $this->installSiteComponents();
111 | }
112 |
113 | // Install only for Control Panel requests
114 | if ($request->getIsCpRequest()) {
115 | $this->installCpComponents();
116 | }
117 | }
118 | }
119 |
120 | /**
121 | * Install components for site requests only
122 | */
123 | protected function installSiteComponents(): void
124 | {
125 | if (self::$settings->siteTemplateComments) {
126 | $this->installTemplateComponents();
127 | }
128 | }
129 |
130 | /**
131 | * Install components for Control Panel requests only
132 | */
133 | protected function installCpComponents(): void
134 | {
135 | if (self::$settings->cpTemplateComments) {
136 | $this->installTemplateComponents();
137 | }
138 | }
139 |
140 | /**
141 | * @inheritdoc
142 | */
143 | protected function createSettingsModel(): ?Model
144 | {
145 | return new Settings();
146 | }
147 |
148 | /**
149 | * Install our event listeners
150 | */
151 | protected function installEventListeners()
152 | {
153 | $request = Craft::$app->getRequest();
154 | // Do nothing at all on AJAX requests
155 | if (!$request->getIsConsoleRequest() && $request->getIsAjax()) {
156 | return;
157 | }
158 | // Install only for non-console site requests
159 | if ($request->getIsSiteRequest() && !$request->getIsConsoleRequest()) {
160 | $this->installSiteEventListeners();
161 | }
162 | // Install only for non-console Control Panel requests
163 | if ($request->getIsCpRequest() && !$request->getIsConsoleRequest()) {
164 | $this->installCpEventListeners();
165 | }
166 | }
167 |
168 | /**
169 | * Install site event listeners for site requests only
170 | */
171 | protected function installSiteEventListeners()
172 | {
173 | if (self::$settings->siteTemplateComments) {
174 | $this->installTemplateEventListeners();
175 | }
176 | }
177 |
178 | /**
179 | * Install site event listeners for Control Panel requests only
180 | */
181 | protected function installCpEventListeners()
182 | {
183 | if (self::$settings->cpTemplateComments) {
184 | $this->installTemplateEventListeners();
185 | }
186 | }
187 |
188 | // Private Methods
189 | // =========================================================================
190 |
191 | /**
192 | * Install our template components
193 | */
194 | private function installTemplateComponents(): void
195 | {
196 | $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
197 | if (!self::$settings->onlyCommentsInDevMode || $devMode) {
198 | $view = Craft::$app->getView();
199 | self::$originalTwigLoader = $view->getTwig()->getLoader();
200 | $view->registerTwigExtension(new CommentsTwigExtension());
201 | }
202 | }
203 |
204 | /**
205 | * Install our template event listeners
206 | */
207 | private function installTemplateEventListeners()
208 | {
209 | $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
210 | if (!self::$settings->onlyCommentsInDevMode || $devMode) {
211 | // Remember the name of the currently rendering template
212 | Event::on(
213 | View::class,
214 | View::EVENT_BEFORE_RENDER_PAGE_TEMPLATE,
215 | function(TemplateEvent $event) {
216 | $view = Craft::$app->getView();
217 | $twig = $view->getTwig();
218 | if ($this->enabledForTemplate($event->template)) {
219 | $twig->setLoader(new CommentTemplateLoader($view));
220 | $twig->setParser(new TemplateCommentsParser($twig));
221 | }
222 | }
223 | );
224 | }
225 | }
226 |
227 | /**
228 | * Is template parsing enabled for this template?
229 | *
230 | * @param string $templateName
231 | *
232 | * @return bool
233 | */
234 | private function enabledForTemplate(string $templateName): bool
235 | {
236 | $ext = pathinfo($templateName, PATHINFO_EXTENSION);
237 | return (self::$settings->templateCommentsEnabled
238 | && in_array($ext, self::$settings->allowedTemplateSuffixes, false));
239 | }
240 | }
241 |
--------------------------------------------------------------------------------
/src/web/twig/TemplateCommentsParser.php:
--------------------------------------------------------------------------------
1 | {% 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 AllowDynamicProperties;
47 | use nystudio107\templatecomments\helpers\Reflection as ReflectionHelper;
48 | use ReflectionException;
49 | use Twig\Environment;
50 | use Twig\Error\SyntaxError;
51 | use Twig\ExpressionParser;
52 | use Twig\Node\BlockNode;
53 | use Twig\Node\BlockReferenceNode;
54 | use Twig\Node\BodyNode;
55 | use Twig\Node\EmptyNode;
56 | use Twig\Node\Expression\AbstractExpression;
57 | use Twig\Node\Expression\Variable\AssignTemplateVariable;
58 | use Twig\Node\Expression\Variable\TemplateVariable;
59 | use Twig\Node\MacroNode;
60 | use Twig\Node\ModuleNode;
61 | use Twig\Node\Node;
62 | use Twig\Node\NodeCaptureInterface;
63 | use Twig\Node\NodeOutputInterface;
64 | use Twig\Node\Nodes;
65 | use Twig\Node\PrintNode;
66 | use Twig\Node\TextNode;
67 | use Twig\NodeTraverser;
68 | use Twig\Parser;
69 | use Twig\Token;
70 | use Twig\TokenParser\TokenParserInterface;
71 | use Twig\TokenStream;
72 | use Twig\TwigTest;
73 | use Twig\Util\ReflectionCallable;
74 | use function chr;
75 | use function count;
76 | use function get_class;
77 | use function is_array;
78 | use function sprintf;
79 |
80 | /**
81 | * @author Fabien Potencier
82 | */
83 | #[AllowDynamicProperties]
84 | class TemplateCommentsParser extends Parser
85 | {
86 | private $stack = [];
87 | private $stream;
88 | private $parent;
89 | private $visitors;
90 | private $expressionParser;
91 | private $blocks;
92 | private $blockStack;
93 | private $macros;
94 | private $importedSymbols;
95 | private $traits;
96 | private $embeddedTemplates = [];
97 | private $varNameSalt = 0;
98 | private $expressionParserClass;
99 | private $ignoreUnknownTwigCallables = false;
100 |
101 | public function __construct(
102 | private Environment $env,
103 | ) {
104 | $this->expressionParserClass = ExpressionParser::class;
105 | // Get the existing parser object used by the Twig $env
106 | try {
107 | $parserReflection = ReflectionHelper::getReflectionProperty($env, 'parser');
108 | } catch (ReflectionException $e) {
109 | return;
110 | }
111 | $parserReflection->setAccessible(true);
112 | $parser = $parserReflection->getValue($env);
113 | if ($parser === null) {
114 | return;
115 | }
116 | // Get the expression parser used by the current parser
117 | try {
118 | $expressionParserReflection = ReflectionHelper::getReflectionProperty($parser, 'expressionParser');
119 | } catch (ReflectionException $e) {
120 | return;
121 | }
122 | // Preserve the existing expression parser and use it
123 | $expressionParserReflection->setAccessible(true);
124 | $expressionParser = $expressionParserReflection->getValue($parser);
125 | $this->expressionParserClass = get_class($expressionParser);
126 | }
127 |
128 | public function getEnvironment(): Environment
129 | {
130 | return $this->env;
131 | }
132 |
133 | public function getVarName(): string
134 | {
135 | trigger_deprecation('twig/twig', '3.15', 'The "%s()" method is deprecated.', __METHOD__);
136 |
137 | return sprintf('__internal_parse_%d', $this->varNameSalt++);
138 | }
139 |
140 | public function parse(TokenStream $stream, $test = null, bool $dropNeedle = false): ModuleNode
141 | {
142 | $vars = get_object_vars($this);
143 | unset($vars['stack'], $vars['env'], $vars['handlers'], $vars['visitors'], $vars['expressionParser'], $vars['reservedMacroNames'], $vars['varNameSalt']);
144 | $this->stack[] = $vars;
145 |
146 | // node visitors
147 | if (null === $this->visitors) {
148 | $this->visitors = $this->env->getNodeVisitors();
149 | }
150 |
151 | if (null === $this->expressionParser) {
152 | $this->expressionParser = new $this->expressionParserClass($this, $this->env);
153 | }
154 |
155 | $this->stream = $stream;
156 | $this->parent = null;
157 | $this->blocks = [];
158 | $this->macros = [];
159 | $this->traits = [];
160 | $this->blockStack = [];
161 | $this->importedSymbols = [[]];
162 | $this->embeddedTemplates = [];
163 |
164 | try {
165 | $body = $this->subparse($test, $dropNeedle);
166 |
167 | if (null !== $this->parent && null === $body = $this->filterBodyNodes($body)) {
168 | $body = new EmptyNode();
169 | }
170 | } catch (SyntaxError $e) {
171 | if (!$e->getSourceContext()) {
172 | $e->setSourceContext($this->stream->getSourceContext());
173 | }
174 |
175 | if (!$e->getTemplateLine()) {
176 | $e->setTemplateLine($this->getCurrentToken()->getLine());
177 | }
178 |
179 | throw $e;
180 | }
181 |
182 | $node = new ModuleNode(new BodyNode([$body]), $this->parent, new Nodes($this->blocks), new Nodes($this->macros), new Nodes($this->traits), $this->embeddedTemplates, $stream->getSourceContext());
183 |
184 | $traverser = new NodeTraverser($this->env, $this->visitors);
185 |
186 | /**
187 | * @var ModuleNode $node
188 | */
189 | $node = $traverser->traverse($node);
190 |
191 | // restore previous stack so previous parse() call can resume working
192 | foreach (array_pop($this->stack) as $key => $val) {
193 | $this->$key = $val;
194 | }
195 |
196 | return $node;
197 | }
198 |
199 | public function shouldIgnoreUnknownTwigCallables(): bool
200 | {
201 | return $this->ignoreUnknownTwigCallables;
202 | }
203 |
204 | public function subparseIgnoreUnknownTwigCallables($test, bool $dropNeedle = false): void
205 | {
206 | $previous = $this->ignoreUnknownTwigCallables;
207 | $this->ignoreUnknownTwigCallables = true;
208 | try {
209 | $this->subparse($test, $dropNeedle);
210 | } finally {
211 | $this->ignoreUnknownTwigCallables = $previous;
212 | }
213 | }
214 |
215 | public function subparse($test, bool $dropNeedle = false): Node
216 | {
217 | $lineno = $this->getCurrentToken()->getLine();
218 | $rv = [];
219 | while (!$this->stream->isEOF()) {
220 | switch ($this->getCurrentToken()->getType()) {
221 | case Token::TEXT_TYPE:
222 | $token = $this->stream->next();
223 | $rv[] = new TextNode($token->getValue(), $token->getLine());
224 | break;
225 |
226 | case Token::VAR_START_TYPE:
227 | $token = $this->stream->next();
228 | $expr = $this->expressionParser->parseExpression();
229 | $this->stream->expect(Token::VAR_END_TYPE);
230 | $rv[] = new PrintNode($expr, $token->getLine());
231 | break;
232 |
233 | case Token::BLOCK_START_TYPE:
234 | $this->stream->next();
235 | $token = $this->getCurrentToken();
236 |
237 | if (Token::NAME_TYPE !== $token->getType()) {
238 | throw new SyntaxError('A block must start with a tag name.', $token->getLine(), $this->stream->getSourceContext());
239 | }
240 |
241 | if (null !== $test && $test($token)) {
242 | if ($dropNeedle) {
243 | $this->stream->next();
244 | }
245 |
246 | if (1 === count($rv)) {
247 | return $rv[0];
248 | }
249 |
250 | return new Nodes($rv, $lineno);
251 | }
252 |
253 | if (!$subparser = $this->env->getTokenParser($token->getValue())) {
254 | if (null !== $test) {
255 | $e = new SyntaxError(sprintf('Unexpected "%s" tag', $token->getValue()), $token->getLine(), $this->stream->getSourceContext());
256 |
257 | $callable = (new ReflectionCallable(new TwigTest('decision', $test)))->getCallable();
258 | if (is_array($callable) && $callable[0] instanceof TokenParserInterface) {
259 | $e->appendMessage(sprintf(' (expecting closing tag for the "%s" tag defined near line %s).', $callable[0]->getTag(), $lineno));
260 | }
261 | } else {
262 | $e = new SyntaxError(sprintf('Unknown "%s" tag.', $token->getValue()), $token->getLine(), $this->stream->getSourceContext());
263 | $e->addSuggestions($token->getValue(), array_keys($this->env->getTokenParsers()));
264 | }
265 |
266 | throw $e;
267 | }
268 |
269 | $this->stream->next();
270 |
271 | $subparser->setParser($this);
272 | $node = $subparser->parse($token);
273 | if (!$node) {
274 | trigger_deprecation('twig/twig', '3.12', 'Returning "null" from "%s" is deprecated and forbidden by "TokenParserInterface".', $subparser::class);
275 | } else {
276 | $node->setNodeTag($subparser->getTag());
277 | $rv[] = $node;
278 | }
279 | break;
280 |
281 | default:
282 | throw new SyntaxError('The lexer or the parser ended up in an unsupported state.', $this->getCurrentToken()->getLine(), $this->stream->getSourceContext());
283 | }
284 | }
285 |
286 | if (1 === count($rv)) {
287 | return $rv[0];
288 | }
289 |
290 | return new Nodes($rv, $lineno);
291 | }
292 |
293 | public function getBlockStack(): array
294 | {
295 | trigger_deprecation('twig/twig', '3.12', 'Method "%s()" is deprecated.', __METHOD__);
296 |
297 | return $this->blockStack;
298 | }
299 |
300 | public function peekBlockStack()
301 | {
302 | return $this->blockStack[count($this->blockStack) - 1] ?? null;
303 | }
304 |
305 | public function popBlockStack(): void
306 | {
307 | array_pop($this->blockStack);
308 | }
309 |
310 | public function pushBlockStack($name): void
311 | {
312 | $this->blockStack[] = $name;
313 | }
314 |
315 | public function hasBlock(string $name): bool
316 | {
317 | trigger_deprecation('twig/twig', '3.12', 'Method "%s()" is deprecated.', __METHOD__);
318 |
319 | return isset($this->blocks[$name]);
320 | }
321 |
322 | public function getBlock(string $name): Node
323 | {
324 | trigger_deprecation('twig/twig', '3.12', 'Method "%s()" is deprecated.', __METHOD__);
325 |
326 | return $this->blocks[$name];
327 | }
328 |
329 | public function setBlock(string $name, BlockNode $value): void
330 | {
331 | if (isset($this->blocks[$name])) {
332 | throw new SyntaxError(sprintf("The block '%s' has already been defined line %d.", $name, $this->blocks[$name]->getTemplateLine()), $this->getCurrentToken()->getLine(), $this->blocks[$name]->getSourceContext());
333 | }
334 |
335 | $this->blocks[$name] = new BodyNode([$value], [], $value->getTemplateLine());
336 | }
337 |
338 | public function hasMacro(string $name): bool
339 | {
340 | trigger_deprecation('twig/twig', '3.12', 'Method "%s()" is deprecated.', __METHOD__);
341 |
342 | return isset($this->macros[$name]);
343 | }
344 |
345 | public function setMacro(string $name, MacroNode $node): void
346 | {
347 | $this->macros[$name] = $node;
348 | }
349 |
350 | public function addTrait($trait): void
351 | {
352 | $this->traits[] = $trait;
353 | }
354 |
355 | public function hasTraits(): bool
356 | {
357 | trigger_deprecation('twig/twig', '3.12', 'Method "%s()" is deprecated.', __METHOD__);
358 |
359 | return count($this->traits) > 0;
360 | }
361 |
362 | public function embedTemplate(ModuleNode $template)
363 | {
364 | $template->setIndex(mt_rand());
365 |
366 | $this->embeddedTemplates[] = $template;
367 | }
368 |
369 | public function addImportedSymbol(string $type, string $alias, ?string $name = null, AbstractExpression|AssignTemplateVariable|null $internalRef = null): void
370 | {
371 | if ($internalRef && !$internalRef instanceof AssignTemplateVariable) {
372 | trigger_deprecation('twig/twig', '3.15', 'Not passing a "%s" instance as an internal reference is deprecated ("%s" given).', __METHOD__, AssignTemplateVariable::class, $internalRef::class);
373 |
374 | $internalRef = new AssignTemplateVariable(new TemplateVariable($internalRef->getAttribute('name'), $internalRef->getTemplateLine()), $internalRef->getAttribute('global'));
375 | }
376 |
377 | $this->importedSymbols[0][$type][$alias] = ['name' => $name, 'node' => $internalRef];
378 | }
379 |
380 | public function getImportedSymbol(string $type, string $alias)
381 | {
382 | // if the symbol does not exist in the current scope (0), try in the main/global scope (last index)
383 | return $this->importedSymbols[0][$type][$alias] ?? ($this->importedSymbols[count($this->importedSymbols) - 1][$type][$alias] ?? null);
384 | }
385 |
386 | public function isMainScope(): bool
387 | {
388 | return 1 === count($this->importedSymbols);
389 | }
390 |
391 | public function pushLocalScope(): void
392 | {
393 | array_unshift($this->importedSymbols, []);
394 | }
395 |
396 | public function popLocalScope(): void
397 | {
398 | array_shift($this->importedSymbols);
399 | }
400 |
401 | public function getExpressionParser(): ExpressionParser
402 | {
403 | return $this->expressionParser;
404 | }
405 |
406 | public function getParent(): ?Node
407 | {
408 | trigger_deprecation('twig/twig', '3.12', 'Method "%s()" is deprecated.', __METHOD__);
409 |
410 | return $this->parent;
411 | }
412 |
413 | public function setParent(?Node $parent): void
414 | {
415 | if (null === $parent) {
416 | trigger_deprecation('twig/twig', '3.12', 'Passing "null" to "%s()" is deprecated.', __METHOD__);
417 | }
418 |
419 | if (null !== $this->parent) {
420 | throw new SyntaxError('Multiple extends tags are forbidden.', $parent->getTemplateLine(), $parent->getSourceContext());
421 | }
422 |
423 | $this->parent = $parent;
424 | }
425 |
426 | public function hasInheritance()
427 | {
428 | return $this->parent || 0 < count($this->traits);
429 | }
430 |
431 | public function getStream(): TokenStream
432 | {
433 | return $this->stream;
434 | }
435 |
436 | public function getCurrentToken(): Token
437 | {
438 | return $this->stream->getCurrent();
439 | }
440 |
441 | private function filterBodyNodes(Node $node, bool $nested = false): ?Node
442 | {
443 | // check that the body does not contain non-empty output nodes
444 | if (
445 | ($node instanceof TextNode && !ctype_space($node->getAttribute('data')))
446 | || (!$node instanceof TextNode && !$node instanceof BlockReferenceNode && $node instanceof NodeOutputInterface)
447 | ) {
448 | if (str_contains((string)$node, chr(0xEF) . chr(0xBB) . chr(0xBF))) {
449 | $t = substr($node->getAttribute('data'), 3);
450 | if ('' === $t || ctype_space($t)) {
451 | // bypass empty nodes starting with a BOM
452 | return null;
453 | }
454 | }
455 |
456 | 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());
457 | }
458 |
459 | // bypass nodes that "capture" the output
460 | if ($node instanceof NodeCaptureInterface) {
461 | // a "block" tag in such a node will serve as a block definition AND be displayed in place as well
462 | return $node;
463 | }
464 |
465 | /**
466 | * We intentionally skip this check to avoid throwing an exception, so our {% comments %} tag can
467 | * render correctly
468 | * // "block" tags that are not captured (see above) are only used for defining
469 | * // the content of the block. In such a case, nesting it does not work as
470 | * // expected as the definition is not part of the default template code flow.
471 | * if ($nested && $node instanceof BlockReferenceNode) {
472 | * throw new SyntaxError('A block definition cannot be nested under non-capturing nodes.', $node->getTemplateLine(), $this->stream->getSourceContext());
473 | * }
474 | */
475 | if ($node instanceof NodeOutputInterface) {
476 | return null;
477 | }
478 |
479 | // "block" tags that are not captured (see above) are only used for defining
480 | // the content of the block. In such a case, nesting it does not work as
481 | // expected as the definition is not part of the default template code flow.
482 | if ($nested && $node instanceof BlockReferenceNode) {
483 | throw new SyntaxError('A block definition cannot be nested under non-capturing nodes.', $node->getTemplateLine(), $this->stream->getSourceContext());
484 | }
485 |
486 | // here, $nested means "being at the root level of a child template"
487 | // we need to discard the wrapping "Node" for the "body" node
488 | // Node::class !== \get_class($node) should be removed in Twig 4.0
489 | $nested = $nested || (Node::class !== get_class($node) && !$node instanceof Nodes);
490 | foreach ($node as $k => $n) {
491 | if (null !== $n && null === $this->filterBodyNodes($n, $nested)) {
492 | $node->removeNode($k);
493 | }
494 | }
495 |
496 | return $node;
497 | }
498 | }
499 |
--------------------------------------------------------------------------------