Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -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`)
Expand Down
16 changes: 16 additions & 0 deletions doc/tags/block.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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>`
13 changes: 13 additions & 0 deletions doc/tags/types.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/Node/BlockNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/Node/TypesNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
class TypesNode extends Node
{
/**
* @param array<string, array{type: string, optional: bool}> $types
* @param array<string, array{type: string, optional: bool, docs: string|null}> $types
*/
public function __construct(array $types, int $lineno)
{
Expand Down
12 changes: 11 additions & 1 deletion src/TokenParser/BlockTokenParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, '=')) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Info — Duplicated, divergent option parsing across two tags raises future maintenance cost.

The docs option is parsed differently in the two tags: BlockTokenParser uses test()+look() and silently falls back to the shortcut body when docs is not followed by =, while TypesTokenParser uses nextIf()+expect() and raises The "docs" option must be followed by an equal sign (=). The divergent block behavior is required to preserve the {% block title docs %} shortcut, but the two implementations duplicate the option-detection logic with different error semantics, so a future change to the option (e.g. renaming docs to desc) must be made in two places kept manually in sync.

$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);

Expand Down
11 changes: 9 additions & 2 deletions src/TokenParser/TypesTokenParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <jeroen@alisqi.com>
*
Expand All @@ -38,7 +38,7 @@ public function parse(Token $token): Node
}

/**
* @return array<string, array{type: string, optional: bool}>
* @return array<string, array{type: string, optional: bool, docs: string|null}>
*
* @throws SyntaxError
*/
Expand Down Expand Up @@ -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,
];
}

Expand Down
59 changes: 59 additions & 0 deletions tests/TokenParser/BlockTokenParserTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Twig\Tests\TokenParser;

use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use Twig\Node\BlockNode;
use Twig\Parser;
use Twig\Source;

class BlockTokenParserTest extends TestCase
{
public function testDocsOption(): void
{
$block = $this->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');
}
}
54 changes: 46 additions & 8 deletions tests/TokenParser/TypesTokenParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 [
Expand All @@ -46,42 +59,67 @@ public static function getMappingTests(): array
[
'{% types {foo: "bar"} %}',
[
'foo' => ['type' => 'bar', 'optional' => false],
'foo' => ['type' => 'bar', 'optional' => false, 'docs' => null],
],
],

// trailing comma
[
'{% types {foo: "bar",} %}',
[
'foo' => ['type' => 'bar', 'optional' => false],
'foo' => ['type' => 'bar', 'optional' => false, 'docs' => null],
],
],

// optional name
[
'{% types {foo?: "bar"} %}',
[
'foo' => ['type' => 'bar', 'optional' => true],
'foo' => ['type' => 'bar', 'optional' => true, 'docs' => null],
],
],

// multiple pairs, duplicate values
[
'{% 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],
],
],

// without {} enclosing
[
'{% 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],
],
],
];
Expand Down
Loading