From 3d20e12a446924e68439f848ec29a5d3cbb8a00b Mon Sep 17 00:00:00 2001 From: Lina Wolf <48202465+linawolf@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:27:56 +0200 Subject: [PATCH] [BUGFIX] Let node transformers visit nodes kept outside the children A role or a substitution in a definition-list term is rendered exactly as the parser created it, while the same role in a paragraph is transformed. The compiler builds its shadow tree from getChildren() only, and DefinitionListItemNode keeps its term and classifiers in properties of their own. Each transformer that works on inline nodes has to walk them itself, as the TYPO3 theme does. The term is not the only such node. Code and menu captions, admonition and sidebar titles, the type, default and options of a confval, the argument of a general directive, the cells of a table, the image and caption of a figure, the target of an image and the parts of a bootstrap card or accordion item are kept the same way, and were just as invisible. A substitution in a table cell, for example, was rendered as "|known|". Such nodes now implement HasAttachedNodes and name the nodes they keep beside their children. The shadow tree visits attached nodes before the children. A change inside an attached node is made in the attached node itself, as its parent keeps it in a readonly property and cannot take a copy. The attached node itself cannot be replaced or removed. Admonitions, sidebars, confvals and cards are built by DirectiveProcessPass at priority 100, after most built-in transformers, so only transformers that run later reach their attached nodes. Resolves #1425 Signed-off-by: linawolf Assisted-By: Claude Opus 5.5 (1M context) --- docs/developers/compiler.rst | 7 ++ .../RestructuredText/Nodes/ConfvalNode.php | 22 ++++- .../Nodes/GeneralDirectiveNode.php | 9 +- .../RestructuredText/Nodes/SidebarNode.php | 9 +- .../tests/unit/Nodes/HasAttachedNodesTest.php | 57 +++++++++++ .../src/Bootstrap/Nodes/AccordionItemNode.php | 6 ++ .../src/Bootstrap/Nodes/CardNode.php | 17 ++++ .../src/Compiler/DocumentNodeTraverser.php | 8 ++ .../src/Compiler/ShadowTree/TreeNode.php | 60 ++++++++++++ packages/guides/src/Nodes/AdmonitionNode.php | 8 +- packages/guides/src/Nodes/CodeNode.php | 8 +- .../DefinitionListItemNode.php | 14 ++- packages/guides/src/Nodes/FigureNode.php | 13 ++- .../guides/src/Nodes/HasAttachedNodes.php | 36 +++++++ packages/guides/src/Nodes/ImageNode.php | 8 +- packages/guides/src/Nodes/Menu/MenuNode.php | 9 +- packages/guides/src/Nodes/TableNode.php | 23 ++++- .../Compiler/DocumentNodeTraverserTest.php | 56 +++++++++++ .../unit/Compiler/ShadowTree/TreeNodeTest.php | 95 +++++++++++++++++++ .../tests/unit/Nodes/HasAttachedNodesTest.php | 85 +++++++++++++++++ .../expected/index.html | 15 +++ .../input/index.rst | 13 +++ .../expected/index.html | 2 +- .../expected/logs/warning.log | 1 + 24 files changed, 570 insertions(+), 11 deletions(-) create mode 100644 packages/guides-restructured-text/tests/unit/Nodes/HasAttachedNodesTest.php create mode 100644 packages/guides/src/Nodes/HasAttachedNodes.php create mode 100644 packages/guides/tests/unit/Nodes/HasAttachedNodesTest.php create mode 100644 tests/Integration/tests/directives/variables-replace-attached-nodes/expected/index.html create mode 100644 tests/Integration/tests/directives/variables-replace-attached-nodes/input/index.rst create mode 100644 tests/Integration/tests/tables/simple-table-with-variable/expected/logs/warning.log diff --git a/docs/developers/compiler.rst b/docs/developers/compiler.rst index 6c0460a46..10041fd7d 100644 --- a/docs/developers/compiler.rst +++ b/docs/developers/compiler.rst @@ -36,6 +36,13 @@ interface and you should register it in the dependency injection container. the priority of the node transformer, the earlier it will be executed. Where highest priority is `PHP_INT_MAX`, lower number is lower priority. +A node transformer visits the children of each node, and also the nodes a node keeps in properties of its own, like +the term of a definition-list item, the caption of a code block or the cells of a table. A node that keeps such nodes +implements :php:interface:`phpDocumentor\Guides\Nodes\HasAttachedNodes` and names them in ``getAttachedNodes()``. +A transformer can change what is inside an attached node, but not replace or remove the attached node itself. Implement +the interface on your own nodes too, if they keep nodes outside their children, so the node transformers reach these +nodes as well. + CompilerPass ============ diff --git a/packages/guides-restructured-text/src/RestructuredText/Nodes/ConfvalNode.php b/packages/guides-restructured-text/src/RestructuredText/Nodes/ConfvalNode.php index f193f55b1..8e6248828 100644 --- a/packages/guides-restructured-text/src/RestructuredText/Nodes/ConfvalNode.php +++ b/packages/guides-restructured-text/src/RestructuredText/Nodes/ConfvalNode.php @@ -14,6 +14,7 @@ namespace phpDocumentor\Guides\RestructuredText\Nodes; use phpDocumentor\Guides\Nodes\CompoundNode; +use phpDocumentor\Guides\Nodes\HasAttachedNodes; use phpDocumentor\Guides\Nodes\InlineCompoundNode; use phpDocumentor\Guides\Nodes\LinkTargetNode; use phpDocumentor\Guides\Nodes\Node; @@ -27,7 +28,7 @@ * * @extends CompoundNode */ -final class ConfvalNode extends CompoundNode implements LinkTargetNode, OptionalLinkTargetsNode, PrefixedLinkTargetNode +final class ConfvalNode extends CompoundNode implements LinkTargetNode, OptionalLinkTargetsNode, PrefixedLinkTargetNode, HasAttachedNodes { public const LINK_TYPE = 'std:confval'; public const LINK_PREFIX = 'confval-'; @@ -104,4 +105,23 @@ public function isNoindex(): bool { return $this->noindex; } + + /** @return array */ + public function getAttachedNodes(): array + { + $nodes = []; + if ($this->type !== null) { + $nodes['type'] = $this->type; + } + + if ($this->default !== null) { + $nodes['default'] = $this->default; + } + + foreach ($this->additionalOptions as $option => $value) { + $nodes['option-' . $option] = $value; + } + + return $nodes; + } } diff --git a/packages/guides-restructured-text/src/RestructuredText/Nodes/GeneralDirectiveNode.php b/packages/guides-restructured-text/src/RestructuredText/Nodes/GeneralDirectiveNode.php index 215696533..1735c70ca 100644 --- a/packages/guides-restructured-text/src/RestructuredText/Nodes/GeneralDirectiveNode.php +++ b/packages/guides-restructured-text/src/RestructuredText/Nodes/GeneralDirectiveNode.php @@ -14,6 +14,7 @@ namespace phpDocumentor\Guides\RestructuredText\Nodes; use phpDocumentor\Guides\Nodes\CompoundNode; +use phpDocumentor\Guides\Nodes\HasAttachedNodes; use phpDocumentor\Guides\Nodes\InlineCompoundNode; use phpDocumentor\Guides\Nodes\Node; @@ -23,7 +24,7 @@ * @template TValue of Node = Node * @extends CompoundNode */ -class GeneralDirectiveNode extends CompoundNode +class GeneralDirectiveNode extends CompoundNode implements HasAttachedNodes { /** @param TValue[] $value */ public function __construct( @@ -49,4 +50,10 @@ public function getContent(): InlineCompoundNode { return $this->content; } + + /** @return array */ + public function getAttachedNodes(): array + { + return ['content' => $this->getContent()]; + } } diff --git a/packages/guides-restructured-text/src/RestructuredText/Nodes/SidebarNode.php b/packages/guides-restructured-text/src/RestructuredText/Nodes/SidebarNode.php index 551a733fc..ca1be794d 100644 --- a/packages/guides-restructured-text/src/RestructuredText/Nodes/SidebarNode.php +++ b/packages/guides-restructured-text/src/RestructuredText/Nodes/SidebarNode.php @@ -14,11 +14,12 @@ namespace phpDocumentor\Guides\RestructuredText\Nodes; use phpDocumentor\Guides\Nodes\CompoundNode; +use phpDocumentor\Guides\Nodes\HasAttachedNodes; use phpDocumentor\Guides\Nodes\InlineCompoundNode; use phpDocumentor\Guides\Nodes\Node; /** @extends CompoundNode */ -final class SidebarNode extends CompoundNode +final class SidebarNode extends CompoundNode implements HasAttachedNodes { /** {@inheritDoc} */ public function __construct(private readonly InlineCompoundNode $title, array $value) @@ -30,4 +31,10 @@ public function getTitle(): InlineCompoundNode { return $this->title; } + + /** @return array */ + public function getAttachedNodes(): array + { + return ['title' => $this->title]; + } } diff --git a/packages/guides-restructured-text/tests/unit/Nodes/HasAttachedNodesTest.php b/packages/guides-restructured-text/tests/unit/Nodes/HasAttachedNodesTest.php new file mode 100644 index 000000000..c2848fedd --- /dev/null +++ b/packages/guides-restructured-text/tests/unit/Nodes/HasAttachedNodesTest.php @@ -0,0 +1,57 @@ + $names */ + #[DataProvider('provideNodes')] + public function testNamesTheAttachedNodes(HasAttachedNodes $node, array $names): void + { + self::assertSame($names, array_keys($node->getAttachedNodes())); + } + + /** @return Generator}> */ + public static function provideNodes(): Generator + { + yield 'sidebar' => [new SidebarNode(InlineCompoundNode::getPlainTextInlineNode('title'), []), ['title']]; + + yield 'confval' => [ + new ConfvalNode( + 'id', + 'id', + InlineCompoundNode::getPlainTextInlineNode('string'), + false, + InlineCompoundNode::getPlainTextInlineNode('default'), + ['scope' => InlineCompoundNode::getPlainTextInlineNode('global')], + ), + ['type', 'default', 'option-scope'], + ]; + + yield 'confval without type and default' => [new ConfvalNode('id', 'id'), []]; + + yield 'general directive' => [ + new GeneralDirectiveNode('custom', 'argument', InlineCompoundNode::getPlainTextInlineNode('argument')), + ['content'], + ]; + } +} diff --git a/packages/guides-theme-bootstrap/src/Bootstrap/Nodes/AccordionItemNode.php b/packages/guides-theme-bootstrap/src/Bootstrap/Nodes/AccordionItemNode.php index 670b1d7cf..b74b6cb49 100644 --- a/packages/guides-theme-bootstrap/src/Bootstrap/Nodes/AccordionItemNode.php +++ b/packages/guides-theme-bootstrap/src/Bootstrap/Nodes/AccordionItemNode.php @@ -93,4 +93,10 @@ public function isShow(): bool { return $this->show; } + + /** @return array */ + public function getAttachedNodes(): array + { + return ['title' => $this->title, ...parent::getAttachedNodes()]; + } } diff --git a/packages/guides-theme-bootstrap/src/Bootstrap/Nodes/CardNode.php b/packages/guides-theme-bootstrap/src/Bootstrap/Nodes/CardNode.php index 850e2d291..2a860bc70 100644 --- a/packages/guides-theme-bootstrap/src/Bootstrap/Nodes/CardNode.php +++ b/packages/guides-theme-bootstrap/src/Bootstrap/Nodes/CardNode.php @@ -24,6 +24,8 @@ use phpDocumentor\Guides\Nodes\TitleNode; use phpDocumentor\Guides\RestructuredText\Nodes\GeneralDirectiveNode; +use function array_filter; + final class CardNode extends GeneralDirectiveNode implements LinkTargetNode, OptionalLinkTargetsNode, PrefixedLinkTargetNode { public const LINK_TYPE = 'std:card'; @@ -129,4 +131,19 @@ public function setCardHeight(int $cardHeight): CardNode return $this; } + + /** @return array */ + public function getAttachedNodes(): array + { + return array_filter( + [ + 'header' => $this->cardHeader, + 'image' => $this->cardImage, + 'title' => $this->title, + ...parent::getAttachedNodes(), + 'footer' => $this->cardFooter, + ], + static fn (Node|null $node): bool => $node !== null, + ); + } } diff --git a/packages/guides/src/Compiler/DocumentNodeTraverser.php b/packages/guides/src/Compiler/DocumentNodeTraverser.php index 3742e5e92..793f9db98 100644 --- a/packages/guides/src/Compiler/DocumentNodeTraverser.php +++ b/packages/guides/src/Compiler/DocumentNodeTraverser.php @@ -70,6 +70,10 @@ private function traverseForTransformer( CompilerContext $compilerContext, ): void { if ($transformer instanceof ReverseNodeTransformer) { + foreach ($shadowNode->getAttachedNodes() as $shadowChild) { + $this->traverseForTransformer($transformer, $shadowChild, $compilerContext); + } + foreach ($shadowNode->getChildren() as $shadowChild) { $this->traverseForTransformer($transformer, $shadowChild, $compilerContext); } @@ -87,6 +91,10 @@ private function traverseForTransformer( } if ($transformer instanceof ReverseNodeTransformer === false) { + foreach ($shadowNode->getAttachedNodes() as $shadowChild) { + $this->traverseForTransformer($transformer, $shadowChild, $compilerContext); + } + foreach ($shadowNode->getChildren() as $shadowChild) { $this->traverseForTransformer($transformer, $shadowChild, $compilerContext); } diff --git a/packages/guides/src/Compiler/ShadowTree/TreeNode.php b/packages/guides/src/Compiler/ShadowTree/TreeNode.php index 1d5cbeb9a..bc449b86b 100644 --- a/packages/guides/src/Compiler/ShadowTree/TreeNode.php +++ b/packages/guides/src/Compiler/ShadowTree/TreeNode.php @@ -16,11 +16,13 @@ use LogicException; use phpDocumentor\Guides\Nodes\CompoundNode; use phpDocumentor\Guides\Nodes\DocumentNode; +use phpDocumentor\Guides\Nodes\HasAttachedNodes; use phpDocumentor\Guides\Nodes\Node; use function array_unshift; use function array_values; use function count; +use function sprintf; /** @template-covariant TNode of Node */ final class TreeNode @@ -31,6 +33,15 @@ final class TreeNode /** @var self[] */ private array $children = []; + /** @var array> */ + private array $attachedNodes = []; + + /** + * An attached node is held in a property of its parent node, which cannot take a copy of + * it, so changes to its children are made in the attached node itself. + */ + private bool $isAttached = false; + private function __construct( /** @var TNode */ private Node $node, @@ -77,6 +88,13 @@ private static function createFromNode(Node $node, self $parent): self { $treeNode = new self($node, $parent); $treeNode->root = $parent->root; + if ($node instanceof HasAttachedNodes) { + foreach ($node->getAttachedNodes() as $name => $attachedNode) { + $treeNode->attachedNodes[$name] = self::createFromNode($attachedNode, $treeNode); + $treeNode->attachedNodes[$name]->isAttached = true; + } + } + if ($node instanceof CompoundNode === false) { return $treeNode; } @@ -114,6 +132,16 @@ public function getChildren(): array return $this->children; } + /** + * The nodes the node keeps beside its children, see {@see HasAttachedNodes}. + * + * @return array> + */ + public function getAttachedNodes(): array + { + return $this->attachedNodes; + } + public function addChild(Node $child): void { if ($this->node instanceof CompoundNode === false) { @@ -142,6 +170,8 @@ public function getParent(): TreeNode|null public function removeChild(Node $node): void { + $this->assertNotAttached($node, 'remove'); + if ($this->node instanceof CompoundNode === false) { throw new LogicException('Cannot remove a child from a non-compound node'); } @@ -150,6 +180,13 @@ public function removeChild(Node $node): void if ($child->getNode() === $node) { unset($this->children[$key]); $child->parent = null; + if ($this->isAttached) { + $children = $this->node->getChildren(); + unset($children[$key]); + $this->node->setValue(array_values($children)); + break; + } + $newNode = $this->node->removeNode($key); $this->parent?->replaceChild($this->node, $newNode); $this->node = $newNode; @@ -162,6 +199,8 @@ public function removeChild(Node $node): void public function replaceChild(Node $oldChildNode, Node $newChildNode): void { + $this->assertNotAttached($oldChildNode, 'replace'); + if ($this->node instanceof CompoundNode === false) { throw new LogicException('Cannot remove a child from a non-compound node'); } @@ -169,6 +208,13 @@ public function replaceChild(Node $oldChildNode, Node $newChildNode): void foreach ($this->children as $key => $child) { if ($child->getNode() === $oldChildNode) { $child->node = $newChildNode; + if ($this->isAttached) { + $children = $this->node->getChildren(); + $children[$key] = $newChildNode; + $this->node->setValue($children); + break; + } + $newNode = $this->node->replaceNode($key, $newChildNode); $this->parent?->replaceChild($this->node, $newNode); $this->node = $newNode; @@ -194,10 +240,15 @@ public function findPosition(Node $node): int|null */ public function release(): void { + foreach ($this->attachedNodes as $attachedNode) { + $attachedNode->release(); + } + foreach ($this->children as $child) { $child->release(); } + $this->attachedNodes = []; $this->children = []; $this->parent = null; unset($this->root); @@ -211,4 +262,13 @@ public function isLastChildOfParent(): bool return $this->parent->findPosition($this->node) === count($this->parent->getChildren()) - 1; } + + private function assertNotAttached(Node $node, string $action): void + { + foreach ($this->attachedNodes as $name => $attachedNode) { + if ($attachedNode->getNode() === $node) { + throw new LogicException(sprintf('Cannot %s the attached node "%s", change its children instead', $action, $name)); + } + } + } } diff --git a/packages/guides/src/Nodes/AdmonitionNode.php b/packages/guides/src/Nodes/AdmonitionNode.php index 26e03fe23..2af353493 100644 --- a/packages/guides/src/Nodes/AdmonitionNode.php +++ b/packages/guides/src/Nodes/AdmonitionNode.php @@ -14,7 +14,7 @@ namespace phpDocumentor\Guides\Nodes; /** @extends CompoundNode */ -class AdmonitionNode extends CompoundNode +class AdmonitionNode extends CompoundNode implements HasAttachedNodes { /** @param Node[] $value */ public function __construct(private readonly string $name, private readonly InlineCompoundNode|null $title, private readonly string $text, array $value, private readonly bool $isTitled = false) @@ -41,4 +41,10 @@ public function isTitled(): bool { return $this->isTitled; } + + /** @return array */ + public function getAttachedNodes(): array + { + return $this->title === null ? [] : ['title' => $this->title]; + } } diff --git a/packages/guides/src/Nodes/CodeNode.php b/packages/guides/src/Nodes/CodeNode.php index 6aa822a19..e7de7051d 100644 --- a/packages/guides/src/Nodes/CodeNode.php +++ b/packages/guides/src/Nodes/CodeNode.php @@ -15,7 +15,7 @@ use function implode; -final class CodeNode extends TextNode implements ConsumesDefaultHighlightLanguage +final class CodeNode extends TextNode implements ConsumesDefaultHighlightLanguage, HasAttachedNodes { /** @var int|null The line number to start counting from and display, or null to hide line numbers */ private int|null $startingLineNumber = null; @@ -69,4 +69,10 @@ public function setEmphasizeLines(string|null $emphasizeLines): void { $this->emphasizeLines = $emphasizeLines; } + + /** @return array */ + public function getAttachedNodes(): array + { + return $this->caption === null ? [] : ['caption' => $this->caption]; + } } diff --git a/packages/guides/src/Nodes/DefinitionLists/DefinitionListItemNode.php b/packages/guides/src/Nodes/DefinitionLists/DefinitionListItemNode.php index 2ba6398d7..befb75688 100644 --- a/packages/guides/src/Nodes/DefinitionLists/DefinitionListItemNode.php +++ b/packages/guides/src/Nodes/DefinitionLists/DefinitionListItemNode.php @@ -14,10 +14,11 @@ namespace phpDocumentor\Guides\Nodes\DefinitionLists; use phpDocumentor\Guides\Nodes\CompoundNode; +use phpDocumentor\Guides\Nodes\HasAttachedNodes; use phpDocumentor\Guides\Nodes\InlineCompoundNode; /** @extends CompoundNode */ -final class DefinitionListItemNode extends CompoundNode +final class DefinitionListItemNode extends CompoundNode implements HasAttachedNodes { /** * @param InlineCompoundNode[] $classifiers @@ -38,4 +39,15 @@ public function getClassifiers(): array { return $this->classifiers; } + + /** @return array */ + public function getAttachedNodes(): array + { + $nodes = ['term' => $this->term]; + foreach ($this->classifiers as $key => $classifier) { + $nodes['classifier-' . $key] = $classifier; + } + + return $nodes; + } } diff --git a/packages/guides/src/Nodes/FigureNode.php b/packages/guides/src/Nodes/FigureNode.php index d2f522f1e..c189d3839 100644 --- a/packages/guides/src/Nodes/FigureNode.php +++ b/packages/guides/src/Nodes/FigureNode.php @@ -14,7 +14,7 @@ namespace phpDocumentor\Guides\Nodes; /** @extends AbstractNode */ -final class FigureNode extends AbstractNode +final class FigureNode extends AbstractNode implements HasAttachedNodes { public function __construct(ImageNode $image, protected Node|null $document = null) { @@ -30,4 +30,15 @@ public function getDocument(): Node|null { return $this->document; } + + /** @return array */ + public function getAttachedNodes(): array + { + $nodes = ['image' => $this->value]; + if ($this->document !== null) { + $nodes['document'] = $this->document; + } + + return $nodes; + } } diff --git a/packages/guides/src/Nodes/HasAttachedNodes.php b/packages/guides/src/Nodes/HasAttachedNodes.php new file mode 100644 index 000000000..535f29071 --- /dev/null +++ b/packages/guides/src/Nodes/HasAttachedNodes.php @@ -0,0 +1,36 @@ + + */ + public function getAttachedNodes(): array; +} diff --git a/packages/guides/src/Nodes/ImageNode.php b/packages/guides/src/Nodes/ImageNode.php index 14cbd9a57..2d0524710 100644 --- a/packages/guides/src/Nodes/ImageNode.php +++ b/packages/guides/src/Nodes/ImageNode.php @@ -15,7 +15,7 @@ use phpDocumentor\Guides\Nodes\Inline\LinkInlineNode; -final class ImageNode extends TextNode +final class ImageNode extends TextNode implements HasAttachedNodes { public LinkInlineNode|null $target = null; @@ -28,4 +28,10 @@ public function setTarget(LinkInlineNode|null $target): void { $this->target = $target; } + + /** @return array */ + public function getAttachedNodes(): array + { + return $this->target === null ? [] : ['target' => $this->target]; + } } diff --git a/packages/guides/src/Nodes/Menu/MenuNode.php b/packages/guides/src/Nodes/Menu/MenuNode.php index c19dd656d..291e522a6 100644 --- a/packages/guides/src/Nodes/Menu/MenuNode.php +++ b/packages/guides/src/Nodes/Menu/MenuNode.php @@ -14,6 +14,7 @@ namespace phpDocumentor\Guides\Nodes\Menu; use phpDocumentor\Guides\Nodes\CompoundNode; +use phpDocumentor\Guides\Nodes\HasAttachedNodes; use phpDocumentor\Guides\Nodes\InlineCompoundNode; use const PHP_INT_MAX; @@ -23,7 +24,7 @@ * * @extends CompoundNode */ -abstract class MenuNode extends CompoundNode +abstract class MenuNode extends CompoundNode implements HasAttachedNodes { private InlineCompoundNode|null $caption = null; private bool $reversed = false; @@ -70,4 +71,10 @@ public function withReversed(bool $reversed): MenuNode return $that; } + + /** @return array */ + public function getAttachedNodes(): array + { + return $this->caption === null ? [] : ['caption' => $this->caption]; + } } diff --git a/packages/guides/src/Nodes/TableNode.php b/packages/guides/src/Nodes/TableNode.php index e66706949..f13b160cd 100644 --- a/packages/guides/src/Nodes/TableNode.php +++ b/packages/guides/src/Nodes/TableNode.php @@ -13,13 +13,14 @@ namespace phpDocumentor\Guides\Nodes; +use phpDocumentor\Guides\Nodes\Table\TableColumn; use phpDocumentor\Guides\Nodes\Table\TableRow; use function count; use function max; /** @extends CompoundNode */ -final class TableNode extends CompoundNode +final class TableNode extends CompoundNode implements HasAttachedNodes { /** * @param TableRow[] $data @@ -72,4 +73,24 @@ public function withColumnWidth(array $columnWidth): TableNode return $table; } + + /** + * The cells of the header rows and then of the body rows, as + * "header--" and "row--". + * + * @return array + */ + public function getAttachedNodes(): array + { + $nodes = []; + foreach (['header' => $this->headers, 'row' => $this->data] as $prefix => $rows) { + foreach ($rows as $rowIndex => $row) { + foreach ($row->getColumns() as $columnIndex => $column) { + $nodes[$prefix . '-' . $rowIndex . '-' . $columnIndex] = $column; + } + } + } + + return $nodes; + } } diff --git a/packages/guides/tests/unit/Compiler/DocumentNodeTraverserTest.php b/packages/guides/tests/unit/Compiler/DocumentNodeTraverserTest.php index 1b0620d46..4106d274d 100644 --- a/packages/guides/tests/unit/Compiler/DocumentNodeTraverserTest.php +++ b/packages/guides/tests/unit/Compiler/DocumentNodeTraverserTest.php @@ -14,7 +14,11 @@ namespace phpDocumentor\Guides\Compiler; use phpDocumentor\Guides\Compiler\NodeTransformers\CustomNodeTransformerFactory; +use phpDocumentor\Guides\Nodes\DefinitionListNode; +use phpDocumentor\Guides\Nodes\DefinitionLists\DefinitionListItemNode; +use phpDocumentor\Guides\Nodes\DefinitionLists\DefinitionNode; use phpDocumentor\Guides\Nodes\DocumentNode; +use phpDocumentor\Guides\Nodes\Inline\PlainTextInlineNode; use phpDocumentor\Guides\Nodes\InlineCompoundNode; use phpDocumentor\Guides\Nodes\Menu\InternalMenuEntryNode; use phpDocumentor\Guides\Nodes\Menu\TocNode; @@ -171,4 +175,56 @@ public function getPriority(): int $actual->getChildren(), ); } + + public function testTransformsAttachedNodes(): void + { + $document = new DocumentNode('foo', '/index.rst'); + $document->addChildNode(new DefinitionListNode(new DefinitionListItemNode( + InlineCompoundNode::getPlainTextInlineNode('term'), + [InlineCompoundNode::getPlainTextInlineNode('classifier')], + [new DefinitionNode([InlineCompoundNode::getPlainTextInlineNode('definition')])], + ))); + + /** @var iterable> $transformers */ + $transformers = [ + new /** @implements NodeTransformer */ + class implements NodeTransformer { + public function enterNode(Node $node, CompilerContext $compilerContext): Node + { + return new PlainTextInlineNode('transformed ' . $node->getValue()); + } + + public function leaveNode(Node $node, CompilerContext $compilerContext): Node + { + return $node; + } + + public function supports(Node $node): bool + { + return $node instanceof PlainTextInlineNode; + } + + public function getPriority(): int + { + return 2000; + } + }, + ]; + + $traverser = new DocumentNodeTraverser(new CustomNodeTransformerFactory($transformers), 2000); + + $actual = $traverser->traverse($document, (new CompilerContext(new ProjectNode()))->withDocumentShadowTree($document)); + + self::assertInstanceOf(DocumentNode::class, $actual); + self::assertEquals( + [ + new DefinitionListNode(new DefinitionListItemNode( + InlineCompoundNode::getPlainTextInlineNode('transformed term'), + [InlineCompoundNode::getPlainTextInlineNode('transformed classifier')], + [new DefinitionNode([InlineCompoundNode::getPlainTextInlineNode('transformed definition')])], + )), + ], + $actual->getChildren(), + ); + } } diff --git a/packages/guides/tests/unit/Compiler/ShadowTree/TreeNodeTest.php b/packages/guides/tests/unit/Compiler/ShadowTree/TreeNodeTest.php index c7c7abae0..91b74e71c 100644 --- a/packages/guides/tests/unit/Compiler/ShadowTree/TreeNodeTest.php +++ b/packages/guides/tests/unit/Compiler/ShadowTree/TreeNodeTest.php @@ -16,7 +16,10 @@ use Error; use LogicException; use phpDocumentor\Guides\Nodes\CompoundNode; +use phpDocumentor\Guides\Nodes\DefinitionListNode; +use phpDocumentor\Guides\Nodes\DefinitionLists\DefinitionListItemNode; use phpDocumentor\Guides\Nodes\DocumentNode; +use phpDocumentor\Guides\Nodes\Inline\PlainTextInlineNode; use phpDocumentor\Guides\Nodes\InlineCompoundNode; use phpDocumentor\Guides\Nodes\Node; use phpDocumentor\Guides\Nodes\RawNode; @@ -25,6 +28,7 @@ use PHPUnit\Framework\TestCase; use WeakReference; +use function array_keys; use function count; use function gc_disable; use function gc_enable; @@ -436,4 +440,95 @@ public function testReleaseFreesTheTreeWithoutTheCycleCollector(): void gc_enable(); } } + + public function testCreateFromDocumentWrapsAttachedNodes(): void + { + $term = InlineCompoundNode::getPlainTextInlineNode('term'); + $item = new DefinitionListItemNode($term, []); + $document = new DocumentNode('test', '/test'); + $document->addChildNode(new DefinitionListNode($item)); + + $itemTree = TreeNode::createFromDocument($document)->getChildren()[0]->getChildren()[0]; + + self::assertSame($item, $itemTree->getNode()); + self::assertSame([], $itemTree->getChildren()); + self::assertSame(['term'], array_keys($itemTree->getAttachedNodes())); + self::assertSame($term, $itemTree->getAttachedNodes()['term']->getNode()); + self::assertSame($itemTree, $itemTree->getAttachedNodes()['term']->getParent()); + } + + public function testReplaceChildInAnAttachedNodeChangesTheAttachedNodeItself(): void + { + $term = InlineCompoundNode::getPlainTextInlineNode('term'); + $item = new DefinitionListItemNode($term, []); + $list = new DefinitionListNode($item); + $document = new DocumentNode('test', '/test'); + $document->addChildNode($list); + $treeNode = TreeNode::createFromDocument($document); + $termTree = $treeNode->getChildren()[0]->getChildren()[0]->getAttachedNodes()['term']; + $replacement = new PlainTextInlineNode('replaced'); + + $termTree->replaceChild($termTree->getChildren()[0]->getNode(), $replacement); + + self::assertSame([$replacement], $term->getChildren()); + self::assertSame($term, $termTree->getNode()); + self::assertSame($replacement, $termTree->getChildren()[0]->getNode()); + self::assertSame($document, $treeNode->getNode()); + self::assertSame($list, $document->getChildren()[0]); + self::assertSame($item, $list->getChildren()[0]); + } + + public function testRemoveChildFromAnAttachedNodeChangesTheAttachedNodeItself(): void + { + $term = new InlineCompoundNode([new PlainTextInlineNode('first'), new PlainTextInlineNode('second')]); + $document = new DocumentNode('test', '/test'); + $document->addChildNode(new DefinitionListNode(new DefinitionListItemNode($term, []))); + $termTree = TreeNode::createFromDocument($document)->getChildren()[0]->getChildren()[0]->getAttachedNodes()['term']; + $second = $term->getChildren()[1]; + + $termTree->removeChild($term->getChildren()[0]); + + self::assertSame([$second], $term->getChildren()); + self::assertSame($term, $termTree->getNode()); + self::assertCount(1, $termTree->getChildren()); + } + + public function testReplaceAttachedNodeThrows(): void + { + $term = InlineCompoundNode::getPlainTextInlineNode('term'); + $document = new DocumentNode('test', '/test'); + $document->addChildNode(new DefinitionListNode(new DefinitionListItemNode($term, []))); + $itemTree = TreeNode::createFromDocument($document)->getChildren()[0]->getChildren()[0]; + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot replace the attached node "term"'); + + $itemTree->replaceChild($term, InlineCompoundNode::getPlainTextInlineNode('replaced')); + } + + public function testRemoveAttachedNodeThrows(): void + { + $term = InlineCompoundNode::getPlainTextInlineNode('term'); + $document = new DocumentNode('test', '/test'); + $document->addChildNode(new DefinitionListNode(new DefinitionListItemNode($term, []))); + $itemTree = TreeNode::createFromDocument($document)->getChildren()[0]->getChildren()[0]; + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot remove the attached node "term"'); + + $itemTree->removeChild($term); + } + + public function testReleaseClearsAttachedNodes(): void + { + $document = new DocumentNode('test', '/test'); + $document->addChildNode(new DefinitionListNode(new DefinitionListItemNode(InlineCompoundNode::getPlainTextInlineNode('term'), []))); + $itemTree = TreeNode::createFromDocument($document)->getChildren()[0]->getChildren()[0]; + $termTree = $itemTree->getAttachedNodes()['term']; + + $itemTree->release(); + + self::assertSame([], $itemTree->getAttachedNodes()); + self::assertNull($termTree->getParent()); + } } diff --git a/packages/guides/tests/unit/Nodes/HasAttachedNodesTest.php b/packages/guides/tests/unit/Nodes/HasAttachedNodesTest.php new file mode 100644 index 000000000..556b25ae1 --- /dev/null +++ b/packages/guides/tests/unit/Nodes/HasAttachedNodesTest.php @@ -0,0 +1,85 @@ + $names */ + #[DataProvider('provideNodes')] + public function testNamesTheAttachedNodes(HasAttachedNodes $node, array $names): void + { + self::assertSame($names, array_keys($node->getAttachedNodes())); + } + + /** @return Generator}> */ + public static function provideNodes(): Generator + { + yield 'definition-list item' => [ + new DefinitionListItemNode( + InlineCompoundNode::getPlainTextInlineNode('term'), + [InlineCompoundNode::getPlainTextInlineNode('first'), InlineCompoundNode::getPlainTextInlineNode('second')], + ), + ['term', 'classifier-0', 'classifier-1'], + ]; + + yield 'admonition with a title' => [ + new AdmonitionNode('note', InlineCompoundNode::getPlainTextInlineNode('title'), 'title', []), + ['title'], + ]; + + yield 'admonition without a title' => [new AdmonitionNode('note', null, '', []), []]; + + $code = new CodeNode(['code']); + $code->setCaption(InlineCompoundNode::getPlainTextInlineNode('caption')); + + yield 'code with a caption' => [$code, ['caption']]; + + yield 'code without a caption' => [new CodeNode(['code']), []]; + + yield 'menu with a caption' => [ + (new TocNode([]))->withCaption(InlineCompoundNode::getPlainTextInlineNode('caption')), + ['caption'], + ]; + + yield 'table' => [ + new TableNode( + [new TableRow([new TableColumn('a', 1), new TableColumn('b', 1)])], + [new TableRow([new TableColumn('header', 2)])], + ), + ['header-0-0', 'row-0-0', 'row-0-1'], + ]; + + $image = new ImageNode('image.png'); + $image->setTarget(new HyperLinkNode([new PlainTextInlineNode('target')], 'https://example.org')); + + yield 'image with a target' => [$image, ['target']]; + + yield 'figure with a caption' => [ + new FigureNode(new ImageNode('image.png'), new ParagraphNode([InlineCompoundNode::getPlainTextInlineNode('caption')])), + ['image', 'document'], + ]; + } +} diff --git a/tests/Integration/tests/directives/variables-replace-attached-nodes/expected/index.html b/tests/Integration/tests/directives/variables-replace-attached-nodes/expected/index.html new file mode 100644 index 000000000..e71db8b49 --- /dev/null +++ b/tests/Integration/tests/directives/variables-replace-attached-nodes/expected/index.html @@ -0,0 +1,15 @@ + +
+

Document Title

+
+
reStructuredText term : + reStructuredText classifier
+ +
A definition about reStructuredText.
+
+
+ Code about reStructuredText +
Some code.
+ +
+ diff --git a/tests/Integration/tests/directives/variables-replace-attached-nodes/input/index.rst b/tests/Integration/tests/directives/variables-replace-attached-nodes/input/index.rst new file mode 100644 index 000000000..19e18e6cd --- /dev/null +++ b/tests/Integration/tests/directives/variables-replace-attached-nodes/input/index.rst @@ -0,0 +1,13 @@ +.. |rest| replace:: reStructuredText + +============== +Document Title +============== + +|rest| term : |rest| classifier + A definition about |rest|. + +.. code-block:: text + :caption: Code about |rest| + + Some code. diff --git a/tests/Integration/tests/tables/simple-table-with-variable/expected/index.html b/tests/Integration/tests/tables/simple-table-with-variable/expected/index.html index 8eefb5453..19f33c124 100644 --- a/tests/Integration/tests/tables/simple-table-with-variable/expected/index.html +++ b/tests/Integration/tests/tables/simple-table-with-variable/expected/index.html @@ -11,7 +11,7 @@

Some table with long lines

TSconfig Reference (full) - |unknown| and another |known| variable + |unknown| and another reStructuredText variable diff --git a/tests/Integration/tests/tables/simple-table-with-variable/expected/logs/warning.log b/tests/Integration/tests/tables/simple-table-with-variable/expected/logs/warning.log new file mode 100644 index 000000000..0812f80b4 --- /dev/null +++ b/tests/Integration/tests/tables/simple-table-with-variable/expected/logs/warning.log @@ -0,0 +1 @@ +app.WARNING: No replacement was found for variable |unknown| {"rst-file":"index.rst"} []