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() { // Check if the URL match with the webhook $webhook = 'search'; if ($this->webhook($webhook, false, false)) { global $site; global $url; // Change the whereAmI to avoid load pages in the rule 69.pages // This is only for performance purpose $url->setWhereAmI('search'); // Get the string to search from the URL $stringToSearch = $this->webhook($webhook, true, false); $stringToSearch = trim($stringToSearch, '/'); $stringToSearch = urldecode($stringToSearch); $normalizedWhitespace = preg_replace('/\s+/u', ' ', $stringToSearch); if ($normalizedWhitespace === null) { $normalizedWhitespace = preg_replace('/\s+/', ' ', $stringToSearch); } if ($normalizedWhitespace !== null) { $stringToSearch = $normalizedWhitespace; } $stringToSearch = trim((string) $stringToSearch); if (mb_strlen($stringToSearch, 'UTF-8') > 120) { $stringToSearch = mb_substr($stringToSearch, 0, 120, 'UTF-8'); } $this->searchTerm = $stringToSearch; // Search the string in the cache and get all pages with matches $list = $this->search($stringToSearch); $this->numberOfItems = count($list); // Split the content in pages // The first page number is 1, so the real is 0 $realPageNumber = $url->pageNumber() - 1; $itemsPerPage = $site->itemsPerPage(); if ($itemsPerPage <= 0) { if ($realPageNumber === 0) { $this->pagesFound = $list; } } else { $chunks = array_chunk($list, $itemsPerPage); if (isset($chunks[$realPageNumber])) { $this->pagesFound = $chunks[$realPageNumber]; } } } } public function paginator() { $webhook = 'search'; if ($this->webhook($webhook, false, false)) { // 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() { $webhook = 'search'; if ($this->webhook($webhook, false, false)) { global $url; global $WHERE_AM_I; $WHERE_AM_I = 'search'; // Get the pre-defined variable from the rule 69.pages.php // We change the content to show in the website global $content; $content = array(); foreach ($this->pagesFound as $pageKey) { try { $page = new Page($pageKey); $excerpt = $this->getSearchExcerpt($page->content(), $this->searchTerm); if (!empty($excerpt)) { $page->setField('content', '

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

'); } array_push($content, $page); } catch (Exception $e) { // continue } } } } /** * Highlight search terms in plain text and return safe HTML. */ public function highlightTerms($text, $searchTerm) { $safeText = htmlspecialchars((string) $text, ENT_QUOTES, 'UTF-8'); if (!$this->getValue('highlightResults') || empty($searchTerm)) { return $safeText; } $words = $this->splitWords($searchTerm); $words = array_filter($words, function($word) { return mb_strlen($word, 'UTF-8') >= 2; }); $words = array_values(array_unique($words)); if (empty($words)) { return $safeText; } usort($words, function($a, $b) { return mb_strlen($b, 'UTF-8') - mb_strlen($a, 'UTF-8'); }); $escapedWords = array_map(function($word) { return preg_quote(htmlspecialchars($word, ENT_QUOTES, 'UTF-8'), '/'); }, $words); $pattern = '/(' . implode('|', $escapedWords) . ')/iu'; $highlighted = preg_replace($pattern, '$1', $safeText); if ($highlighted === null) { return $safeText; } return $highlighted; } /** * Get excerpt around the search term */ public function getSearchExcerpt($content, $searchTerm, $length = null) { if ($length === null) { $length = $this->getValue('excerptLength'); } $length = min(800, max(80, (int) $length)); $content = $this->plainText($content); if (empty($content)) { return ''; } $words = $this->splitWords($searchTerm); $words = array_filter($words, function($word) { return mb_strlen($word, 'UTF-8') >= 2; }); $firstPos = false; foreach ($words as $word) { $pos = mb_stripos($content, $word, 0, 'UTF-8'); 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, 'UTF-8'); $spacePos = mb_strrpos($prefix, ' ', 0, 'UTF-8'); if ($spacePos !== false) { $start = $spacePos + 1; } } $excerpt = mb_substr($content, $start, $length, 'UTF-8'); $contentLength = mb_strlen($content, 'UTF-8'); if ($start > 0) { $excerpt = '...' . $excerpt; } if ($start + $length < $contentLength) { $lastSpace = mb_strrpos($excerpt, ' ', 0, 'UTF-8'); if ($lastSpace !== false && $lastSpace > max(20, $length - 40)) { $excerpt = mb_substr($excerpt, 0, $lastSpace, 'UTF-8'); } $excerpt .= '...'; } return $excerpt; } private function plainText($text) { $text = Text::removeHTMLTags((string) $text); $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8'); $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 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); } if (!is_array($words)) { return array(); } return $words; } // Generate the cache file // This function is necessary to call it when you create, edit or remove content private function createCache() { // Get all pages published global $pages; global $categories; $list = $pages->getList($pageNumber = 1, $numberOfItems = -1, $published = true, $static = true, $sticky = true, $draft = false, $scheduled = false); $cache = array(); foreach ($list as $pageKey) { $page = buildPage($pageKey); if ($page === false) { continue; } // Process content $characterLimit = max(250, (int) $this->getValue('wordsToCachePerPage') * 6); $content = Text::truncate($this->plainText($page->content()), $characterLimit, ''); // Include the page to the cache $cache[$pageKey] = array( 'title' => $this->plainText($page->title()), 'description' => $this->plainText($page->description()), 'content' => $content, 'tags' => '', 'category' => '' ); // Add tags if enabled if ($this->getValue('searchInTags')) { $pageTags = $page->tags(true); if (!empty($pageTags)) { $cache[$pageKey]['tags'] = implode(' ', $pageTags); } } // Add category if enabled if ($this->getValue('searchInCategories')) { $categoryKey = $page->categoryKey(); if (!empty($categoryKey) && isset($categories)) { try { $category = new Category($categoryKey); $cache[$pageKey]['category'] = $this->plainText($category->name()); } catch (Exception $e) { // Category not found } } } } // Generate JSON file with the cache $json = json_encode($cache, JSON_UNESCAPED_UNICODE); return file_put_contents($this->cacheFile(), $json, LOCK_EX); } // Returns the absolute path of the cache file private function cacheFile() { return $this->workspace() . 'cache.json'; } // Search text inside the cache // Returns an array with the pages keys related to the text // The array is sorted by score private function search($text) { $text = trim($text); // Check minimum characters $minChars = max(1, (int) $this->getValue('minChars')); if (mb_strlen($text, 'UTF-8') < $minChars) { return array(); } // Read the cache file $cacheFile = $this->cacheFile(); if (!file_exists($cacheFile)) { $this->createCache(); } $json = file_get_contents($cacheFile); $cache = json_decode($json, true); if (!is_array($cache)) { $this->createCache(); $json = file_get_contents($cacheFile); $cache = json_decode($json, true); } if (empty($cache) || !is_array($cache)) { return array(); } if ($this->cacheContainsStoredSearchFields($cache)) { $this->createCache(); $json = file_get_contents($cacheFile); $cache = json_decode($json, true); if (empty($cache) || !is_array($cache)) { return array(); } } $terms = $this->tokenizeSearchTerm($text); if (empty($terms)) { return array(); } $scores = array(); foreach ($cache as $pageKey => $data) { if (!is_array($data)) { continue; } $score = $this->scoreSearchResult($data, $text, $terms); if ($score > 0) { $scores[$pageKey] = $score; } } arsort($scores, SORT_NUMERIC); return array_keys($scores); } private function cacheContainsStoredSearchFields($cache) { foreach ($cache as $data) { return is_array($data) && isset($data['search']); } return false; } private function buildSearchFields($data) { $fields = array( 'title' => $this->normalizeSearchText(isset($data['title']) ? $data['title'] : ''), 'description' => $this->normalizeSearchText(isset($data['description']) ? $data['description'] : ''), 'content' => $this->normalizeSearchText(isset($data['content']) ? $data['content'] : ''), 'tags' => $this->normalizeSearchText(isset($data['tags']) ? $data['tags'] : ''), 'category' => $this->normalizeSearchText(isset($data['category']) ? $data['category'] : '') ); $fields['all'] = trim(implode(' ', $fields)); return $fields; } private function searchableFields($data) { if (isset($data['search']) && is_array($data['search'])) { return $data['search'] + array( 'title' => '', 'description' => '', 'content' => '', 'tags' => '', 'category' => '', 'all' => '' ); } return $this->buildSearchFields($data); } private function normalizeSearchText($text) { $text = $this->plainText($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('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text); if ($transliterated !== false) { $text = $transliterated; } } $text = mb_strtolower($text, 'UTF-8'); $normalized = preg_replace('/[^\p{L}\p{N}]+/u', ' ', $text); if ($normalized === null) { $normalized = preg_replace('/[^a-z0-9]+/i', ' ', $text); } if ($normalized === null) { $normalized = $text; } $collapsed = preg_replace('/\s+/u', ' ', $normalized); if ($collapsed === null) { $collapsed = preg_replace('/\s+/', ' ', $normalized); } if ($collapsed === null) { $collapsed = $normalized; } return trim((string) $collapsed); } private function tokenizeSearchTerm($text) { $normalized = $this->normalizeSearchText($text); $words = $this->splitWords($normalized); $tokens = array(); foreach ($words as $word) { if (mb_strlen($word, 'UTF-8') >= 2) { $tokens[$word] = true; } } return array_keys($tokens); } private function scoreSearchResult($data, $query, $terms) { $fields = $this->searchableFields($data); $query = $this->normalizeSearchText($query); $score = 0; if (!empty($query)) { $phraseWeights = array( 'title' => 220, 'tags' => 180, 'category' => 160, 'description' => 110, 'content' => 70 ); foreach ($phraseWeights as $field => $weight) { if (!empty($fields[$field]) && strpos($fields[$field], $query) !== false) { $score += $weight + min(60, $this->countOccurrences($fields[$field], $query) * 8); } } } $matchedTerms = 0; foreach ($terms as $term) { $termScore = 0; $termScore += $this->scoreTermInField($fields['title'], $term, 95); $termScore += $this->scoreTermInField($fields['tags'], $term, 80); $termScore += $this->scoreTermInField($fields['category'], $term, 75); $termScore += $this->scoreTermInField($fields['description'], $term, 45); $termScore += $this->scoreTermInField($fields['content'], $term, 25); if ($termScore === 0) { $termScore += $this->fuzzyTermScore($term, trim($fields['title'] . ' ' . $fields['tags'] . ' ' . $fields['category'] . ' ' . $fields['description']), 24); } if ($termScore > 0) { $matchedTerms++; $score += $termScore; } } if ($matchedTerms === 0) { return 0; } if (count($terms) > 1) { if ($matchedTerms === count($terms)) { $score += 45; } else { $score -= (count($terms) - $matchedTerms) * 25; } } return max(0, $score); } private function scoreTermInField($field, $term, $weight) { if (empty($field)) { return 0; } $quoted = preg_quote($term, '/'); $fullMatches = preg_match_all('/(^| )' . $quoted . '(?= |$)/u', $field, $matches); if ($fullMatches > 0) { return $weight + min(40, $fullMatches * 6); } if (mb_strlen($term, 'UTF-8') >= 4 && strpos($field, $term) !== false) { return (int) ($weight * 0.55) + min(25, $this->countOccurrences($field, $term) * 4); } return 0; } private function countOccurrences($field, $needle) { if ($needle === '') { return 0; } return substr_count($field, $needle); } private function fuzzyTermScore($term, $field, $weight) { if (mb_strlen($term, 'UTF-8') < 4 || empty($field)) { return 0; } $words = $this->splitWords($field); $bestScore = 0; $checked = array(); $checkedCount = 0; $termLength = strlen($term); $allowedDistance = ($termLength <= 5) ? 1 : 2; foreach ($words as $word) { if (isset($checked[$word])) { continue; } $checked[$word] = true; if (strlen($word) > 64 || abs(strlen($word) - $termLength) > $allowedDistance) { continue; } $checkedCount++; if ($checkedCount > 250) { break; } $distance = levenshtein($term, $word); if ($distance <= $allowedDistance) { $bestScore = max($bestScore, $weight - ($distance * 5)); } } return $bestScore; } }