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"} []