Files
main-site/implementation.md
Mark 5adff60d4b
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Failing after 20s
docs+comments: align plan with gitea PR-only CI and close remaining blockers
2026-02-28 17:17:19 +00:00

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

  1. Product Overview
  2. Technical Architecture
  3. Test Strategy
  4. Milestone 0 — Project Scaffold & Tooling
  5. Milestone 1 — Authors & Core Blog Models
  6. Milestone 2 — StreamField Block Library
  7. Milestone 3 — Homepage & Article Index
  8. Milestone 4 — Article Read View & Related Content
  9. Milestone 5 — SEO, RSS & Sitemap
  10. Milestone 6 — Cookie Consent System
  11. Milestone 7 — Comment System
  12. Milestone 8 — Newsletter Integration
  13. Milestone 9 — Legal Pages
  14. Milestone 10 — Production Hardening
  15. Dependency Map
  16. 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 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 runserver starts without errors
  • pytest runs and exits 0 (no tests yet = trivially passing)
  • ruff and mypy pass 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.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 .gitea/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:

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.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:

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-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):

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.


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_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:

# 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 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 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.


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

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: SAMEORIGINnot 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:

<!-- 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:

# /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: 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)

# 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.