dbFields = array( 'label' => 'Search', 'minChars' => 3, 'wordsToCachePerPage' => 800, 'showButtonSearch' => false, 'highlightResults' => true, 'showResultCount' => true, 'searchInTags' => true, 'searchInCategories' => true, 'excerptLength' => 200 ); } public function form() { global $L; $html = ''; $html .= '
'; $html .= ''; $html .= ''; $html .= '' . $L->get('This title is almost always used in the sidebar of the site') . ''; $html .= '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; return $html; } // HTML for sidebar public function siteSidebar() { global $L; $label = $this->getValue('label'); $labelEscaped = htmlspecialchars($label, ENT_QUOTES, 'UTF-8'); $searchText = $L->get('Search'); $searchTextEscaped = htmlspecialchars($searchText, ENT_QUOTES, 'UTF-8'); $html = ''; $domainBase = json_encode(DOMAIN_BASE); $minChars = max(1, (int) $this->getValue('minChars')); $minCharsMessage = json_encode('Please enter at least ' . $minChars . ' characters to search.'); $html .= << function pluginSearchSubmit() { var text = document.getElementById("jspluginSearchText").value.trim(); if (text.length < {$minChars}) { alert({$minCharsMessage}); return false; } window.location.href = {$domainBase} + 'search/' + encodeURIComponent(text); return false; } EOF; return $html; } // Inject CSS styles for search results (works with any theme) public function siteHead() { $css = <<<'CSS' CSS; // Add JavaScript for automatic highlighting on search results page $webhook = 'search'; if ($this->webhook($webhook, false, false) && $this->getValue('highlightResults')) { $searchTerm = $this->getSearchTerm(); $searchTermJS = json_encode($searchTerm); $js = '' . PHP_EOL; $css .= $js; } return $css; } // Display search header before content public function siteBodyBegin() { $webhook = 'search'; if (!$this->webhook($webhook, false, false)) { return false; } global $L; $searchTerm = $this->getSearchTerm(); $searchTermEscaped = htmlspecialchars($searchTerm, ENT_QUOTES, 'UTF-8'); $count = $this->numberOfItems; if (empty($searchTerm)) { return ''; } $html = '
'; $html .= '
'; $html .= '

' . htmlspecialchars($L->get('Search results for'), ENT_QUOTES, 'UTF-8') . ' "' . $searchTermEscaped . '"

'; if ($this->getValue('showResultCount')) { if ($count === 0) { $html .= '

' . $L->get('No pages found') . '

'; } elseif ($count === 1) { $html .= '

1 ' . strtolower($L->get('result found')) . '

'; } else { $html .= '

' . $count . ' ' . strtolower($L->get('results found')) . '

'; } } $html .= '
'; $html .= '
'; // If no results, show helpful message if ($count === 0) { $domainBase = json_encode(DOMAIN_BASE); $minChars = max(1, (int) $this->getValue('minChars')); $searchLabel = htmlspecialchars($L->get('Search'), ENT_QUOTES, 'UTF-8'); $html .= '
'; $html .= '
'; $html .= '

' . $L->get('No results found') . '

'; $html .= '

' . sprintf($L->get('no-pages-found-for-the-search'), $searchTermEscaped) . '

'; $html .= '
'; $html .= '

' . $L->get('Suggestions') . ':

'; $html .= '
    '; $html .= '
  • ' . $L->get('Check your spelling') . '
  • '; $html .= '
  • ' . $L->get('Try different keywords') . '
  • '; $html .= '
  • ' . $L->get('Try more general keywords') . '
  • '; $html .= '
'; $html .= '
'; $html .= '
'; $html .= '
'; $html .= ''; $html .= '
'; $html .= '
'; $html .= '
'; $html .= '
'; $html .= << function searchAgain() { var text = document.getElementById("searchAgainInput").value.trim(); if (text.length < {$minChars}) return false; window.location.href = {$domainBase} + 'search/' + encodeURIComponent(text); return false; } EOF; } return $html; } public function install($position = 0) { parent::install($position); return $this->createCache(); } // Method called when the user click on button save in the settings of the plugin public function post() { parent::post(); return $this->createCache(); } public function afterPageCreate() { $this->createCache(); } public function afterPageModify() { $this->createCache(); } public function afterPageDelete() { $this->createCache(); } // Get the current search term public function getSearchTerm() { return $this->searchTerm; } // Get the number of results public function getResultCount() { return $this->numberOfItems; } public function beforeAll() { if (!$this->isSearchRequest()) { return false; } global $site; global $url; // Stop the normal page builder from treating /search/... as a page. $url->setWhereAmI('search'); $this->searchTerm = $this->searchTermFromUrl(); try { $list = $this->search($this->searchTerm); } catch (Throwable $e) { $this->logSearchError('Search failed: ' . $e->getMessage()); $list = array(); } $this->numberOfItems = count($list); $pageNumber = max(1, (int) $url->pageNumber()); $itemsPerPage = max(0, (int) $site->itemsPerPage()); if ($itemsPerPage === 0) { $this->pagesFound = ($pageNumber === 1) ? $list : array(); return true; } $offset = ($pageNumber - 1) * $itemsPerPage; $this->pagesFound = array_slice($list, $offset, $itemsPerPage); return true; } public function paginator() { if ($this->isSearchRequest()) { // Get the pre-defined variable from the rule 99.paginator.php // Is necessary to change this variable to fit the paginator with the result from the search global $numberOfItems; $numberOfItems = $this->numberOfItems; } } public function beforeSiteLoad() { if (!$this->isSearchRequest()) { return false; } global $url; global $WHERE_AM_I; global $content; // Render with regular listing templates; many themes only know home/page. $url->setWhereAmI('home'); $WHERE_AM_I = 'home'; $content = array(); foreach ($this->pagesFound as $pageKey) { try { $page = new Page($pageKey); $excerpt = $this->getSearchExcerpt($this->pageRawContent($page), $this->searchTerm); if (!empty($excerpt)) { $page->setField('content', '

' . $this->highlightTerms($excerpt, $this->searchTerm) . '

'); } $content[] = $page; } catch (Exception $e) { $this->logSearchError('Result page failed: ' . $e->getMessage()); } } return true; } public function highlightTerms($text, $searchTerm) { $safeText = htmlspecialchars((string) $text, ENT_QUOTES, 'UTF-8'); if (!$this->getValue('highlightResults') || empty($searchTerm)) { return $safeText; } $terms = $this->queryTerms($searchTerm); if (empty($terms)) { return $safeText; } usort($terms, function($a, $b) { return mb_strlen($b, CHARSET) - mb_strlen($a, CHARSET); }); $escapedTerms = array_map(function($term) { return preg_quote(htmlspecialchars($term, ENT_QUOTES, 'UTF-8'), '/'); }, $terms); $pattern = '/(' . implode('|', $escapedTerms) . ')/iu'; $highlighted = preg_replace($pattern, '$1', $safeText); return ($highlighted === null) ? $safeText : $highlighted; } public function getSearchExcerpt($content, $searchTerm, $length = null) { $length = ($length === null) ? $this->getValue('excerptLength') : $length; $length = min(800, max(80, (int) $length)); $content = $this->plainText($content); if (empty($content)) { return ''; } $firstPos = false; foreach ($this->queryTerms($searchTerm) as $term) { $pos = mb_stripos($content, $term, 0, CHARSET); if ($pos !== false && ($firstPos === false || $pos < $firstPos)) { $firstPos = $pos; } } $start = ($firstPos === false) ? 0 : max(0, $firstPos - (int) ($length / 3)); if ($start > 0) { $prefix = mb_substr($content, 0, $start, CHARSET); $spacePos = mb_strrpos($prefix, ' ', 0, CHARSET); if ($spacePos !== false) { $start = $spacePos + 1; } } $excerpt = mb_substr($content, $start, $length, CHARSET); $contentLength = mb_strlen($content, CHARSET); if ($start > 0) { $excerpt = '...' . $excerpt; } if ($start + $length < $contentLength) { $lastSpace = mb_strrpos($excerpt, ' ', 0, CHARSET); if ($lastSpace !== false && $lastSpace > max(20, $length - 40)) { $excerpt = mb_substr($excerpt, 0, $lastSpace, CHARSET); } $excerpt .= '...'; } return $excerpt; } private function plainText($text) { $text = Text::removeHTMLTags((string) $text); $text = html_entity_decode($text, ENT_QUOTES, CHARSET); $plainText = preg_replace('/\s+/u', ' ', $text); if ($plainText === null) { $plainText = preg_replace('/\s+/', ' ', $text); } if ($plainText === null) { $plainText = $text; } return trim((string) $plainText); } private function createCache() { global $pages; $list = $pages->getList($pageNumber = 1, $numberOfItems = -1, $published = true, $static = true, $sticky = true, $draft = false, $scheduled = false); if (!is_array($list)) { return false; } $cache = array(); foreach ($list as $pageKey) { $page = buildPage($pageKey); if ($page === false) { continue; } $characterLimit = max(250, (int) $this->getValue('wordsToCachePerPage') * 6); $cache[$pageKey] = array( 'title' => $this->plainText($page->title()), 'description' => $this->plainText($page->description()), 'content' => Text::truncate($this->plainText($this->pageRawContent($page)), $characterLimit, ''), 'tags' => '', 'category' => '' ); if ($this->getValue('searchInTags')) { $pageTags = $page->tags(true); if (!empty($pageTags)) { $cache[$pageKey]['tags'] = $this->plainText(implode(' ', $pageTags)); } } if ($this->getValue('searchInCategories')) { $cache[$pageKey]['category'] = $this->plainText($page->category()); } } $workspace = $this->workspace(); if (!is_dir($workspace)) { $permissions = defined('DIR_PERMISSIONS') ? DIR_PERMISSIONS : 0755; mkdir($workspace, $permissions, true); } $json = json_encode($cache, JSON_UNESCAPED_UNICODE); return file_put_contents($this->cacheFile(), $json, LOCK_EX); } private function cacheFile() { return $this->workspace() . 'cache.json'; } private function search($text) { $query = $this->normalizeQuery($text); if (mb_strlen($query, CHARSET) < $this->minChars()) { return array(); } $cache = $this->loadCache(); $terms = $this->queryTerms($query); if (empty($cache) || empty($terms)) { return array(); } $scores = array(); foreach ($cache as $pageKey => $pageData) { $score = $this->scorePage($pageData, $query, $terms); if ($score > 0) { $scores[$pageKey] = $score; } } if (empty($scores)) { return array(); } arsort($scores, SORT_NUMERIC); return array_keys($scores); } private function loadCache() { $cacheFile = $this->cacheFile(); if (!file_exists($cacheFile)) { $this->createCache(); } $cache = $this->readCache(); if (!is_array($cache) || $this->cacheIsLegacy($cache)) { $this->createCache(); $cache = $this->readCache(); } return is_array($cache) ? $cache : array(); } private function readCache() { $cacheFile = $this->cacheFile(); if (!file_exists($cacheFile)) { return array(); } $json = file_get_contents($cacheFile); if ($json === false || $json === '') { return array(); } $cache = json_decode($json, true); return is_array($cache) ? $cache : array(); } private function cacheIsLegacy($cache) { foreach ($cache as $pageData) { if (is_array($pageData) && isset($pageData['search'])) { return true; } return false; } return false; } private function scorePage($pageData, $query, $terms) { if (!is_array($pageData)) { return 0; } $fields = array( 'title' => $this->normalizeSearchText($this->arrayValue($pageData, 'title')), 'tags' => $this->normalizeSearchText($this->arrayValue($pageData, 'tags')), 'category' => $this->normalizeSearchText($this->arrayValue($pageData, 'category')), 'description' => $this->normalizeSearchText($this->arrayValue($pageData, 'description')), 'content' => $this->normalizeSearchText($this->arrayValue($pageData, 'content')) ); $weights = array( 'title' => 100, 'tags' => 80, 'category' => 70, 'description' => 45, 'content' => 20 ); $score = 0; $matches = 0; foreach ($fields as $fieldName => $fieldText) { if ($fieldText === '') { continue; } if (strpos($fieldText, $query) !== false) { $score += $weights[$fieldName] * 3; } foreach ($terms as $term) { $termScore = $this->scoreTerm($fieldText, $term, $weights[$fieldName]); if ($termScore > 0) { $score += $termScore; $matches++; } } } if ($matches === 0) { $score += $this->fuzzyTitleScore($fields, $terms); } if (count($terms) > 1 && $matches >= count($terms)) { $score += 40; } return $score; } private function scoreTerm($fieldText, $term, $weight) { if ($fieldText === '' || $term === '') { return 0; } $quoted = preg_quote($term, '/'); $wholeWordMatches = preg_match_all('/(^| )' . $quoted . '(?= |$)/u', $fieldText, $matches); if ($wholeWordMatches > 0) { return $weight + min(50, $wholeWordMatches * 8); } if (mb_strlen($term, CHARSET) >= 4 && strpos($fieldText, $term) !== false) { return (int) ($weight * 0.45); } return 0; } private function fuzzyTitleScore($fields, $terms) { $text = trim($fields['title'] . ' ' . $fields['tags'] . ' ' . $fields['category']); if ($text === '') { return 0; } $words = $this->splitWords($text); if (empty($words)) { return 0; } $score = 0; foreach ($terms as $term) { if (mb_strlen($term, CHARSET) < 4) { continue; } $allowedDistance = (mb_strlen($term, CHARSET) <= 5) ? 1 : 2; foreach ($words as $word) { if (abs(strlen($word) - strlen($term)) > $allowedDistance) { continue; } if (levenshtein($term, $word) <= $allowedDistance) { $score += 30; break; } } } return $score; } private function normalizeQuery($text) { $text = $this->plainText(urldecode((string) $text)); if (mb_strlen($text, CHARSET) > 120) { $text = mb_substr($text, 0, 120, CHARSET); } return $text; } private function searchTermFromUrl() { $term = $this->webhook('search', true, false); if (!is_string($term)) { return ''; } return $this->normalizeQuery(trim($term, '/')); } private function queryTerms($query) { $normalized = $this->normalizeSearchText($query); $terms = array(); foreach ($this->splitWords($normalized) as $word) { if (mb_strlen($word, CHARSET) >= 2) { $terms[$word] = true; } } return array_keys($terms); } private function splitWords($text) { $words = preg_split('/\s+/u', trim((string) $text), -1, PREG_SPLIT_NO_EMPTY); if (!is_array($words)) { $words = preg_split('/\s+/', trim((string) $text), -1, PREG_SPLIT_NO_EMPTY); } return is_array($words) ? $words : array(); } private function normalizeSearchText($text) { $text = $this->plainText($text); $originalText = $text; if (function_exists('transliterator_transliterate')) { $transliterated = @transliterator_transliterate('Any-Latin; Latin-ASCII;', $text); if ($transliterated !== false) { $text = $transliterated; } } elseif (function_exists('iconv')) { $transliterated = @iconv(CHARSET, 'ASCII//TRANSLIT//IGNORE', $text); if ($transliterated !== false) { $text = $transliterated; } } $text = mb_strtolower($text, CHARSET); $text = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $text); if ($text === null) { $text = preg_replace('/[^a-z0-9]+/i', ' ', $originalText); } if ($text === null) { return ''; } $text = preg_replace('/\s+/u', ' ', $text); return trim((string) $text); } private function pageRawContent($page) { if (method_exists($page, 'contentRaw')) { return $page->contentRaw(); } return $page->content(); } private function minChars() { return max(1, (int) $this->getValue('minChars')); } private function isSearchRequest() { return $this->webhook('search', false, false); } private function arrayValue($array, $key) { return (is_array($array) && isset($array[$key])) ? $array[$key] : ''; } private function logSearchError($message) { if (class_exists('Log') && defined('LOG_SEP')) { Log::set(__METHOD__ . LOG_SEP . $message); } } }