60 KiB
No Hype AI — PRD & Implementation Plan
Project: No Hype AI Blog Engine
Stack: Django 5.2 · Wagtail 7.x · PostgreSQL · Tailwind CSS · Alpine.js · Prism.js
Methodology: Test-Driven Development (TDD) throughout
Deployment Target: Self-hosted VPS (Caddy + Gunicorn)
Design References: ~/wireframe.html · ~/design-language.md
Table of Contents
- Product Overview
- Technical Architecture
- Test Strategy
- Milestone 0 — Project Scaffold & Tooling
- Milestone 1 — Authors & Core Blog Models
- Milestone 2 — StreamField Block Library
- Milestone 3 — Homepage & Article Index
- Milestone 4 — Article Read View & Related Content
- Milestone 5 — SEO, RSS & Sitemap
- Milestone 6 — Cookie Consent System
- Milestone 7 — Comment System
- Milestone 8 — Newsletter Integration
- Milestone 9 — Legal Pages
- Milestone 10 — Production Hardening
- Dependency Map
- Definition of Done
1. Product Overview
1.1 Purpose
No Hype AI is a developer-focused blog publishing honest, benchmark-driven reviews of AI coding tools. The platform must function as both a reading-first editorial site and a CMS-powered publishing workflow for one primary author, designed to scale to a small team without structural rework.
1.2 Core Features
| Feature | Priority | Notes |
|---|---|---|
| Homepage with featured article + latest feed | P0 | Per wireframe |
| Article list view with tag filtering | P0 | Per wireframe |
| Article read view with rich body content | P0 | Per wireframe, includes code blocks, callouts |
| Dark/light mode toggle | P0 | Defaults dark, persisted in localStorage |
| Author profiles | P0 | Single author now; multi-author ready |
| Tag system with manually assigned colours | P0 | cyan / pink / neutral per tag |
| SEO metadata per page | P0 | OG, Twitter cards, canonical, JSON-LD |
| RSS feed (main + per-tag) | P0 | |
| XML Sitemap | P0 | |
| Cookie consent (granular: analytics + advertising) | P0 | GDPR/PECR compliant |
| Legal pages (Privacy Policy, ToS) | P0 | Footer-linked |
| Comment system (per-article toggle, moderation queue) | P1 | |
| Newsletter subscription + double opt-in | P1 | External provider sync |
| Social share buttons | P1 | Twitter/X, LinkedIn, copy link |
| About page | P1 |
1.3 Non-Goals (v1)
- User authentication / accounts for readers
- Paid subscriptions / paywalling
- Native search (Wagtail search can be added in v2)
- Multilingual content
2. Technical Architecture
2.1 Project Layout
nohypeai/
├── config/
│ ├── settings/
│ │ ├── base.py
│ │ ├── development.py
│ │ └── production.py
│ ├── urls.py
│ └── wsgi.py
├── apps/
│ ├── core/ # Shared utilities, middleware, template tags
│ ├── blog/ # Page models: HomePage, ArticleIndexPage, ArticlePage
│ ├── authors/ # Author snippet model
│ ├── comments/ # Comment model + moderation
│ ├── newsletter/ # Subscription model + provider integration
│ └── legal/ # LegalPage model
├── templates/
│ ├── base.html
│ ├── blog/
│ │ ├── home_page.html
│ │ ├── article_index_page.html
│ │ └── article_page.html
│ ├── legal/
│ │ └── legal_page.html
│ ├── comments/
│ ├── newsletter/
│ └── components/ # Reusable partials
│ ├── article_card.html
│ ├── article_card_featured.html
│ ├── cookie_banner.html
│ ├── nav.html
│ ├── footer.html
│ └── sidebar.html
├── static/
│ ├── css/
│ ├── js/
│ │ ├── consent.js # Cookie consent read/write + script loader
│ │ ├── theme.js # Dark mode toggle
│ │ └── prism.js # Syntax highlighting
│ └── img/
├── manage.py
└── requirements/
├── base.txt
└── production.txt
2.2 Wagtail Page Tree
Root
└── HomePage /
├── ArticleIndexPage /articles/
│ └── ArticlePage /articles/{slug}/
├── LegalIndexPage /legal/ ← redirects to home, org only
│ ├── LegalPage /legal/privacy-policy/
│ └── LegalPage /legal/terms-of-service/
└── AboutPage /about/
2.3 Key Package Versions (requirements/base.txt)
Django~=5.2
wagtail~=7.0
wagtail-seo~=3.0
psycopg2-binary~=2.9
Pillow~=11.0
django-taggit~=6.0
whitenoise~=6.0
gunicorn~=23.0
python-dotenv~=1.0
Pinning strategy: Use
~=(compatible release) to lock major/minor and allow patch updates only. Upgrade minor versions deliberately.django-cookie-consenthas been removed — the consent system is implemented custom (see M6).wagtail-seo~=3.0should be verified against the Wagtail 7 compatibility matrix before locking; pin to the exact version confirmed compatible once identified.
requirements/production.txt (extends base):
-r base.txt
django-csp~=3.8
sentry-sdk~=2.0 # error monitoring
2.4 Frontend Stack
| Tool | Role | Delivery |
|---|---|---|
| Tailwind CSS | Styling | django-tailwind (compiled) |
| Alpine.js | Interactive UI (banner, menu, copy button) | CDN (local fallback in prod) |
| Prism.js | Code block syntax highlighting | Local static file |
| Lucide Icons | Icon set | Per wireframe |
3. Test Strategy
3.1 Philosophy
Every milestone follows the Red → Green → Refactor cycle. No production code is written without a failing test first. This is non-negotiable regardless of how "simple" a feature appears.
3.2 Test Layers
| Layer | Tool | What it Covers |
|---|---|---|
| Unit | pytest-django + django.test |
Model methods, validators, template tags, utility functions |
| Integration | wagtail.test.utils + Django test client |
Page routing, view logic, form submission, ORM queries |
| System / E2E | playwright (Python) |
Critical user journeys: reading an article, submitting a comment, cookie consent flow, RSS feed validity |
| Contract | pytest |
RSS/Atom feed schema, JSON-LD schema, sitemap schema |
3.3 Coverage Requirements
- Minimum 90% line coverage on all
apps/code, enforced viapytest-covin CI - Coverage reports generated on every pull request; PRs blocked below threshold
- E2E tests run nightly, not on every pull request (they are slow)
3.4 Test Organisation
Each app contains its own tests/ package:
apps/blog/tests/
├── __init__.py
├── factories.py # wagtail-factories / factory_boy fixtures
├── test_models.py # Unit tests for model methods and validation
├── test_views.py # Integration tests for page rendering and routing
├── test_feeds.py # Contract tests for RSS validity
└── test_seo.py # Meta tag rendering assertions
3.5 Test Fixtures Strategy
Use factory_boy with wagtail-factories throughout. Never use raw fixtures/*.json — they become stale and are a maintenance burden. All test data is created programmatically:
# apps/blog/tests/factories.py
class ArticlePageFactory(wagtail_factories.PageFactory):
class Meta:
model = ArticlePage
title = factory.Sequence(lambda n: f"Test Article {n}")
author = factory.SubFactory(AuthorFactory)
summary = factory.Faker("paragraph")
comments_enabled = True
# Note: no is_featured — featured article is set on HomePage.featured_article only
3.6 CI Pipeline (Gitea Actions)
on: [pull_request]
jobs:
test:
steps:
- Lint (ruff)
- Type check (mypy)
- Run pytest with coverage
- Assert coverage >= 90%
- Build Tailwind (assert no uncommitted diff)
e2e: # nightly only
steps:
- Start dev server
- Run Playwright suite
Rationale: all merges should flow through pull requests. Running the same checks on both push and pull_request duplicates work and wastes compute.
Milestone 0 — Project Scaffold & Tooling
Goal: A running Django/Wagtail project with the database connected, settings split, and CI pipeline active. No feature code yet — only the skeleton that every subsequent milestone builds on.
Acceptance Criteria:
./manage.py runserverstarts without errorspytestruns and exits 0 (no tests yet = trivially passing)ruffandmypypass on an empty codebase- Gitea Actions workflow file committed and green
M0 — Tasks
M0.1 — Environment Setup
- Initialise git repo; add
.gitignore(Python, Django, IDE,.env) - Create
requirements/base.txtandrequirements/production.txt - Configure
python-dotenvfor secrets; document required env vars inREADME.md:SECRET_KEY,DATABASE_URL,ALLOWED_HOSTS,DEBUG,WAGTAIL_SITE_NAME
- Split settings:
base.py(shared),development.py(SQLite or local PG,DEBUG=True),production.py(PG, security headers,DEBUG=False)
M0.2 — Django + Wagtail Init
- Create project with
django-admin startproject config . - Install Wagtail; run
wagtail startscaffold or manually wireINSTALLED_APPS - Add all
apps/directories as empty packages withAppConfig - Register apps in
INSTALLED_APPS - Configure
TEMPLATES,STATICFILES_DIRS,MEDIA_ROOT,MEDIA_URL
M0.3 — Database
- Configure
dj-database-urlforDATABASE_URLenv var - Run initial migrations (
wagtailanddjango.contrib.*) - Confirm Wagtail admin loads at
/cms/
M0.4 — Frontend Tooling
- Install
django-tailwind; init Tailwind with the project's font/colour config from~/design-language.md - Add Tailwind
theme.extendvalues (Space Grotesk, Fira Code, Inter, brand colours, box shadows) matching~/wireframe.html - Add Prism.js and Alpine.js to
static/js/; wire intobase.html
M0.5 — CI
- Create
.gitea/workflows/ci.yml - Install
pytest-django,pytest-cov,ruff,mypy,factory_boy,wagtail-factories - Create
pytest.ini/pyproject.tomlconfig pointing atconfig.settings.development - Write the only M0 test: a trivial smoke test that asserts
1 == 1to confirm CI runs
Milestone 1 — Authors & Core Blog Models
Goal: All data models are defined, tested, and migrated. No templates yet — just working, validated models the rest of the project depends on.
M1 — TDD Cycle
Write tests first for each of the following, then implement.
M1.1 — authors App
Tests to write first (test_models.py):
- Author can be created with required fields
- Author with no linked User can still be created (nullable FK)
- Author avatar saves with Wagtail image FK
- Author __str__ returns name
- Author.get_social_links() returns dict of non-empty URLs only
Design decision:
Authoris a Snippet only — it has no public URL and no corresponding page in the tree. There is no/authors/{slug}/route. Author information is displayed inline on the article page. This avoids a structural conflict with the singleAboutPage. If multi-author public profiles are needed in a future version, promoteAuthorto aPagemodel at that point.
Model to implement:
class Author(models.Model):
user = models.OneToOneField(User, null=True, blank=True, on_delete=SET_NULL)
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
bio = models.TextField(blank=True)
avatar = models.ForeignKey(
'wagtailimages.Image', null=True, blank=True,
on_delete=SET_NULL, related_name='+'
)
twitter_url = models.URLField(blank=True)
github_url = models.URLField(blank=True)
panels = [...] # FieldPanel, ImageChooserPanel
class Meta:
verbose_name = "Author"
def __str__(self) -> str:
return self.name
def get_social_links(self) -> dict[str, str]:
"""Returns dict of {platform: url} for non-empty social URLs only."""
links = {}
if self.twitter_url:
links['twitter'] = self.twitter_url
if self.github_url:
links['github'] = self.github_url
return links
Register as a Wagtail @register_snippet using a SnippetViewSet:
# apps/authors/wagtail_hooks.py
from wagtail.snippets.models import register_snippet
from wagtail.snippets.views.snippets import SnippetViewSet
from .models import Author
class AuthorViewSet(SnippetViewSet):
model = Author
icon = 'user'
list_display = ['name', 'slug']
search_fields = ['name']
add_to_admin_menu = True
register_snippet(AuthorViewSet)
M1.2 — Tag Colour System
Tests to write first:
- TagMetadata can be created linking a Tag to a colour
- TagMetadata.get_css_classes() returns correct Tailwind classes for 'cyan'
- TagMetadata.get_css_classes() returns correct Tailwind classes for 'pink'
- TagMetadata.get_css_classes() returns fallback neutral classes for unknown colour
- Attempting to assign two TagMetadata to the same Tag raises IntegrityError
Models to implement:
class ArticleTag(TaggedItemBase):
content_object = ParentalKey(
'blog.ArticlePage', related_name='tagged_items', on_delete=CASCADE
)
class TagMetadata(models.Model):
COLOUR_CHOICES = [
('cyan', 'Cyan'),
('pink', 'Pink'),
('neutral', 'Neutral'),
]
tag = models.OneToOneField('taggit.Tag', on_delete=CASCADE, related_name='metadata')
colour = models.CharField(max_length=20, choices=COLOUR_CHOICES, default='neutral')
def get_css_classes(self) -> dict[str, str]:
"""Returns bg and text Tailwind classes for this tag's colour."""
...
Register TagMetadata as a @register_snippet via a SnippetViewSet:
# apps/blog/wagtail_hooks.py (partial)
class TagMetadataViewSet(SnippetViewSet):
model = TagMetadata
icon = 'tag'
list_display = ['tag', 'colour']
register_snippet(TagMetadataViewSet)
M1.3 — Core Page Models
Tests to write first (test_models.py):
- HomePage can be created as a child of Root
- ArticleIndexPage can only be created as child of HomePage
- ArticlePage can only be created as child of ArticleIndexPage
- ArticlePage.read_time_mins is computed and stored on save
- ArticlePage.read_time_mins is > 0 for any non-empty body
- ArticlePage.read_time_mins rounds up (never 0 for any content)
- ArticlePage.read_time_mins excludes CodeBlock word counts (code is not prose)
- ArticlePage.read_time_mins is recomputed correctly after body edit
- ArticlePage.get_tags_with_metadata() returns list of (tag, TagMetadata|None) tuples
- ArticlePage summary is required; save fails without it
- ArticlePage without hero_image saves successfully (nullable)
- HomePage.featured_article is the sole mechanism for featuring an article (no is_featured flag on ArticlePage)
Models to implement:
class HomePage(Page):
featured_article = models.ForeignKey(
'blog.ArticlePage', null=True, blank=True,
on_delete=SET_NULL, related_name='+'
)
subpage_types = ['blog.ArticleIndexPage', 'legal.LegalIndexPage', 'blog.AboutPage']
content_panels = Page.content_panels + [
PageChooserPanel('featured_article', 'blog.ArticlePage'),
]
class ArticleIndexPage(Page):
parent_page_types = ['blog.HomePage']
subpage_types = ['blog.ArticlePage']
ARTICLES_PER_PAGE = 12
def get_articles(self) -> PageQuerySet:
return (
ArticlePage.objects
.child_of(self)
.live()
.select_related('author')
.prefetch_related('tags__metadata') # avoids N+1 on tag colour lookups
.order_by('-first_published_at')
)
def get_context(self, request, *args, **kwargs) -> dict:
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
ctx = super().get_context(request, *args, **kwargs)
tag_slug = request.GET.get('tag')
articles = self.get_articles()
if tag_slug:
articles = articles.filter(tags__slug=tag_slug)
paginator = Paginator(articles, self.ARTICLES_PER_PAGE)
page_num = request.GET.get('page')
try:
page_obj = paginator.page(page_num)
except PageNotAnInteger:
page_obj = paginator.page(1)
except EmptyPage:
page_obj = paginator.page(paginator.num_pages)
ctx['articles'] = page_obj
ctx['paginator'] = paginator
ctx['active_tag'] = tag_slug
return ctx
class ArticlePage(Page):
author = models.ForeignKey('authors.Author', on_delete=PROTECT)
hero_image = models.ForeignKey(
'wagtailimages.Image', null=True, blank=True,
on_delete=SET_NULL, related_name='+'
)
summary = models.TextField()
body = StreamField([...], use_json_field=True)
tags = ClusterTaggableManager(through='blog.ArticleTag')
read_time_mins = models.PositiveIntegerField(editable=False, default=1)
comments_enabled = models.BooleanField(default=True)
# NOTE: No is_featured field. Featured article is controlled exclusively
# via HomePage.featured_article (PageChooserPanel). Single source of truth.
parent_page_types = ['blog.ArticleIndexPage']
subpage_types = []
def save(self, *args, **kwargs) -> None:
self.read_time_mins = self._compute_read_time()
super().save(*args, **kwargs)
def _compute_read_time(self) -> int:
"""
Walk StreamField block values to extract prose word count.
Excludes CodeBlock values (code is not reading time).
Returns ceil(word_count / 200), minimum 1.
"""
...
def get_tags_with_metadata(self) -> list[tuple]:
...
M1.4 — LegalPage Models
Tests to write first:
- LegalPage can be created as child of LegalIndexPage
- LegalPage with show_in_footer=True appears in footer queryset
- LegalPage with show_in_footer=False does not appear in footer queryset
- LegalIndexPage.get() redirects to home with 302
Models to implement:
class LegalIndexPage(Page):
"""Organisational parent only. Not rendered — redirects to home."""
parent_page_types = ['blog.HomePage']
subpage_types = ['legal.LegalPage']
def serve(self, request):
from django.shortcuts import redirect
return redirect('/')
def get_sitemap_urls(self, request=None) -> list:
# Explicitly excluded from sitemap — it's a redirect, not indexable content
return []
class LegalPage(Page):
body = RichTextField()
last_updated = models.DateField()
show_in_footer = models.BooleanField(default=True)
parent_page_types = ['legal.LegalIndexPage']
subpage_types = []
Milestone 2 — StreamField Block Library
Goal: The full set of content blocks available to editors in ArticlePage.body is implemented, tested, and renders correctly in isolation.
M2 — TDD Cycle
Tests to write first for each block (test_blocks.py):
- RichTextBlock renders HTML with correct tags
- CodeBlock stores language and raw_code; get_language_label() returns display name
- CodeBlock with unsupported language falls back to 'plaintext'
- CalloutBlock requires heading and body; icon is optional with sane default
- ImageBlock renders correct Wagtail rendition spec
- PullQuoteBlock renders quote and optional attribution
- EmbedBlock delegates to Wagtail's embed finder
M2 — Blocks to Implement
# apps/blog/blocks.py
class CodeBlock(StructBlock):
LANGUAGE_CHOICES = [
('python', 'Python'),
('javascript', 'JavaScript'),
('typescript', 'TypeScript'),
('tsx', 'TSX'),
('bash', 'Bash'),
('json', 'JSON'),
('css', 'CSS'),
('html', 'HTML'),
('plaintext', 'Plain Text'),
]
language = ChoiceBlock(choices=LANGUAGE_CHOICES, default='python')
filename = CharBlock(required=False, help_text="Optional filename label")
raw_code = TextBlock()
class Meta:
icon = 'code'
template = 'blog/blocks/code_block.html'
class CalloutBlock(StructBlock):
ICON_CHOICES = [
('info', 'Info'),
('warning', 'Warning'),
('trophy', 'Trophy / Conclusion'),
('tip', 'Tip'),
]
icon = ChoiceBlock(choices=ICON_CHOICES, default='info')
heading = CharBlock()
body = RichTextBlock(features=['bold', 'italic', 'link'])
class Meta:
icon = 'pick'
template = 'blog/blocks/callout_block.html'
class PullQuoteBlock(StructBlock):
quote = TextBlock()
attribution = CharBlock(required=False)
class Meta:
icon = 'openquote'
template = 'blog/blocks/pull_quote_block.html'
class ImageBlock(StructBlock):
image = ImageChooserBlock()
caption = CharBlock(required=False)
alt = CharBlock(help_text="Required for accessibility")
class Meta:
icon = 'image'
template = 'blog/blocks/image_block.html'
# Assemble the body StreamField
ARTICLE_BODY_BLOCKS = [
('rich_text', RichTextBlock(features=[
'h2', 'h3', 'h4', 'bold', 'italic', 'link',
'ol', 'ul', 'hr', 'blockquote', 'code'
])),
('code', CodeBlock()),
('callout', CalloutBlock()),
('image', ImageBlock()),
('embed', EmbedBlock()),
('pull_quote', PullQuoteBlock()),
]
Milestone 3 — Homepage & Article Index
Goal: The homepage and article list views are fully rendered, matching ~/wireframe.html. Tag filtering works. All templates reference ~/design-language.md for any styling decisions not explicit in the wireframe.
M3 — TDD Cycle
Tests to write first (test_views.py):
HOMEPAGE
- GET / returns 200
- Homepage renders featured article from HomePage.featured_article FK
- Homepage renders up to 5 latest articles in main feed
- Homepage renders up to 3 articles in "More Articles" card grid
- Homepage with no featured article set renders gracefully (no 500)
- Featured article card renders: title, tags, author name, date, read_time_mins
- Homepage issues <= 10 DB queries (assertNumQueries)
ARTICLE INDEX
- GET /articles/ returns 200
- Article index lists all live ArticlePages ordered by date descending
- GET /articles/?tag=llms filters to only articles tagged 'llms'
- GET /articles/?tag=nonexistent returns 200 with empty list (no 404)
- Tag filter preserves active state in the UI (active_tag in context)
- GET /articles/?page=2 returns page 2 of paginated results
- GET /articles/?page=999 returns last page, not 404
- GET /articles/?page=notanumber returns page 1
- Context contains 'paginator' and 'articles' (a Page object from Paginator, not a raw queryset)
- Article cards render tag colours from TagMetadata (cyan class for cyan tag)
- Draft articles do not appear in the list
- Article index page issues <= 10 DB queries (assertNumQueries)
M3 — Implementation Notes
base.htmlmust include: nav, dark mode toggle, cookie banner include, footer. All subsequent templates extend this.- Implement
{% get_legal_pages %}template tag incoreapp that queriesLegalPage.objects.live().filter(show_in_footer=True)— used in footer partial. - Implement
{% get_tag_css %}template tag that accepts a tag and returns the correct Tailwind colour classes fromTagMetadata, falling back to neutral. - Pagination is manual Django
Paginator— Wagtail page models do not have apaginate_byclass attribute like Django'sListView. Pagination is implemented entirely withinArticleIndexPage.get_context()as shown in the M1.3 model code. The template receives aPageobject (fromPaginator.page()) inctx['articles'], not a raw queryset. - N+1 query prevention: Tag colour lookups on the article index are a classic N+1 risk.
select_related('author')andprefetch_related('tags__metadata')are applied inget_articles()(see M1.3 model). Validate withassertNumQueriesin M3 tests — do not defer this to M10. - Dark mode: The
<html>element receives classdarkbased on localStorage via a blocking inline script in<head>(before paint, to avoid flash). This inline script requires a CSP nonce (see M10.1).
Milestone 4 — Article Read View & Related Content
Goal: The full article read experience is implemented: rich body rendering with all block types, sticky sidebar (share + newsletter CTA), and related articles.
M4 — TDD Cycle
Tests to write first (test_views.py):
ARTICLE PAGE
- GET /articles/{valid-slug}/ returns 200
- GET /articles/{nonexistent-slug}/ returns 404
- Draft article returns 404 to unauthenticated users
- Draft article returns 200 to authenticated Wagtail editors (Wagtail preview)
- Article page context contains 'related_articles' (max 3)
- Related articles are drawn from same tags, excluding self
- Related articles fallback to latest articles if no tag overlap
- Article page renders read_time_mins in header
- Article page with comments_enabled=True renders comment section
- Article page with comments_enabled=False does not render comment section
- JSON-LD script tag is present and valid on article page
- JSON-LD contains correct @type: Article, headline, author, datePublished
BLOCK RENDERING
- CodeBlock template renders <pre data-lang="python"> wrapper
- CodeBlock with filename renders filename label above block
- CalloutBlock with icon='trophy' renders correct Lucide icon class
- ImageBlock renders <img> with correct srcset from Wagtail renditions
- PullQuoteBlock renders quote in <blockquote> with optional attribution
SHARE BUTTONS
- Twitter/X share URL contains article absolute URL and title
- LinkedIn share URL contains article absolute URL
- Copy link button rendered with data-url attribute
M4 — Implementation Notes
Related Articles Logic:
def get_related_articles(self, count: int = 3) -> PageQuerySet:
tag_ids = self.tags.values_list('id', flat=True)
related = (
ArticlePage.objects.live()
.filter(tags__in=tag_ids)
.exclude(pk=self.pk)
.distinct()
.order_by('-first_published_at')[:count]
)
if related.count() < count:
# Pad with latest articles
exclude_ids = list(related.values_list('pk', flat=True)) + [self.pk]
fallback = (
ArticlePage.objects.live()
.exclude(pk__in=exclude_ids)
.order_by('-first_published_at')[:count - related.count()]
)
return list(related) + list(fallback)
return related
JSON-LD Template Tag:
Implemented in core/templatetags/seo_tags.py as {% article_json_ld article %}. Outputs a <script type="application/ld+json"> block with Article schema including headline, author, datePublished, dateModified, image, description.
Prism.js Integration:
Code block template outputs <pre><code class="language-{lang}"> — Prism.js automatically highlights on DOMContentLoaded. Copy button uses Alpine.js: @click="navigator.clipboard.writeText($el.previousElementSibling.textContent)".
Milestone 5 — SEO, RSS & Sitemap
Goal: Every indexable page has correct meta tags; a valid RSS feed and sitemap are served; robots.txt is configured.
M5 — TDD Cycle
Tests to write first (test_seo.py, test_feeds.py):
SITE URL & ABSOLUTE URLs
- RSS feed item links are absolute URLs (include scheme + domain)
- Canonical tags contain fully qualified URLs, not relative paths
- JSON-LD 'url' field is an absolute URL
- OG url tag is an absolute URL
- WAGTAILADMIN_BASE_URL is set in production settings (assert in settings test)
SEO TAGS (via wagtail-seo)
- ArticlePage renders <title> as "{article title} | No Hype AI"
- ArticlePage renders <meta name="description"> from Page.search_description (falls back to summary)
- ArticlePage renders og:title, og:description, og:image
- ArticlePage og:image points to hero_image rendition (1200x630)
- ArticlePage without hero_image falls back to site-wide default OG image (from SiteSettings)
- ArticlePage renders twitter:card = "summary_large_image"
- HomePage renders og:type = "website"
- ArticlePage renders og:type = "article"
- Canonical URL tag present and correct on all page types
RSS FEEDS
- GET /feed/ returns 200 with Content-Type: application/rss+xml
- RSS feed contains all live ArticlePages
- RSS feed items contain: title, link, description, pubDate, author
- RSS item link is a fully qualified absolute URL
- RSS feed validates against RSS 2.0 schema (feedparser assertion)
- GET /feed/tag/llms/ returns feed filtered to 'llms' tag
- GET /feed/tag/nonexistent/ returns 404
SITEMAP
- GET /sitemap.xml returns 200 with Content-Type: application/xml
- Sitemap contains entries for all live ArticlePages and LegalPages
- LegalIndexPage does NOT appear in sitemap (get_sitemap_urls returns [])
- Draft pages are absent from sitemap
- Sitemap validates against sitemap.org schema
ROBOTS.TXT
- GET /robots.txt returns 200 with Content-Type: text/plain
- robots.txt contains Sitemap: directive pointing to /sitemap.xml
- robots.txt disallows /cms/
M5 — Implementation Notes
Site URL configuration — do this before anything SEO-related:
Absolute URLs in Wagtail depend on the Site record being correct and WAGTAILADMIN_BASE_URL being set. Add these to settings and document in the runbook:
# config/settings/production.py
WAGTAILADMIN_BASE_URL = env('WAGTAILADMIN_BASE_URL') # e.g. 'https://nohypeai.com'
# Ensure Django trusts X-Forwarded-Proto from Caddy
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
USE_X_FORWARDED_HOST = True
The Wagtail Site record (/cms/wagtailcore/site/) must have its hostname and port set correctly for page.get_full_url(request) to return correct absolute URLs. Verify this in the M0 setup step. In templates and feeds, always use page.get_full_url(request) rather than page.url — the latter returns a relative URL.
Default OG image (for articles without hero_image):
Add a default_og_image to SiteSettings (from wagtail.contrib.settings):
# apps/core/models.py
from wagtail.contrib.settings.models import BaseSiteSetting, register_setting
@register_setting
class SiteSettings(BaseSiteSetting):
default_og_image = models.ForeignKey(
'wagtailimages.Image', null=True, blank=True,
on_delete=SET_NULL, related_name='+'
)
privacy_policy_page = models.ForeignKey(
'wagtailcore.Page', null=True, blank=True,
on_delete=SET_NULL, related_name='+',
help_text="Used in cookie banner 'learn more' link"
)
The {% article_json_ld %} template tag and OG image logic both fall back to SiteSettings.default_og_image when article.hero_image is None.
wagtail-seo configuration:
# In ArticlePage, add wagtail-seo mixin
class ArticlePage(SeoMixin, Page):
...
# wagtail-seo reads og_image from hero_image if configured in SeoMixin
promote_panels = SeoMixin.seo_panels
wagtail-seo compatibility note:
wagtail-seohas had breaking changes between major versions (particularly around site-settings fields). Verify the pinned version (~=3.0) against the Wagtail 7 changelog before starting M5. Review the package'sCHANGELOG.mdfor any requiredSeoSettingsmigration steps.
RSS Feed views (apps/blog/feeds.py):
class AllArticlesFeed(Feed):
title = "No Hype AI"
link = "/articles/"
description = "Honest AI coding tool reviews for developers."
def items(self):
return ArticlePage.objects.live().order_by('-first_published_at')[:20]
def item_title(self, item: ArticlePage) -> str:
return item.title
def item_description(self, item: ArticlePage) -> str:
return item.summary
def item_pubdate(self, item: ArticlePage):
return item.first_published_at
def item_author_name(self, item: ArticlePage) -> str:
return item.author.name
def item_link(self, item: ArticlePage) -> str:
# get_full_url requires request; use WAGTAILADMIN_BASE_URL as fallback
from django.conf import settings
return f"{settings.WAGTAILADMIN_BASE_URL}{item.url}"
class TagArticlesFeed(AllArticlesFeed):
def get_object(self, request, tag_slug: str):
return get_object_or_404(Tag, slug=tag_slug)
def title(self, tag) -> str:
return f"No Hype AI — {tag.name}"
def items(self, tag):
return (
ArticlePage.objects.live()
.filter(tags=tag)
.order_by('-first_published_at')[:20]
)
robots.txt — served as a TemplateView pointing to core/robots.txt template. Not a static file, so ALLOWED_HOSTS and WAGTAILADMIN_BASE_URL env var can be interpolated.
Milestone 6 — Cookie Consent System
Goal: A GDPR/PECR compliant, granular cookie consent system is live. Consent is stored per-category. Analytics and advertising scripts are only loaded after explicit consent. The consent version is tied to the privacy policy version, prompting re-consent on policy changes.
M6 — TDD Cycle
Tests to write first (apps/core/tests/test_consent.py):
COOKIE ENCODING & DECODING
- ConsentService.set_consent() writes cookie value as URL-encoded query string (not raw JSON)
- ConsentService.get_consent() correctly parses 'a=1&d=0&v=1&ts=...' format
- ConsentService.get_consent() on request with no cookie returns ConsentState with all False
- ConsentState.requires_prompt is True if stored version != settings.CONSENT_POLICY_VERSION
- ConsentState.requires_prompt is False if versions match
- ConsentService.get_consent() with malformed/corrupted cookie returns safe default (all False, no 500)
- ConsentService.get_consent() with missing individual fields returns False for those fields
- Cookie value round-trips correctly: set then get returns identical ConsentState
MIDDLEWARE
- ConsentMiddleware injects request.consent on every request
- request.consent.analytics is True when cookie has a=1
- request.consent.advertising is False when cookie has d=0
- request.consent.requires_prompt is True when no cookie present
- request.consent.requires_prompt is True when cookie version is stale
CONSENT VIEW
- POST /consent/ with analytics=true, advertising=false sets correct cookie and redirects to HTTP_REFERER
- POST /consent/ with accept_all=true sets all categories True
- POST /consent/ with reject_all=true sets all categories False
- POST /consent/ redirects to '/' when HTTP_REFERER is absent
- GET /consent/ returns 405 Method Not Allowed
TEMPLATE RENDERING
- Cookie banner is rendered when request.consent.requires_prompt is True
- Cookie banner is NOT rendered when consent has been given (any choice)
- Analytics script tag is NOT rendered when request.consent.analytics is False
- Advertising placeholder renders "You've rejected advertising cookies" when consent.advertising is False
M6 — Implementation
Why not
django-cookie-consent? That package is retained as a dependency in several tutorials but adds a DB-backed consent log that is overkill for a single-author blog with a simple two-category consent model. We implement custom — lighter, no extra migrations, no extra admin UI to explain. If regulatory audit requirements grow (e.g. IAB TCF 2.2 for ad networks), migrate to a proper CMP at that point.
Cookie encoding — use URL-encoded query string, not JSON:
Raw JSON in cookies is unreliable across browsers and Django middleware due to quoting/escaping behaviour. A simple URL-encoded format (a=1&d=0&v=1&ts=1234567890) is unambiguous, safe to parse in both Python and JavaScript without edge cases.
# apps/core/consent.py
from dataclasses import dataclass
from urllib.parse import urlencode, parse_qs
import time
from django.conf import settings
CONSENT_COOKIE_NAME = 'nhAiConsent'
@dataclass
class ConsentState:
analytics: bool = False
advertising: bool = False
policy_version: int = 0
timestamp: int = 0
@property
def requires_prompt(self) -> bool:
return self.policy_version != settings.CONSENT_POLICY_VERSION
class ConsentService:
@staticmethod
def get_consent(request) -> ConsentState:
raw = request.COOKIES.get(CONSENT_COOKIE_NAME, '')
if not raw:
return ConsentState()
try:
# parse_qs returns lists; take first value of each key
data = {k: v[0] for k, v in parse_qs(raw).items()}
return ConsentState(
analytics= data.get('a', '0') == '1',
advertising= data.get('d', '0') == '1',
policy_version= int(data.get('v', '0')),
timestamp= int(data.get('ts', '0')),
)
except (ValueError, AttributeError):
return ConsentState() # safe fallback; never crash on bad cookie
@staticmethod
def set_consent(response, analytics: bool, advertising: bool) -> None:
payload = urlencode({
'a': int(analytics),
'd': int(advertising),
'v': settings.CONSENT_POLICY_VERSION,
'ts': int(time.time()),
})
response.set_cookie(
CONSENT_COOKIE_NAME,
payload,
max_age=60 * 60 * 24 * 365, # 1 year
httponly=False, # JS must read this client-side
samesite='Lax',
secure=not settings.DEBUG,
)
settings.CONSENT_POLICY_VERSION = 1 — bump this integer whenever the Privacy Policy changes. Any stored version less than this will cause requires_prompt = True.
Client-side Script Loader (static/js/consent.js):
// Reads nhAiConsent cookie using URL-encoded format (a=1&d=0&v=1&ts=...)
// Runs synchronously in <head> — do NOT defer or async.
// Requires a CSP nonce (see M10.1).
(function () {
function parseCookieValue(name) {
const match = document.cookie.match(
new RegExp('(?:^|;)\\s*' + name + '\\s*=\\s*([^;]+)')
);
if (!match) return {};
try {
return Object.fromEntries(new URLSearchParams(match[1]));
} catch (_) { return {}; }
}
function loadScript(src) {
const s = document.createElement('script');
s.src = src; s.async = true;
document.head.appendChild(s);
}
const c = parseCookieValue('nhAiConsent');
if (c.a === '1') {
// loadScript('https://www.googletagmanager.com/gtag/js?id=GA_ID');
}
if (c.d === '1') {
// loadScript('https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js');
}
// Expose parsed consent to Alpine.js components
window.__nhConsent = { analytics: c.a === '1', advertising: c.d === '1' };
})();
CSP nonce requirement: This inline script runs before any external scripts and must be CSP-compliant. Generate a per-request nonce in Django middleware, inject it into the template context, and apply it to all inline <script> tags (the consent loader, the dark mode toggle script, and any Alpine.js x-init inline code). See M10.1 for the full CSP configuration. Self-host Alpine.js and Prism.js (do not use CDN) to keep the script-src allowlist tight.
Cookie Banner Template (templates/components/cookie_banner.html):
The banner is an Alpine.js component. On load, it reads window.__nhConsent (set synchronously before Alpine initialises) to decide visibility. "Accept All" / "Reject All" POST to /consent/ immediately. "Manage Preferences" expands a per-category toggles panel using Alpine x-show. Style to match ~/design-language.md — use the solid border / neobrutalist aesthetic of the site.
Milestone 7 — Comment System
Goal: Readers can submit comments on articles where enabled. Comments go into a moderation queue and appear only after approval. Threading is supported one level deep.
M7 — TDD Cycle
Tests to write first (apps/comments/tests/):
MODEL
- Comment requires article, author_name, body
- Comment default is_approved=False
- Comment.get_absolute_url() returns article URL + #comment-{id}
- Reply comment has parent FK set; top-level comment has parent=None
- Reply to a reply is rejected by clean() (max one level deep)
FORMS
- CommentForm validates required fields
- CommentForm rejects empty body
- CommentForm rejects body over 2000 chars
- CommentForm honeypot field: non-empty value causes silent discard (spam trap)
VIEWS
- GET /articles/{slug}/ with comments_enabled=True renders comment form
- GET /articles/{slug}/ with comments_enabled=False renders no comment form
- POST valid comment to article returns 302 redirect to article + success flash message
- POST valid comment creates Comment with is_approved=False
- POST invalid comment re-renders form with errors
- Approved comments appear in article page context under 'approved_comments'
- Unapproved comments do NOT appear in article page context
- POST comment with honeypot filled returns 302 (silent discard, no error shown)
- Reply form POST sets parent FK correctly
- POST comment from same IP more than 3 times in 60 seconds returns 429 Too Many Requests
ADMIN (SnippetViewSet)
- CommentViewSet list view is accessible in Wagtail admin at /cms/snippets/comments/comment/
- List view shows author_name, article title, is_approved, created_at columns
- List view can be filtered by is_approved=False (pending queue)
- Bulk approve action sets is_approved=True on selected comments
- Bulk delete action removes selected comments
- Pending comment count is visible in admin (queryset annotation)
M7 — Implementation Notes
Model:
class Comment(models.Model):
article = models.ForeignKey('blog.ArticlePage', on_delete=CASCADE, related_name='comments')
parent = models.ForeignKey('self', null=True, blank=True, on_delete=CASCADE, related_name='replies')
author_name = models.CharField(max_length=100)
author_email = models.EmailField() # stored, never displayed publicly
body = models.TextField(max_length=2000)
is_approved = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
def clean(self) -> None:
if self.parent and self.parent.parent_id is not None:
raise ValidationError("Replies cannot be nested beyond one level.")
GDPR note:
author_emailandip_addressare personal data. Document retention policy in the Privacy Policy (e.g. deleted after 24 months). Add a management commandpurge_old_comment_datathat nullifiesauthor_emailandip_addresson comments older than the retention window. Schedule via cron. This is not a v1 blocker but must be in the Privacy Policy from day one.
Comment posting is handled by a standalone CommentCreateView (Django CBV), not via Wagtail's page serve() method. The view POST target is /comments/post/ with article_id as a hidden field. On success, redirect to article.url + '?commented=1' and show a flash message ("Your comment is awaiting moderation").
Rate limiting — use Django's cache-based rate limiting in CommentCreateView.dispatch(). Limit to 3 POSTs per 60 seconds per IP. Return HttpResponse(status=429) on breach. The client IP must be read via X-Forwarded-For — behind Caddy, only trust the first IP in the header from Caddy's known proxy address (set TRUSTED_PROXIES in settings).
Moderation UI — use SnippetViewSet, not the removed ModelAdmin:
# apps/comments/wagtail_hooks.py
from wagtail.snippets.models import register_snippet
from wagtail.snippets.views.snippets import SnippetViewSet
from wagtail.admin.ui.tables import BooleanColumn
from .models import Comment
class CommentViewSet(SnippetViewSet):
model = Comment
icon = 'comment'
list_display = ['author_name', 'article', BooleanColumn('is_approved'), 'created_at']
list_filter = ['is_approved']
search_fields = ['author_name', 'body']
add_to_admin_menu = True
def get_queryset(self, request):
return super().get_queryset(request).select_related('article', 'parent')
register_snippet(CommentViewSet)
Bulk actions (approve, delete) are registered via Wagtail's register_bulk_action hook. See Wagtail docs: Customising admin views for snippets → Bulk actions.
Future: django-akismet can be wired into CommentCreateView.form_valid() as a pre-save hook — the model and view are structured to accommodate this without changes.
Milestone 8 — Newsletter Integration
Goal: Newsletter subscription form (appearing in nav, article sidebar, footer) works end-to-end with double opt-in. Subscriptions are stored locally and synced to an external provider.
M8 — TDD Cycle
Tests to write first (apps/newsletter/tests/):
MODEL
- NewsletterSubscription requires email
- NewsletterSubscription email is unique; duplicate raises IntegrityError
- NewsletterSubscription default confirmed=False
- NewsletterSubscription.source stores which CTA triggered the signup
FORMS
- SubscriptionForm validates email format
- SubscriptionForm rejects blank email
- SubscriptionForm includes honeypot field
VIEWS
- POST /newsletter/subscribe/ with valid email creates unconfirmed subscription
- POST /newsletter/subscribe/ with valid email returns 200 JSON {"status": "ok"}
- POST /newsletter/subscribe/ with duplicate email returns 200 JSON {"status": "ok"} (silent, no enumeration)
- POST /newsletter/subscribe/ with invalid email returns 400 JSON {"status": "error", "field": "email"}
- POST /newsletter/subscribe/ with honeypot filled returns 200 JSON {"status": "ok"} (silent discard)
- GET /newsletter/confirm/{token}/ sets confirmed=True for matching subscription
- GET /newsletter/confirm/{invalid-token}/ returns 404
PROVIDER SYNC
- ProviderSyncService.sync(subscription) is called after confirmation
- ProviderSyncService raises ProviderSyncError on API failure; error is logged, not re-raised
- Failed sync is retried (if using Celery) or logged for manual retry
M8 — Implementation Notes
Email delivery — must be configured before double opt-in can work:
Double opt-in is impossible without working outbound email. Configure this as the first task in M8, before writing any subscription view code.
# config/settings/base.py
EMAIL_BACKEND = env('EMAIL_BACKEND',
default='django.core.mail.backends.console.EmailBackend')
EMAIL_HOST = env('EMAIL_HOST', default='')
EMAIL_PORT = env.int('EMAIL_PORT', default=587)
EMAIL_USE_TLS = env.bool('EMAIL_USE_TLS', default=True)
EMAIL_HOST_USER = env('EMAIL_HOST_USER', default='')
EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD', default='')
DEFAULT_FROM_EMAIL = env('DEFAULT_FROM_EMAIL', default='hello@nohypeai.com')
In development, EMAIL_BACKEND = django.core.mail.backends.console.EmailBackend prints emails to stdout — no SMTP needed. In production, use a transactional provider (Postmark, Mailgun, or Amazon SES) by setting EMAIL_BACKEND = django.core.mail.backends.smtp.EmailBackend and the relevant env vars.
Email templates live in templates/newsletter/email/:
confirmation_subject.txt— single line, no HTMLconfirmation_body.txt— plain text versionconfirmation_body.html— HTML version (inline CSS, tested in major email clients)
Both plain text and HTML versions are required. Use Django's EmailMultiAlternatives to send both. Test email rendering with Litmus or Mailtrap before launch.
Confirmation token — use Django's signing.dumps() / signing.loads() with the email address and a salt. No separate model field needed for the token itself.
Provider Sync — implement a ProviderSyncService base class with a concrete ButtondownSyncService (or MailchimpSyncService). Provider is configurable via NEWSLETTER_PROVIDER env var. The service is called synchronously on confirmation (v1); if latency becomes an issue, swap in a Celery task with no model changes required.
Milestone 9 — Legal Pages
Goal: Privacy Policy and Terms of Service pages are live, editable in the CMS, and linked from the footer. The cookie banner links to the Privacy Policy.
M9 — TDD Cycle
Tests to write first:
- LegalPage renders body RichTextField as HTML
- LegalPage renders last_updated date in human-readable format
- Footer renders links to all LegalPages where show_in_footer=True
- GET /legal/privacy-policy/ returns 200
- GET /legal/terms-of-service/ returns 200
- LegalIndexPage GET redirects to / with 302
- Cookie banner "Privacy Policy" link href matches /legal/privacy-policy/
M9 — Implementation Notes
- Editors create and edit Legal pages entirely in Wagtail CMS — no hardcoded content.
last_updatedis displayed prominently at the top of each legal page ("Last updated: March 2026").- The footer template uses
{% get_legal_pages %}template tag (implemented in M3) — no additional work needed. - Cookie banner "learn more" link must point to the Privacy Policy page. Implement via a
SiteSettingsWagtail snippet (fromwagtail.contrib.settings) with aPageChooserPanelforprivacy_policy_page. This avoids hardcoding the URL.
Milestone 9b — About Page
Goal: The About page is live, editable in the CMS, and linked from the navigation. It displays site description and author bio, referencing ~/design-language.md for layout.
This milestone exists because
AboutPageappears in the Wagtail page tree andHomePage.subpage_typesfrom M1.3, but was absent from the original milestone plan. It is a P1 feature but must be scaffolded before M10 (which validates the full page tree).
M9b — TDD Cycle
Tests to write first:
- AboutPage can only be created as child of HomePage
- GET /about/ returns 200
- AboutPage renders author bio from Author snippet (via ForeignKey)
- AboutPage renders author avatar via Wagtail rendition
- AboutPage renders site mission/description field
- Nav link to /about/ is present and correct in base.html
M9b — Models to implement
class AboutPage(Page):
mission_statement = models.TextField(
help_text="Displayed as the page intro/hero text"
)
body = RichTextField(blank=True)
featured_author = models.ForeignKey(
'authors.Author', null=True, blank=True,
on_delete=SET_NULL, related_name='+'
)
parent_page_types = ['blog.HomePage']
subpage_types = []
content_panels = Page.content_panels + [
FieldPanel('mission_statement'),
FieldPanel('body'),
SnippetChooserPanel('featured_author'),
]
Milestone 10 — Production Hardening
Goal: The application is secure, performant, and deployable on the target VPS. Monitoring is configured. A deployment runbook exists.
M10 — TDD Cycle
Tests to write first (test_security.py, test_performance.py):
SECURITY
- CSRF token is present on all forms (comment, newsletter, consent)
- X-Frame-Options header is set to SAMEORIGIN on all responses (DENY breaks Wagtail preview)
- Content-Security-Policy header is present on all responses
- CSP script-src does not contain 'unsafe-inline' (nonces are used instead)
- Referrer-Policy header is set
- HTTPS redirect is active (production settings)
- Django admin URL is not /admin/ (obscured)
- robots.txt disallows /cms/
PERFORMANCE (query count, not wall-clock time — wall-clock assertions are flaky in CI)
- Homepage issues <= 10 DB queries (assertNumQueries)
- Article index page (12 articles, with tags) issues <= 12 DB queries (assertNumQueries)
- Article read view issues <= 8 DB queries (assertNumQueries)
- read_time computation on a 1000-word body completes in < 50ms (pytest-benchmark, stored baseline)
- Hero image renditions are generated at correct dimensions (not full-size source)
CONTENT INTEGRITY (management command checks, not request tests)
- All live ArticlePages have a non-empty summary
- All live ArticlePages have an Author assigned
- No live ArticlePage has a null hero_image AND no site-wide default OG image set in SiteSettings
M10 — Tasks
M10.1 — Security Headers & CSP Nonces
Configure django-csp in Django middleware (Caddy sets a second enforcement layer — see M10.3):
Content-Security-Policy:script-src 'self' 'nonce-{nonce}'— the nonce covers the inline consent loader, dark mode script, and any Alpine.js inline initialisers. No'unsafe-inline'.X-Frame-Options: SAMEORIGIN— notDENY. Wagtail's page preview renders the page inside an iframe on the same origin;DENYbreaks this entirely.X-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: disable camera, microphone, geolocation
Nonce implementation: django-csp generates a per-request nonce accessible in templates as {{ request.csp_nonce }}. Apply it to every inline <script> tag:
<!-- base.html -->
<script nonce="{{ request.csp_nonce }}">
/* dark mode toggle — must run before paint */
</script>
<script nonce="{{ request.csp_nonce }}" src="{% static 'js/consent.js' %}"></script>
Alpine.js and Prism.js must be self-hosted (in static/js/) — not loaded from CDN — to keep script-src tight. A CDN URL requires adding it to the allowlist or using 'unsafe-hashes', both of which weaken the policy.
M10.2 — Performance
- Static files:
whitenoiseis used forcollectstaticand is always present for the./manage.py runserverdevelopment workflow. In production, Caddy serves/static/and/media/directly (see Caddyfile in M10.3), so WhiteNoise is bypassed in practice — but it remains inINSTALLED_APPSas a fallback and forcollectstaticsupport. Do not remove it. - Configure Wagtail image renditions cache: generate renditions on first request, store in
MEDIA_ROOT. - Add
django-debug-toolbarin development only to catch N+1 queries during template development. - All views must pass
assertNumQuerieschecks established in M3/M4 before M10 is considered done.
M10.3 — Caddy + Gunicorn
Caddy handles TLS automatically via ACME/Let's Encrypt — no certificate management needed. Configure a Unix socket between Caddy and Gunicorn:
# /etc/caddy/Caddyfile
nohypeai.com {
# Reverse proxy to Gunicorn Unix socket
reverse_proxy unix//run/gunicorn/gunicorn.sock
# Static and media files served directly by Caddy
handle /static/* {
root * /srv/nohypeai
file_server
header Cache-Control "public, max-age=31536000, immutable"
}
handle /media/* {
root * /srv/nohypeai
file_server
}
# Security headers (complement Django middleware)
header {
X-Frame-Options "SAMEORIGIN"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
# CSP managed by Django middleware (nonce required for inline scripts)
-Server
}
encode zstd gzip
log {
output file /var/log/caddy/nohypeai.log
}
}
Gunicorn systemd socket + service (preferred over inline socket path):
Using systemd socket activation ensures the socket file exists before Gunicorn starts and that Caddy can always connect, with correct permissions managed by systemd:
# /etc/systemd/system/gunicorn.socket
[Unit]
Description=No Hype AI — Gunicorn socket
[Socket]
ListenStream=/run/gunicorn/gunicorn.sock
SocketUser=www-data
SocketGroup=www-data
SocketMode=0660
[Install]
WantedBy=sockets.target
# /etc/systemd/system/gunicorn.service
[Unit]
Description=No Hype AI — Gunicorn
Requires=gunicorn.socket
After=network.target
[Service]
User=www-data
Group=www-data
WorkingDirectory=/srv/nohypeai
RuntimeDirectory=gunicorn
RuntimeDirectoryMode=0755
ExecStart=/srv/nohypeai/.venv/bin/gunicorn \
--workers 3 \
--bind unix:/run/gunicorn/gunicorn.sock \
--access-logfile - \
config.wsgi:application
Restart=on-failure
[Install]
WantedBy=multi-user.target
RuntimeDirectory=gunicorn instructs systemd to create /run/gunicorn/ with correct ownership on start and clean it up on stop — no manual mkdir needed. Caddy must run as a user that is in the www-data group, or the socket mode/group must be adjusted accordingly.
Note on security headers:
X-Frame-Options: SAMEORIGINis set both in Caddy and enforced by Django'sXFrameOptionsMiddleware(the Django default is alreadySAMEORIGIN). Do not override it toDENYas this breaks Wagtail's live preview feature. Ifdjango-cspis added, ensure theframe-ancestorsCSP directive is also set to'self'rather than'none'.
M10.4 — Deployment Runbook (README.md section)
# Zero-downtime deploy
git pull origin main
pip install -r requirements/production.txt
./manage.py migrate --run-syncdb
./manage.py collectstatic --noinput
./manage.py tailwind build # compile Tailwind CSS
sudo systemctl reload gunicorn
# Caddy picks up static/media changes automatically — no reload needed
M10.5 — Backups
- Daily PostgreSQL dump via cron:
pg_dump | gzip > backup-$(date +%Y%m%d).sql.gz MEDIA_ROOTrsync to offsite location daily- Document restore procedure in
README.md
Dependency Map
Each milestone depends on prior milestones being fully green (all tests passing):
M0 (Scaffold)
└── M1 (Models)
├── M2 (Blocks) ← depends on M1 (ArticlePage.body)
│ ├── M3 (Homepage + Index) ← depends on M1 + M2; N+1 query guards here
│ │ └── M4 (Article Read) ← depends on M3
│ │ └── M5 (SEO + RSS) ← depends on M4; site URL config done here
│ │ └── M9 (Legal) ← depends on M5 (footer template complete)
│ │ └── M9b (About)← depends on M9 (nav/footer complete)
│ └── M6 (Cookies) ← depends on M3 (base.html exists)
│ └── M8 (Newsletter) ← depends on M6 (banner/CTA placement);
│ email delivery configured first within M8
└── M7 (Comments) ← depends on M1 (ArticlePage FK), M4 (article template)
M10 (Hardening) ← depends on all milestones complete
Key sequencing notes:
- N+1 query prevention (prefetch_related) is enforced from M3 via
assertNumQueries— not deferred to M10 - Email SMTP/provider must be configured as the first task inside M8, before any subscription view code
SiteSettingsmodel (default OG image, privacy policy page) is introduced in M5 alongside the SEO work that depends on itAboutPage(M9b) must exist before M10 validates the complete page tree
Definition of Done
A milestone is Done when all of the following are true:
- All tests for the milestone pass (
pytest -x) - Coverage remains ≥ 90% across all
apps/code ruffreports zero lint errorsmypyreports zero type errors on modified files- CI pipeline is green
- The feature is demonstrable in a running development server
- Any new Wagtail models are registered in the admin and editable by a non-technical editor without requiring code changes
- No feature ships without a corresponding entry in
CHANGELOG.md
This document is the source of truth for implementation order and test requirements. Revise it when requirements change — do not let it drift from the codebase.