diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index 4f72e7c49..8d89a86cf 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -39,7 +39,14 @@ class PageContent public function setNewHTML(string $html, User $updater): void { $html = $this->extractBase64ImagesFromHtml($html, $updater); - $this->page->html = $this->formatHtml($html); + $html = $this->formatHtml($html); + + $themeResult = Theme::dispatch(ThemeEvents::PAGE_CONTENT_PRE_STORE, $html, $this->page); + if (is_string($themeResult)) { + $html = $themeResult; + } + + $this->page->html = $html; $this->page->text = $this->toPlainText(); $this->page->markdown = ''; } @@ -52,7 +59,14 @@ class PageContent $markdown = $this->extractBase64ImagesFromMarkdown($markdown, $updater); $this->page->markdown = $markdown; $html = (new MarkdownToHtml($markdown))->convert(); - $this->page->html = $this->formatHtml($html); + $html = $this->formatHtml($html); + + $themeResult = Theme::dispatch(ThemeEvents::PAGE_CONTENT_PRE_STORE, $html, $this->page); + if (is_string($themeResult)) { + $html = $themeResult; + } + + $this->page->html = $html; $this->page->text = $this->toPlainText(); } @@ -81,7 +95,7 @@ class PageContent /** * Convert all inline base64 content to uploaded image files. - * Regex is used to locate the start of data-uri definitions then + * Regex is used to locate the start of data-uri definitions, then * manual looping over content is done to parse the whole data uri. * Attempting to capture the whole data uri using regex can cause PHP * PCRE limits to be hit with larger, multi-MB, files. @@ -301,7 +315,7 @@ class PageContent $html = $this->page->html ?? ''; if (empty($html)) { - return $html; + return $this->handlePostRender(''); } $doc = new HtmlDocument($html); @@ -322,7 +336,7 @@ class PageContent $cacheKey = $this->getContentCacheKey($doc->getBodyInnerHtml()); $cached = cache()->get($cacheKey, null); if ($cached !== null) { - return $cached; + return $this->handlePostRender($cached); } $filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering')); @@ -332,7 +346,13 @@ class PageContent $cacheTime = 86400 * 7; // 1 week cache()->put($cacheKey, $filtered, $cacheTime); - return $filtered; + return $this->handlePostRender($filtered); + } + + protected function handlePostRender(string $html): string + { + $themeResult = Theme::dispatch(ThemeEvents::PAGE_CONTENT_POST_RENDER, $html, $this->page); + return is_string($themeResult) ? $themeResult : $html; } protected function getContentCacheKey(string $html): string diff --git a/app/Theming/ThemeEvents.php b/app/Theming/ThemeEvents.php index 71778ec44..511a9c1de 100644 --- a/app/Theming/ThemeEvents.php +++ b/app/Theming/ThemeEvents.php @@ -111,6 +111,31 @@ class ThemeEvents */ const OIDC_ID_TOKEN_PRE_VALIDATE = 'oidc_id_token_pre_validate'; + /** + * Page content post-render event. + * Runs after any display rendering of page content, typically when page content is being processed for viewing. + * Rendering typically includes parsing of page includes, and content filtering. + * Provides the HTML content about to be shown, along with the related page instance. + * If the listener returns a string value, that will be used as the HTML content instead. + * + * @param string $html + * @param \BookStack\Entities\Models\Page $page + * @return string|null + */ + const PAGE_CONTENT_POST_RENDER = 'page_content_post_render'; + + /** + * Page content pre-store event. + * Runs just before page HTML is stored in the database, after BookStack's own processing. + * Provides the HTML content about to be stored, along with the related page instance. + * If the listener returns a string value, that will be used as the HTML content instead. + * + * @param string $html + * @param \BookStack\Entities\Models\Page $page + * @return string|null + */ + const PAGE_CONTENT_PRE_STORE = 'page_content_pre_store'; + /** * Page include parse event. * Runs when a page include tag is being parsed, typically when page content is being processed for viewing. diff --git a/tests/Theme/LogicalThemeEventsTest.php b/tests/Theme/LogicalThemeEventsTest.php index 0a4afd2f4..2add83868 100644 --- a/tests/Theme/LogicalThemeEventsTest.php +++ b/tests/Theme/LogicalThemeEventsTest.php @@ -215,6 +215,82 @@ class LogicalThemeEventsTest extends TestCase $this->assertEquals($book->id, $args[1]->id); } + public function test_page_content_pre_store_fires_on_page_save() + { + $page = $this->entities->page(); + + $args = []; + $callback = function (...$eventArgs) use (&$args) { + $args = $eventArgs; + return '

New Content!

'; + }; + + Theme::listen(ThemeEvents::PAGE_CONTENT_PRE_STORE, $callback); + + $this->asEditor(); + $this->entities->updatePage($page, ['name' => 'My cool update page!', 'html' => '

Old content!

']); + + $this->assertCount(2, $args); + $this->assertEquals($page->id, $args[1]->id); + $this->assertEquals('

Old content!

', $args[0]); + + $newPageHtml = $page->refresh()->html; + $this->assertEquals('

New Content!

', $newPageHtml); + } + + public function test_page_content_pre_store_does_not_change_content_if_nothing_returned() + { + $page = $this->entities->page(); + Theme::listen(ThemeEvents::PAGE_CONTENT_PRE_STORE, fn() => null); + + $this->asEditor(); + $this->entities->updatePage($page, ['name' => 'My cool update page!', 'html' => '

Old content!

']); + + $newPageHtml = $page->refresh()->html; + $this->assertEquals('

Old content!

', $newPageHtml); + } + + public function test_page_content_post_render_fires_on_page_view() + { + $page = $this->entities->page(); + $page->html = '

Old content!

'; + $page->save(); + + $args = []; + $callback = function (...$eventArgs) use (&$args) { + $args = $eventArgs; + return '

New postrendercontentforyou!

'; + }; + + Theme::listen(ThemeEvents::PAGE_CONTENT_POST_RENDER, $callback); + + $resp = $this->asEditor()->get($page->getUrl()); + $resp->assertSee('

New postrendercontentforyou!

', false); + + $this->assertCount(2, $args); + $this->assertEquals($page->id, $args[1]->id); + $this->assertEquals('

Old content!

', $args[0]); + } + + public function test_page_content_post_render_returns_original_content_if_no_return() + { + $page = $this->entities->page(); + $page->html = '

Old content!

'; + $page->save(); + + $args = []; + $callback = function (...$eventArgs) use (&$args) { + $args = $eventArgs; + }; + + Theme::listen(ThemeEvents::PAGE_CONTENT_POST_RENDER, $callback); + + $resp = $this->asEditor()->get($page->getUrl()); + $resp->assertSee('

Old content!

', false); + + $this->assertCount(2, $args); + } + public function test_page_include_parse() { /** @var Page $page */