├── LICENSE ├── README.md ├── composer.json └── src ├── Attributes.php ├── CodeBlockExtension.php ├── Exception ├── RuntimeException.php └── SyntaxException.php ├── Highlighter ├── HighlighterFactory.php ├── HighlighterInterface.php ├── HighlighterReference.php └── PygmentsHighlighter.php ├── Node └── CodeBlockNode.php └── TokenParser └── CodeBlockParser.php /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2025 Ben Ramsey 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

ramsey/twig-codeblock

2 | 3 |

4 | 🌿 Syntax highlighting for Twig with the {% codeblock %} tag. 5 |

6 | 7 |

8 | Source Code 9 | Download Package 10 | PHP Programming Language 11 | Read License 12 | Build Status 13 | Codecov Code Coverage 14 |

15 | 16 | ## About 17 | 18 | Add code snippets with syntax highlighting and more to any [Twig][] template 19 | with ramsey/twig-codeblock, a port of the `{% codeblock %}` 20 | [Liquid tag for Octopress/Jekyll][octopress-codeblock]. 21 | 22 | ramsey/twig-codeblock includes an adapter for using [Pygments][], the Python 23 | syntax highlighter, with [ramsey/pygments][], but it may use any syntax 24 | highlighter. To use another syntax highlighter, implement `HighlighterInterface` 25 | (see below for an example). 26 | 27 | This project adheres to a [code of conduct](CODE_OF_CONDUCT.md). 28 | By participating in this project and its community, you are expected to 29 | uphold this code. 30 | 31 | ## Installation 32 | 33 | Install this package as a dependency using [Composer](https://getcomposer.org). 34 | 35 | ```bash 36 | composer require ramsey/twig-codeblock 37 | ``` 38 | 39 | ## Usage 40 | 41 | ``` 42 | {% codeblock [attributes] %} 43 | [lines of code] 44 | {% endcodeblock %} 45 | ``` 46 | 47 | ### Attributes 48 | 49 | A number of attributes are available to `{% codeblock %}`: 50 | 51 | | Attribute | Example | Description | 52 | |-------------|------------------------------|----------------------------------------------------------------------------------------------------------------| 53 | | `lang` | `lang:"php"` | Tells the syntax highlighter the programming language being highlighted. Pass "plain" to disable highlighting. | 54 | | `title` | `title:"Figure 2."` | Add a title to your code block. | 55 | | `link` | `link:"https://example.com"` | Add a link to your code block title. | 56 | | `link_text` | `link_text:"Download Code"` | Text to use for the link. Defaults to `"link"`. | 57 | | `linenos` | `linenos:false` | Use `false` to disable line numbering. Defaults to `true`. | 58 | | `start` | `start:42` | Start the line numbering in your code block at this value. | 59 | | `mark` | `mark:4-6,12` | Mark specific lines of code. This example marks lines 4, 5, 6, and 12. | 60 | | `class` | `class:"myclass foo"` | Add CSS class names to the code `
` element. | 61 | | `format` | `format:"html"` | The output format for the syntax highlighter. Defaults to "html." | 62 | 63 | > [!TIP] 64 | > Order of attributes does not matter. 65 | 66 | > [!WARNING] 67 | > Not all highlighters will support all attributes. However, the Pygments 68 | > highlighter does support each of these attributes. 69 | 70 | ### Example 71 | 72 | ``` twig 73 | {% codeblock lang:"php" %} 74 | class Bar implements BarInterface 75 | { 76 | private $baz; 77 | 78 | public function __construct(BazInterface $baz) 79 | { 80 | $this->baz = $baz; 81 | } 82 | 83 | public function doIt() 84 | { 85 | return $this->baz->do('it'); 86 | } 87 | } 88 | {% endcodeblock %} 89 | ``` 90 | 91 | ### Configuration 92 | 93 | To use ramsey/twig-codeblock, create a `HighlighterReference` that defines the 94 | highlighter you want to use. If using `PygmentsHighlighter`, by default, it will 95 | look for `pygmentize` in your `PATH`. 96 | 97 | ``` php 98 | use Ramsey\Twig\CodeBlock\CodeBlockExtension; 99 | use Ramsey\Twig\CodeBlock\Highlighter\HighlighterReference; 100 | use Ramsey\Twig\CodeBlock\Highlighter\PygmentsHighlighter; 101 | use Twig\Environment; 102 | use Twig\Loader\FilesystemLoader; 103 | 104 | $reference = new HighlighterReference(PygmentsHighlighter::class); 105 | 106 | $env = new Environment(new FilesystemLoader('/path/to/templates')); 107 | $env->addExtension(new CodeBlockExtension($reference)); 108 | ``` 109 | 110 | If `pygmentize` is not in the `PATH`, you may pass its location to the 111 | highlighter reference: 112 | 113 | ``` php 114 | $reference = new HighlighterReference( 115 | PygmentsHighlighter::class, 116 | ['/path/to/pygmentize'], 117 | ); 118 | ``` 119 | 120 | > [!NOTE] 121 | > We use a `HighlighterReference` instead of an actual instance of 122 | > `HighlighterInterface` because these values will be compiled into the Twig 123 | > templates and cached for later execution. 124 | 125 | ### Pygments 126 | 127 | This library provides `PygmentsHighlighter`, which depends on [ramsey/pygments][], 128 | but ramsey/pygments is not a dependency, since you may use other highlighters 129 | that implement `Ramsey\Twig\CodeBlock\Highlighter\HighlighterInterface`. 130 | 131 | To use this library with ramsey/pygments, you must also require ramsey/pygments 132 | as a dependency: 133 | 134 | ``` bash 135 | composer require ramsey/pygments 136 | ``` 137 | 138 | Additionally, you will need to install [Python][] and [Pygments][] and ensure the 139 | `pygmentize` CLI tool is available on your system. See the [Configuration](#configuration) 140 | section for help configuring Codeblock if `pygmentize` is not in your `PATH`. 141 | 142 | ``` bash 143 | pip install Pygments 144 | ``` 145 | 146 | #### Styles 147 | 148 | A syntax highlighter, such as Pygments, requires a stylesheet for the markup it 149 | generates. Pygments provides some stylesheets for you, which you may list from 150 | the command line: 151 | 152 | ``` bash 153 | pygmentize -L styles 154 | ``` 155 | 156 | To output and save one of these styles for use in your application, use 157 | something like: 158 | 159 | ``` bash 160 | pygmentize -S rainbow_dash -f html > rainbow_dash.css 161 | ``` 162 | 163 | Additionally, there are many custom Pygments styles found on the web, or you may 164 | create your own. 165 | 166 | #### Languages 167 | 168 | If using Pygments, here are just a few of the languages (i.e., lexers) it supports: 169 | 170 | * css 171 | * diff 172 | * html 173 | * html+php 174 | * javascript 175 | * json 176 | * php 177 | * sass 178 | * shell 179 | * sql 180 | * twig 181 | * yaml 182 | 183 | To see more, type the following from the command line: 184 | 185 | ``` bash 186 | pygmentize -L lexers 187 | ``` 188 | 189 | ### Using your own highlighter 190 | 191 | If you have your own highlighter class that implements `Ramsey\Twig\CodeBlock\Highlighter\HighlighterInterface`, 192 | you may create a `HighlighterReference` using it. The array of values passed 193 | as the second argument will be passed to your class's constructor upon instantiation. 194 | 195 | The arguments must be scalar values or arrays of scalar values, or they may be 196 | expressions that evaluate to scalar values or arrays of scalar values. Null 197 | values are also allowed. This restriction is because of the way these values are 198 | compiled into the Twig templates. 199 | 200 | ``` php 201 | use Ramsey\Twig\CodeBlock\CodeBlockExtension; 202 | use Ramsey\Twig\CodeBlock\Highlighter\HighlighterReference; 203 | use Ramsey\Twig\CodeBlock\Highlighter\PygmentsHighlighter; 204 | use Twig\Environment; 205 | use Twig\Loader\FilesystemLoader; 206 | use Your\Own\Highlighter as MyHighlighter; 207 | 208 | $reference = new HighlighterReference(MyHighlighter::class, [$arg1, $arg2, $arg3]); 209 | 210 | $env = new Environment(new FilesystemLoader('/path/to/templates')); 211 | $env->addExtension(new CodeBlockExtension($reference)); 212 | ``` 213 | 214 | ## Contributing 215 | 216 | Contributions are welcome! To contribute, please familiarize yourself with 217 | [CONTRIBUTING.md](CONTRIBUTING.md). 218 | 219 | ## Coordinated Disclosure 220 | 221 | Keeping user information safe and secure is a top priority, and we welcome the 222 | contribution of external security researchers. If you believe you've found a 223 | security issue in software that is maintained in this repository, please read 224 | [SECURITY.md](SECURITY.md) for instructions on submitting a vulnerability report. 225 | 226 | ## Copyright and License 227 | 228 | The ramsey/twig-codeblock library is copyright © [Ben Ramsey](https://benramsey.com/) 229 | and licensed for use under the MIT License (MIT). Please see [LICENSE](LICENSE) 230 | for more information. 231 | 232 | [octopress-codeblock]: https://github.com/octopress/codeblock 233 | [python]: https://www.python.org 234 | [pygments]: http://pygments.org/ 235 | [twig]: https://twig.symfony.com 236 | [ramsey/pygments]: https://github.com/ramsey/pygments 237 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ramsey/twig-codeblock", 3 | "description": "A Twig extension for defining blocks of code for syntax highlighting (with Pygments) and more.", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": [ 7 | "pygmentize", 8 | "pygments", 9 | "syntax-highlighting", 10 | "twig", 11 | "twig-extension" 12 | ], 13 | "authors": [ 14 | { 15 | "name": "Ben Ramsey", 16 | "email": "ben@benramsey.com", 17 | "homepage": "https://benramsey.com" 18 | } 19 | ], 20 | "require": { 21 | "php": "^8.2", 22 | "twig/twig": "^3.11.2" 23 | }, 24 | "require-dev": { 25 | "captainhook/captainhook": "^5.25", 26 | "captainhook/plugin-composer": "^5.3", 27 | "ergebnis/composer-normalize": "^2.45", 28 | "mockery/mockery": "^1.6", 29 | "php-parallel-lint/php-console-highlighter": "^1.0", 30 | "php-parallel-lint/php-parallel-lint": "^1.4", 31 | "phpstan/extension-installer": "^1.4", 32 | "phpstan/phpstan": "^2.1", 33 | "phpstan/phpstan-mockery": "^2.0", 34 | "phpstan/phpstan-phpunit": "^2.0", 35 | "phpunit/phpunit": "^9.6", 36 | "ramsey/coding-standard": "^2.3", 37 | "ramsey/conventional-commits": "^1.6", 38 | "ramsey/pygments": "^3.0", 39 | "roave/security-advisories": "dev-latest" 40 | }, 41 | "suggest": { 42 | "ramsey/pygments": "Required when using PygmentsHighlighter" 43 | }, 44 | "minimum-stability": "dev", 45 | "prefer-stable": true, 46 | "autoload": { 47 | "psr-4": { 48 | "Ramsey\\Twig\\CodeBlock\\": "src/" 49 | } 50 | }, 51 | "autoload-dev": { 52 | "psr-4": { 53 | "Ramsey\\Twig\\CodeBlock\\Test\\": "tests/" 54 | } 55 | }, 56 | "config": { 57 | "allow-plugins": { 58 | "captainhook/plugin-composer": true, 59 | "dealerdirect/phpcodesniffer-composer-installer": true, 60 | "ergebnis/composer-normalize": true, 61 | "phpstan/extension-installer": true 62 | }, 63 | "sort-packages": true 64 | }, 65 | "extra": { 66 | "captainhook": { 67 | "force-install": true 68 | }, 69 | "ramsey/conventional-commits": { 70 | "configFile": "conventional-commits.json" 71 | } 72 | }, 73 | "scripts": { 74 | "analyze": "@dev:analyze", 75 | "build:clean": "git clean -fX build/.", 76 | "build:clean:cache": "git clean -fX build/cache/.", 77 | "build:clean:coverage": "git clean -fX build/coverage/.", 78 | "dev:analyze": "@dev:analyze:phpstan", 79 | "dev:analyze:phpstan": "phpstan analyze --ansi", 80 | "dev:lint": [ 81 | "@dev:lint:syntax", 82 | "@dev:lint:style" 83 | ], 84 | "dev:lint:fix": "phpcbf --cache=build/cache/phpcs.cache", 85 | "dev:lint:style": "phpcs --colors --cache=build/cache/phpcs.cache", 86 | "dev:lint:syntax": "parallel-lint --colors src tests", 87 | "dev:test:coverage:ci": "XDEBUG_MODE=coverage phpunit --colors=always --coverage-text --coverage-clover build/coverage/clover.xml --coverage-crap4j build/coverage/crap4j.xml --coverage-xml build/coverage/coverage-xml --log-junit build/junit.xml", 88 | "dev:test:coverage:html": "XDEBUG_MODE=coverage phpunit --colors=always --coverage-html build/coverage/coverage-html", 89 | "dev:test:unit": "phpunit --colors=always", 90 | "lint": "@dev:lint", 91 | "test": [ 92 | "@dev:lint:syntax", 93 | "@dev:lint:style", 94 | "@dev:analyze:phpstan", 95 | "@dev:test:unit" 96 | ] 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Attributes.php: -------------------------------------------------------------------------------- 1 | 10 | * @license http://opensource.org/licenses/MIT MIT 11 | */ 12 | 13 | declare(strict_types=1); 14 | 15 | namespace Ramsey\Twig\CodeBlock; 16 | 17 | use function array_filter; 18 | 19 | /** 20 | * Attributes defined on the Twig `{% codeblock %}` tag 21 | */ 22 | final class Attributes 23 | { 24 | /** 25 | * Additional CSS class to add to the rendered code block. 26 | * 27 | * If specifying more than one CSS class, separate each with a space. 28 | * 29 | * Note: Not all highlighters support this attribute. 30 | */ 31 | public ?string $class = null; 32 | 33 | /** 34 | * The formatter to use when rendering the code block. 35 | * 36 | * For example, Pygments supports formatting as html, svg, latex, png, etc. 37 | * 38 | * Note: Not all highlighters support this attribute, and those that do may 39 | * support different formatters. Check with the highlighters' documentation 40 | * for more information. 41 | * 42 | * This defaults to "html." 43 | */ 44 | public string $format = 'html'; 45 | 46 | /** 47 | * The language of the code in the code block. 48 | * 49 | * For example: php, javascript, html, rust, c, bash, html+php, etc. 50 | * 51 | * Note: Some highlighters may attempt to guess the language if it is not 52 | * specified. 53 | */ 54 | public ?string $lang = null; 55 | 56 | /** 57 | * Whether to display line numbers with the rendered code block. 58 | * 59 | * Note: Not all highlighters support this attribute. 60 | */ 61 | public bool $linenos = true; 62 | 63 | /** 64 | * Figure captions may include links. If a `title` and `link_url` are present, 65 | * then `link_text` is the clickable text for the link alongside the caption 66 | * title. 67 | * 68 | * Note: Not all highlighters support this attribute. 69 | * 70 | * This defaults to "link." 71 | */ 72 | public string $linkText = 'link'; 73 | 74 | /** 75 | * Figure captions may include links. If a `title` and `link_url` are present, 76 | * a link will be rendered alongside the caption title. 77 | * 78 | * Note: Not all highlighters support this attribute. 79 | */ 80 | public ?string $linkUrl = null; 81 | 82 | /** 83 | * Lines to be marked (highlighted) in the rendered code block. 84 | * 85 | * When marking lines, the lines are 1-indexed and always begin at 1, even 86 | * if `start` is a different value. Multiple lines may be specified, 87 | * separated by commas, and ranges may be specified using dashes. 88 | * 89 | * For example: `7,11-14,18` 90 | * 91 | * Note: Not all highlighters support this attribute. 92 | */ 93 | public ?string $mark = null; 94 | 95 | /** 96 | * If `linenos` is `true`, then `start` may be used to set a different 97 | * starting number. 98 | * 99 | * Note: Not all highlighters support this attribute. 100 | */ 101 | public ?int $start = null; 102 | 103 | /** 104 | * A figure caption title to render with the code block. 105 | * 106 | * Note: Not all highlighters support this attribute. 107 | */ 108 | public ?string $title = null; 109 | 110 | /** 111 | * @return array 112 | */ 113 | public function toArray(): array 114 | { 115 | return array_filter((array) $this, fn ($value) => $value !== null); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/CodeBlockExtension.php: -------------------------------------------------------------------------------- 1 | 10 | * @license http://opensource.org/licenses/MIT MIT 11 | */ 12 | 13 | declare(strict_types=1); 14 | 15 | namespace Ramsey\Twig\CodeBlock; 16 | 17 | use Ramsey\Twig\CodeBlock\Highlighter\HighlighterReference; 18 | use Twig\Extension\ExtensionInterface; 19 | 20 | /** 21 | * A Twig extension providing codeblock tag functionality for marking up 22 | * blocks of source code in content (i.e., syntax highlighting) 23 | */ 24 | final readonly class CodeBlockExtension implements ExtensionInterface 25 | { 26 | /** 27 | * @param HighlighterReference $highlighterReference Reference details for the highlighter to use 28 | */ 29 | public function __construct(private HighlighterReference $highlighterReference) 30 | { 31 | } 32 | 33 | /** 34 | * @inheritDoc 35 | */ 36 | public function getFilters(): array 37 | { 38 | return []; 39 | } 40 | 41 | /** 42 | * @inheritDoc 43 | */ 44 | public function getFunctions(): array 45 | { 46 | return []; 47 | } 48 | 49 | /** 50 | * @inheritDoc 51 | */ 52 | public function getNodeVisitors(): array 53 | { 54 | return []; 55 | } 56 | 57 | /** 58 | * @inheritDoc 59 | */ 60 | public function getOperators(): array 61 | { 62 | return [[], []]; 63 | } 64 | 65 | /** 66 | * @inheritDoc 67 | */ 68 | public function getTests(): array 69 | { 70 | return []; 71 | } 72 | 73 | /** 74 | * @inheritDoc 75 | */ 76 | public function getTokenParsers(): array 77 | { 78 | return [ 79 | new TokenParser\CodeBlockParser($this->highlighterReference), 80 | ]; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Exception/RuntimeException.php: -------------------------------------------------------------------------------- 1 | 10 | * @license http://opensource.org/licenses/MIT MIT 11 | */ 12 | 13 | declare(strict_types=1); 14 | 15 | namespace Ramsey\Twig\CodeBlock\Exception; 16 | 17 | use Twig\Error\RuntimeError; 18 | 19 | /** 20 | * Exception thrown when an error occurs at runtime in the Twig extension 21 | */ 22 | final class RuntimeException extends RuntimeError 23 | { 24 | } 25 | -------------------------------------------------------------------------------- /src/Exception/SyntaxException.php: -------------------------------------------------------------------------------- 1 | 10 | * @license http://opensource.org/licenses/MIT MIT 11 | */ 12 | 13 | declare(strict_types=1); 14 | 15 | namespace Ramsey\Twig\CodeBlock\Exception; 16 | 17 | use Twig\Error\SyntaxError; 18 | 19 | /** 20 | * Exception thrown when an error occurs when parsing the syntax of the Twig extension 21 | */ 22 | final class SyntaxException extends SyntaxError 23 | { 24 | } 25 | -------------------------------------------------------------------------------- /src/Highlighter/HighlighterFactory.php: -------------------------------------------------------------------------------- 1 | 10 | * @license http://opensource.org/licenses/MIT MIT 11 | */ 12 | 13 | declare(strict_types=1); 14 | 15 | namespace Ramsey\Twig\CodeBlock\Highlighter; 16 | 17 | use Ramsey\Twig\CodeBlock\Exception\RuntimeException; 18 | 19 | use function is_a; 20 | use function sprintf; 21 | 22 | /** 23 | * Factory to get a highlighter by name or by fully-qualified classname 24 | */ 25 | final class HighlighterFactory 26 | { 27 | /** 28 | * Returns an instance of a highlighter 29 | * 30 | * @param class-string $highlighter Fully-qualified 31 | * classname of the highlighter 32 | * @param array | null> $arguments Array of 33 | * arguments to pass to the highlighter upon instantiation 34 | * 35 | * @throws RuntimeException if highlighter class does not exist or is not an instance of HighlighterInterface 36 | */ 37 | public static function getHighlighter(string $highlighter, array $arguments = []): HighlighterInterface 38 | { 39 | if (is_a($highlighter, HighlighterInterface::class, true)) { 40 | return new $highlighter(...$arguments); 41 | } 42 | 43 | throw new RuntimeException( 44 | sprintf("'%s' must be an instance of '%s'", $highlighter, HighlighterInterface::class), 45 | ); 46 | } 47 | 48 | /** 49 | * @throws RuntimeException 50 | */ 51 | public static function getHighlighterFromReference(HighlighterReference $reference): HighlighterInterface 52 | { 53 | return self::getHighlighter($reference->highlighter, $reference->arguments); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Highlighter/HighlighterInterface.php: -------------------------------------------------------------------------------- 1 | 10 | * @license http://opensource.org/licenses/MIT MIT 11 | */ 12 | 13 | declare(strict_types=1); 14 | 15 | namespace Ramsey\Twig\CodeBlock\Highlighter; 16 | 17 | /** 18 | * Enforces a common interface for all highlighters used by the 19 | * Ramsey\Twig\CodeBlock extension 20 | */ 21 | interface HighlighterInterface 22 | { 23 | /** 24 | * Returns the syntax-highlighted code 25 | * 26 | * @param string $code The source code to highlight 27 | * @param array | null> $options Parsed 28 | * codeblock options that may be used when highlighting the code; see 29 | * {@see \Ramsey\Twig\CodeBlock\Attributes} for option details 30 | */ 31 | public function highlight(string $code, array $options = []): string; 32 | } 33 | -------------------------------------------------------------------------------- /src/Highlighter/HighlighterReference.php: -------------------------------------------------------------------------------- 1 | 10 | * @license http://opensource.org/licenses/MIT MIT 11 | */ 12 | 13 | declare(strict_types=1); 14 | 15 | namespace Ramsey\Twig\CodeBlock\Highlighter; 16 | 17 | /** 18 | * A reference to the highlighter class and its arguments, for use when compiling 19 | * Twig templates 20 | */ 21 | final readonly class HighlighterReference 22 | { 23 | /** 24 | * @param class-string $highlighter Fully-qualified 25 | * classname of the highlighter 26 | * @param array | null> $arguments Array of arguments 27 | * to pass to the highlighter upon instantiation 28 | */ 29 | public function __construct( 30 | public string $highlighter, 31 | public array $arguments = [], 32 | ) { 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Highlighter/PygmentsHighlighter.php: -------------------------------------------------------------------------------- 1 | 10 | * @license http://opensource.org/licenses/MIT MIT 11 | */ 12 | 13 | declare(strict_types=1); 14 | 15 | namespace Ramsey\Twig\CodeBlock\Highlighter; 16 | 17 | use Ramsey\Pygments\Pygments; 18 | 19 | use function array_merge; 20 | use function explode; 21 | use function implode; 22 | use function is_int; 23 | use function is_string; 24 | use function range; 25 | use function str_contains; 26 | use function stripos; 27 | use function strtolower; 28 | 29 | /** 30 | * A syntax-highlighter that uses [Python Pygments](http://pygments.org/) and 31 | * the [ramsey/pygments](https://github.com/ramsey/pygments) library for 32 | * highlighting 33 | */ 34 | final readonly class PygmentsHighlighter implements HighlighterInterface 35 | { 36 | private const DEFAULT_LEXER = 'text'; 37 | 38 | /** 39 | * @param string $pygmentizePath The path to pygmentize or just "pygmentize," 40 | * if it's in the PATH 41 | */ 42 | public function __construct(private string $pygmentizePath = 'pygmentize') 43 | { 44 | } 45 | 46 | /** 47 | * @inheritdoc 48 | */ 49 | public function highlight(string $code, array $options = []): string 50 | { 51 | /** @var string $formatter */ 52 | $formatter = $options['format'] ?? 'html'; 53 | 54 | $lexer = $this->parseLexer($options); 55 | $pygmentsOptions = $this->parsePygmentsOptions($options, $formatter, $code); 56 | 57 | return $this->getPygments()->highlight($code, $lexer, $formatter, $pygmentsOptions); 58 | } 59 | 60 | /** 61 | * Returns the programming language from the options 62 | * 63 | * @param array | null> $options 64 | */ 65 | private function parseLexer(array $options): string 66 | { 67 | if (isset($options['lang']) && is_string($options['lang'])) { 68 | return strtolower($options['lang']) === 'plain' ? self::DEFAULT_LEXER : $options['lang']; 69 | } 70 | 71 | return self::DEFAULT_LEXER; 72 | } 73 | 74 | /** 75 | * Returns an array of options formatted for use with pygmentize 76 | * 77 | * @param array | null> $options 78 | * 79 | * @return array{ 80 | * encoding: 'utf-8', 81 | * linenos?: 'True' | 'table', 82 | * linenostart?: int, 83 | * hl_lines?: string, 84 | * startinline?: 'True', 85 | * } 86 | */ 87 | private function parsePygmentsOptions(array $options, string $formatter, string $code): array 88 | { 89 | $pygmentsOptions = ['encoding' => 'utf-8']; 90 | 91 | if (isset($options['linenos']) && $options['linenos'] === true) { 92 | $pygmentsOptions['linenos'] = $formatter === 'html' ? 'table' : 'True'; 93 | } 94 | 95 | if (isset($options['start']) && is_int($options['start'])) { 96 | $pygmentsOptions['linenostart'] = $options['start']; 97 | } 98 | 99 | if (isset($options['mark']) && is_string($options['mark'])) { 100 | $pygmentsOptions['hl_lines'] = $this->parseMarks($options['mark']); 101 | } 102 | 103 | if ( 104 | isset($options['lang']) 105 | && is_string($options['lang']) 106 | && strtolower($options['lang']) === 'php' 107 | && stripos($code, 'pygmentizePath); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Node/CodeBlockNode.php: -------------------------------------------------------------------------------- 1 | 10 | * @license http://opensource.org/licenses/MIT MIT 11 | */ 12 | 13 | declare(strict_types=1); 14 | 15 | namespace Ramsey\Twig\CodeBlock\Node; 16 | 17 | use Ramsey\Twig\CodeBlock\Attributes; 18 | use Ramsey\Twig\CodeBlock\Highlighter\HighlighterReference; 19 | use Twig\Attribute\YieldReady; 20 | use Twig\Compiler; 21 | use Twig\Node\Node; 22 | 23 | use function assert; 24 | use function is_string; 25 | use function strtolower; 26 | 27 | /** 28 | * Represents a codeblock node in Twig 29 | */ 30 | #[YieldReady] 31 | final class CodeBlockNode extends Node 32 | { 33 | /** 34 | * Creates a codeblock node 35 | * 36 | * @param HighlighterReference $highlighterReference Reference details for the highlighter to use 37 | * @param Attributes $codeblockAttributes The attributes set on the codeblock tag 38 | * @param Node $body The body node contained within the codeblock tag 39 | * @param int $lineno The line number of this node (for debugging) 40 | */ 41 | public function __construct( 42 | private readonly HighlighterReference $highlighterReference, 43 | private readonly Attributes $codeblockAttributes, 44 | Node $body, 45 | int $lineno, 46 | ) { 47 | parent::__construct(['body' => $body], [], $lineno); 48 | } 49 | 50 | /** 51 | * Compiles the node into PHP code for execution by Twig 52 | * 53 | * @param Compiler $compiler The compiler to which we add the node's PHP code 54 | */ 55 | public function compile(Compiler $compiler): void 56 | { 57 | $compiler->addDebugInfo($this); 58 | 59 | $compiler 60 | ->write('$highlighter = \Ramsey\Twig\CodeBlock\Highlighter\HighlighterFactory::getHighlighter(') 61 | ->string($this->highlighterReference->highlighter) 62 | ->raw(', ') 63 | ->repr($this->highlighterReference->arguments) 64 | ->raw(");\n"); 65 | 66 | $data = $this->getNode('body')->getAttribute('data'); 67 | assert(is_string($data)); 68 | 69 | $compiler 70 | ->write('$highlightedCode = $highlighter->highlight(') 71 | ->string($data) 72 | ->raw(', ') 73 | ->repr($this->codeblockAttributes->toArray()) 74 | ->raw(");\n"); 75 | 76 | if (strtolower($this->codeblockAttributes->format) === 'html') { 77 | $classnames = 'code-highlight-figure'; 78 | if ($this->codeblockAttributes->class !== null) { 79 | $classnames .= ' ' . $this->codeblockAttributes->class; 80 | } 81 | 82 | $compiler 83 | ->write('$classnames = ') 84 | ->string($classnames) 85 | ->raw(";\n"); 86 | 87 | $compiler 88 | ->write('$figcaption = ') 89 | ->string($this->getFigcaption()) 90 | ->raw(";\n"); 91 | 92 | $compiler 93 | ->write('yield sprintf(') 94 | ->string('
%s%s
') 95 | ->raw(', $classnames, $figcaption, $highlightedCode') 96 | ->raw(");\n"); 97 | } else { 98 | $compiler->write('yield $highlightedCode;' . "\n"); 99 | } 100 | } 101 | 102 | private function getFigcaption(): string 103 | { 104 | $figcaption = ''; 105 | 106 | if ($this->codeblockAttributes->title !== null) { 107 | $figcaption = '
'; 108 | $figcaption .= ''; 109 | $figcaption .= $this->codeblockAttributes->title; 110 | $figcaption .= ''; 111 | $figcaption .= $this->getFigcaptionLink(); 112 | $figcaption .= '
'; 113 | } 114 | 115 | return $figcaption; 116 | } 117 | 118 | private function getFigcaptionLink(): string 119 | { 120 | $link = ''; 121 | 122 | if ($this->codeblockAttributes->linkUrl !== null) { 123 | $link = ''; 124 | $link .= $this->codeblockAttributes->linkText; 125 | $link .= ''; 126 | } 127 | 128 | return $link; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/TokenParser/CodeBlockParser.php: -------------------------------------------------------------------------------- 1 | 10 | * @license http://opensource.org/licenses/MIT MIT 11 | */ 12 | 13 | declare(strict_types=1); 14 | 15 | namespace Ramsey\Twig\CodeBlock\TokenParser; 16 | 17 | use Ramsey\Twig\CodeBlock\Attributes; 18 | use Ramsey\Twig\CodeBlock\Exception\SyntaxException; 19 | use Ramsey\Twig\CodeBlock\Highlighter\HighlighterReference; 20 | use Ramsey\Twig\CodeBlock\Node\CodeBlockNode; 21 | use Twig\Error\SyntaxError; 22 | use Twig\Node\Expression\ConstantExpression; 23 | use Twig\Node\Node; 24 | use Twig\Parser; 25 | use Twig\Source; 26 | use Twig\Token; 27 | use Twig\TokenParser\TokenParserInterface; 28 | use Twig\TokenStream; 29 | 30 | use function assert; 31 | use function is_bool; 32 | use function is_scalar; 33 | 34 | /** 35 | * Parses a codeblock tag for Twig 36 | */ 37 | final class CodeBlockParser implements TokenParserInterface 38 | { 39 | private readonly Attributes $attributes; 40 | 41 | private Node $body; 42 | 43 | private Parser $parser; 44 | 45 | /** 46 | * @param HighlighterReference $highlighterReference Reference details for the highlighter to use 47 | */ 48 | public function __construct(private readonly HighlighterReference $highlighterReference) 49 | { 50 | $this->attributes = new Attributes(); 51 | } 52 | 53 | public function setParser(Parser $parser): void 54 | { 55 | $this->parser = $parser; 56 | } 57 | 58 | /** 59 | * @throws SyntaxError 60 | */ 61 | public function parse(Token $token): Node 62 | { 63 | $this->parseCodeBlock(); 64 | 65 | return new CodeBlockNode( 66 | $this->highlighterReference, 67 | $this->getAttributes(), 68 | $this->getBody(), 69 | $token->getLine(), 70 | ); 71 | } 72 | 73 | public function getTag(): string 74 | { 75 | return 'codeblock'; 76 | } 77 | 78 | /** 79 | * Returns true if $token is the endcodeblock tag 80 | * 81 | * @param Token $token Token to test for endcodeblock 82 | */ 83 | public function decideBlockEnd(Token $token): bool 84 | { 85 | return $token->test('endcodeblock'); 86 | } 87 | 88 | /** 89 | * Returns the attributes parsed from the codeblock tag 90 | */ 91 | public function getAttributes(): Attributes 92 | { 93 | return $this->attributes; 94 | } 95 | 96 | /** 97 | * Returns a token representing the codeblock source code 98 | */ 99 | public function getBody(): Node 100 | { 101 | return $this->body; 102 | } 103 | 104 | /** 105 | * Parses the options found on the codeblock tag for use by the node 106 | * 107 | * @throws SyntaxError 108 | */ 109 | private function parseCodeBlock(): void 110 | { 111 | $stream = $this->parser->getStream(); 112 | 113 | while (!$stream->getCurrent()->test(Token::BLOCK_END_TYPE)) { 114 | $this->parseEncounteredToken($stream->getCurrent(), $stream); 115 | } 116 | 117 | $stream->expect(Token::BLOCK_END_TYPE); 118 | $this->body = $this->parser->subparse([$this, 'decideBlockEnd'], true); 119 | $stream->expect(Token::BLOCK_END_TYPE); 120 | } 121 | 122 | /** 123 | * Parses each specific token found when looping through the codeblock tag 124 | * 125 | * @param Token $token The token being parsed 126 | * @param TokenStream $stream The token stream being traversed 127 | * 128 | * @throws SyntaxError 129 | */ 130 | private function parseEncounteredToken(Token $token, TokenStream $stream): void 131 | { 132 | switch ($token->getValue()) { 133 | case 'lang': 134 | $this->attributes->lang = $this->getNextExpectedStringValueFromStream($stream); 135 | 136 | break; 137 | case 'format': 138 | $this->attributes->format = $this->getNextExpectedStringValueFromStream($stream); 139 | 140 | break; 141 | case 'start': 142 | $this->attributes->start = $this->getNextExpectedNumberValueFromStream($stream); 143 | 144 | break; 145 | case 'mark': 146 | $this->attributes->mark = $this->parseMarkOption($stream); 147 | 148 | break; 149 | case 'linenos': 150 | $this->attributes->linenos = $this->parseLinenosOption($stream); 151 | 152 | break; 153 | case 'class': 154 | $this->attributes->class = $this->getNextExpectedStringValueFromStream($stream); 155 | 156 | break; 157 | case 'title': 158 | $this->attributes->title = $this->getNextExpectedStringValueFromStream($stream); 159 | 160 | break; 161 | case 'link': 162 | $this->attributes->linkUrl = $this->getNextExpectedStringValueFromStream($stream); 163 | 164 | break; 165 | case 'link_text': 166 | $this->attributes->linkText = $this->getNextExpectedStringValueFromStream($stream); 167 | 168 | break; 169 | } 170 | } 171 | 172 | /** 173 | * Returns the mark option value from the mark token 174 | * 175 | * @throws SyntaxError 176 | */ 177 | private function parseMarkOption(TokenStream $stream): string 178 | { 179 | $markValue = $this->getNextExpectedNumberValueFromStream($stream); 180 | 181 | while ( 182 | $stream->test(Token::OPERATOR_TYPE) 183 | || $stream->test(Token::PUNCTUATION_TYPE) 184 | || $stream->test(Token::NUMBER_TYPE) 185 | ) { 186 | $value = $stream->getCurrent()->getValue(); 187 | assert(is_scalar($value)); 188 | $markValue .= $value; 189 | $stream->next(); 190 | } 191 | 192 | return (string) $markValue; 193 | } 194 | 195 | /** 196 | * Returns the linenos option value from the linenos token 197 | * 198 | * @throws SyntaxError 199 | */ 200 | private function parseLinenosOption(TokenStream $stream): bool 201 | { 202 | $stream->next(); 203 | $stream->expect(Token::PUNCTUATION_TYPE); 204 | 205 | $expr = $this->parser->getExpressionParser()->parseExpression(); 206 | 207 | if (!($expr instanceof ConstantExpression) || !is_bool($expr->getAttribute('value'))) { 208 | throw new SyntaxException( 209 | 'The linenos option must be boolean true or false (i.e. linenos:false).', 210 | $stream->getCurrent()->getLine(), 211 | new Source( 212 | $stream->getSourceContext()->getCode(), 213 | $stream->getSourceContext()->getName(), 214 | $stream->getSourceContext()->getPath(), 215 | ), 216 | ); 217 | } 218 | 219 | return $expr->getAttribute('value'); 220 | } 221 | 222 | /** 223 | * Helper method for the common operation of grabbing the next string value 224 | * from the stream 225 | * 226 | * @throws SyntaxError 227 | */ 228 | private function getNextExpectedStringValueFromStream(TokenStream $stream): string 229 | { 230 | $stream->next(); 231 | $stream->expect(Token::PUNCTUATION_TYPE); 232 | 233 | /** @var string */ 234 | return $stream->expect(Token::STRING_TYPE)->getValue(); 235 | } 236 | 237 | /** 238 | * Helper method for the common operation of grabbing the next int value 239 | * from the stream 240 | * 241 | * @throws SyntaxError 242 | */ 243 | private function getNextExpectedNumberValueFromStream(TokenStream $stream): int 244 | { 245 | $stream->next(); 246 | $stream->expect(Token::PUNCTUATION_TYPE); 247 | 248 | /** @var int */ 249 | return $stream->expect(Token::NUMBER_TYPE)->getValue(); 250 | } 251 | } 252 | --------------------------------------------------------------------------------