diff --git a/website/common/templates/base.html b/website/common/templates/base.html
index 3adf201..90c9fc2 100644
--- a/website/common/templates/base.html
+++ b/website/common/templates/base.html
@@ -14,6 +14,8 @@
{% block extra_head %}{% endblock %}
+
+
diff --git a/website/search/models.py b/website/search/models.py
index b8c4488..c140d6b 100644
--- a/website/search/models.py
+++ b/website/search/models.py
@@ -1,4 +1,5 @@
from django.core.paginator import EmptyPage, Paginator
+from django.db import models
from django.http.request import HttpRequest
from django.http.response import Http404, HttpResponse, HttpResponseBadRequest
from django.template.response import TemplateResponse
@@ -43,6 +44,13 @@ class SearchPage(RoutablePageMixin, BaseContentPage):
context["SEO_INDEX"] = False
return context
+ def get_listing_pages(self) -> models.QuerySet:
+ return (
+ Page.objects.live()
+ .public()
+ .not_type(self.__class__, *self.EXCLUDED_PAGE_TYPES)
+ )
+
@route(r"^results/$")
@method_decorator(require_GET)
def results(self, request: HttpRequest) -> HttpResponse:
@@ -68,12 +76,7 @@ class SearchPage(RoutablePageMixin, BaseContentPage):
}
filters, query = parse_query_string(search_query)
- pages = (
- Page.objects.live()
- .public()
- .not_type(self.__class__, *self.EXCLUDED_PAGE_TYPES)
- .search(query, order_by_relevance=True)
- )
+ pages = self.get_listing_pages().search(query, order_by_relevance=True)
paginator = Paginator(pages, self.PAGE_SIZE)
context["paginator"] = paginator
diff --git a/website/search/templates/search/opensearch.xml b/website/search/templates/search/opensearch.xml
new file mode 100644
index 0000000..a1d98f0
--- /dev/null
+++ b/website/search/templates/search/opensearch.xml
@@ -0,0 +1,11 @@
+
+
+ {{ site_title }}
+ {{ site_title }}
+ UTF-8
+ {% if favicon_url %}
+ {{ favicon_url }}
+ {% endif %}
+
+
+
diff --git a/website/search/tests.py b/website/search/tests.py
index bcdbfc1..58ea34f 100644
--- a/website/search/tests.py
+++ b/website/search/tests.py
@@ -1,5 +1,6 @@
from bs4 import BeautifulSoup
from django.test import TestCase
+from django.urls import reverse
from website.common.factories import ContentPageFactory
from website.home.models import HomePage
@@ -127,3 +128,30 @@ class SearchPageResultsTestCase(TestCase):
with self.assertNumQueries(7):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 400)
+
+
+class OpenSearchTestCase(TestCase):
+ @classmethod
+ def setUpTestData(cls) -> None:
+ cls.home_page = HomePage.objects.get()
+ cls.page = SearchPageFactory(parent=cls.home_page)
+
+ for i in range(6):
+ ContentPageFactory(parent=cls.home_page, title=f"Post {i}")
+
+ def test_opensearch_description(self) -> None:
+ with self.assertNumQueries(11):
+ response = self.client.get(reverse("opensearch"))
+ self.assertEqual(response.status_code, 200)
+
+ self.assertContains(response, self.page.get_url())
+ self.assertContains(response, reverse("opensearch-suggestions"))
+
+ def test_opensearch_suggestions(self) -> None:
+ with self.assertNumQueries(4):
+ response = self.client.get(reverse("opensearch-suggestions"), {"q": "post"})
+ self.assertEqual(response.status_code, 200)
+
+ data = response.json()
+ self.assertEqual(data[0], "post")
+ self.assertEqual(data[1], [f"Post {i}" for i in range(5)])
diff --git a/website/search/urls.py b/website/search/urls.py
new file mode 100644
index 0000000..9b725c5
--- /dev/null
+++ b/website/search/urls.py
@@ -0,0 +1,12 @@
+from django.urls import path
+
+from . import views
+
+urlpatterns = [
+ path("opensearch.xml", views.OpenSearchView.as_view(), name="opensearch"),
+ path(
+ "opensearch-suggestions/",
+ views.OpenSearchSuggestionsView.as_view(),
+ name="opensearch-suggestions",
+ ),
+]
diff --git a/website/search/views.py b/website/search/views.py
new file mode 100644
index 0000000..5631b5b
--- /dev/null
+++ b/website/search/views.py
@@ -0,0 +1,71 @@
+from django.http import HttpRequest, JsonResponse
+from django.shortcuts import get_object_or_404
+from django.urls import reverse
+from django.utils.decorators import method_decorator
+from django.views.decorators.cache import cache_control
+from django.views.generic import TemplateView, View
+from wagtail.search.utils import parse_query_string
+from wagtail_favicon.models import FaviconSettings
+from wagtail_favicon.utils import get_rendition_url
+
+from website.common.utils import get_site_title
+from website.contrib.singleton_page.utils import SingletonPageCache
+
+from .models import SearchPage
+from .serializers import SearchParamsSerializer
+
+
+@method_decorator(cache_control(max_age=60 * 60), name="dispatch")
+class OpenSearchView(TemplateView):
+ template_name = "search/opensearch.xml"
+ content_type = "application/xml"
+
+ def get_context_data(self, **kwargs: dict) -> dict:
+ context = super().get_context_data(**kwargs)
+
+ favicon_settings = FaviconSettings.for_request(self.request)
+
+ if favicon_settings.base_favicon_image_id:
+ context["favicon_url"] = self.request.build_absolute_uri(
+ get_rendition_url(
+ favicon_settings.base_favicon_image, "fill-100|format-png"
+ )
+ )
+
+ context["search_page_url"] = self.request.build_absolute_uri(
+ SingletonPageCache.get_url(SearchPage, self.request)
+ )
+ context["search_suggestions_url"] = self.request.build_absolute_uri(
+ reverse("opensearch-suggestions")
+ )
+
+ context["site_title"] = get_site_title()
+
+ return context
+
+
+@method_decorator(cache_control(max_age=60 * 60), name="dispatch")
+class OpenSearchSuggestionsView(View):
+ def get(self, request: HttpRequest) -> JsonResponse:
+ serializer = SearchParamsSerializer(data=request.GET)
+
+ if not serializer.is_valid():
+ return JsonResponse(serializer.errors, status=400)
+
+ search_page = get_object_or_404(SearchPage)
+
+ filters, query = parse_query_string(serializer.validated_data["q"])
+
+ results = (
+ search_page.get_listing_pages()
+ .search(query, order_by_relevance=True)[:5]
+ .get_queryset()
+ )
+
+ return JsonResponse(
+ [
+ serializer.validated_data["q"],
+ list(results.values_list("title", flat=True)),
+ ],
+ safe=False,
+ )
diff --git a/website/urls.py b/website/urls.py
index 63abd37..1614885 100644
--- a/website/urls.py
+++ b/website/urls.py
@@ -50,6 +50,7 @@ urlpatterns = [
path("feed/", AllPagesFeed(), name="feed"),
path(".health/", include("health_check.urls")),
path("", include("website.legacy.urls")),
+ path("", include("website.search.urls")),
path("api/", include("website.api.urls", namespace="api")),
path(
"@jake",