Files
main-site/implementation.md

1490 lines
60 KiB
Markdown

# 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
1. [Product Overview](#1-product-overview)
2. [Technical Architecture](#2-technical-architecture)
3. [Test Strategy](#3-test-strategy)
4. [Milestone 0 — Project Scaffold & Tooling](#milestone-0--project-scaffold--tooling)
5. [Milestone 1 — Authors & Core Blog Models](#milestone-1--authors--core-blog-models)
6. [Milestone 2 — StreamField Block Library](#milestone-2--streamfield-block-library)
7. [Milestone 3 — Homepage & Article Index](#milestone-3--homepage--article-index)
8. [Milestone 4 — Article Read View & Related Content](#milestone-4--article-read-view--related-content)
9. [Milestone 5 — SEO, RSS & Sitemap](#milestone-5--seo-rss--sitemap)
10. [Milestone 6 — Cookie Consent System](#milestone-6--cookie-consent-system)
11. [Milestone 7 — Comment System](#milestone-7--comment-system)
12. [Milestone 8 — Newsletter Integration](#milestone-8--newsletter-integration)
13. [Milestone 9 — Legal Pages](#milestone-9--legal-pages)
14. [Milestone 10 — Production Hardening](#milestone-10--production-hardening)
15. [Dependency Map](#dependency-map)
16. [Definition of Done](#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-consent` has been removed — the consent system is implemented custom (see M6). `wagtail-seo~=3.0` should 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 via `pytest-cov` in CI
- Coverage reports generated on every push; PRs blocked below threshold
- E2E tests run nightly, not on every push (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:
```python
# 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 (GitHub Actions)
```
on: [push, 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
```
---
## 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 runserver` starts without errors
- `pytest` runs and exits 0 (no tests yet = trivially passing)
- `ruff` and `mypy` pass on an empty codebase
- GitHub Actions workflow file committed and green
### M0 — Tasks
#### M0.1 — Environment Setup
- Initialise git repo; add `.gitignore` (Python, Django, IDE, `.env`)
- Create `requirements/base.txt` and `requirements/production.txt`
- Configure `python-dotenv` for secrets; document required env vars in `README.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 start` scaffold or manually wire `INSTALLED_APPS`
- Add all `apps/` directories as empty packages with `AppConfig`
- Register apps in `INSTALLED_APPS`
- Configure `TEMPLATES`, `STATICFILES_DIRS`, `MEDIA_ROOT`, `MEDIA_URL`
#### M0.3 — Database
- Configure `dj-database-url` for `DATABASE_URL` env var
- Run initial migrations (`wagtail` and `django.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.extend` values (Space Grotesk, Fira Code, Inter, brand colours, box shadows) matching `~/wireframe.html`
- Add Prism.js and Alpine.js to `static/js/`; wire into `base.html`
#### M0.5 — CI
- Create `.github/workflows/ci.yml`
- Install `pytest-django`, `pytest-cov`, `ruff`, `mypy`, `factory_boy`, `wagtail-factories`
- Create `pytest.ini` / `pyproject.toml` config pointing at `config.settings.development`
- Write the only M0 test: a trivial smoke test that asserts `1 == 1` to 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:** `Author` is 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 single `AboutPage`. If multi-author public profiles are needed in a future version, promote `Author` to a `Page` model at that point.
**Model to implement:**
```python
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`:
```python
# 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:**
```python
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`:
```python
# 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:**
```python
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:**
```python
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
```python
# 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.html` must include: nav, dark mode toggle, cookie banner include, footer. All subsequent templates extend this.
- Implement `{% get_legal_pages %}` template tag in `core` app that queries `LegalPage.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 from `TagMetadata`, falling back to neutral.
- **Pagination is manual Django `Paginator`** — Wagtail page models do not have a `paginate_by` class attribute like Django's `ListView`. Pagination is implemented entirely within `ArticleIndexPage.get_context()` as shown in the M1.3 model code. The template receives a `Page` object (from `Paginator.page()`) in `ctx['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')` and `prefetch_related('tags__metadata')` are applied in `get_articles()` (see M1.3 model). Validate with `assertNumQueries` in M3 tests — do not defer this to M10.
- Dark mode: The `<html>` element receives class `dark` based 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:**
```python
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:
```python
# 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`):
```python
# 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:**
```python
# 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-seo` has 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's `CHANGELOG.md` for any required `SeoSettings` migration steps.
**RSS Feed views (`apps/blog/feeds.py`):**
```python
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.
```python
# 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`):**
```javascript
// 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:**
```python
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_email` and `ip_address` are personal data. Document retention policy in the Privacy Policy (e.g. deleted after 24 months). Add a management command `purge_old_comment_data` that nullifies `author_email` and `ip_address` on 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`:
```python
# 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.
```python
# 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 HTML
- `confirmation_body.txt` — plain text version
- `confirmation_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](https://litmus.com) or [Mailtrap](https://mailtrap.io) 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_updated` is 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 `SiteSettings` Wagtail snippet (from `wagtail.contrib.settings`) with a `PageChooserPanel` for `privacy_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 `AboutPage` appears in the Wagtail page tree and `HomePage.subpage_types` from 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
```python
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`**not** `DENY`. Wagtail's page preview renders the page inside an iframe on the same origin; `DENY` breaks this entirely.
- `X-Content-Type-Options: nosniff`
- `Referrer-Policy: strict-origin-when-cross-origin`
- `Permissions-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:
```html
<!-- 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:** `whitenoise` is used for `collectstatic` and is always present for the `./manage.py runserver` development workflow. In production, Caddy serves `/static/` and `/media/` directly (see Caddyfile in M10.3), so WhiteNoise is bypassed in practice — but it remains in `INSTALLED_APPS` as a fallback and for `collectstatic` support. Do not remove it.
- Configure Wagtail image renditions cache: generate renditions on first request, store in `MEDIA_ROOT`.
- Add `django-debug-toolbar` in development only to catch N+1 queries during template development.
- All views must pass `assertNumQueries` checks 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:
```caddy
# /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:
```ini
# /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
```
```ini
# /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: SAMEORIGIN` is set both in Caddy and enforced by Django's `XFrameOptionsMiddleware` (the Django default is already `SAMEORIGIN`). Do not override it to `DENY` as this breaks Wagtail's live preview feature. If `django-csp` is added, ensure the `frame-ancestors` CSP directive is also set to `'self'` rather than `'none'`.
#### M10.4 — Deployment Runbook (README.md section)
```bash
# 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_ROOT` rsync 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
- `SiteSettings` model (default OG image, privacy policy page) is introduced in M5 alongside the SEO work that depends on it
- `AboutPage` (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
- [ ] `ruff` reports zero lint errors
- [ ] `mypy` reports 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.*