diff --git a/README.md b/README.md index 0c1270d..a85bb83 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,17 @@ `SimpleSearch` is a simple, yet very powerful [Grav][grav] plugin that adds search capabilities to your Grav instance. By default it can search Page **Titles**, **Content**, **Taxonomy**, and also a raw page **Header**. +## Key Features + +* **Flexible Searching:** Search page titles, content, taxonomy, and raw page headers. +* **AJAX Search Suggestions (Autocomplete):** Provides instant search suggestions as you type for a faster, more interactive search experience. +* **AJAX Search Results:** Optionally, load full search results dynamically on the current page without a full page reload. +* **Search Term Highlighting:** Automatically highlights the searched terms in the results (titles and snippets) for improved visibility. +* **Pagination:** Search results are paginated in both traditional (server-rendered) and AJAX modes. +* **Configurable:** Fine-tune search behavior with various options, including filters, content source (rendered HTML vs. raw Markdown), and more. +* **Customizable Templates:** Easily customize the appearance of the search box and search results. +* **Event for Extensibility:** Hook into SimpleSearch to add custom data sources to your search results. + # Installation Installing the SimpleSearch plugin can be done in one of two ways. Our GPM (Grav Package Manager) installation method enables you to quickly and easily install the plugin with a simple terminal command, while the manual method enables you to do so via a zip file. @@ -52,6 +63,63 @@ searchable_types: taxonomy: true header: false header_keys_ignored: ['title', 'taxonomy','content', 'form', 'forms', 'media_order'] + enable_search_suggestions: true + min_query_length_suggestions: 3 + max_suggestions: 5 + enable_ajax_search: false + per_page: 10 +``` + +By creating the configuration file: `user/config/plugins/simplesearch.yaml` you have effectively created a site-wide configuration for SimpleSearch. Most of these options can also be configured via the Grav Admin Panel. + +Below is a detailed explanation of the configuration options: + +* `enabled`: (Default: `true`) Set to `false` to disable the plugin. +* `built_in_css`: (Default: `true`) Use the plugin's built-in CSS for styling the search box and results. Disable if you want to provide all styles from your theme. +* `built_in_js`: (Default: `true`) Use the plugin's built-in JavaScript for AJAX suggestions and results. +* `display_button`: (Default: `false`) Show a submit button next to the search input field. +* `min_query_length`: (Default: `3`) Minimum number of characters required in the search input before a search is performed (for non-AJAX traditional search). +* `route`: (Default: `/search`) The base route for displaying search results (for non-AJAX traditional search). +* `search_content`: (Default: `rendered`) Determines what content is searched: + * `rendered`: Searches the fully rendered HTML content of the page (slower, but includes content generated by Twig or other plugins). + * `raw`: Searches the raw Markdown content of the page (faster, but might miss dynamically generated content). +* `template`: (Default: `simplesearch_results`) The Twig template used to display the search results page (for non-AJAX traditional search). +* `filters`: (Default: `category: null`) Allows you to restrict searching to pages that match certain taxonomy filters. For example, `category: blog` would only search in pages with the category `blog`. Set to `@none` or leave empty to search all pages. +* `filter_combinator`: (Default: `and`) If multiple filters are specified, this determines how they are combined: + * `and`: All filters must match. + * `or`: Any of the filters can match. +* `ignore_accented_characters`: (Default: `false`) If true, searches will be accent-insensitive (e.g., "cafe" will match "café"). Requires the `en_US` locale to be installed on the server. +* `order.by`: (Default: `date`) How search results should be ordered. Common options: `date`, `title`, `folder`, `default` (page's default collection order). +* `order.dir`: (Default: `desc`) Direction of ordering: `asc` (ascending) or `desc` (descending). +* `searchable_types`: Defines which parts of a page are searched: + * `title`: (Default: `true`) Search page titles. + * `content`: (Default: `true`) Search page content (respects `search_content` setting). + * `taxonomy`: (Default: `true`) Search page taxonomy values. + * `header`: (Default: `false`) Search raw page headers (excluding those in `header_keys_ignored`). +* `header_keys_ignored`: (Default: `['title', 'taxonomy','content', 'form', 'forms', 'media_order']`) A list of page header keys to ignore when `searchable_types.header` is enabled. + +### Search Suggestions (Autocomplete) +These options control the AJAX-powered search suggestions that appear as you type. +* **`enable_search_suggestions`**: (Default: `true`) Set to `false` to disable search suggestions. +* **`min_query_length_suggestions`**: (Default: `3`) The minimum number of characters a user needs to type before suggestions are fetched and displayed. +* **`max_suggestions`**: (Default: `5`) The maximum number of search suggestions to display in the dropdown. + +### AJAX Search Results +This feature allows search results to be loaded and displayed directly on the current page without a full page reload. +* **`enable_ajax_search`**: (Default: `false`) Set to `true` to enable this mode. If `false`, submitting the search form will redirect to the traditional search results page defined by the `route` option. + +### Pagination +These options control how search results are paginated. +* **`per_page`**: (Default: `10`) The number of search results to display on each page. This applies to both traditional (server-rendered) and AJAX search results. + +### Search Term Highlighting +Search terms are automatically highlighted in the search results (titles and content snippets) using `` HTML tags. This feature is enabled by default and works for both AJAX and non-AJAX results. +You can customize the appearance of these highlights using CSS. For example, to change the default yellow background: +```css +mark { + background-color: lightblue; + color: black; +} ``` By creating the configuration file: `user/config/plugins/simplesearch.yaml` you have effectively created a site-wide configuration for SimpleSearch. However, you may want to have multiple searches. diff --git a/blueprints.yaml b/blueprints.yaml index 2c98b5d..6fa3187 100644 --- a/blueprints.yaml +++ b/blueprints.yaml @@ -98,6 +98,60 @@ form: type: number min: 0 + enable_search_suggestions: + type: toggle + label: PLUGIN_SIMPLESEARCH.ENABLE_SEARCH_SUGGESTIONS + help: PLUGIN_SIMPLESEARCH.ENABLE_SEARCH_SUGGESTIONS_HELP + highlight: 1 + default: 0 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + min_query_length_suggestions: + type: text + size: x-small + label: PLUGIN_SIMPLESEARCH.MIN_QUERY_LENGTH_SUGGESTIONS + help: PLUGIN_SIMPLESEARCH.MIN_QUERY_LENGTH_SUGGESTIONS_HELP + default: 3 + validate: + type: number + min: 0 + + max_suggestions: + type: text + size: x-small + label: PLUGIN_SIMPLESEARCH.MAX_SUGGESTIONS + help: PLUGIN_SIMPLESEARCH.MAX_SUGGESTIONS_HELP + default: 5 + validate: + type: number + min: 1 + + enable_ajax_search: + type: toggle + label: PLUGIN_SIMPLESEARCH.ENABLE_AJAX_SEARCH + help: PLUGIN_SIMPLESEARCH.ENABLE_AJAX_SEARCH_HELP + highlight: 1 + default: 0 + options: + 1: PLUGIN_ADMIN.ENABLED + 0: PLUGIN_ADMIN.DISABLED + validate: + type: bool + + per_page: + type: text + size: x-small + label: PLUGIN_SIMPLESEARCH.PER_PAGE + help: PLUGIN_SIMPLESEARCH.PER_PAGE_HELP + default: 10 + validate: + type: number + min: 1 + route: type: text size: medium diff --git a/css/simplesearch.css b/css/simplesearch.css index 3d0eba7..fe16e5b 100644 --- a/css/simplesearch.css +++ b/css/simplesearch.css @@ -39,4 +39,94 @@ .search-row:last-child hr { display: none; +} + +/* Search Suggestions Styles */ +.search-wrapper { + position: relative; /* Important for absolute positioning of suggestions */ +} + +.simplesearch-suggestions-container { + position: absolute; + left: 0; + /* Assuming the input is 80% and inside .search-wrapper. + If .search-input is directly inside .search-wrapper, this might need adjustment + or make .search-input 100% of .search-wrapper if that's the layout. + For now, let's assume it should span the same width as .search-input approximately. + A more robust way would be to set this width via JS based on the input's actual width. + Given the current .search-input width: 80%, this will make it 80% of the .search-wrapper. + */ + width: 80%; + top: 100%; /* Position it right below the input field */ + background-color: #fff; + border: 1px solid #ccc; + border-top: none; + z-index: 1000; /* Ensure it's above other content */ + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + max-height: 300px; + overflow-y: auto; +} + +ul.simplesearch-suggestions-list { + list-style-type: none; + margin: 0; + padding: 0; +} + +li.simplesearch-suggestion-item a { + display: block; + padding: 8px 12px; + text-decoration: none; + color: #333; + border-bottom: 1px solid #eee; /* Separator for items */ +} + +li.simplesearch-suggestion-item:last-child a { + border-bottom: none; +} + +li.simplesearch-suggestion-item a:hover { + background-color: #f5f5f5; +} + +/* Search Term Highlighting */ +mark { + background-color: yellow; + color: black; + padding: 0.1em 0.2em; /* Slightly less padding than example for subtlety */ + border-radius: 3px; /* Optional: slightly rounded corners */ +} + +/* Specificity for simplesearch results if needed */ +.simplesearch .search-item mark { + /* Styles here would override generic mark if needed, e.g. different color */ + /* For now, the generic mark style is likely fine */ +} + +/* Pagination Styles */ +.simplesearch-pagination { + margin-top: 20px; + text-align: center; +} + +.simplesearch-pagination a, +.simplesearch-pagination span.current { + padding: 5px 10px; + margin: 0 2px; + border: 1px solid #ddd; + text-decoration: none; + color: #337ab7; /* A common link color */ + background-color: #fff; + border-radius: 4px; +} + +.simplesearch-pagination span.current { + background-color: #eee; + color: #333; + border-color: #ccc; +} + +.simplesearch-pagination a:hover { + background-color: #f5f5f5; + border-color: #bbb; } \ No newline at end of file diff --git a/js/simplesearch.js b/js/simplesearch.js index ce0f7a8..df0687a 100644 --- a/js/simplesearch.js +++ b/js/simplesearch.js @@ -7,26 +7,252 @@ return el; }; - var fields = document.querySelectorAll('input[name="searchfield"][data-search-input]'); - Array.prototype.forEach.call(fields, function(field) { - var form = findAncestor(field, 'form[data-simplesearch-form]'), - min = field.getAttribute('data-min') || false, - location = field.getAttribute('data-search-input'), - separator = field.getAttribute('data-search-separator'); - - if (min) { - var invalid = field.getAttribute('data-search-invalid'); - field.addEventListener('keydown', function() { - field.setCustomValidity(field.value.length >= min ? '' : invalid); + const forms = document.querySelectorAll('form[data-simplesearch-form]'); + + forms.forEach(function(form) { + const field = form.querySelector('input[data-search-input]'); + if (!field) return; + + const minChars = field.getAttribute('data-min') || false; + const searchBaseUrl = field.dataset.searchInput; // e.g., ".../search/query" - used for non-AJAX + const paramSep = field.dataset.searchSeparator; + const ajaxEnabled = form.dataset.ajaxSearchEnabled === 'true'; + + // For AJAX full results, the base URL for query needs to be constructed slightly differently + // It should not contain '/query' itself if we append '/query:searchTerm' + // Example: if data-search-input is "/base/search/query", we want "/base/search" + let ajaxBaseUrl = searchBaseUrl; + if (ajaxBaseUrl.endsWith('/query')) { + ajaxBaseUrl = ajaxBaseUrl.substring(0, ajaxBaseUrl.lastIndexOf('/query')); + } + + + if (minChars) { + const invalidMessage = field.getAttribute('data-search-invalid'); + field.addEventListener('input', function() { // Changed from keydown to input for better UX with custom validity + field.setCustomValidity(field.value.length >= minChars ? '' : invalidMessage); }); } form.addEventListener('submit', function(event) { - event.preventDefault(); + const query = field.value.trim(); + if (!field.checkValidity() || !query) { + // If query is empty or field is invalid, prevent AJAX and let browser handle (or do nothing) + if(!query && field.hasAttribute('required')) event.preventDefault(); // Prevent empty required field submission + else if (!field.checkValidity()) event.preventDefault(); // Prevent invalid submission + return; + } + + event.preventDefault(); // Prevent default for all valid submissions initially - if (field.checkValidity()) { - window.location.href = location + separator + field.value; + if (ajaxEnabled) { + let ajaxUrl = ajaxBaseUrl + paramSep + encodeURIComponent(query) + '/ajax_results:1'; + fetchResults(ajaxUrl, query); // Pass query for display purposes + } else { + // Fallback to original behavior if AJAX is not enabled + window.location.href = searchBaseUrl + paramSep + encodeURIComponent(query); } }); + + // --- Search suggestions logic (mostly as before) --- + let suggestionsContainer; + let debounceTimer; + const minLengthSuggestions = parseInt(field.dataset.minSuggestions) || 3; + const suggestionsUrl = field.dataset.suggestionsUrl; + + if (suggestionsUrl && field.parentNode) { // Check parentNode for safety + suggestionsContainer = document.createElement('div'); + suggestionsContainer.classList.add('simplesearch-suggestions-container'); + // Insert after the parent of the input field (div.search-wrapper) + if (field.parentNode.parentNode) { // Ensure div.search-wrapper exists + field.parentNode.parentNode.insertBefore(suggestionsContainer, field.parentNode.nextSibling); + } else { // Fallback if structure is simpler + field.parentNode.insertBefore(suggestionsContainer, field.nextSibling); + } + + field.addEventListener('input', function() { + clearTimeout(debounceTimer); + const query = this.value.trim(); + + if (query.length < minLengthSuggestions) { + if (suggestionsContainer) clearSuggestions(); + return; + } + + debounceTimer = setTimeout(function() { + fetch(suggestionsUrl + '?query=' + encodeURIComponent(query), { + method: 'GET', + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }) + .then(response => { + if (!response.ok) throw new Error('Network response was not ok for suggestions'); + return response.json(); + }) + .then(data => { + if (suggestionsContainer) displaySuggestions(data); + }) + .catch(error => { + console.error('Error fetching search suggestions:', error); + if (suggestionsContainer) clearSuggestions(); + }); + }, 250); + }); + + document.addEventListener('click', function(event) { + if (suggestionsContainer && !field.contains(event.target) && !suggestionsContainer.contains(event.target)) { + clearSuggestions(); + } + }); + } + + function displaySuggestions(suggestions) { + if (!suggestionsContainer) return; + clearSuggestions(); + if (suggestions && suggestions.length > 0) { + const ul = document.createElement('ul'); + ul.classList.add('simplesearch-suggestions-list'); + suggestions.forEach(function(suggestion) { + const li = document.createElement('li'); + li.classList.add('simplesearch-suggestion-item'); + const a = document.createElement('a'); + a.href = suggestion.url; + a.textContent = suggestion.title; + li.appendChild(a); + ul.appendChild(li); + }); + suggestionsContainer.appendChild(ul); + } + } + + function clearSuggestions() { + if (suggestionsContainer) { + suggestionsContainer.innerHTML = ''; + } + } }); + + // --- AJAX Full Results Functions --- + function fetchResults(url, queryForDisplay) { + const resultsContainer = document.getElementById('simplesearch-ajax-results-container'); + if (!resultsContainer) { + console.error('AJAX results container (#simplesearch-ajax-results-container) not found.'); + // Fallback to standard navigation if container is missing and it's a form submission context + // This is tricky here, as fetchResults is called after preventDefault. + // A more robust solution would be to check for container existence *before* preventDefault. + // For now, just log error. + return; + } + resultsContainer.innerHTML = '

Loading...

'; // Simple loading indicator + + fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } }) + .then(response => { + if (!response.ok) throw new Error('Network response was not ok for full results.'); + return response.json(); + }) + .then(data => { + renderSearchResults(data, resultsContainer, url); + }) + .catch(error => { + console.error('Error fetching search results:', error); + resultsContainer.innerHTML = `

Error loading results. Query: ${queryForDisplay || ''}

`; + }); + } + + function renderSearchResults(data, container, baseUrlForPagination) { + container.innerHTML = ''; // Clear loading/previous results + + if (data.results && data.results.length > 0) { + const summary = document.createElement('p'); + summary.className = 'simplesearch-results-summary'; // Added class for styling + // Use the query from data if available, otherwise it might be undefined if not passed to fetchResults + const displayQuery = data.query || (baseUrlForPagination.includes(paramSep) ? decodeURIComponent(baseUrlForPagination.split(paramSep).pop().split('/')[0]) : ''); + + if (data.pagination.total_results === 1) { + summary.innerHTML = `Query: ${displayQuery} found one result`; // Mimic Twig + } else { + summary.innerHTML = `Query: ${displayQuery} found ${data.pagination.total_results} results`; // Mimic Twig + } + container.appendChild(summary); + + data.results.forEach(result => { + const itemDiv = document.createElement('div'); + itemDiv.classList.add('search-item'); + + const titleH3 = document.createElement('h3'); + titleH3.classList.add('search-title'); + const link = document.createElement('a'); + link.href = result.url; + link.innerHTML = result.title; // Changed from textContent to innerHTML + titleH3.appendChild(link); + itemDiv.appendChild(titleH3); + + const snippetP = document.createElement('p'); + snippetP.innerHTML = result.content_snippet; // Use innerHTML as snippet might have highlights later + itemDiv.appendChild(snippetP); + + container.appendChild(itemDiv); + }); + + if (data.pagination && data.pagination.total_pages > 0) { + renderPagination(data.pagination, container, baseUrlForPagination); + } + } else { + const displayQuery = data.query || (baseUrlForPagination.includes(paramSep) ? decodeURIComponent(baseUrlForPagination.split(paramSep).pop().split('/')[0]) : ''); + container.innerHTML = `

No results found for "${displayQuery}".

`; + } + } + + function renderPagination(paginationData, container, baseUrl) { + if (paginationData.total_pages <= 1) return; // No pagination if only one page + + const paginationDiv = document.createElement('div'); + paginationDiv.classList.add('simplesearch-pagination'); + + const paramSep = document.querySelector('input[data-search-input]')?.dataset.searchSeparator || ':'; + + + if (paginationData.current_page > 1) { + const prevLink = document.createElement('a'); + prevLink.href = '#'; + prevLink.textContent = '<< Previous'; + prevLink.addEventListener('click', (e) => { + e.preventDefault(); + fetchResults(updateQueryParam(baseUrl, 'page', paginationData.current_page - 1, paramSep), null); + }); + paginationDiv.appendChild(prevLink); + paginationDiv.appendChild(document.createTextNode(' ')); + } + + const pageInfo = document.createElement('span'); + pageInfo.textContent = `Page ${paginationData.current_page} of ${paginationData.total_pages}`; + paginationDiv.appendChild(pageInfo); + + if (paginationData.current_page < paginationData.total_pages) { + paginationDiv.appendChild(document.createTextNode(' ')); + const nextLink = document.createElement('a'); + nextLink.href = '#'; + nextLink.textContent = 'Next >>'; + nextLink.addEventListener('click', (e) => { + e.preventDefault(); + fetchResults(updateQueryParam(baseUrl, 'page', paginationData.current_page + 1, paramSep), null); + }); + paginationDiv.appendChild(nextLink); + } + container.appendChild(paginationDiv); + } + + function updateQueryParam(url, key, value, paramSeparator) { + // Ensure paramSeparator is defined, default to ':' if not. + const sep = paramSeparator || ':'; + // Remove existing key parameter, ensuring it's a whole segment + const keyPattern = new RegExp("(\\/)" + key + sep + "[^\\/]+", "i"); + let newUrl = url.replace(keyPattern, ""); + + // Add the new key parameter + // Ensure no double slashes if url ends with / after stripping + if (newUrl.slice(-1) === '/') newUrl = newUrl.slice(0, -1); + newUrl += "/" + key + sep + value; + return newUrl; + } + })()); diff --git a/languages.yaml b/languages.yaml index 1ba12a4..7207c7d 100644 --- a/languages.yaml +++ b/languages.yaml @@ -31,6 +31,18 @@ en: SEARCHABLE_TYPES_DESCRIPTION: "Title = Search Page Title
Content = Search Page Content
Header = Search Raw Page Headers
Taxonomy = Search Taxonomy" HEADER_KEYS_IGNORED: Header Keys to Ignore HEADER_KEYS_IGNORED_HELP: The root-level header keys that should be skipped when searching type "Header" + ENABLE_SEARCH_SUGGESTIONS: Enable search suggestions + ENABLE_SEARCH_SUGGESTIONS_HELP: Enable AJAX-based search suggestions + MIN_QUERY_LENGTH_SUGGESTIONS: Minimum query length for suggestions + MIN_QUERY_LENGTH_SUGGESTIONS_HELP: The minimum number of characters to trigger search suggestions + MAX_SUGGESTIONS: Maximum suggestions + MAX_SUGGESTIONS_HELP: The maximum number of search suggestions to display + ENABLE_AJAX_SEARCH: Enable AJAX Search Results + ENABLE_AJAX_SEARCH_HELP: If enabled, search results can be fetched via AJAX and returned as JSON. + PER_PAGE: Results Per Page + PER_PAGE_HELP: How many search results to display per page (used for pagination, primarily with AJAX search). + PREVIOUS: "Previous" + NEXT: "Next" ro: PLUGIN_SIMPLESEARCH: diff --git a/simplesearch.php b/simplesearch.php index b635b57..a1040de 100644 --- a/simplesearch.php +++ b/simplesearch.php @@ -28,6 +28,11 @@ class SimplesearchPlugin extends Plugin */ protected $collection; + /** + * @var ?array + */ + protected $pagination_details = null; + /** * @return array */ @@ -37,6 +42,7 @@ public static function getSubscribedEvents() 'onPluginsInitialized' => ['onPluginsInitialized', 0], 'onTwigTemplatePaths' => ['onTwigTemplatePaths', 0], 'onGetPageTemplates' => ['onGetPageTemplates', 0], + 'onTask.simplesearch.searchSuggestions' => ['onAjaxSearchSuggestions', 0], ]; } @@ -249,7 +255,96 @@ public function onPagesInitialized() ); } - // Display simplesearch page if no page was found for the current route + // Determine if this is an AJAX request for full results + $is_ajax_request = (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest'); + $is_ajax_results_request = $is_ajax_request && + $this->config->get('plugins.simplesearch.enable_ajax_search') && + ($uri->param('ajax_results') || $uri->query('ajax_results')); + + if ($this->query) { // Only proceed if there's a query + if ($is_ajax_results_request) { + // AJAX full results processing + $output_results = []; + // For AJAX, we send all results and let client handle pagination based on this data, + // or the client can send a page param which we'd use to slice $this->collection. + // Current JS sends all results and paginates client-side, so no server-side slice for AJAX here. + // However, the pagination data should still reflect the full set. + $total_results_ajax = $this->collection->count(); + $per_page_ajax = (int)$this->config->get('plugins.simplesearch.per_page', 10); + $current_page_ajax = (int)($uri->param('page') ?: $uri->query('page') ?: 1); // Client might send this + $total_pages_ajax = $total_results_ajax > 0 ? ceil($total_results_ajax / $per_page_ajax) : 0; + + foreach ($this->collection as $cpage) { // Iterate over potentially full collection for AJAX + $page_content_raw = $this->config->get('plugins.simplesearch.search_content', 'rendered') === 'raw' + ? $cpage->rawMarkdown() + : $cpage->content(); + $snippet_plain = mb_substr(strip_tags($page_content_raw), 0, 200) . '...'; + + $output_results[] = [ + 'title' => $this->highlightQueryTerms($cpage->title(), $this->query), + 'url' => $cpage->url(), + 'content_snippet' => $this->highlightQueryTerms($snippet_plain, $this->query), + ]; + } + // If server-side pagination for AJAX is desired in future: + // $offset = ($current_page_ajax - 1) * $per_page_ajax; + // $output_results = array_slice($output_results, $offset, $per_page_ajax); + + $pagination_data_ajax = [ + 'total_results' => $total_results_ajax, + 'per_page' => $per_page_ajax, + 'current_page' => $current_page_ajax, + 'total_pages' => $total_pages_ajax, + ]; + + header('Content-Type: application/json'); + echo json_encode([ + 'query' => implode(', ', $this->query), + 'results' => $output_results, // This might be the full set or sliced if server-side AJAX pagination + 'pagination' => $pagination_data_ajax, + ]); + exit; + } else { + // Non-AJAX HTML results: Paginate the collection server-side + $current_page = (int)($uri->param('page') ?: $uri->query('page') ?: 1); + $per_page = (int)$this->config->get('plugins.simplesearch.per_page', 10); + $total_results = $this->collection->count(); + + if ($total_results > 0) { + $total_pages = ceil($total_results / $per_page); + if ($current_page < 1) $current_page = 1; + if ($current_page > $total_pages) $current_page = $total_pages; + + $this->collection = $this->collection->slice(($current_page - 1) * $per_page, $per_page); + + $this->pagination_details = [ + 'total_results' => $total_results, + 'current_page' => $current_page, + 'per_page' => $per_page, + 'total_pages' => $total_pages, + // base_url will be added in onTwigSiteVariables using page context + ]; + } else { + $this->pagination_details = [ // Still set empty pagination data + 'total_results' => 0, + 'current_page' => 1, + 'per_page' => $per_page, + 'total_pages' => 0, + ]; + } + } + } else { // No query + $this->pagination_details = [ + 'total_results' => 0, + 'current_page' => 1, + 'per_page' => (int)$this->config->get('plugins.simplesearch.per_page', 10), + 'total_pages' => 0, + ]; + } + + + // Display simplesearch page if no page was found for the current route (for non-AJAX requests) + // This part should only run for non-AJAX requests. AJAX requests would have exited. $pages = $this->grav['pages']; $page = $pages->dispatch($this->config->get('plugins.simplesearch.route', '/search'), true); if (!isset($page)) { @@ -408,9 +503,79 @@ public function onTwigSiteVariables() { $twig = $this->grav['twig']; - if ($this->query) { + if ($this->query && isset($this->collection)) { $twig->twig_vars['query'] = implode(', ', $this->query); $twig->twig_vars['search_results'] = $this->collection; + + // Prepare highlighted versions for non-AJAX display + // Check if this is NOT an AJAX request for full results, as that's handled separately + $uri = $this->grav['uri']; + $is_ajax_request = (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest'); + $is_ajax_results_request = $is_ajax_request && + $this->config->get('plugins.simplesearch.enable_ajax_search') && + ($uri->param('ajax_results') || $uri->query('ajax_results')); + + if (!$is_ajax_results_request && $this->collection->count() > 0) { + $highlighted_titles = []; + $highlighted_snippets = []; + // $this->collection is now paginated for non-AJAX requests + foreach ($this->collection as $page_item) { // Renamed to avoid conflict with outer $page + $page_content_raw = $this->config->get('plugins.simplesearch.search_content', 'rendered') === 'raw' + ? $page_item->rawMarkdown() + : $page_item->content(); + $snippet_plain = mb_substr(strip_tags($page_content_raw), 0, 200) . '...'; + + $highlighted_titles[$page_item->path()] = $this->highlightQueryTerms($page_item->title(), $this->query); + $highlighted_snippets[$page_item->path()] = $this->highlightQueryTerms($snippet_plain, $this->query); + } + $twig->twig_vars['highlighted_titles'] = $highlighted_titles; + $twig->twig_vars['highlighted_snippets'] = $highlighted_snippets; + } + + // Pass pagination details to Twig for non-AJAX requests + if (!$is_ajax_results_request && $this->pagination_details) { + $current_search_page = $this->grav['page']; // This should be the search results page itself + $this->pagination_details['base_url'] = $current_search_page->url(); + // Ensure query parameters are part of the base_url for pagination links if using standard URL query params + // However, Grav uses segment params, so `uri.params` will be appended in Twig. + // For segment based like /query:foo, the base_url should be /search-results-page/query:foo + // And then /page:N is added. + // If $current_search_page->url() is just /search-results-page, then we need to add query params. + // Let's build the base_url for pagination to include the query params correctly. + + $base_pagination_url = $current_search_page->route(); + $query_params_for_link = []; + if ($this->query) { + // Assuming $this->query is an array of terms and we want to pass it as a single 'query' param + $query_string = implode(' ', $this->query); // Or how it was originally passed + // Check if $uri->params() already contains the query. + $current_uri_params = $uri->params(null, true); // Get as array + if (isset($current_uri_params['query'])) { + $base_pagination_url = rtrim($current_search_page->url(), '/'); + // remove /page:X if it exists from current url for base + $base_pagination_url = preg_replace('/\/page' . preg_quote($this->grav['config']->get('system.param_sep')) . '\d+$/', '', $base_pagination_url); + + } else { + // This case might not happen if route is /search/query:myterm + // If route is just /search and query is from ?query=myterm, then this is needed. + // For now, assuming Grav's segment based routing is primary. + // $base_pagination_url .= $this->grav['config']->get('system.param_sep') . 'query' . $this->grav['config']->get('system.param_sep') . urlencode(implode(' ', $this->query)); + } + } + $this->pagination_details['base_url'] = $base_pagination_url; + + + $twig->twig_vars['pagination'] = $this->pagination_details; + } elseif (!$is_ajax_results_request && !$this->pagination_details && $this->query) { + // Case where query was made, but no results, still provide empty pagination structure for Twig + $twig->twig_vars['pagination'] = [ + 'total_results' => 0, + 'current_page' => 1, + 'per_page' => (int)$this->config->get('plugins.simplesearch.per_page', 10), + 'total_pages' => 0, + 'base_url' => $this->grav['page']->url() + ]; + } } if ($this->config->get('plugins.simplesearch.built_in_css')) { @@ -450,4 +615,87 @@ protected function getArrayValues($array, $ignore_keys = null, $level = 0) { } return trim($output); } + + /** + * Handles AJAX requests for search suggestions. + * + * @param Event $event + * @return void + */ + public function onAjaxSearchSuggestions(Event $event) + { + // Ensure this is an AJAX request + if (empty($_SERVER['HTTP_X_REQUESTED_WITH']) || strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) !== 'xmlhttprequest') { + // Not an AJAX request + return; + } + + $query_param = trim(strtolower($this->grav['uri']->param('query', $this->grav['uri']->query('query','')))); // Also check actual query + $min_query_length = $this->config->get('plugins.simplesearch.min_query_length_suggestions', 3); + + if (strlen($query_param) < $min_query_length) { + header('Content-Type: application/json'); + echo json_encode([]); + exit; + } + + $suggestions = []; + $max_suggestions = $this->config->get('plugins.simplesearch.max_suggestions', 5); + + $this->grav['pages']->enablePages(); // Ensure pages are loaded + $pages = $this->grav['pages']->all(); + $pages->published()->routable(); + + foreach ($pages as $page) { + if (count($suggestions) >= $max_suggestions) { + break; + } + + // Using a simplified match for titles + if ($this->matchText(strip_tags($page->title()), $query_param) !== false) { + $suggestions[] = [ + // For suggestions, we usually don't highlight, but if we wanted to: + // 'title' => $this->highlightQueryTerms($page->title(), [$query_param]), + 'title' => $page->title(), + 'url' => $page->url(), + ]; + } + } + + header('Content-Type: application/json'); + echo json_encode($suggestions); + exit; + } + + /** + * Highlights search terms in a given text string. + * + * @param string $text The text to highlight. + * @param array|string $query_terms The search term(s) as an array or a single string. + * @param string $tag The HTML tag to wrap around highlighted terms. + * @return string The text with search terms highlighted. + */ + private function highlightQueryTerms($text, $query_terms, $tag = 'mark') { + if (empty($query_terms) || empty(trim((string)$text))) { + return $text; + } + if (!is_array($query_terms)) { + $query_terms = [$query_terms]; + } + + foreach ($query_terms as $term) { + $term = trim($term); + if (empty($term)) { + continue; + } + + $escapedTerm = preg_quote($term, '/'); + // Regex: + // (?![^<]*?>) -- Negative lookahead: ensures we are not inside an HTML tag's attributes. Not foolproof for all HTML. + // ( ... ) -- Capturing group for the term itself. + // /ui -- Case-insensitive (u) and Unicode (u) flags. + $text = preg_replace('/(?![^<]*?>)(' . $escapedTerm . ')/ui', "<{$tag}>$1", (string)$text); + } + return $text; + } } diff --git a/templates/partials/simplesearch_item.html.twig b/templates/partials/simplesearch_item.html.twig index feab4a6..38cbc09 100644 --- a/templates/partials/simplesearch_item.html.twig +++ b/templates/partials/simplesearch_item.html.twig @@ -9,14 +9,14 @@ {% endif %}
{{ page.date|date(config.system.pages.dateformat.short) }}
-

{{ page.summary|raw }}

+

{% if highlighted_snippets[page.path()] %}{{ highlighted_snippets[page.path()]|raw }}{% else %}{{ page.summary|slice(0, 200) ~ '...' }}{% endif %}

{# Fallback to basic summary if no snippet #}
diff --git a/templates/partials/simplesearch_searchbox.html.twig b/templates/partials/simplesearch_searchbox.html.twig index eb7dbe5..6ca1524 100644 --- a/templates/partials/simplesearch_searchbox.html.twig +++ b/templates/partials/simplesearch_searchbox.html.twig @@ -1,6 +1,6 @@ {% set min_chars = config.get('plugins.simplesearch.min_query_length', 3) %}
-
+ {% if config.plugins.simplesearch.display_button %} diff --git a/templates/simplesearch_results.html.twig b/templates/simplesearch_results.html.twig index 539a1d7..6454558 100644 --- a/templates/simplesearch_results.html.twig +++ b/templates/simplesearch_results.html.twig @@ -8,16 +8,52 @@

{% if query %} - {% set count = search_results ? search_results.count : 0 %} - {% if count is same as( 1 ) %} + {# For non-AJAX, total count comes from pagination data. For AJAX, JS will handle summary. #} + {% set total_count = pagination.total_results ?? (search_results ? search_results.count : 0) %} + {% if total_count is same as(1) %} {{ "PLUGIN_SIMPLESEARCH.SEARCH_RESULTS_SUMMARY_SINGULAR"|t(query|e)|raw }} {% else %} - {{ "PLUGIN_SIMPLESEARCH.SEARCH_RESULTS_SUMMARY_PLURAL"|t(query|e, count)|raw }} + {{ "PLUGIN_SIMPLESEARCH.SEARCH_RESULTS_SUMMARY_PLURAL"|t(query|e, total_count)|raw }} {% endif %} {% endif %}

- {% for page in search_results %} - {% include 'partials/simplesearch_item.html.twig' with {'page': page} %} - {% endfor %} + +
+ {# JavaScript will populate this if AJAX search is enabled and query is made #} + {# Fallback or initial server-rendered content for non-AJAX or no-query AJAX: #} + {% if not config.plugins.simplesearch.enable_ajax_search or not query %} + {% for page_item in search_results %} {# Renamed to avoid conflict with outer page if any #} + {% include 'partials/simplesearch_item.html.twig' with {'page': page_item} %} + {% endfor %} + {% endif %} +
+ + {# Non-AJAX Pagination Controls #} + {% if not config.plugins.simplesearch.enable_ajax_search and pagination and pagination.total_pages > 1 %} +
+ {# Previous Page Link #} + {% if pagination.current_page > 1 %} + « {{ "PLUGIN_SIMPLESEARCH.PREVIOUS"|t }} + {% else %} + « {{ "PLUGIN_SIMPLESEARCH.PREVIOUS"|t }} + {% endif %} + + {# Page Number Links #} + {% for i in 1..pagination.total_pages %} + {% if i == pagination.current_page %} + {{ i }} + {% else %} + {{ i }} + {% endif %} + {% endfor %} + + {# Next Page Link #} + {% if pagination.current_page < pagination.total_pages %} + {{ "PLUGIN_SIMPLESEARCH.NEXT"|t }} » + {% else %} + {{ "PLUGIN_SIMPLESEARCH.NEXT"|t }} » + {% endif %} +
+ {% endif %} {% endblock %}