diff --git a/packages/reflex-site-shared/README.md b/packages/reflex-site-shared/README.md index d04dd97f0ab..a710653feac 100644 --- a/packages/reflex-site-shared/README.md +++ b/packages/reflex-site-shared/README.md @@ -108,6 +108,13 @@ the exported `render_docgen_document` helper directly. Add `defer` to a `python demo exec` fence to mount a heavy preview as it approaches the viewport. The example's source code remains in the initial HTML; the preview reserves 450px of height and stays mounted once shown. This is useful for pages containing many Plotly charts. +### Component search + +The shared search matches complete `rx.*` identifiers to words in documentation +titles: `rx.button` searches for `button`, `rx.radio_group` for `radio group`, +and `rx.alert_dialog.root` for `alert dialog root`. The input keeps the original +text. Ordinary search phrases are sent unchanged. + ## Marketing footer Use the marketing site's responsive five-column directory footer in another diff --git a/packages/reflex-site-shared/news/+normalize-component-search.bugfix.md b/packages/reflex-site-shared/news/+normalize-component-search.bugfix.md new file mode 100644 index 00000000000..98cde988cdd --- /dev/null +++ b/packages/reflex-site-shared/news/+normalize-component-search.bugfix.md @@ -0,0 +1 @@ +Normalize component searches such as `rx.button` and `rx.radio_group` to match documentation titles, improving component page relevance. diff --git a/packages/reflex-site-shared/src/reflex_site_shared/plugins.py b/packages/reflex-site-shared/src/reflex_site_shared/plugins.py index 1ff2dba698c..eb34b0d78a1 100644 --- a/packages/reflex-site-shared/src/reflex_site_shared/plugins.py +++ b/packages/reflex-site-shared/src/reflex_site_shared/plugins.py @@ -25,6 +25,7 @@ "icons/search.svg", "components/marketing-date.jsx", "homepage/lib/use-isomorphic-layout-effect.ts", + "components/search-query.mjs", ) diff --git a/packages/reflex-site-shared/src/reflex_site_shared/styles/assets/components/AlgoliaSearch.tsx b/packages/reflex-site-shared/src/reflex_site_shared/styles/assets/components/AlgoliaSearch.tsx index 77cfe3e4e28..a80284657d2 100644 --- a/packages/reflex-site-shared/src/reflex_site_shared/styles/assets/components/AlgoliaSearch.tsx +++ b/packages/reflex-site-shared/src/reflex_site_shared/styles/assets/components/AlgoliaSearch.tsx @@ -22,6 +22,8 @@ import React, { } from "react"; import { createPortal } from "react-dom"; +import { normalizeSearchQuery } from "./search-query.mjs"; + const ALGOLIA_APP_ID = "WLK9YABRW4"; const ALGOLIA_SEARCH_API_KEY = "7abb638647d3aa25e7a6d29dfdca2212"; const ALGOLIA_INDEX_NAME = "reflex_dev_wlk9yabrw4_pages"; @@ -447,7 +449,7 @@ export function AlgoliaSearch() { "X-Algolia-Application-Id": ALGOLIA_APP_ID, }, body: JSON.stringify({ - query: trimmedQuery, + query: normalizeSearchQuery(trimmedQuery), hitsPerPage: 10, attributesToRetrieve: [ "objectID", diff --git a/packages/reflex-site-shared/src/reflex_site_shared/styles/assets/components/search-query.mjs b/packages/reflex-site-shared/src/reflex_site_shared/styles/assets/components/search-query.mjs new file mode 100644 index 00000000000..1b967442d7d --- /dev/null +++ b/packages/reflex-site-shared/src/reflex_site_shared/styles/assets/components/search-query.mjs @@ -0,0 +1,11 @@ +/** + * Convert a complete Reflex component identifier to searchable title words. + * + * @param {string} query A trimmed search query. + * @returns {string} The component's title words, or the unchanged query. + */ +export function normalizeSearchQuery(query) { + return /^rx\.[a-z]\w*(?:\.[a-z]\w*)*$/i.test(query) + ? query.slice(3).replace(/[_.]+/g, " ") + : query; +} diff --git a/tests/units/reflex_site_shared/test_algolia.py b/tests/units/reflex_site_shared/test_algolia.py index 0c21ab5efba..eb04bd4ccae 100644 --- a/tests/units/reflex_site_shared/test_algolia.py +++ b/tests/units/reflex_site_shared/test_algolia.py @@ -1,11 +1,94 @@ """Tests for the shared keyword-only Algolia search.""" +import json +import re +import shutil +import subprocess from pathlib import Path +import pytest from reflex_site_shared.components.algolia import AlgoliaSearch, Search from reflex_site_shared.plugins import SharedSiteStylesPlugin +@pytest.mark.skipif(shutil.which("node") is None, reason="node missing") +@pytest.mark.parametrize( + ("query", "expected_query"), + [ + ("rx.button", "button"), + ("rx.input", "input"), + ("rx.radio_group", "radio group"), + ("rx.data_table", "data table"), + ("rx.alert_dialog.root", "alert dialog root"), + ("rx.scroll_area", "scroll area"), + ("rx.button.", "rx.button."), + ("RX.BUTTON", "BUTTON"), + ("button", "button"), + ("radio group", "radio group"), + ("on_click", "on_click"), + ("how to style rx.button", "how to style rx.button"), + ("example.rx.button", "example.rx.button"), + ("https://rx.button", "https://rx.button"), + ("rx.", "rx."), + ], +) +def test_algolia_search_normalizes_component_queries( + query: str, expected_query: str, tmp_path: Path +) -> None: + """Normalize component names through the same module imported by the client. + + Args: + query: Trimmed search text. + expected_query: Query sent to Algolia. + tmp_path: Temporary directory for the published JavaScript module. + """ + assets = dict(SharedSiteStylesPlugin().get_static_assets()) + module = tmp_path / "search-query.mjs" + module.write_text( + assets[Path("public/components/search-query.mjs")], encoding="utf-8" + ) + script = """ +import { pathToFileURL } from "node:url"; +const { normalizeSearchQuery } = await import(pathToFileURL(process.argv[1])); +console.log(JSON.stringify(normalizeSearchQuery(JSON.parse(process.argv[2])))); +""" + result = subprocess.run( + [ + "node", + "--input-type=module", + "--eval", + script, + str(module), + json.dumps(query), + ], + capture_output=True, + encoding="utf-8", + timeout=10, + check=True, + ) + assert json.loads(result.stdout) == expected_query + + +def test_algolia_search_wires_normalization_into_requests() -> None: + """Normalize requests through the published helper while keeping raw cache keys.""" + assets = dict(SharedSiteStylesPlugin().get_static_assets()) + source = assets[Path("public/components/AlgoliaSearch.tsx")] + + assert re.search( + r"import\s*\{\s*normalizeSearchQuery\s*,?\s*\}\s*from\s*" + r"[\"']\./search-query\.mjs[\"']", + source, + ) + assert re.search( + r"\bquery\s*:\s*normalizeSearchQuery\s*\(\s*trimmedQuery\s*,?\s*\)", + source, + ) + assert re.search( + r"\bconst\s+cacheKey\s*=\s*trimmedQuery\.toLocaleLowerCase\(\s*\)", + source, + ) + + def test_algolia_search_uses_local_keyword_only_component() -> None: """Use the local Algolia UI without shipping an AI search dependency.""" assert AlgoliaSearch.library == "$/public/components/AlgoliaSearch" diff --git a/tests/units/reflex_site_shared/test_plugins.py b/tests/units/reflex_site_shared/test_plugins.py index 64f2c18e35e..53f56837e0c 100644 --- a/tests/units/reflex_site_shared/test_plugins.py +++ b/tests/units/reflex_site_shared/test_plugins.py @@ -42,6 +42,7 @@ def test_shared_site_styles_plugin_emits_package_css(): Path("public/icons/search.svg"), Path("public/components/marketing-date.jsx"), Path("public/homepage/lib/use-isomorphic-layout-effect.ts"), + Path("public/components/search-query.mjs"), ] assert all(content.strip() for _path, content in assets) assert "ph-conversations-widget" in assets[1][1]