diff --git a/implementation.md b/implementation.md index e69de29..bd409e3 100644 --- a/implementation.md +++ b/implementation.md @@ -0,0 +1,1490 @@ +# 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 `` 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 renderswrapper +- CodeBlock with filename renders filename label above block +- CalloutBlock with icon='trophy' renders correct Lucide icon class +- ImageBlock renderswith 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.* \ No newline at end of file