feat: implement article search with PostgreSQL full-text search #42

Merged
mark merged 2 commits from feature/article-search into main 2026-03-03 21:58:08 +00:00
12 changed files with 300 additions and 9 deletions

View File

@@ -235,8 +235,27 @@ class ArticlePage(SeoMixin, Page):
search_fields = Page.search_fields + [
index.SearchField("summary"),
index.SearchField("body_text", es_extra={"analyzer": "english"}),
index.AutocompleteField("title"),
index.RelatedFields("tags", [
index.SearchField("name"),
]),
index.FilterField("category"),
index.FilterField("published_date"),
]
@property
def body_text(self) -> str:
"""Extract prose text from body StreamField, excluding code blocks."""
parts: list[str] = []
for block in self.body:
if block.block_type == "code":
continue
value = block.value
text = value.source if hasattr(value, "source") else str(value)
parts.append(text)
return " ".join(parts)
def save(self, *args: Any, **kwargs: Any) -> None:
if not self.category_id:
self.category, _ = Category.objects.get_or_create(

View File

@@ -0,0 +1,140 @@
import pytest
from apps.blog.models import ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory
from apps.blog.views import MAX_QUERY_LENGTH
@pytest.fixture
def search_articles(home_page):
"""Create an article index with searchable articles."""
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
articles = []
for title, summary in [
("Understanding LLM Benchmarks", "A deep dive into how language models are evaluated"),
("Local Models on Apple Silicon", "Running open-source models on your MacBook"),
("Agent Frameworks Compared", "Comparing LangChain, CrewAI, and AutoGen"),
]:
a = ArticlePage(
title=title,
slug=title.lower().replace(" ", "-"),
author=author,
summary=summary,
body=[("rich_text", f"<p>{summary} in detail.</p>")],
)
index.add_child(instance=a)
a.save_revision().publish()
articles.append(a)
return articles
@pytest.mark.django_db
class TestSearchView:
def test_empty_query_returns_no_results(self, client, home_page):
resp = client.get("/search/")
assert resp.status_code == 200
assert resp.context["query"] == ""
assert resp.context["results"] is None
def test_whitespace_query_returns_no_results(self, client, home_page):
resp = client.get("/search/?q= ")
assert resp.status_code == 200
assert resp.context["query"] == ""
assert resp.context["results"] is None
def test_search_returns_matching_articles(self, client, search_articles):
resp = client.get("/search/?q=benchmarks")
assert resp.status_code == 200
assert resp.context["query"] == "benchmarks"
assert resp.context["results"] is not None
def test_search_no_match_returns_empty_page(self, client, search_articles):
resp = client.get("/search/?q=zzzznonexistent")
assert resp.status_code == 200
assert resp.context["query"] == "zzzznonexistent"
# Either None or empty page object
results = resp.context["results"]
if results is not None:
assert len(list(results)) == 0
def test_query_is_truncated_to_max_length(self, client, home_page):
long_query = "a" * 500
resp = client.get(f"/search/?q={long_query}")
assert resp.status_code == 200
assert len(resp.context["query"]) <= MAX_QUERY_LENGTH
def test_query_preserved_in_template(self, client, search_articles):
resp = client.get("/search/?q=LLM")
html = resp.content.decode()
assert 'value="LLM"' in html
def test_search_results_page_renders(self, client, search_articles):
resp = client.get("/search/?q=models")
assert resp.status_code == 200
html = resp.content.decode()
assert "Search" in html
def test_search_url_resolves(self, client, home_page):
from django.urls import reverse
assert reverse("search") == "/search/"
@pytest.mark.django_db
class TestSearchFields:
def test_search_fields_include_summary(self):
field_names = [
f.field_name for f in ArticlePage.search_fields if hasattr(f, "field_name")
]
assert "summary" in field_names
def test_search_fields_include_body_text(self):
field_names = [
f.field_name for f in ArticlePage.search_fields if hasattr(f, "field_name")
]
assert "body_text" in field_names
def test_search_fields_include_autocomplete_title(self):
from wagtail.search.index import AutocompleteField
autocomplete_fields = [
f for f in ArticlePage.search_fields if isinstance(f, AutocompleteField)
]
assert any(f.field_name == "title" for f in autocomplete_fields)
def test_search_fields_include_related_tags(self):
from wagtail.search.index import RelatedFields
related = [f for f in ArticlePage.search_fields if isinstance(f, RelatedFields)]
assert any(f.field_name == "tags" for f in related)
def test_body_text_excludes_code_blocks(self):
author = AuthorFactory()
article = ArticlePage(
title="Test",
slug="test",
author=author,
summary="summary",
body=[
("rich_text", "<p>prose content here</p>"),
("code", {"language": "python", "filename": "", "raw_code": "def secret(): pass"}),
],
)
assert "prose content here" in article.body_text
assert "secret" not in article.body_text
@pytest.mark.django_db
class TestSearchNavIntegration:
def test_nav_contains_search_form(self, client, home_page):
resp = client.get("/")
html = resp.content.decode()
assert 'role="search"' in html
assert 'name="q"' in html
assert 'placeholder="Search articles..."' in html
def test_article_index_contains_search_form(self, client, home_page):
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
resp = client.get("/articles/")
html = resp.content.decode()
assert 'name="q"' in html

View File

@@ -69,8 +69,9 @@ def test_newsletter_forms_render_in_nav_and_footer(client, home_page):
resp = client.get("/")
html = resp.content.decode()
assert resp.status_code == 200
# Nav has a Subscribe CTA link (no inline form — wireframe spec)
assert 'href="#newsletter"' in html
# Nav has a search form instead of Subscribe CTA
assert 'role="search"' in html
assert 'name="q"' in html
# Footer has Connect section with social/RSS links (no newsletter form)
assert "Connect" in html
assert 'name="source" value="nav"' not in html

43
apps/blog/views.py Normal file
View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.http import HttpRequest, HttpResponse
from django.template.response import TemplateResponse
from apps.blog.models import ArticlePage
RESULTS_PER_PAGE = 12
MAX_QUERY_LENGTH = 200
def search(request: HttpRequest) -> HttpResponse:
query = request.GET.get("q", "").strip()[:MAX_QUERY_LENGTH]
results_page = None
paginator = None
if query:
results = (
ArticlePage.objects.live()
.public()
.select_related("author", "category")
.prefetch_related("tags__metadata")
.search(query)
)
paginator = Paginator(results, RESULTS_PER_PAGE)
page_num = request.GET.get("page")
try:
results_page = paginator.page(page_num)
except PageNotAnInteger:
results_page = paginator.page(1)
except EmptyPage:
results_page = paginator.page(paginator.num_pages)
return TemplateResponse(
request,
"blog/search_results.html",
{
"query": query,
"results": results_page,
"paginator": paginator,
},
)

View File

@@ -29,6 +29,7 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sitemaps",
"django.contrib.postgres",
"taggit",
"modelcluster",
"wagtail.contrib.forms",
@@ -152,3 +153,10 @@ STORAGES = {
}
TAILWIND_APP_NAME = "theme"
WAGTAILSEARCH_BACKENDS = {
"default": {
"BACKEND": "wagtail.search.backends.database",
"SEARCH_CONFIG": "english",
}
}

View File

@@ -7,6 +7,7 @@ from wagtail import urls as wagtail_urls
from wagtail.contrib.sitemaps.views import sitemap
from apps.blog.feeds import AllArticlesFeed, CategoryArticlesFeed, TagArticlesFeed
from apps.blog.views import search as search_view
from apps.core.views import consent_view, robots_txt
urlpatterns = [
@@ -22,6 +23,7 @@ urlpatterns = [
path("feed/tag/<slug:tag_slug>/", TagArticlesFeed(), name="rss_feed_by_tag"),
path("sitemap.xml", sitemap),
path("admin/", RedirectView.as_view(url="/cms/", permanent=False)),
path("search/", search_view, name="search"),
path("", include(wagtail_urls)),
]

View File

@@ -5,6 +5,7 @@ python manage.py tailwind install --no-input
python manage.py tailwind build
python manage.py migrate --noinput
python manage.py collectstatic --noinput
python manage.py update_index
# Set Wagtail site hostname from first entry in ALLOWED_HOSTS
python manage.py shell -c "

View File

@@ -37,11 +37,10 @@ def test_theme_toggle_adds_dark_class(page: Page, base_url: str) -> None:
@pytest.mark.e2e
def test_nav_subscribe_cta_present(page: Page, base_url: str) -> None:
def test_nav_search_box_present(page: Page, base_url: str) -> None:
page.goto(f"{base_url}/", wait_until="networkidle")
nav = page.locator("nav")
# Nav has a Subscribe CTA link (not a form — wireframe spec)
expect(nav.get_by_role("link", name="Subscribe")).to_be_visible()
expect(nav.locator('input[name="q"]')).to_be_visible()
@pytest.mark.e2e

View File

@@ -23,12 +23,20 @@
<p class="text-zinc-600 dark:text-zinc-400 mb-6">{{ active_category.description }}</p>
{% endif %}
<!-- Category Filters -->
<div class="flex flex-wrap gap-3 mb-4">
<!-- Filters / Search -->
<div class="flex flex-col md:flex-row justify-between gap-6 mb-4">
<!-- Category Filters -->
<div class="flex flex-wrap gap-3">
<a href="/articles/{% if active_tag %}?tag={{ active_tag }}{% endif %}" class="px-4 py-2 font-mono text-sm font-bold border transition-colors {% if not active_category %}bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark border-transparent{% else %}bg-transparent text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-zinc-700 hover:text-brand-dark dark:hover:text-brand-light hover:border-brand-dark dark:hover:border-brand-light{% endif %}" {% if not active_category %}aria-current="page"{% endif %}>Categories</a>
{% for category_link in category_links %}
<a href="{{ category_link.url }}{% if active_tag %}?tag={{ active_tag }}{% endif %}" class="px-4 py-2 font-mono text-sm font-bold border transition-colors {% if active_category and active_category.slug == category_link.category.slug %}bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark border-transparent{% else %}bg-transparent text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-zinc-700 hover:text-brand-dark dark:hover:text-brand-light hover:border-brand-dark dark:hover:border-brand-light{% endif %}" {% if active_category and active_category.slug == category_link.category.slug %}aria-current="page"{% endif %}>{{ category_link.category.name }}</a>
{% endfor %}
</div>
<form action="{% url 'search' %}" method="get" role="search" class="relative w-full md:w-64">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
<input type="search" name="q" placeholder="Search articles..." aria-label="Search articles"
class="w-full bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-300 dark:border-zinc-700 pl-10 pr-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-cyan dark:focus:border-brand-cyan focus:ring-1 focus:ring-brand-cyan transition-shadow" />
</form>
</div>
<!-- Tag Filters -->

View File

@@ -0,0 +1,59 @@
{% extends 'base.html' %}
{% block title %}{% if query %}Search: {{ query }}{% else %}Search{% endif %} | No Hype AI{% endblock %}
{% block head_meta %}
<meta name="robots" content="noindex" />
{% endblock %}
{% block content %}
<!-- Page Header -->
<div class="py-8 md:py-12 border-b border-zinc-200 dark:border-zinc-800 mb-12">
<h1 class="font-display font-black text-4xl md:text-6xl mb-6">Search</h1>
<form action="{% url 'search' %}" method="get" role="search" class="relative w-full md:w-96">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
<input type="search" name="q" value="{{ query }}" placeholder="Search articles..." autofocus
class="w-full bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-300 dark:border-zinc-700 pl-11 pr-4 py-3 font-mono text-sm focus:outline-none focus:border-brand-cyan dark:focus:border-brand-cyan focus:ring-1 focus:ring-brand-cyan transition-shadow" />
</form>
{% if query %}
<p class="mt-4 font-mono text-sm text-zinc-500">
{% if results %}{{ results.paginator.count }} result{{ results.paginator.count|pluralize }} for "{{ query }}"{% else %}No results for "{{ query }}"{% endif %}
</p>
{% endif %}
</div>
{% if results %}
<!-- Results -->
<div class="space-y-8">
{% for article in results %}
{% include 'components/article_card.html' with article=article %}
{% endfor %}
</div>
<!-- Pagination -->
{% if results.has_previous or results.has_next %}
<nav aria-label="Pagination" class="mt-12 flex justify-center items-center gap-4 font-mono text-sm">
{% if results.has_previous %}
<a href="?q={{ query|urlencode }}&page={{ results.previous_page_number }}" class="px-6 py-3 border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors">← Previous</a>
{% endif %}
<span class="text-zinc-500">Page {{ results.number }} of {{ paginator.num_pages }}</span>
{% if results.has_next %}
<a href="?q={{ query|urlencode }}&page={{ results.next_page_number }}" class="px-6 py-3 border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors">Next →</a>
{% endif %}
</nav>
{% endif %}
{% elif query %}
<!-- No Results -->
<div class="py-16 text-center">
<svg class="w-16 h-16 text-zinc-300 dark:text-zinc-700 mx-auto mb-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
<p class="font-mono text-zinc-500 mb-2">No articles match your search.</p>
<p class="font-mono text-sm text-zinc-400">Try different keywords or browse <a href="/articles/" class="text-brand-cyan hover:underline">all articles</a>.</p>
</div>
{% else %}
<!-- Empty State -->
<div class="py-16 text-center">
<svg class="w-16 h-16 text-zinc-300 dark:text-zinc-700 mx-auto mb-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
<p class="font-mono text-zinc-500">Enter a search term to find articles.</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -19,7 +19,11 @@
{% for category in category_nav_items %}
<a href="{{ category.url }}" class="hover:text-brand-cyan transition-colors {% if category.url in request.path %}text-brand-cyan{% endif %}" {% if category.url in request.path %}aria-current="page"{% endif %}>{{ category.name }}</a>
{% endfor %}
<a href="#newsletter" class="px-5 py-2.5 bg-brand-dark dark:bg-brand-light text-brand-light dark:text-brand-dark font-display font-bold hover:-translate-y-1 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all border border-transparent dark:border-zinc-700">Subscribe</a>
<form action="{% url 'search' %}" method="get" role="search" class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
<input type="search" name="q" placeholder="Search articles..." aria-label="Search articles"
class="w-48 bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-300 dark:border-zinc-700 pl-9 pr-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-cyan dark:focus:border-brand-cyan focus:ring-1 focus:ring-brand-cyan transition-shadow" />
</form>
</div>
<!-- Theme Toggle + Hamburger -->
@@ -44,6 +48,13 @@
{% for category in category_nav_items %}
<a href="{{ category.url }}" class="font-medium py-2 hover:text-brand-cyan transition-colors {% if category.url in request.path %}text-brand-cyan{% endif %}" {% if category.url in request.path %}aria-current="page"{% endif %}>{{ category.name }}</a>
{% endfor %}
<form action="{% url 'search' %}" method="get" role="search" class="pt-2 border-t border-zinc-200 dark:border-zinc-800">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
<input type="search" name="q" placeholder="Search articles..." aria-label="Search articles"
class="w-full bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-300 dark:border-zinc-700 pl-9 pr-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-cyan dark:focus:border-brand-cyan focus:ring-1 focus:ring-brand-cyan transition-shadow" />
</div>
</form>
<form method="post" action="/newsletter/subscribe/" data-newsletter-form class="space-y-2 pt-2 border-t border-zinc-200 dark:border-zinc-800" id="mobile-newsletter">
{% csrf_token %}
<input type="hidden" name="source" value="nav-mobile" />

File diff suppressed because one or more lines are too long