1490 lines
60 KiB
Markdown
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.* |