1 <?php namespace BookStack\Entities\Managers;
3 use BookStack\Entities\Page;
15 * PageContent constructor.
17 public function __construct(Page $page)
23 * Update the content of the page with new provided HTML.
25 public function setNewHTML(string $html)
27 $this->page->html = $this->formatHtml($html);
28 $this->page->text = $this->toPlainText();
32 * Formats a page's html to be tagged correctly within the system.
34 protected function formatHtml(string $htmlText): string
36 if ($htmlText == '') {
40 libxml_use_internal_errors(true);
41 $doc = new DOMDocument();
42 $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8'));
44 $container = $doc->documentElement;
45 $body = $container->childNodes->item(0);
46 $childNodes = $body->childNodes;
48 // Set ids on top-level nodes
50 foreach ($childNodes as $index => $childNode) {
51 $this->setUniqueId($childNode, $idMap);
54 // Ensure no duplicate ids within child items
55 $xPath = new DOMXPath($doc);
56 $idElems = $xPath->query('//body//*//*[@id]');
57 foreach ($idElems as $domElem) {
58 $this->setUniqueId($domElem, $idMap);
61 // Generate inner html as a string
63 foreach ($childNodes as $childNode) {
64 $html .= $doc->saveHTML($childNode);
71 * Set a unique id on the given DOMElement.
72 * A map for existing ID's should be passed in to check for current existence.
73 * @param DOMElement $element
76 protected function setUniqueId($element, array &$idMap)
78 if (get_class($element) !== 'DOMElement') {
82 // Overwrite id if not a BookStack custom id
83 $existingId = $element->getAttribute('id');
84 if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
85 $idMap[$existingId] = true;
89 // Create an unique id for the element
90 // Uses the content as a basis to ensure output is the same every time
91 // the same content is passed through.
92 $contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
93 $newId = urlencode($contentId);
96 while (isset($idMap[$newId])) {
97 $newId = urlencode($contentId . '-' . $loopIndex);
101 $element->setAttribute('id', $newId);
102 $idMap[$newId] = true;
106 * Get a plain-text visualisation of this page.
108 protected function toPlainText(): string
110 $html = $this->render(true);
111 return strip_tags($html);
115 * Render the page for viewing
117 public function render(bool $blankIncludes = false) : string
119 $content = $this->page->html;
121 if (!config('app.allow_content_scripts')) {
122 $content = $this->escapeScripts($content);
125 if ($blankIncludes) {
126 $content = $this->blankPageIncludes($content);
128 $content = $this->parsePageIncludes($content);
135 * Parse the headers on the page to get a navigation menu
137 public function getNavigation(string $htmlContent): array
139 if (empty($htmlContent)) {
143 libxml_use_internal_errors(true);
144 $doc = new DOMDocument();
145 $doc->loadHTML(mb_convert_encoding($htmlContent, 'HTML-ENTITIES', 'UTF-8'));
146 $xPath = new DOMXPath($doc);
147 $headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6");
149 return $headers ? $this->headerNodesToLevelList($headers) : [];
153 * Convert a DOMNodeList into an array of readable header attributes
154 * with levels normalised to the lower header level.
156 protected function headerNodesToLevelList(DOMNodeList $nodeList): array
158 $tree = collect($nodeList)->map(function ($header) {
159 $text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
160 $text = mb_substr($text, 0, 100);
163 'nodeName' => strtolower($header->nodeName),
164 'level' => intval(str_replace('h', '', $header->nodeName)),
165 'link' => '#' . $header->getAttribute('id'),
168 })->filter(function ($header) {
169 return mb_strlen($header['text']) > 0;
172 // Shift headers if only smaller headers have been used
173 $levelChange = ($tree->pluck('level')->min() - 1);
174 $tree = $tree->map(function ($header) use ($levelChange) {
175 $header['level'] -= ($levelChange);
179 return $tree->toArray();
183 * Remove any page include tags within the given HTML.
185 protected function blankPageIncludes(string $html) : string
187 return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html);
191 * Parse any include tags "{{@<page_id>#section}}" to be part of the page.
193 protected function parsePageIncludes(string $html) : string
196 preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches);
198 foreach ($matches[1] as $index => $includeId) {
199 $fullMatch = $matches[0][$index];
200 $splitInclude = explode('#', $includeId, 2);
202 // Get page id from reference
203 $pageId = intval($splitInclude[0]);
204 if (is_nan($pageId)) {
208 // Find page and skip this if page not found
209 $matchedPage = Page::visible()->find($pageId);
210 if ($matchedPage === null) {
211 $html = str_replace($fullMatch, '', $html);
215 // If we only have page id, just insert all page html and continue.
216 if (count($splitInclude) === 1) {
217 $html = str_replace($fullMatch, $matchedPage->html, $html);
221 // Create and load HTML into a document
222 $innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
223 $html = str_replace($fullMatch, trim($innerContent), $html);
231 * Fetch the content from a specific section of the given page.
233 protected function fetchSectionOfPage(Page $page, string $sectionId): string
235 $topLevelTags = ['table', 'ul', 'ol'];
236 $doc = new DOMDocument();
237 libxml_use_internal_errors(true);
238 $doc->loadHTML(mb_convert_encoding('<body>'.$page->html.'</body>', 'HTML-ENTITIES', 'UTF-8'));
240 // Search included content for the id given and blank out if not exists.
241 $matchingElem = $doc->getElementById($sectionId);
242 if ($matchingElem === null) {
246 // Otherwise replace the content with the found content
247 // Checks if the top-level wrapper should be included by matching on tag types
249 $isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags);
251 $innerContent .= $doc->saveHTML($matchingElem);
253 foreach ($matchingElem->childNodes as $childNode) {
254 $innerContent .= $doc->saveHTML($childNode);
257 libxml_clear_errors();
259 return $innerContent;
263 * Escape script tags within HTML content.
265 protected function escapeScripts(string $html) : string
271 libxml_use_internal_errors(true);
272 $doc = new DOMDocument();
273 $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
274 $xPath = new DOMXPath($doc);
276 // Remove standard script tags
277 $scriptElems = $xPath->query('//script');
278 foreach ($scriptElems as $scriptElem) {
279 $scriptElem->parentNode->removeChild($scriptElem);
282 // Remove data or JavaScript iFrames
283 $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
284 foreach ($badIframes as $badIframe) {
285 $badIframe->parentNode->removeChild($badIframe);
288 // Remove 'on*' attributes
289 $onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
290 foreach ($onAttributes as $attr) {
291 /** @var \DOMAttr $attr*/
292 $attrName = $attr->nodeName;
293 $attr->parentNode->removeAttribute($attrName);
297 $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
298 foreach ($topElems as $child) {
299 $html .= $doc->saveHTML($child);