diff --git a/README.md b/README.md index 9bb51f3..d48c233 100644 --- a/README.md +++ b/README.md @@ -51,24 +51,31 @@ Check the URL before rendering each external menu entry. The ## Documentation search **Search documentation** searches titles and page text in the selected language -with [Pagefind](https://pagefind.app/), which runs in the browser and requires -JavaScript. Results include highlighted excerpts and are limited to 50 pages. -The sidebar **Filter** still filters titles only. - -The phpbbdocs-hugo build writes one Pagefind bundle per language and section -to `/
/pagefind/`. Rebuild with `phpbbdocs_hugo.sh` after -changing content. A build without these bundles still serves pages, but search -reports that it is unavailable. - -The search page checks language and section permissions and gives the browser -only the bundles for sections the user may read. Bundle files are served through +with [Pagefind](https://pagefind.app/), which runs in the browser. Results +include highlighted excerpts. The ACP's **Search results limit** sets how many +are shown (10-200, default 50). The sidebar **Filter** still filters titles only. + +Visitors without JavaScript get server-side results instead: every word must +match, title matches come first, and the same limit applies. The server also +answers when a build has no Pagefind bundles. With JavaScript, the search form +tells the server to skip its own search and leave it to Pagefind. + +The phpbbdocs-hugo build writes, for each language and top-level section, a +Pagefind bundle to `/
/pagefind/` and a server-side index to +`/
/search-index.json`. Rebuild with `phpbbdocs_hugo.sh` after +changing content. A build with neither still serves pages, but search reports +that it is unavailable. + +The search page checks language and section permissions, searches only the +server-side indexes of sections the user may read, and gives the browser only +those sections' bundles. Bundle files are served through `/documentation-search-bundle/{lang}/{section}/{path}`, which repeats those checks for every file. Index files named by content hash may stay in the browser's private cache for the ACP's **Search index cache time** (default 60 minutes, 0 turns it off), so a user who loses access to a section can still search what they already downloaded until it expires. Separate navigation limits search to the current documentation side. Keep raw access to the build directory blocked, including -its Pagefind bundles, as described in [docs/securing-docs-build.md](docs/securing-docs-build.md). +its Pagefind bundles and search indexes, as described in [docs/securing-docs-build.md](docs/securing-docs-build.md). `styles/all/template/js/documentation-search.js` is a copy of phpbbdocs-hugo's `site/static/js/documentation-search.js`; keep the two in sync. diff --git a/acp/main_module.php b/acp/main_module.php index 88b4c6f..de86619 100644 --- a/acp/main_module.php +++ b/acp/main_module.php @@ -81,6 +81,7 @@ public function main($id, $mode) 'phpbbmodders_documentation_nav_icon_enabled' => !empty($config['phpbbmodders_documentation_nav_icon_enabled']), 'phpbbmodders_documentation_devdocs_nav_icon_enabled' => !empty($config['phpbbmodders_documentation_devdocs_nav_icon_enabled']), 'phpbbmodders_documentation_search_cache_minutes' => isset($config['phpbbmodders_documentation_search_cache_minutes']) ? (string) $config['phpbbmodders_documentation_search_cache_minutes'] : '60', + 'phpbbmodders_documentation_search_max_results' => (string) $doc_helper->get_search_max_results(), ); if ($cfg_array['phpbbmodders_documentation_fallback_lang'] === '') @@ -115,6 +116,7 @@ public function main($id, $mode) $cfg_array['phpbbmodders_documentation_nav_icon_enabled'] = $request->variable('phpbbmodders_documentation_nav_icon_enabled', false); $cfg_array['phpbbmodders_documentation_devdocs_nav_icon_enabled'] = $request->variable('phpbbmodders_documentation_devdocs_nav_icon_enabled', false); $cfg_array['phpbbmodders_documentation_search_cache_minutes'] = trim($request->variable('phpbbmodders_documentation_search_cache_minutes', '')); + $cfg_array['phpbbmodders_documentation_search_max_results'] = trim($request->variable('phpbbmodders_documentation_search_max_results', '')); // Whole minutes from 0 (no caching) to 24 hours. if (!preg_match('/\A\d{1,4}\z/', $cfg_array['phpbbmodders_documentation_search_cache_minutes']) @@ -123,9 +125,17 @@ public function main($id, $mode) $error[] = $language->lang('ACP_DOCUMENTATION_SEARCH_CACHE_MINUTES_INVALID', $doc_helper::SEARCH_CACHE_MAX_MINUTES); } + if (!preg_match('/\A\d{1,3}\z/', $cfg_array['phpbbmodders_documentation_search_max_results']) + || (int) $cfg_array['phpbbmodders_documentation_search_max_results'] < $doc_helper::SEARCH_RESULTS_MIN + || (int) $cfg_array['phpbbmodders_documentation_search_max_results'] > $doc_helper::SEARCH_RESULTS_MAX) + { + $error[] = $language->lang('ACP_DOCUMENTATION_SEARCH_MAX_RESULTS_INVALID', $doc_helper::SEARCH_RESULTS_MIN, $doc_helper::SEARCH_RESULTS_MAX); + } + if (empty($error)) { $cfg_array['phpbbmodders_documentation_search_cache_minutes'] = (int) $cfg_array['phpbbmodders_documentation_search_cache_minutes']; + $cfg_array['phpbbmodders_documentation_search_max_results'] = (int) $cfg_array['phpbbmodders_documentation_search_max_results']; foreach ($cfg_array as $key => $value) { $config->set($key, $value); @@ -175,6 +185,9 @@ public function main($id, $mode) 'S_DOCUMENTATION_DEVDOCS_NAV_ICON_ENABLED' => $cfg_array['phpbbmodders_documentation_devdocs_nav_icon_enabled'], 'DOCUMENTATION_SEARCH_CACHE_MINUTES' => $cfg_array['phpbbmodders_documentation_search_cache_minutes'], 'DOCUMENTATION_SEARCH_CACHE_MAX_MINUTES' => $doc_helper::SEARCH_CACHE_MAX_MINUTES, + 'DOCUMENTATION_SEARCH_MAX_RESULTS' => $cfg_array['phpbbmodders_documentation_search_max_results'], + 'DOCUMENTATION_SEARCH_RESULTS_MIN' => $doc_helper::SEARCH_RESULTS_MIN, + 'DOCUMENTATION_SEARCH_RESULTS_MAX' => $doc_helper::SEARCH_RESULTS_MAX, 'U_ACTION' => $this->u_action, )); diff --git a/adm/style/acp_documentation.html b/adm/style/acp_documentation.html index d6d513a..7cd203f 100644 --- a/adm/style/acp_documentation.html +++ b/adm/style/acp_documentation.html @@ -53,6 +53,10 @@

{{ lang('WARNING') }}


{{ lang('ACP_DOCUMENTATION_SEARCH_CACHE_MINUTES_EXPLAIN') }}
{{ lang('MINUTES') }}
+
+

{{ lang('ACP_DOCUMENTATION_SEARCH_MAX_RESULTS_EXPLAIN', DOCUMENTATION_SEARCH_RESULTS_MIN, DOCUMENTATION_SEARCH_RESULTS_MAX) }}
+
+

{{ lang('ACP_DOCUMENTATION_NAV_LINK_EXPLAIN') }}
diff --git a/controller/documentation_controller.php b/controller/documentation_controller.php index 9348f8a..6f9e5da 100644 --- a/controller/documentation_controller.php +++ b/controller/documentation_controller.php @@ -26,6 +26,10 @@ class documentation_controller /** Stands in for a page path in the page route given to the search script. */ const SEARCH_PATH_PLACEHOLDER = 'documentation-search-path'; + /** Search query length limits, matching the search form's minlength/maxlength. */ + const SEARCH_QUERY_MIN_LENGTH = 2; + const SEARCH_QUERY_MAX_LENGTH = 100; + /** Image types the image route serves. SVG is left out on purpose. */ const IMAGE_MIME_TYPES = array('image/png', 'image/gif', 'image/jpeg', 'image/webp', 'image/avif', 'image/bmp', 'image/x-icon'); @@ -157,9 +161,13 @@ public function handle($lang, $path) } /** - * Search page. Results come from Pagefind in the browser - * (documentation-search.js); this only passes it the search bundles - * of sections the user may read. + * Search page. With JavaScript, results come from Pagefind in the + * browser (documentation-search.js), which gets only the search + * bundles of sections the user may read. Without JavaScript, or when + * no section has a Pagefind bundle, the same sections' server-side + * indexes are searched here instead. The search form sends js=1 when + * its script runs, so the server search is skipped when Pagefind will + * replace it anyway. * * @param string $lang * @return \Symfony\Component\HttpFoundation\Response @@ -190,11 +198,24 @@ public function search($lang) { $bundles[] = $this->search_bundle_url($lang, $section); } - if (empty($bundles)) + $index_sections = $this->doc_helper->get_search_index_sections($lang, $sections); + if (empty($bundles) && empty($index_sections)) { throw new http_exception(503, 'DOCUMENTATION_SEARCH_UNAVAILABLE'); } $query = trim(htmlspecialchars_decode($this->request->variable('q', '', true), ENT_QUOTES)); + $max_results = $this->doc_helper->get_search_max_results(); + $server_search = !empty($index_sections) && ($this->request->variable('js', 0) !== 1 || empty($bundles)); + $valid = mb_strlen($query) >= self::SEARCH_QUERY_MIN_LENGTH && mb_strlen($query) <= self::SEARCH_QUERY_MAX_LENGTH; + $results = ($server_search && $valid) ? $this->doc_helper->search_pages($lang, $query, $index_sections, $max_results) : array(); + foreach ($results as $result) + { + $this->template->assign_block_vars('documentation_search_result', array( + 'TITLE' => $result['title'], + 'EXCERPT' => $result['excerpt'], + 'U_PAGE' => $result['url'], + )); + } // Pagefind returns Hugo paths (///); the search script // swaps the placeholder in this page route for each . $page_template = $this->controller_helper->route('phpbbmodders_documentation_page', @@ -203,8 +224,11 @@ public function search($lang) 'U_DOCUMENTATION_SEARCH' => $this->controller_helper->route('phpbbmodders_documentation_search', array('lang' => $lang)), 'U_DOCUMENTATION_SEARCH_BACK' => $this->route_for($lang, $scope), 'DOCUMENTATION_SEARCH_SCOPE' => $scope, - 'DOCUMENTATION_SEARCH_QUERY' => mb_substr($query, 0, 100), + 'DOCUMENTATION_SEARCH_QUERY' => mb_substr($query, 0, self::SEARCH_QUERY_MAX_LENGTH), 'DOCUMENTATION_SEARCH_BUNDLES' => json_encode($bundles), + 'DOCUMENTATION_SEARCH_MAX_RESULTS' => $max_results, + 'S_DOCUMENTATION_SEARCH_SERVER' => $server_search, + 'S_DOCUMENTATION_SEARCH_VALID' => $valid, 'DOCUMENTATION_SEARCH_LINKS' => json_encode(array( 'prefix' => '/' . $lang . '/', 'template' => $page_template, diff --git a/controller/documentation_helper.php b/controller/documentation_helper.php index e5019eb..903e671 100644 --- a/controller/documentation_helper.php +++ b/controller/documentation_helper.php @@ -86,6 +86,21 @@ class documentation_helper /** Upper limit of the ACP search cache time, in minutes (24 hours). */ const SEARCH_CACHE_MAX_MINUTES = 1440; + /** Search result limit: default, and the range the ACP accepts. */ + const SEARCH_RESULTS_DEFAULT = 50; + const SEARCH_RESULTS_MIN = 10; + const SEARCH_RESULTS_MAX = 200; + + /** Server-side search index file written by Hugo in each top-level section. */ + const SEARCH_INDEX_FILE = 'search-index.json'; + + /** Largest search index file read, so one oversized file can't exhaust PHP's memory. */ + const SEARCH_INDEX_MAX_BYTES = 16777216; + + /** Characters of page text shown before the first match in an excerpt, and the excerpt length. */ + const SEARCH_EXCERPT_LEAD = 60; + const SEARCH_EXCERPT_LENGTH = 240; + /** @var config */ protected $config; @@ -772,6 +787,170 @@ public function get_search_bundle_cache_control($path) ? 'private, max-age=' . ($minutes * 60) : 'private, no-store'; } + /** + * The ACP search result limit, clamped to SEARCH_RESULTS_MIN..MAX. + * + * @return int + */ + public function get_search_max_results() + { + if (!isset($this->config['phpbbmodders_documentation_search_max_results'])) + { + return self::SEARCH_RESULTS_DEFAULT; + } + + return min(max((int) $this->config['phpbbmodders_documentation_search_max_results'], self::SEARCH_RESULTS_MIN), self::SEARCH_RESULTS_MAX); + } + + /** + * Resolves a section's server-side search index, containment checked + * against //
/. Callers must check the user's + * language and section permissions first. + * + * @param string $lang + * @param string $section + * @return string|false + */ + public function get_search_index_file($lang, $section) + { + if (!in_array($section, $this->get_available_sections($lang), true)) + { + return false; + } + + $root = $this->get_docs_root(); + $section_root = realpath($root . '/' . $lang . '/' . $section); + $file = realpath($root . '/' . $lang . '/' . $section . '/' . self::SEARCH_INDEX_FILE); + + return $section_root !== false && strpos($section_root, $root . DIRECTORY_SEPARATOR . $lang . DIRECTORY_SEPARATOR) === 0 + && $file !== false && is_file($file) && strpos($file, $section_root . DIRECTORY_SEPARATOR) === 0 + && filesize($file) <= self::SEARCH_INDEX_MAX_BYTES + ? $file : false; + } + + /** + * Sections of $lang, limited to $allowed_sections, that have a + * server-side search index. + * + * @param string $lang + * @param array $allowed_sections Sections the current user may read. + * @return array Section slugs, in $allowed_sections order. + */ + public function get_search_index_sections($lang, array $allowed_sections) + { + $sections = array(); + foreach ($allowed_sections as $section) + { + if ($this->get_search_index_file($lang, $section) !== false) + { + $sections[] = $section; + } + } + + return $sections; + } + + /** + * Server-side search for visitors without JavaScript. Every word of + * $query must appear in a page's title or text; pages with more title + * matches come first, then by title. + * + * @param string $lang + * @param string $query + * @param array $sections Sections to search; the caller has checked the user may read them. + * @param int $limit Most results returned. + * @return array Each ['title', 'excerpt', 'url'], url being a phpBB route. + */ + public function search_pages($lang, $query, array $sections, $limit) + { + $terms = preg_split('/\s+/u', mb_strtolower(trim($query)), -1, PREG_SPLIT_NO_EMPTY); + if (empty($terms)) + { + return array(); + } + + $results = array(); + foreach ($sections as $section) + { + $file = $this->get_search_index_file($lang, $section); + $pages = $file !== false ? json_decode(file_get_contents($file), true) : null; + if (!is_array($pages)) + { + continue; + } + + $prefix = '/' . $lang . '/' . $section . '/'; + foreach ($pages as $page) + { + if (!is_array($page) || !isset($page['url'], $page['title'], $page['text']) + || !is_string($page['url']) || !is_string($page['title']) || !is_string($page['text']) + || strpos($page['url'], $prefix) !== 0) + { + continue; + } + + $result = $this->match_page($page, $terms); + $path = trim(substr($page['url'], strlen('/' . $lang . '/')), '/'); + if ($result === false || $this->get_content_file($lang, $path) === false) + { + continue; + } + + $result['url'] = $this->controller_helper->route('phpbbmodders_documentation_page', array('lang' => $lang, 'path' => $path)); + $results[] = $result; + } + } + + usort($results, function ($a, $b) { + return $b['score'] <=> $a['score'] ?: strcmp($a['title'], $b['title']); + }); + + return array_map(function ($result) { + unset($result['score']); + return $result; + }, array_slice($results, 0, $limit)); + } + + /** + * @param array $page One search index entry. + * @param array $terms Lowercase search words. + * @return array|false ['title', 'excerpt', 'score'], or false if a word is missing. + */ + protected function match_page(array $page, array $terms) + { + $text = trim(preg_replace('/\s+/u', ' ', html_entity_decode($page['text'], ENT_QUOTES | ENT_HTML5, 'UTF-8'))); + $title = html_entity_decode($page['title'], ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $title_lower = mb_strtolower($title); + $text_lower = mb_strtolower($text); + $score = 0; + $position = null; + + foreach ($terms as $term) + { + $in_title = mb_strpos($title_lower, $term) !== false; + $in_text = mb_strpos($text_lower, $term); + if (!$in_title && $in_text === false) + { + return false; + } + + $score += $in_title ? 2 : 1; + if ($in_text !== false && ($position === null || $in_text < $position)) + { + $position = $in_text; + } + } + + $start = max(0, (int) $position - self::SEARCH_EXCERPT_LEAD); + $excerpt = ($start > 0 ? '…' : '') . mb_substr($text, $start, self::SEARCH_EXCERPT_LENGTH); + if (mb_strlen($text) > $start + self::SEARCH_EXCERPT_LENGTH) + { + $excerpt .= '…'; + } + + return array('title' => $title, 'excerpt' => $excerpt, 'score' => $score); + } + /** @return string|false An image contained in its own language build. */ public function get_image_file($lang, $path) { diff --git a/docs/securing-docs-build.md b/docs/securing-docs-build.md index 8f9c731..9bed949 100644 --- a/docs/securing-docs-build.md +++ b/docs/securing-docs-build.md @@ -22,8 +22,11 @@ Search bundles are served the same way, through own Pagefind bundle, and the route serves a file only when the user may read that language and section. Content-hashed index files may be cached privately by the browser for the ACP's **Search index cache time** (default 60 -minutes; 0 turns it off). Direct access to the build would expose every -section's bundle, so the deny rule matters for search too. +minutes; 0 turns it off). The server-side search indexes +(`/
/search-index.json`) are never served; the search page reads +the indexes of sections the user may read. Direct access to the build would +expose every section's bundle and index, so the deny rule matters for search +too. ## Trusting the build diff --git a/language/en/info_acp_documentation.php b/language/en/info_acp_documentation.php index 708c018..78d579e 100644 --- a/language/en/info_acp_documentation.php +++ b/language/en/info_acp_documentation.php @@ -41,6 +41,10 @@ 'ACP_DOCUMENTATION_SEARCH_CACHE_MINUTES_EXPLAIN' => 'How long each visitor’s browser may keep the search index files it downloads, in minutes. Longer makes repeat searches faster, but after you remove someone’s access to a section, their browser can still search that section’s cached files until they expire. 0 turns caching off.', 'ACP_DOCUMENTATION_SEARCH_CACHE_MINUTES_INVALID' => 'Search index cache time must be a whole number of minutes from 0 to %d.', + 'ACP_DOCUMENTATION_SEARCH_MAX_RESULTS' => 'Search results limit', + 'ACP_DOCUMENTATION_SEARCH_MAX_RESULTS_EXPLAIN' => 'Most results a documentation search shows, from %1$d to %2$d. Each result loads its own excerpt, so a higher limit makes searches with many matches slower.', + 'ACP_DOCUMENTATION_SEARCH_MAX_RESULTS_INVALID' => 'Search results limit must be a whole number from %1$d to %2$d.', + 'ACP_DOCUMENTATION_NAV_LINK' => 'Show “Documentation” in navigation bar', 'ACP_DOCUMENTATION_NAV_LINK_EXPLAIN' => 'Add a “Documentation” entry to the forum’s top navigation bar. Disabling this only hides the link — the documentation itself stays reachable at its URL for anyone with permission, so you can link it from your own site menu instead.', diff --git a/migrations/v10x/m8_search_max_results.php b/migrations/v10x/m8_search_max_results.php new file mode 100644 index 0000000..8fbcfe4 --- /dev/null +++ b/migrations/v10x/m8_search_max_results.php @@ -0,0 +1,32 @@ +config['phpbbmodders_documentation_search_max_results']); + } + + public static function depends_on() + { + return array('\phpbbmodders\documentation\migrations\v10x\m7_store_docs_path'); + } + + public function update_data() + { + return array( + // 50 was the fixed limit before this setting existed. + array('config.add', array('phpbbmodders_documentation_search_max_results', 50)), + ); + } +} diff --git a/styles/all/template/documentation_search.html b/styles/all/template/documentation_search.html index fefa8e2..8911bb2 100644 --- a/styles/all/template/documentation_search.html +++ b/styles/all/template/documentation_search.html @@ -8,9 +8,28 @@

{{ lang('DOCUMENTATION_SEARCH') }}

data-doc-search-bundles="{{ DOCUMENTATION_SEARCH_BUNDLES|e }}" data-doc-search-links="{{ DOCUMENTATION_SEARCH_LINKS|e }}" data-doc-search-messages="{{ DOCUMENTATION_SEARCH_MESSAGES|e }}" + data-doc-search-max-results="{{ DOCUMENTATION_SEARCH_MAX_RESULTS|e }}" data-doc-search-query="{{ DOCUMENTATION_SEARCH_QUERY|e }}"> + {% if S_DOCUMENTATION_SEARCH_SERVER %} + {# Server-side results; documentation-search.js replaces them with Pagefind's when it can. #} +
+ {% if not S_DOCUMENTATION_SEARCH_VALID %} +

{{ lang('DOCUMENTATION_SEARCH_LENGTH') }}

+ {% elseif documentation_search_result|length %} +
    + {% for result in documentation_search_result %} +
  1. {{ result.TITLE|e }}

    {{ result.EXCERPT|e }}

  2. + {% endfor %} +
+ {% else %} +

{{ lang('DOCUMENTATION_SEARCH_NO_RESULTS') }}

+ {% endif %} +
+ {% else %} + {% endif %} +{% INCLUDEJS 'js/documentation.js' %} {% INCLUDEJS 'js/documentation-search.js' %} {% include 'overall_footer.html' %} diff --git a/styles/all/template/documentation_search_form.html b/styles/all/template/documentation_search_form.html index 836d7a3..241bda0 100644 --- a/styles/all/template/documentation_search_form.html +++ b/styles/all/template/documentation_search_form.html @@ -3,6 +3,8 @@
+ {# Set to 1 by documentation.js, so the server skips its own search when Pagefind will run. #} +
diff --git a/styles/all/template/js/documentation-search.js b/styles/all/template/js/documentation-search.js index 8be38fd..1ccae9c 100644 --- a/styles/all/template/js/documentation-search.js +++ b/styles/all/template/js/documentation-search.js @@ -19,11 +19,19 @@ * prefix becomes template with placeholder * replaced by the rest of the URL (used to map * Hugo paths to phpBB routes). + * data-doc-search-max-results Optional number of results to show, + * 10-200; defaults to 50. + * + * A child element carrying data-doc-search-fallback holds results the + * server already rendered. It stays when Pagefind can't run and is + * replaced once Pagefind's results arrive. */ (function () { "use strict"; - var MAX_RESULTS = 50; + var DEFAULT_MAX_RESULTS = 50; + var MIN_MAX_RESULTS = 10; + var MAX_MAX_RESULTS = 200; var panel = document.querySelector("[data-doc-search]"); if (!panel) { @@ -43,6 +51,12 @@ var links = readJson("data-doc-search-links", null); var query = panel.getAttribute("data-doc-search-query"); var baseUrl = panel.getAttribute("data-doc-search-base-url") || "/"; + var maxResults = parseInt(panel.getAttribute("data-doc-search-max-results"), 10); + var fallback = panel.querySelector("[data-doc-search-fallback]"); + + if (!(maxResults >= MIN_MAX_RESULTS && maxResults <= MAX_MAX_RESULTS)) { + maxResults = DEFAULT_MAX_RESULTS; + } if (query === null) { query = new URLSearchParams(window.location.search).get("q") || ""; @@ -94,7 +108,15 @@ return url; } + function dropFallback() { + if (fallback) { + fallback.remove(); + fallback = null; + } + } + function render(results) { + dropFallback(); if (!results.length) { showStatus(messages.noResults); return; @@ -122,13 +144,18 @@ panel.appendChild(list); } + // Server-rendered results, if any, already cover these cases. if (query.length < 2 || query.length > 100) { - showStatus(messages.length); + if (!fallback) { + showStatus(messages.length); + } return; } if (!bundles.length) { - showStatus(messages.unavailable); + if (!fallback) { + showStatus(messages.unavailable); + } return; } @@ -151,12 +178,12 @@ }); }) .then(function (search) { - return Promise.all(search.results.slice(0, MAX_RESULTS).map(function (result) { + return Promise.all(search.results.slice(0, maxResults).map(function (result) { return result.data(); })); }) .then(render) .catch(function () { - showStatus(messages.unavailable); + showStatus(fallback ? "" : messages.unavailable); }); }()); diff --git a/styles/all/template/js/documentation.js b/styles/all/template/js/documentation.js index cacb9c4..9d8c9de 100644 --- a/styles/all/template/js/documentation.js +++ b/styles/all/template/js/documentation.js @@ -1,6 +1,12 @@ (function () { "use strict"; + // Tell the search page this browser runs scripts, so it leaves the + // search to Pagefind instead of also searching on the server. + document.querySelectorAll(".documentation-search-form input[name=js]").forEach(function (input) { + input.value = "1"; + }); + document.querySelectorAll(".docs-filter").forEach(function (input) { input.addEventListener("input", function () { var query = input.value.trim().toLowerCase(); diff --git a/styles/all/theme/documentation.css b/styles/all/theme/documentation.css index 487e86c..e1109ca 100644 --- a/styles/all/theme/documentation.css +++ b/styles/all/theme/documentation.css @@ -325,7 +325,7 @@ line-height: 1.6; } -/* Rendered by documentation-search.js. */ +/* Rendered by documentation-search.js, or on the server without JavaScript. */ .doc-search-results { margin: 20px 0; padding-left: 24px; diff --git a/tests/acp/template_test.php b/tests/acp/template_test.php index c654af3..66fd1f5 100644 --- a/tests/acp/template_test.php +++ b/tests/acp/template_test.php @@ -42,11 +42,14 @@ public function getPriority(): int @$dom->loadHTML($html); $xpath = new \DOMXPath($dom); $controls = $xpath->query('//*[@data-doc-tooltip]'); - $this->assertCount(8, $controls); + $this->assertCount(9, $controls); $cache_minutes = $xpath->query('//input[@id="phpbbmodders_documentation_search_cache_minutes"]')->item(0); $this->assertNotNull($cache_minutes); $this->assertSame('number', $cache_minutes->getAttribute('type')); $this->assertSame('0', $cache_minutes->getAttribute('min')); + $max_results = $xpath->query('//input[@id="phpbbmodders_documentation_search_max_results"]')->item(0); + $this->assertNotNull($max_results); + $this->assertSame('number', $max_results->getAttribute('type')); foreach ($controls as $control) { $this->assertSame($description, $control->getAttribute('data-doc-tooltip')); diff --git a/tests/controller/documentation_controller_test.php b/tests/controller/documentation_controller_test.php index 5738b07..2e59e36 100644 --- a/tests/controller/documentation_controller_test.php +++ b/tests/controller/documentation_controller_test.php @@ -27,11 +27,15 @@ class documentation_controller_test extends TestCase { protected $root; protected $assigned; + protected $blocks; + protected $request_params; protected function setUp(): void { $this->root = sys_get_temp_dir() . '/documentation_controller_' . uniqid(); $this->assigned = array(); + $this->blocks = array(); + $this->request_params = array(); foreach (array('en' => array('', 'quickstart', 'quickstart/install', 'englishonly'), 'da' => array('', 'quickstart', 'danishonly')) as $lang => $pages) { foreach ($pages as $page) @@ -72,6 +76,10 @@ protected function get_controller(array $denied = array(), $query = '', array $o }); $request = $this->createMock(request_interface::class); $request->method('variable')->willReturnCallback(function ($name, $default) use ($query, $scope) { + if (array_key_exists($name, $this->request_params)) + { + return $this->request_params[$name]; + } return $name === 'q' ? $query : ($name === 'scope' ? $scope : $default); }); $user = $this->createMock(user::class); @@ -89,6 +97,9 @@ protected function get_controller(array $denied = array(), $query = '', array $o $template->method('assign_vars')->willReturnCallback(function ($vars) { $this->assigned = $vars; }); + $template->method('assign_block_vars')->willReturnCallback(function ($block, $vars) { + $this->blocks[$block][] = $vars; + }); $helper = $this->getMockBuilder(documentation_helper::class) ->setConstructorArgs(array($config, $this->createMock(language::class), $request, $user, '', $routes)) ->onlyMethods(array('set_language_cookie'))->getMock(); @@ -332,4 +343,80 @@ public function test_search_messages_come_from_the_language_service() 'loading' => 'DOCUMENTATION_SEARCH_LOADING', ), json_decode($this->assigned['DOCUMENTATION_SEARCH_MESSAGES'], true)); } + + protected function make_search_index($lang, $section, array $titles) + { + $pages = array(); + foreach ($titles as $path => $title) + { + $pages[] = array('url' => '/' . $lang . '/' . $section . '/' . ($path !== '' ? $path . '/' : ''), 'title' => $title, 'text' => 'database setup'); + } + file_put_contents($this->root . '/' . $lang . '/' . $section . '/search-index.json', json_encode($pages)); + } + + public function test_search_without_javascript_uses_allowed_server_indexes() + { + $this->make_bundle('en', 'quickstart'); + $this->make_search_index('en', 'quickstart', array('' => 'Quick Start', 'install' => 'Install')); + $this->make_search_index('en', 'englishonly', array('' => 'Restricted')); + + $this->get_controller(array('u_phpbbmodders_documentation_englishonly'), 'database')->search('en'); + + $this->assertTrue($this->assigned['S_DOCUMENTATION_SEARCH_SERVER']); + $this->assertTrue($this->assigned['S_DOCUMENTATION_SEARCH_VALID']); + $this->assertSame(array('Install', 'Quick Start'), array_column($this->blocks['documentation_search_result'], 'TITLE')); + $this->assertSame('/app.php/documentation/en/quickstart/install', $this->blocks['documentation_search_result'][0]['U_PAGE']); + } + + public function test_search_with_javascript_leaves_results_to_pagefind() + { + $this->make_bundle('en', 'quickstart'); + $this->make_search_index('en', 'quickstart', array('' => 'Quick Start')); + $this->request_params = array('js' => 1); + + $this->get_controller(array(), 'database')->search('en'); + + $this->assertFalse($this->assigned['S_DOCUMENTATION_SEARCH_SERVER']); + $this->assertArrayNotHasKey('documentation_search_result', $this->blocks); + } + + public function test_search_with_javascript_but_no_bundles_still_uses_the_server() + { + $this->make_search_index('en', 'quickstart', array('' => 'Quick Start')); + $this->request_params = array('js' => 1); + + $this->get_controller(array(), 'database')->search('en'); + + $this->assertTrue($this->assigned['S_DOCUMENTATION_SEARCH_SERVER']); + $this->assertSame(array(), json_decode($this->assigned['DOCUMENTATION_SEARCH_BUNDLES'], true)); + $this->assertSame(array('Quick Start'), array_column($this->blocks['documentation_search_result'], 'TITLE')); + } + + public function test_search_server_results_follow_the_configured_limit() + { + $titles = array(); + for ($i = 0; $i < 15; $i++) + { + mkdir($this->root . '/en/quickstart/page' . $i); + file_put_contents($this->root . '/en/quickstart/page' . $i . '/index.html', 'page'); + $titles['page' . $i] = 'Page ' . $i; + } + $this->make_search_index('en', 'quickstart', $titles); + + $this->get_controller(array(), 'database', array('phpbbmodders_documentation_search_max_results' => 10))->search('en'); + + $this->assertSame(10, $this->assigned['DOCUMENTATION_SEARCH_MAX_RESULTS']); + $this->assertCount(10, $this->blocks['documentation_search_result']); + } + + public function test_search_short_query_runs_no_server_search() + { + $this->make_search_index('en', 'quickstart', array('' => 'Quick Start')); + + $this->get_controller(array(), 'd')->search('en'); + + $this->assertTrue($this->assigned['S_DOCUMENTATION_SEARCH_SERVER']); + $this->assertFalse($this->assigned['S_DOCUMENTATION_SEARCH_VALID']); + $this->assertArrayNotHasKey('documentation_search_result', $this->blocks); + } } diff --git a/tests/controller/documentation_helper_test.php b/tests/controller/documentation_helper_test.php index 0931639..7f16df1 100644 --- a/tests/controller/documentation_helper_test.php +++ b/tests/controller/documentation_helper_test.php @@ -717,4 +717,77 @@ public function test_resolve_default_language_reads_the_prefixed_cookie() $this->assertSame('da', $helper->resolve_default_language()); } + + protected function make_search_index($lang, $section, array $pages) + { + file_put_contents($this->docs_root . '/' . $lang . '/' . $section . '/search-index.json', json_encode($pages)); + } + + public function test_search_pages_requires_every_word_and_ranks_title_matches_first() + { + $this->make_search_index('en', 'quickstart', array( + array('url' => '/en/quickstart/', 'title' => 'Quick Start', 'text' => 'Install the board & configure the database.'), + array('url' => '/en/quickstart/quick_installation/', 'title' => 'Database install', 'text' => 'Run the installer.'), + array('url' => '/en/quickstart/missing/', 'title' => 'Database install', 'text' => 'No page was built for this entry.'), + array('url' => '/da/quickstart/', 'title' => 'Database install', 'text' => 'Entry outside this section.'), + array('url' => '/en/quickstart/', 'title' => 'Board only', 'text' => 'board'), + 'not an entry', + )); + + $results = $this->get_helper()->search_pages('en', 'DATABASE install', array('quickstart'), 50); + + $this->assertSame(array('Database install', 'Quick Start'), array_column($results, 'title')); + $this->assertSame('/forum/app.php/documentation/en/quickstart/quick_installation?sid=test-session', $results[0]['url']); + $this->assertSame('Install the board & configure the database.', $results[1]['excerpt']); + $this->assertArrayNotHasKey('score', $results[0]); + } + + public function test_search_pages_respects_the_limit_and_trims_long_excerpts() + { + $text = str_repeat('filler ', 40) . 'needle ' . str_repeat('tail ', 80); + $this->make_search_index('en', 'quickstart', array( + array('url' => '/en/quickstart/', 'title' => 'One', 'text' => $text), + array('url' => '/en/quickstart/quick_installation/', 'title' => 'Two', 'text' => $text), + )); + + $results = $this->get_helper()->search_pages('en', 'needle', array('quickstart'), 1); + + $this->assertCount(1, $results); + $this->assertStringStartsWith('…', $results[0]['excerpt']); + $this->assertStringEndsWith('…', $results[0]['excerpt']); + $this->assertStringContainsString('needle', $results[0]['excerpt']); + } + + public function test_search_index_file_is_contained_in_an_available_section() + { + $this->make_search_index('en', 'quickstart', array()); + file_put_contents(dirname($this->docs_root) . '/escaped/search-index.json', '[]'); + mkdir($this->docs_root . '/en/linked'); + file_put_contents($this->docs_root . '/en/linked/index.html', 'page'); + symlink(dirname($this->docs_root) . '/escaped/search-index.json', $this->docs_root . '/en/linked/search-index.json'); + $helper = $this->get_helper(); + + $this->assertSame(realpath($this->docs_root . '/en/quickstart/search-index.json'), $helper->get_search_index_file('en', 'quickstart')); + $this->assertFalse($helper->get_search_index_file('en', 'linked')); + $this->assertFalse($helper->get_search_index_file('en', 'images')); + $this->assertFalse($helper->get_search_index_file('da', 'quickstart')); + $this->assertSame(array('quickstart'), $helper->get_search_index_sections('en', array('quickstart', 'linked'))); + unlink(dirname($this->docs_root) . '/escaped/search-index.json'); + } + + /** @dataProvider search_max_results_provider */ + public function test_search_max_results_is_clamped($config_data, $expected) + { + $this->assertSame($expected, $this->get_helper($config_data)->get_search_max_results()); + } + + public static function search_max_results_provider() + { + return array( + 'missing' => array(array(), 50), + 'configured' => array(array('phpbbmodders_documentation_search_max_results' => 120), 120), + 'too low' => array(array('phpbbmodders_documentation_search_max_results' => 1), 10), + 'too high' => array(array('phpbbmodders_documentation_search_max_results' => 5000), 200), + ); + } }