diff --git a/CHANGELOG b/CHANGELOG index ba4b573764d..cfb7a012df5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # 3.29.0 (2026-XX-XX) + * Add a `docs` option to the `types` tag entries and to the `block` tag to document template variables and blocks * Fix `IntlExtension` ignoring explicit date/time formats and the `format_date`/`format_time` filters when a date formatter prototype is configured * Add a `format_list` filter to `IntlExtension` to format a list of strings using PHP 8.5's `IntlListFormatter` * Fix array access with a `Stringable` key coercing the key to string for `ArrayAccess` objects that use object keys (such as `SplObjectStorage`) diff --git a/doc/tags/block.rst b/doc/tags/block.rst index 8c88d0c3185..422f640d436 100644 --- a/doc/tags/block.rst +++ b/doc/tags/block.rst @@ -7,6 +7,22 @@ the same time. They are documented in detail in the documentation for the Block names must consist of alphanumeric characters, and underscores. The first character can't be a digit and dashes are not permitted. +.. versionadded:: 3.29 + + The ``docs`` option was added in Twig 3.29. + +Document a block by adding a ``docs`` option after its name: + +.. code-block:: twig + + {% block content docs="The main content of the page" %} + ... + {% endblock %} + +While Twig itself does not use this documentation, it is stored on the parsed +``block`` node so that tools like IDEs or documentation generators can analyze +it. + .. seealso:: :doc:`block<../functions/block>`, :doc:`parent<../functions/parent>`, :doc:`use<../tags/use>`, :doc:`extends<../tags/extends>` diff --git a/doc/tags/types.rst b/doc/tags/types.rst index c3a175392cf..5e84a3d9c28 100644 --- a/doc/tags/types.rst +++ b/doc/tags/types.rst @@ -40,6 +40,19 @@ Declare optional variables by adding a ``?`` suffix: score?: 'number', } %} +.. versionadded:: 3.29 + + The ``docs`` option was added in Twig 3.29. + +Document a variable by adding a ``docs`` option after its type: + +.. code-block:: twig + + {% types { + is_correct: 'boolean' docs="Whether the answer is correct", + score?: 'number' docs="The score of the answer", + } %} + By default, this tag does not affect the template compilation or runtime behavior. Its purpose is to enable designers and developers to document and specify the diff --git a/src/Node/BlockNode.php b/src/Node/BlockNode.php index b4f939cf630..a9ad68ebce8 100644 --- a/src/Node/BlockNode.php +++ b/src/Node/BlockNode.php @@ -23,9 +23,9 @@ #[YieldReady] class BlockNode extends Node { - public function __construct(string $name, Node $body, int $lineno) + public function __construct(string $name, Node $body, int $lineno, ?string $docs = null) { - parent::__construct(['body' => $body], ['name' => $name], $lineno); + parent::__construct(['body' => $body], ['name' => $name, 'docs' => $docs], $lineno); } public function compile(Compiler $compiler): void diff --git a/src/Node/TypesNode.php b/src/Node/TypesNode.php index a5dfacb5fd5..0272deede4b 100644 --- a/src/Node/TypesNode.php +++ b/src/Node/TypesNode.php @@ -23,7 +23,7 @@ class TypesNode extends Node { /** - * @param array $types + * @param array $types */ public function __construct(array $types, int $lineno) { diff --git a/src/TokenParser/BlockTokenParser.php b/src/TokenParser/BlockTokenParser.php index 452b323e533..d111969bece 100644 --- a/src/TokenParser/BlockTokenParser.php +++ b/src/TokenParser/BlockTokenParser.php @@ -38,7 +38,17 @@ public function parse(Token $token): Node $lineno = $token->getLine(); $stream = $this->parser->getStream(); $name = $stream->expect(Token::NAME_TYPE)->getValue(); - $this->parser->setBlock($name, $block = new BlockNode($name, new EmptyNode(), $lineno)); + + // "docs" followed by "=" is the docs option; "docs" alone might be an + // expression used as the shortcut syntax for the block body + $docs = null; + if ($stream->test(Token::NAME_TYPE, 'docs') && $stream->look()->test(Token::OPERATOR_TYPE, '=')) { + $stream->next(); + $stream->next(); + $docs = $stream->expect(Token::STRING_TYPE)->getValue(); + } + + $this->parser->setBlock($name, $block = new BlockNode($name, new EmptyNode(), $lineno, $docs)); $this->parser->pushLocalScope(); $this->parser->pushBlockStack($name); diff --git a/src/TokenParser/TypesTokenParser.php b/src/TokenParser/TypesTokenParser.php index 2c7b77c024b..34a59f6fb9c 100644 --- a/src/TokenParser/TypesTokenParser.php +++ b/src/TokenParser/TypesTokenParser.php @@ -20,7 +20,7 @@ /** * Declare variable types. * - * {% types {foo: 'number', bar?: 'string'} %} + * {% types {foo: 'number', bar?: 'string', baz: 'string' docs="Some description"} %} * * @author Jeroen Versteeg * @@ -38,7 +38,7 @@ public function parse(Token $token): Node } /** - * @return array + * @return array * * @throws SyntaxError */ @@ -69,9 +69,16 @@ private function parseSimpleMappingExpression(TokenStream $stream): array $valueToken = $stream->expect(Token::STRING_TYPE); + $docs = null; + if ($stream->nextIf(Token::NAME_TYPE, 'docs')) { + $stream->expect(Token::OPERATOR_TYPE, '=', 'The "docs" option must be followed by an equal sign (=)'); + $docs = $stream->expect(Token::STRING_TYPE)->getValue(); + } + $types[$nameToken->getValue()] = [ 'type' => $valueToken->getValue(), 'optional' => $isOptional, + 'docs' => $docs, ]; } diff --git a/tests/TokenParser/BlockTokenParserTest.php b/tests/TokenParser/BlockTokenParserTest.php new file mode 100644 index 00000000000..713aebee91e --- /dev/null +++ b/tests/TokenParser/BlockTokenParserTest.php @@ -0,0 +1,59 @@ +parseBlock('{% block content docs="The main content" %}Hello{% endblock %}', 'content'); + + $this->assertSame('The main content', $block->getAttribute('docs')); + } + + public function testDocsOptionWithTheShortcutSyntax(): void + { + $block = $this->parseBlock('{% block title docs="The page title" name %}', 'title'); + + $this->assertSame('The page title', $block->getAttribute('docs')); + } + + public function testDocsDefaultsToNull(): void + { + $block = $this->parseBlock('{% block content %}Hello{% endblock %}', 'content'); + + $this->assertNull($block->getAttribute('docs')); + } + + public function testShortcutSyntaxWithAVariableNamedDocs(): void + { + $twig = new Environment(new ArrayLoader(['index' => '{% block title docs %}'])); + + $this->assertSame('Hello', $twig->render('index', ['docs' => 'Hello'])); + } + + private function parseBlock(string $template, string $name): BlockNode + { + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $stream = $env->tokenize(new Source($template, '')); + $parser = new Parser($env); + + return $parser->parse($stream)->getNode('blocks')->getNode($name)->getNode('0'); + } +} diff --git a/tests/TokenParser/TypesTokenParserTest.php b/tests/TokenParser/TypesTokenParserTest.php index 9e891b94bb7..4a16d7be13a 100644 --- a/tests/TokenParser/TypesTokenParserTest.php +++ b/tests/TokenParser/TypesTokenParserTest.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Twig\Environment; +use Twig\Error\SyntaxError; use Twig\Loader\ArrayLoader; use Twig\Parser; use Twig\Source; @@ -33,6 +34,18 @@ public function testMappingParsing(string $template, array $expected): void self::assertEquals($expected, $typesNode->getAttribute('mapping')); } + public function testDocsOptionRequiresAnEqualSign(): void + { + $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); + $stream = $env->tokenize(new Source('{% types {foo: "bar" docs "Some description"} %}', '')); + $parser = new Parser($env); + + $this->expectException(SyntaxError::class); + $this->expectExceptionMessage('The "docs" option must be followed by an equal sign (=)'); + + $parser->parse($stream); + } + public static function getMappingTests(): array { return [ @@ -46,7 +59,7 @@ public static function getMappingTests(): array [ '{% types {foo: "bar"} %}', [ - 'foo' => ['type' => 'bar', 'optional' => false], + 'foo' => ['type' => 'bar', 'optional' => false, 'docs' => null], ], ], @@ -54,7 +67,7 @@ public static function getMappingTests(): array [ '{% types {foo: "bar",} %}', [ - 'foo' => ['type' => 'bar', 'optional' => false], + 'foo' => ['type' => 'bar', 'optional' => false, 'docs' => null], ], ], @@ -62,7 +75,7 @@ public static function getMappingTests(): array [ '{% types {foo?: "bar"} %}', [ - 'foo' => ['type' => 'bar', 'optional' => true], + 'foo' => ['type' => 'bar', 'optional' => true, 'docs' => null], ], ], @@ -70,9 +83,9 @@ public static function getMappingTests(): array [ '{% types {foo: "foo", bar?: "foo", baz: "baz"} %}', [ - 'foo' => ['type' => 'foo', 'optional' => false], - 'bar' => ['type' => 'foo', 'optional' => true], - 'baz' => ['type' => 'baz', 'optional' => false], + 'foo' => ['type' => 'foo', 'optional' => false, 'docs' => null], + 'bar' => ['type' => 'foo', 'optional' => true, 'docs' => null], + 'baz' => ['type' => 'baz', 'optional' => false, 'docs' => null], ], ], @@ -80,8 +93,33 @@ public static function getMappingTests(): array [ '{% types foo: "foo", bar: "bar" %}', [ - 'foo' => ['type' => 'foo', 'optional' => false], - 'bar' => ['type' => 'bar', 'optional' => false], + 'foo' => ['type' => 'foo', 'optional' => false, 'docs' => null], + 'bar' => ['type' => 'bar', 'optional' => false, 'docs' => null], + ], + ], + + // docs option + [ + '{% types {foo: "bar" docs="Some description"} %}', + [ + 'foo' => ['type' => 'bar', 'optional' => false, 'docs' => 'Some description'], + ], + ], + + // docs option on an optional name, with a trailing comma + [ + '{% types {foo?: "bar" docs="Some description",} %}', + [ + 'foo' => ['type' => 'bar', 'optional' => true, 'docs' => 'Some description'], + ], + ], + + // docs option on some entries only, without {} enclosing + [ + '{% types foo: "foo" docs="Some description", bar: "bar" %}', + [ + 'foo' => ['type' => 'foo', 'optional' => false, 'docs' => 'Some description'], + 'bar' => ['type' => 'bar', 'optional' => false, 'docs' => null], ], ], ];