├── 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 | 7 | 8 | 12 | 19 | 20 | 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 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/nystudio107/craft-templatecomments/badges/quality-score.png?b=v5)](https://scrutinizer-ci.com/g/nystudio107/craft-templatecomments/?branch=v5) [![Code Coverage](https://scrutinizer-ci.com/g/nystudio107/craft-templatecomments/badges/coverage.png?b=v5)](https://scrutinizer-ci.com/g/nystudio107/craft-templatecomments/?branch=v5) [![Build Status](https://scrutinizer-ci.com/g/nystudio107/craft-templatecomments/badges/build.png?b=v5)](https://scrutinizer-ci.com/g/nystudio107/craft-templatecomments/build-status/v5) [![Code Intelligence Status](https://scrutinizer-ci.com/g/nystudio107/craft-templatecomments/badges/code-intelligence.svg?b=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 | ![Screenshot](./docs/docs/resources/img/plugin-logo.png) 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 | --------------------------------------------------------------------------------