From db224f5497e4acaa68cdcb41c03994cd8801f02c Mon Sep 17 00:00:00 2001 From: Jerry Kuang Date: Fri, 2 Oct 2026 19:30:16 +0000 Subject: [PATCH 1/2] [PURR][#2814]Knowledge base component home view and searching result user interface change --- core/components/com_kb/site/controllers/articles.php | 12 +++++++++++- .../com_kb/site/language/en-GB/en-GB.com_kb.ini | 2 ++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/core/components/com_kb/site/controllers/articles.php b/core/components/com_kb/site/controllers/articles.php index c89e9699e66..90b05f76964 100644 --- a/core/components/com_kb/site/controllers/articles.php +++ b/core/components/com_kb/site/controllers/articles.php @@ -47,7 +47,16 @@ public function execute() * @return void */ public function displayTask() - { + { + $category = new Category; + $category->set('alias', 'all'); + $category->set('title', Lang::txt('COM_KB_ALL_ARTICLES')); + $category->set('id', 0); + $category->set('published', 1); + $category->set('path', 'all'); + + $this->view->set('category', $category); + $this->view ->set('archive', $this->archive) ->setLayout('display') @@ -79,6 +88,7 @@ public function categoryTask() $category->set('title', Lang::txt('COM_KB_ALL_ARTICLES')); $category->set('id', 0); $category->set('published', 1); + $category->set('path', 'all'); } else { diff --git a/core/components/com_kb/site/language/en-GB/en-GB.com_kb.ini b/core/components/com_kb/site/language/en-GB/en-GB.com_kb.ini index ba5fd0bc4c3..78c98cf944e 100644 --- a/core/components/com_kb/site/language/en-GB/en-GB.com_kb.ini +++ b/core/components/com_kb/site/language/en-GB/en-GB.com_kb.ini @@ -54,6 +54,8 @@ COM_KB_HAVE_A_FEATURE_REQUEST="Have an idea or feature request?" COM_KB_FEATURE_TELL_US="Let us know!" COM_KB_POPULAR_ARTICLES="Most Popular Articles" COM_KB_RECENT_ARTICLES="Most Recent Articles" +COM_KB_CONTACT_US="Contact Us" +COM_KB_CONTACT_INFORMATION="Contact Information" ; Search form COM_KB_SEARCH="Search" From 4e92aa8d079f33bd2df0059b501c6c69bd78da06 Mon Sep 17 00:00:00 2001 From: "Nicholas J. Kisseberth" Date: Wed, 7 Oct 2026 14:56:21 -0400 Subject: [PATCH 2/2] com_kb: search in the header, categories on the home page, snippets in results Rework of the previous commit, which carried the core half of ticket 2814 as a controller tweak plus two template strings while the actual UI lived in a PURR template override. The generic parts of that override belong in core, where every hub gets them and they stop drifting from core's views: - The search box moves into the page header on both the home and the category view (com_blog and com_answers already use that region), via one _search partial. It keeps the current sort. - The category sidebar becomes a _categories partial used by both views, so the home page gets category navigation beside its existing category grid. Its links, and the sort links, carry the search term, so narrowing or re-sorting does not drop a search. - Searching shows a highlighted excerpt under each result. Article::snippet() strips field wrappers and markup, finds the phrase or else its earliest word, cuts on word boundaries, escapes, and wraps the matches in . A "Results for ..." line offers to clear the search. - The home view's help links (Answers, Wishlist, Support) move to a _help partial, so a template that routes help elsewhere overrides that one small file instead of the whole view. - Category::link() falls back to the alias when there is no stored path. The synthetic "all" category has none, so core's own sort links on /kb/all carried an empty section and routed to the wrong task. The controller builds that category in one allCategory() helper and gives the home view the same category/catid/filters contract as the category view. - The two "Contact" strings go back to the template that uses them. Tests cover snippet(): phrase and word matching, case, wrapper and tag stripping, cutting, escaping of the term, and the link fallback. --- core/components/com_kb/models/article.php | 80 +++++ core/components/com_kb/models/category.php | 4 +- core/components/com_kb/site/assets/css/kb.css | 19 ++ .../com_kb/site/controllers/articles.php | 40 ++- .../site/language/en-GB/en-GB.com_kb.ini | 4 +- .../site/views/articles/tmpl/_categories.php | 63 ++++ .../com_kb/site/views/articles/tmpl/_help.php | 37 +++ .../site/views/articles/tmpl/_search.php | 27 ++ .../site/views/articles/tmpl/category.php | 301 ++++++++---------- .../site/views/articles/tmpl/display.php | 290 ++++++++--------- .../tests/models/ArticleSnippetTest.php | 218 +++++++++++++ 11 files changed, 752 insertions(+), 331 deletions(-) create mode 100644 core/components/com_kb/site/views/articles/tmpl/_categories.php create mode 100644 core/components/com_kb/site/views/articles/tmpl/_help.php create mode 100644 core/components/com_kb/site/views/articles/tmpl/_search.php create mode 100644 core/components/com_kb/tests/models/ArticleSnippetTest.php diff --git a/core/components/com_kb/models/article.php b/core/components/com_kb/models/article.php index 050a9ee3b3d..f0a4a8b30c8 100644 --- a/core/components/com_kb/models/article.php +++ b/core/components/com_kb/models/article.php @@ -379,6 +379,86 @@ public function link($type='') return $link; } + /** + * A plain-text excerpt of the article around a search term + * + * The excerpt is HTML-escaped with each match wrapped in . When + * the whole phrase does not occur, the earliest of its words is used. + * + * @param string $needle Search term + * @param integer $radius Characters of context on either side + * @return string HTML, or '' when the term is empty or does not occur + */ + public function snippet($needle, $radius = 160) + { + $needle = trim(preg_replace('/\s+/u', ' ', (string) $needle)); + + if ($needle === '') + { + return ''; + } + + // Field wrappers hold editor markup, not prose + $text = preg_replace('#.*?#is', ' ', stripslashes((string) $this->get('fulltxt'))); + $text = html_entity_decode(strip_tags($text), ENT_QUOTES, 'UTF-8'); + $text = trim(preg_replace('/\s+/u', ' ', $text)); + + if ($text === '') + { + return ''; + } + + // The phrase, else whichever of its words comes first + $terms = array($needle); + $position = mb_stripos($text, $needle, 0, 'UTF-8'); + + if ($position === false) + { + $terms = array(); + + foreach (array_unique(preg_split('/ /', $needle)) as $word) + { + $at = mb_stripos($text, $word, 0, 'UTF-8'); + + if ($at !== false) + { + $terms[] = $word; + $position = ($position === false) ? $at : min($position, $at); + } + } + + if ($position === false) + { + return ''; + } + } + + $start = max(0, $position - $radius); + $length = $radius * 2 + mb_strlen($needle, 'UTF-8'); + $excerpt = mb_substr($text, $start, $length, 'UTF-8'); + + // Cut on word boundaries, then mark the ends that were cut + if ($start > 0 && ($space = mb_strpos($excerpt, ' ', 0, 'UTF-8')) !== false) + { + $excerpt = mb_substr($excerpt, $space + 1, null, 'UTF-8'); + } + if ($start + $length < mb_strlen($text, 'UTF-8') && ($space = mb_strrpos($excerpt, ' ', 0, 'UTF-8')) !== false) + { + $excerpt = mb_substr($excerpt, 0, $space, 'UTF-8'); + } + + $excerpt = htmlspecialchars($excerpt, ENT_QUOTES, 'UTF-8'); + + $patterns = array_map(function ($term) + { + return preg_quote(htmlspecialchars($term, ENT_QUOTES, 'UTF-8'), '/'); + }, $terms); + + $excerpt = preg_replace('/(' . implode('|', $patterns) . ')/iu', '$1', $excerpt); + + return ($start > 0 ? '… ' : '') . $excerpt . ($start + $length < mb_strlen($text, 'UTF-8') ? ' …' : ''); + } + /** * Get a list of votes * diff --git a/core/components/com_kb/models/category.php b/core/components/com_kb/models/category.php index 2fe3482aed2..f399dfa01e6 100644 --- a/core/components/com_kb/models/category.php +++ b/core/components/com_kb/models/category.php @@ -135,7 +135,9 @@ public function parent() public function link($type='') { $link = $this->_base; - $link .= '§ion=' . $this->get('path'); + // A category built in code (the "all" pseudo-category) has an alias + // but no stored path; without a section the router picks the wrong task + $link .= '§ion=' . ($this->get('path') ?: $this->get('alias')); // If it doesn't exist or isn't published switch (strtolower($type)) diff --git a/core/components/com_kb/site/assets/css/kb.css b/core/components/com_kb/site/assets/css/kb.css index f6b2b0d05ef..fd9cf10b1c3 100644 --- a/core/components/com_kb/site/assets/css/kb.css +++ b/core/components/com_kb/site/assets/css/kb.css @@ -70,3 +70,22 @@ line-height: 0; clear: left; } +/* Search results */ + .com_kb .entry-snippet { + display: block; + margin: 0.3em 0 0.5em 0; + color: #666; + font-size: 0.95em; + line-height: 1.4; + } + .com_kb .entry-snippet mark { + padding: 0 0.15em; + background-color: #fff3a0; + color: inherit; + } + .com_kb .search-results-for { + margin: 0 0 0.5em 0; + } + .com_kb .search-results-for a { + margin-left: 0.5em; + } diff --git a/core/components/com_kb/site/controllers/articles.php b/core/components/com_kb/site/controllers/articles.php index 90b05f76964..b7cc07b8667 100644 --- a/core/components/com_kb/site/controllers/articles.php +++ b/core/components/com_kb/site/controllers/articles.php @@ -47,20 +47,35 @@ public function execute() * @return void */ public function displayTask() - { + { + // The same contract as categoryTask(), so the two layouts can share + // the category navigation and search form + $this->view + ->set('archive', $this->archive) + ->set('category', $this->allCategory()) + ->set('catid', 0) + ->set('filters', array( + 'sort' => '', + 'search' => '' + )) + ->setLayout('display') + ->display(); + } + + /** + * The pseudo-category that stands for every article + * + * @return Category + */ + private function allCategory() + { $category = new Category; $category->set('alias', 'all'); $category->set('title', Lang::txt('COM_KB_ALL_ARTICLES')); $category->set('id', 0); $category->set('published', 1); - $category->set('path', 'all'); - - $this->view->set('category', $category); - - $this->view - ->set('archive', $this->archive) - ->setLayout('display') - ->display(); + + return $category; } /** @@ -83,12 +98,7 @@ public function categoryTask() if ($categoryAlias == 'all') { - $category = new Category; - $category->set('alias', 'all'); - $category->set('title', Lang::txt('COM_KB_ALL_ARTICLES')); - $category->set('id', 0); - $category->set('published', 1); - $category->set('path', 'all'); + $category = $this->allCategory(); } else { diff --git a/core/components/com_kb/site/language/en-GB/en-GB.com_kb.ini b/core/components/com_kb/site/language/en-GB/en-GB.com_kb.ini index 78c98cf944e..7c19c9bcf9c 100644 --- a/core/components/com_kb/site/language/en-GB/en-GB.com_kb.ini +++ b/core/components/com_kb/site/language/en-GB/en-GB.com_kb.ini @@ -54,14 +54,14 @@ COM_KB_HAVE_A_FEATURE_REQUEST="Have an idea or feature request?" COM_KB_FEATURE_TELL_US="Let us know!" COM_KB_POPULAR_ARTICLES="Most Popular Articles" COM_KB_RECENT_ARTICLES="Most Recent Articles" -COM_KB_CONTACT_US="Contact Us" -COM_KB_CONTACT_INFORMATION="Contact Information" ; Search form COM_KB_SEARCH="Search" COM_KB_SEARCH_LEGEND="Search for articles" COM_KB_SEARCH_LABEL="Enter keyword or phrase" COM_KB_SEARCH_PLACEHOLDER="Enter keyword or phrase" +COM_KB_SEARCH_RESULTS_FOR="Results for "%s"" +COM_KB_SEARCH_CLEAR="Clear search" ; Errors COM_KB_USER_ALREADY_VOTED="User has already voted." diff --git a/core/components/com_kb/site/views/articles/tmpl/_categories.php b/core/components/com_kb/site/views/articles/tmpl/_categories.php new file mode 100644 index 00000000000..6680b6f65b2 --- /dev/null +++ b/core/components/com_kb/site/views/articles/tmpl/_categories.php @@ -0,0 +1,63 @@ + 1, 'access' => User::getAuthorisedViewLevels()); +$categories = $this->archive->categories($filters); + +$carry = ''; +if (!empty($this->filters['sort'])) +{ + $carry .= '&sort=' . $this->escape($this->filters['sort']); +} +if (!empty($this->filters['search'])) +{ + $carry .= '&search=' . urlencode($this->filters['search']); +} +?> + diff --git a/core/components/com_kb/site/views/articles/tmpl/_help.php b/core/components/com_kb/site/views/articles/tmpl/_help.php new file mode 100644 index 00000000000..6ae1290239f --- /dev/null +++ b/core/components/com_kb/site/views/articles/tmpl/_help.php @@ -0,0 +1,37 @@ + + +
+

+

+ ' . Lang::txt('COM_ANSWERS') . ''); ?> +

+
+ + +
+

+

+ +

+
+ + +
+

+

+ +

+
+ diff --git a/core/components/com_kb/site/views/articles/tmpl/_search.php b/core/components/com_kb/site/views/articles/tmpl/_search.php new file mode 100644 index 00000000000..f7729e14477 --- /dev/null +++ b/core/components/com_kb/site/views/articles/tmpl/_search.php @@ -0,0 +1,27 @@ +filters['search']) ? $this->filters['search'] : ''; +$sort = isset($this->filters['sort']) ? $this->filters['sort'] : ''; +?> + diff --git a/core/components/com_kb/site/views/articles/tmpl/category.php b/core/components/com_kb/site/views/articles/tmpl/category.php index 32b3c1b488b..398ac1c8a8f 100644 --- a/core/components/com_kb/site/views/articles/tmpl/category.php +++ b/core/components/com_kb/site/views/articles/tmpl/category.php @@ -29,6 +29,12 @@

+ view('_search') + ->set('option', $this->option) + ->set('filters', $this->filters) + ->display(); + ?>

@@ -41,184 +47,159 @@

getError(); ?>

-
+ filters['search']; + $carry = $search ? '&search=' . urlencode($search) : ''; + ?> +
+ +

+ escape($search)); ?> + +

+ + -
- -
- - - -
-
+ 1, 'access' => User::getAuthorisedViewLevels()); -
- + $categories = $this->archive->categories($filters); - 1, 'access' => User::getAuthorisedViewLevels()); + if (!$this->category->get('id')) + { + $articles = $this->archive->articles(); + } + else + { + $articles = $this->category->articles(); + } - $categories = $this->archive->categories($filters); + $articles->whereEquals('state', 1) + ->whereIn('access', User::getAuthorisedViewLevels()); - if (!$this->category->get('id')) - { - $articles = $this->archive->articles(); - } - else - { - $articles = $this->category->articles(); - } + if (isset($this->filters['search']) && $this->filters['search']) + { + $articles->whereLike('title', $this->filters['search'], 1) + ->orWhereLike('fulltxt', $this->filters['search'], 1) + ->resetDepth(); + } + if ($this->filters['sort'] == 'popularity') + { + $articles->order('helpful', 'desc'); + } + else + { + $articles->order('modified', 'desc') + ->order('created', 'desc'); + } - $articles->whereEquals('state', 1) - ->whereIn('access', User::getAuthorisedViewLevels()); + $articles = $articles->paginated(); - if (isset($this->filters['search']) && $this->filters['search']) - { - $articles->whereLike('title', $this->filters['search'], 1) - ->orWhereLike('fulltxt', $this->filters['search'], 1) - ->resetDepth(); - } - if ($this->filters['sort'] == 'popularity') - { - $articles->order('helpful', 'desc'); - } - else + if ($articles->count() > 0) { ?> + + + + + + + + + + + order('modified', 'desc') - ->order('created', 'desc'); - } - - $articles = $articles->paginated(); - - if ($articles->count() > 0) { ?> -
- - - - - - - - - - category->get('id')) { - if (!$this->category->get('id')) + foreach ($categories as $cat) { - foreach ($categories as $cat) + if ($cat->get('id') == $row->get('category')) { - if ($cat->get('id') == $row->get('category')) - { - $row->set('ctitle', $cat->get('title')); - $row->set('calias', $cat->get('path')); - break; - } + $row->set('ctitle', $cat->get('title')); + $row->set('calias', $cat->get('path')); + break; } } - else - { - $row->set('calias', $this->category->get('path')); - $row->set('ctitle', $this->category->get('title')); - } - ?> - - - - + + + - - - -
- get('id'); ?> - - escape(stripslashes($row->get('title',''))); ?>
- - catid <= 0) { echo Lang::txt('COM_KB_IN_CATEGORY', $this->escape(stripslashes($row->get('ctitle','') ?? ''))); } ?> - - - modified('time'); ?> - - - -
- view('_vote') - ->set('option', $this->option) - ->set('item', $row) - ->set('type', 'entry') - ->set('vote', '') - ->set('id', ''); - if (!User::isGuest()) + } + else + { + $row->set('calias', $this->category->get('path')); + $row->set('ctitle', $this->category->get('title')); + } + ?> +
+ get('id'); ?> + + escape(stripslashes($row->get('title',''))); ?>
+ snippet($search))) { ?> + + + + catid <= 0) { echo Lang::txt('COM_KB_IN_CATEGORY', $this->escape(stripslashes($row->get('ctitle','') ?? ''))); } ?> + + + modified('time'); ?> + + + +
+ view('_vote') + ->set('option', $this->option) + ->set('item', $row) + ->set('type', 'entry') + ->set('vote', '') + ->set('id', ''); + if (!User::isGuest()) + { + if ($row->get('user_id') == User::get('id')) { - if ($row->get('user_id') == User::get('id')) - { - $view->set('vote', $row->get('vote')); - $view->set('id', $row->get('id')); - } + $view->set('vote', $row->get('vote')); + $view->set('id', $row->get('id')); } - $view->display(); - ?> -
+ } + $view->display(); + ?> + + - pagination - ->setAdditionalUrlParam('search', $this->filters['search']) - ->setAdditionalUrlParam('sort', $this->filters['sort']); - ?> -
- + + + + pagination + ->setAdditionalUrlParam('search', $this->filters['search']) + ->setAdditionalUrlParam('sort', $this->filters['sort']); + ?> +
diff --git a/core/components/com_kb/site/views/articles/tmpl/display.php b/core/components/com_kb/site/views/articles/tmpl/display.php index 7cec6d7fe7a..5ccb4d316d5 100644 --- a/core/components/com_kb/site/views/articles/tmpl/display.php +++ b/core/components/com_kb/site/views/articles/tmpl/display.php @@ -23,177 +23,161 @@ ?>

+ +
+ view('_search') + ->set('option', $this->option) + ->set('filters', $this->filters) + ->display(); + ?> +
-
-
- -
- - - -
-
- -
-
-

-
-
-

- - » - -

- archive->articles() - ->whereIn('access', User::getAuthorisedViewLevels()) - ->whereEquals('state', 1) - ->order('helpful', 'desc') // (a.helpful - a.nothelpful) - ->limit(5) - ->rows(); - if (count($popular) > 0) { ?> - - -

+
+
+

+
+
+

+ + » + +

+ archive->articles() + ->whereIn('access', User::getAuthorisedViewLevels()) + ->whereEquals('state', 1) + ->order('helpful', 'desc') // (a.helpful - a.nothelpful) + ->limit(5) + ->rows(); + if (count($popular) > 0) { ?> +
-
-

- - » - -

- archive->articles() - ->whereIn('access', User::getAuthorisedViewLevels()) - ->whereEquals('state', 1) - ->order('modified', 'desc') - ->order('created', 'desc') - ->limit(5) - ->rows(); - if (count($recent) > 0) { ?> - - -

+ + +

+ +
+
+

+ + » + +

+ archive->articles() + ->whereIn('access', User::getAuthorisedViewLevels()) + ->whereEquals('state', 1) + ->order('modified', 'desc') + ->order('created', 'desc') + ->limit(5) + ->rows(); + if (count($recent) > 0) { ?> +
-
+ + +

+ +
+
-

-
- +
+ archive->categories(array('state' => 1, 'access' => User::getAuthorisedViewLevels())); + $categories = $this->archive->categories(array('state' => 1, 'access' => User::getAuthorisedViewLevels())); - foreach ($categories as $row) - { - $articles = $row->articles() - ->whereEquals('state', 1) - ->whereIn('access', User::getAuthorisedViewLevels()) - ->order('modified', 'desc') - ->order('created', 'desc') - ->limit(3) - ->rows(); + foreach ($categories as $row) + { + $articles = $row->articles() + ->whereEquals('state', 1) + ->whereIn('access', User::getAuthorisedViewLevels()) + ->order('modified', 'desc') + ->order('created', 'desc') + ->limit(3) + ->rows(); - if ($articles->count() <= 0) - { - continue; - } + if ($articles->count() <= 0) + { + continue; + } - $i++; - switch ($i) - { - case 1: - $cls = ''; - break; - case 2: - $cls = ' omega'; - break; - } - ?> -
-

- - escape(stripslashes($row->get('title'))); ?> (get('articles', 0); ?>) » - -

- count() > 0) { ?> - - -

+ $i++; + switch ($i) + { + case 1: + $cls = ''; + break; + case 2: + $cls = ' omega'; + break; + } + ?> + - = 2) ? '
' : ''; + + +

+ +
+ = 2) ? '
' : ''; - if ($i >= 2) - { - $i = 0; - } + if ($i >= 2) + { + $i = 0; } - ?> -
-
-
- + } + ?> +
+
+
diff --git a/core/components/com_kb/tests/models/ArticleSnippetTest.php b/core/components/com_kb/tests/models/ArticleSnippetTest.php new file mode 100644 index 00000000000..a92d62bfedf --- /dev/null +++ b/core/components/com_kb/tests/models/ArticleSnippetTest.php @@ -0,0 +1,218 @@ +container = $container; + } + public function get($id) + { + return $this->container->get($id); + } + public function has($id) + { + return $this->container->has($id); + } + }; + }; + + $this->previousApp = Facade::getApplication(); + Facade::setApplication($app); + } + + /** + * @return void + */ + protected function tearDown(): void + { + Facade::setApplication($this->previousApp); + + parent::tearDown(); + } + + /** + * An article with the given body + * + * @param string $fulltxt + * @return Article + */ + private function article(string $fulltxt): Article + { + $article = new Article(); + $article->set('fulltxt', $fulltxt); + + return $article; + } + + /** + * The phrase is found, escaped, and marked + * + * @return void + */ + public function testMarksThePhrase() + { + $article = $this->article('

Upload your data set & describe it.

'); + + $this->assertSame('Upload your data set & describe it.', $article->snippet('data set')); + } + + /** + * Matching ignores case; the original casing is kept + * + * @return void + */ + public function testMatchesCaseInsensitively() + { + $article = $this->article('

Your Dataset lives here.

'); + + $this->assertSame('Your Dataset lives here.', $article->snippet('dataset')); + } + + /** + * When the phrase is absent, its words are marked instead + * + * @return void + */ + public function testFallsBackToWords() + { + $article = $this->article('

Publish the files, then cite the dataset.

'); + + $this->assertSame( + 'Publish the files, then cite the dataset.', + $article->snippet('dataset files') + ); + } + + /** + * Field wrappers and markup are stripped before searching + * + * @return void + */ + public function testStripsFieldWrappersAndTags() + { + $article = $this->article('summary data

Body text about data.

'); + + $this->assertSame('Body text about data.', $article->snippet('data')); + } + + /** + * A long body is cut to the context either side, on word boundaries + * + * @return void + */ + public function testCutsLongBodiesAroundTheMatch() + { + $article = $this->article(str_repeat('before ', 60) . 'needle here ' . str_repeat('after ', 60)); + + $snippet = $article->snippet('needle', 30); + + $this->assertStringStartsWith('… before', $snippet); + $this->assertStringEndsWith('after …', $snippet); + $this->assertStringContainsString('needle here', $snippet); + $this->assertLessThan(120, strlen(strip_tags($snippet))); + } + + /** + * Nothing to show when the term is empty or absent + * + * @return void + */ + public function testEmptyWhenAbsent() + { + $article = $this->article('

Nothing relevant.

'); + + $this->assertSame('', $article->snippet('')); + $this->assertSame('', $article->snippet(' ')); + $this->assertSame('', $article->snippet('zebra')); + $this->assertSame('', $this->article('')->snippet('zebra')); + } + + /** + * Markup in the term cannot escape into the page + * + * @return void + */ + public function testTermIsEscaped() + { + $article = $this->article('

See bold text.

'); + + $this->assertSame('See <b>bold text.', $this->article('See <b>bold text.')->snippet('bold')); + $this->assertStringNotContainsString('snippet('