# 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 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: ```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 (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:** ```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 `` element receives class `dark` based on localStorage via a blocking inline script in `` (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
 wrapper
- CodeBlock with filename renders filename label above block
- CalloutBlock with icon='trophy' renders correct Lucide icon class
- ImageBlock renders  with correct srcset from Wagtail renditions
- PullQuoteBlock renders quote in 
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 ` ``` 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.*