]> BookStack Code Mirror - bookstack/blob - app/Entities/Tools/PageIncludeParser.php
dad7c29e60ee3392de7b6d56726fee5455074eb5
[bookstack] / app / Entities / Tools / PageIncludeParser.php
1 <?php
2
3 namespace BookStack\Entities\Tools;
4
5 use BookStack\Util\HtmlDocument;
6 use Closure;
7 use DOMDocument;
8 use DOMElement;
9 use DOMNode;
10 use DOMText;
11
12 class PageIncludeParser
13 {
14     protected static string $includeTagRegex = "/{{@\s?([0-9].*?)}}/";
15
16     /**
17      * Elements to clean up and remove if left empty after a parsing operation.
18      * @var DOMElement[]
19      */
20     protected array $toCleanup = [];
21
22     /**
23      * @param Closure(PageIncludeTag $tag): PageContent $pageContentForId
24      */
25     public function __construct(
26         protected HtmlDocument $doc,
27         protected Closure $pageContentForId,
28     ) {
29     }
30
31     /**
32      * Parse out the include tags.
33      * Returns the count of new content DOM nodes added to the document.
34      */
35     public function parse(): int
36     {
37         $nodesAdded = 0;
38         $tags = $this->locateAndIsolateIncludeTags();
39
40         foreach ($tags as $tag) {
41             /** @var PageIncludeContent $content */
42             $content = $this->pageContentForId->call($this, $tag);
43
44             if (!$content->isInline()) {
45                 $parentP = $this->getParentParagraph($tag->domNode);
46                 $isWithinParentP = $parentP === $tag->domNode->parentNode;
47                 if ($parentP && $isWithinParentP) {
48                     $this->splitNodeAtChildNode($tag->domNode->parentNode, $tag->domNode);
49                 } else if ($parentP) {
50                     $this->moveTagNodeToBesideParent($tag, $parentP);
51                 }
52             }
53
54             $replacementNodes = $content->toDomNodes();
55             $nodesAdded += count($replacementNodes);
56             $this->replaceNodeWithNodes($tag->domNode, $replacementNodes);
57         }
58
59         $this->cleanup();
60
61         return $nodesAdded;
62     }
63
64     /**
65      * Locate include tags within the given document, isolating them to their
66      * own nodes in the DOM for future targeted manipulation.
67      * @return PageIncludeTag[]
68      */
69     protected function locateAndIsolateIncludeTags(): array
70     {
71         $includeHosts = $this->doc->queryXPath("//*[text()[contains(., '{{@')]]");
72         $includeTags = [];
73
74         /** @var DOMNode $node */
75         foreach ($includeHosts as $node) {
76             /** @var DOMNode $childNode */
77             foreach ($node->childNodes as $childNode) {
78                 if ($childNode->nodeName === '#text') {
79                     array_push($includeTags, ...$this->splitTextNodesAtTags($childNode));
80                 }
81             }
82         }
83
84         return $includeTags;
85     }
86
87     /**
88      * Takes a text DOMNode and splits its text content at include tags
89      * into multiple text nodes within the original parent.
90      * Returns found PageIncludeTag references.
91      * @return PageIncludeTag[]
92      */
93     protected function splitTextNodesAtTags(DOMNode $textNode): array
94     {
95         $includeTags = [];
96         $text = $textNode->textContent;
97         preg_match_all(static::$includeTagRegex, $text, $matches, PREG_OFFSET_CAPTURE);
98
99         $currentOffset = 0;
100         foreach ($matches[0] as $index => $fullTagMatch) {
101             $tagOuterContent = $fullTagMatch[0];
102             $tagInnerContent = $matches[1][$index][0];
103             $tagStartOffset = $fullTagMatch[1];
104
105             if ($currentOffset < $tagStartOffset) {
106                 $previousText = substr($text, $currentOffset, $tagStartOffset - $currentOffset);
107                 $textNode->parentNode->insertBefore(new DOMText($previousText), $textNode);
108             }
109
110             $node = $textNode->parentNode->insertBefore(new DOMText($tagOuterContent), $textNode);
111             $includeTags[] = new PageIncludeTag($tagInnerContent, $node);
112             $currentOffset = $tagStartOffset + strlen($tagOuterContent);
113         }
114
115         if ($currentOffset > 0) {
116             $textNode->textContent = substr($text, $currentOffset);
117         }
118
119         return $includeTags;
120     }
121
122     /**
123      * Replace the given node with all those in $replacements
124      * @param DOMNode[] $replacements
125      */
126     protected function replaceNodeWithNodes(DOMNode $toReplace, array $replacements): void
127     {
128         /** @var DOMDocument $targetDoc */
129         $targetDoc = $toReplace->ownerDocument;
130
131         foreach ($replacements as $replacement) {
132             if ($replacement->ownerDocument !== $targetDoc) {
133                 $replacement = $targetDoc->importNode($replacement, true);
134             }
135
136             $toReplace->parentNode->insertBefore($replacement, $toReplace);
137         }
138
139         $toReplace->parentNode->removeChild($toReplace);
140     }
141
142     /**
143      * Move a tag node to become a sibling of the given parent.
144      * Will attempt to guess a position based upon the tag content within the parent.
145      */
146     protected function moveTagNodeToBesideParent(PageIncludeTag $tag, DOMNode $parent): void
147     {
148         $parentText = $parent->textContent;
149         $tagPos = strpos($parentText, $tag->tagContent);
150         $before = $tagPos < (strlen($parentText) / 2);
151         $this->toCleanup[] = $tag->domNode->parentNode;
152
153         if ($before) {
154             $parent->parentNode->insertBefore($tag->domNode, $parent);
155         } else {
156             $parent->parentNode->insertBefore($tag->domNode, $parent->nextSibling);
157         }
158     }
159
160     /**
161      * Splits the given $parentNode at the location of the $domNode within it.
162      * Attempts replicate the original $parentNode, moving some of their parent
163      * children in where needed, before adding the $domNode between.
164      */
165     protected function splitNodeAtChildNode(DOMElement $parentNode, DOMNode $domNode): void
166     {
167         $children = [...$parentNode->childNodes];
168         $splitPos = array_search($domNode, $children, true);
169         if ($splitPos === false) {
170             $splitPos = count($children) - 1;
171         }
172
173         $parentClone = $parentNode->cloneNode();
174         $parentNode->parentNode->insertBefore($parentClone, $parentNode);
175         $parentClone->removeAttribute('id');
176
177         for ($i = 0; $i < $splitPos; $i++) {
178             /** @var DOMNode $child */
179             $child = $children[$i];
180             $parentClone->appendChild($child);
181         }
182
183         $parentNode->parentNode->insertBefore($domNode, $parentNode);
184
185         $this->toCleanup[] = $parentNode;
186         $this->toCleanup[] = $parentClone;
187     }
188
189     /**
190      * Get the parent paragraph of the given node, if existing.
191      */
192     protected function getParentParagraph(DOMNode $parent): ?DOMNode
193     {
194         do {
195             if (strtolower($parent->nodeName) === 'p') {
196                 return $parent;
197             }
198
199             $parent = $parent->parentNode;
200         } while ($parent !== null);
201
202         return null;
203     }
204
205     /**
206      * Cleanup after a parse operation.
207      * Removes stranded elements we may have left during the parse.
208      */
209     protected function cleanup(): void
210     {
211         foreach ($this->toCleanup as $element) {
212             $element->normalize();
213             while ($element->parentNode && !$element->hasChildNodes()) {
214                 $parent = $element->parentNode;
215                 $parent->removeChild($element);
216                 $element = $parent;
217             }
218         }
219     }
220 }