{{ page.title }}
+{{ page.read_time_mins }} min read
+ {% if page.hero_image %} + {% image page.hero_image fill-1200x630 %} + {% endif %} + {{ page.body }} + {% article_json_ld page %} +diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..941f4fb
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,15 @@
+.git
+.gitea
+.github
+.venv
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.coverage
+.benchmarks/
+media/
+staticfiles/
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..360e702
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,121 @@
+name: CI
+
+on:
+ pull_request:
+ schedule:
+ - cron: "0 2 * * *"
+
+concurrency:
+ group: ci-pr-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ ci:
+ if: github.event_name == 'pull_request'
+ runs-on: ubuntu-latest
+ env:
+ CI_IMAGE: nohype-ci:${{ github.run_id }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Build
+ run: docker build -t "$CI_IMAGE" .
+
+ - name: Start PostgreSQL
+ run: |
+ docker run -d --name ci-postgres \
+ -e POSTGRES_DB=nohype \
+ -e POSTGRES_USER=nohype \
+ -e POSTGRES_PASSWORD=nohype \
+ postgres:16-alpine
+ for i in $(seq 1 30); do
+ if docker exec ci-postgres pg_isready -U nohype -d nohype >/dev/null; then
+ exit 0
+ fi
+ sleep 2
+ done
+ docker logs ci-postgres || true
+ exit 1
+
+ - name: Ruff
+ run: docker run --rm --network container:ci-postgres -e SECRET_KEY=ci-secret-key -e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype "$CI_IMAGE" ruff check .
+
+ - name: Mypy
+ run: docker run --rm --network container:ci-postgres -e SECRET_KEY=ci-secret-key -e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype "$CI_IMAGE" mypy apps config
+
+ - name: Pytest
+ run: docker run --rm --network container:ci-postgres -e SECRET_KEY=ci-secret-key -e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype "$CI_IMAGE" pytest
+
+ - name: Tailwind build (assert generated diff is clean)
+ run: |
+ docker run --name ci-tailwind \
+ --network container:ci-postgres \
+ -e SECRET_KEY=ci-secret-key \
+ -e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \
+ "$CI_IMAGE" \
+ sh -lc "python manage.py tailwind install --no-input && python manage.py tailwind build"
+ docker cp ci-tailwind:/app/theme/static/css/styles.css /tmp/ci-styles.css
+ docker rm -f ci-tailwind
+ cmp -s theme/static/css/styles.css /tmp/ci-styles.css
+
+ - name: Remove PostgreSQL
+ if: always()
+ run: |
+ docker rm -f ci-postgres || true
+
+ - name: Remove CI image
+ if: always()
+ run: docker image rm -f "$CI_IMAGE" || true
+
+ nightly-e2e:
+ if: github.event_name == 'schedule'
+ runs-on: ubuntu-latest
+ env:
+ CI_IMAGE: nohype-ci-nightly:${{ github.run_id }}
+ steps:
+ - uses: actions/checkout@v4
+ - name: Build
+ run: docker build -t "$CI_IMAGE" .
+ - name: Start PostgreSQL
+ run: |
+ docker run -d --name nightly-postgres \
+ -e POSTGRES_DB=nohype \
+ -e POSTGRES_USER=nohype \
+ -e POSTGRES_PASSWORD=nohype \
+ postgres:16-alpine
+ for i in $(seq 1 30); do
+ if docker exec nightly-postgres pg_isready -U nohype -d nohype >/dev/null; then
+ exit 0
+ fi
+ sleep 2
+ done
+ docker logs nightly-postgres || true
+ exit 1
+ - name: Start dev server with seeded content
+ run: |
+ docker run -d --name nightly-e2e --network container:nightly-postgres \
+ -e SECRET_KEY=ci-secret-key \
+ -e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \
+ "$CI_IMAGE" \
+ sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000"
+ for i in $(seq 1 40); do
+ if docker exec nightly-e2e curl -fsS http://127.0.0.1:8000/ >/dev/null; then
+ exit 0
+ fi
+ sleep 2
+ done
+ docker logs nightly-e2e || true
+ exit 1
+ - name: Run nightly Playwright journey
+ run: |
+ docker exec nightly-e2e python -m playwright install chromium
+ docker exec -e E2E_BASE_URL=http://127.0.0.1:8000 nightly-e2e \
+ pytest -o addopts='' apps/core/tests/test_nightly_e2e_playwright.py -q
+ - name: Remove nightly container
+ if: always()
+ run: |
+ docker rm -f nightly-e2e || true
+ docker rm -f nightly-postgres || true
+ - name: Remove CI image
+ if: always()
+ run: docker image rm -f "$CI_IMAGE" || true
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..051f926
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,16 @@
+__pycache__/
+*.py[cod]
+*.sqlite3
+*.log
+.env
+.pytest_cache/
+.coverage
+htmlcov/
+.mypy_cache/
+.ruff_cache/
+node_modules/
+staticfiles/
+media/
+.DS_Store
+.vscode/
+.idea/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..ad6ff29
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,15 @@
+# Changelog
+
+## 2026-02-28
+
+- Scaffolded Dockerized Django/Wagtail project structure with split settings.
+- Implemented core apps: blog, authors, legal, comments, newsletter, core consent/settings.
+- Added Wagtail models, snippets, StreamField blocks, RSS feeds, sitemap/robots routes.
+- Added consent middleware/service and cookie banner integration.
+- Added comment submission flow with moderation-ready model and rate limiting.
+- Added newsletter subscription + confirmation flow with provider sync abstraction.
+- Added templates/static assets baseline for homepage, article index/read, legal, about.
+- Added pytest suite with >90% coverage enforcement and passing Docker CI checks.
+- Added PR-only containerized CI path (`docker build` + `docker run`) to avoid compose-network exhaustion on shared runners.
+- Added newsletter signup forms in nav/footer/article, client-side progressive submit UX, and article social share controls.
+- Added content integrity management command and comment data-retention purge command with automated tests.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..361c3ff
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,35 @@
+FROM python:3.12-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential \
+ libpq-dev \
+ curl \
+ nodejs \
+ npm \
+ libasound2 \
+ libatk-bridge2.0-0 \
+ libatk1.0-0 \
+ libcups2 \
+ libgbm1 \
+ libgtk-3-0 \
+ libnss3 \
+ libx11-xcb1 \
+ libxcomposite1 \
+ libxdamage1 \
+ libxfixes3 \
+ libxrandr2 \
+ fonts-liberation \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY requirements /app/requirements
+RUN pip install --upgrade pip && pip install -r requirements/base.txt
+
+COPY . /app
+
+CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..171176b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,67 @@
+# No Hype AI
+
+Django 5.2 + Wagtail 7 blog engine for No Hype AI.
+
+## Environment Variables
+
+Required:
+- `SECRET_KEY`
+- `DATABASE_URL`
+- `ALLOWED_HOSTS`
+- `DEBUG`
+- `WAGTAIL_SITE_NAME`
+
+Also used:
+- `WAGTAILADMIN_BASE_URL`
+- `CONSENT_POLICY_VERSION`
+- `EMAIL_BACKEND`
+- `EMAIL_HOST`
+- `EMAIL_PORT`
+- `EMAIL_USE_TLS`
+- `EMAIL_HOST_USER`
+- `EMAIL_HOST_PASSWORD`
+- `DEFAULT_FROM_EMAIL`
+- `NEWSLETTER_PROVIDER`
+
+## Containerized Development
+
+```bash
+docker compose build
+docker compose run --rm web python manage.py migrate
+docker compose up
+```
+
+App is exposed on `http://localhost:8035`.
+
+## Test/Lint/Typecheck (Docker)
+
+```bash
+docker compose run --rm web pytest
+docker compose run --rm web ruff check .
+docker compose run --rm web mypy apps config
+```
+
+## Deploy Runbook
+
+```bash
+git pull origin main
+pip install -r requirements/production.txt
+python manage.py migrate --run-syncdb
+python manage.py collectstatic --noinput
+python manage.py tailwind build
+python manage.py check_content_integrity
+sudo systemctl reload gunicorn
+```
+
+## Backups
+
+- PostgreSQL dump daily: `pg_dump | gzip > backup-$(date +%Y%m%d).sql.gz`
+- `MEDIA_ROOT` rsynced offsite daily
+- Restore DB: `gunzip -c backup-YYYYMMDD.sql.gz | psql "$DATABASE_URL"`
+- Restore media: `rsync -avz Hello world Body Body one two three Body
body
")], + ) + index.add_child(instance=main) + main.save_revision().publish() + + related = ArticlePage( + title="Related", + slug="related", + author=author, + summary="summary", + body=[("rich_text", "body
")], + ) + index.add_child(instance=related) + related.save_revision().publish() + + resp = client.get("/articles/main/") + assert resp.status_code == 200 + assert "related_articles" in resp.context + + +@pytest.mark.django_db +def test_newsletter_forms_render_in_nav_and_footer(client, home_page): + resp = client.get("/") + html = resp.content.decode() + assert resp.status_code == 200 + assert 'name="source" value="nav"' in html + assert 'name="source" value="footer"' in html + + +@pytest.mark.django_db +def test_article_page_renders_share_links_and_newsletter_form(client, home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="Main", + slug="main", + author=author, + summary="summary", + body=[("rich_text", "body
")], + ) + index.add_child(instance=article) + article.save_revision().publish() + + resp = client.get("/articles/main/") + html = resp.content.decode() + assert resp.status_code == 200 + assert "Share on X" in html + assert "Share on LinkedIn" in html + assert 'data-copy-link' in html + assert 'name="source" value="article"' in html + + +@pytest.mark.django_db +def test_article_page_renders_approved_comments_and_reply_form(client, home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="Main", + slug="main", + author=author, + summary="summary", + body=[("rich_text", "body
")], + ) + index.add_child(instance=article) + article.save_revision().publish() + comment = Comment.objects.create( + article=article, + author_name="A", + author_email="a@example.com", + body="Top level", + is_approved=True, + ) + Comment.objects.create( + article=article, + parent=comment, + author_name="B", + author_email="b@example.com", + body="Reply", + is_approved=True, + ) + + resp = client.get("/articles/main/") + html = resp.content.decode() + assert resp.status_code == 200 + assert "Top level" in html + assert "Reply" in html + assert f'name="parent_id" value="{comment.id}"' in html + + +@pytest.mark.django_db +def test_article_index_renders_tag_filter_controls(client, home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="Main", + slug="main", + author=author, + summary="summary", + body=[("rich_text", "body
")], + ) + index.add_child(instance=article) + article.save_revision().publish() + tag = Tag.objects.create(name="TagOne", slug="tag-one") + article.tags.add(tag) + article.save_revision().publish() + + resp = client.get("/articles/") + html = resp.content.decode() + assert resp.status_code == 200 + assert "/articles/?tag=tag-one" in html diff --git a/apps/blog/wagtail_hooks.py b/apps/blog/wagtail_hooks.py new file mode 100644 index 0000000..0dddec4 --- /dev/null +++ b/apps/blog/wagtail_hooks.py @@ -0,0 +1,13 @@ +from wagtail.snippets.models import register_snippet +from wagtail.snippets.views.snippets import SnippetViewSet + +from apps.blog.models import TagMetadata + + +class TagMetadataViewSet(SnippetViewSet): + model = TagMetadata + icon = "tag" + list_display = ["tag", "colour"] + + +register_snippet(TagMetadataViewSet) diff --git a/apps/comments/__init__.py b/apps/comments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/comments/apps.py b/apps/comments/apps.py new file mode 100644 index 0000000..338c0b0 --- /dev/null +++ b/apps/comments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CommentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.comments" diff --git a/apps/comments/forms.py b/apps/comments/forms.py new file mode 100644 index 0000000..255d67c --- /dev/null +++ b/apps/comments/forms.py @@ -0,0 +1,19 @@ +from django import forms + +from apps.comments.models import Comment + + +class CommentForm(forms.ModelForm): + honeypot = forms.CharField(required=False) + article_id = forms.IntegerField(widget=forms.HiddenInput) + parent_id = forms.IntegerField(required=False, widget=forms.HiddenInput) + + class Meta: + model = Comment + fields = ["author_name", "author_email", "body"] + + def clean_body(self): + body = self.cleaned_data["body"] + if not body.strip(): + raise forms.ValidationError("Comment body is required.") + return body diff --git a/apps/comments/management/__init__.py b/apps/comments/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/comments/management/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/comments/management/commands/__init__.py b/apps/comments/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/comments/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/comments/management/commands/purge_old_comment_data.py b/apps/comments/management/commands/purge_old_comment_data.py new file mode 100644 index 0000000..73b17bf --- /dev/null +++ b/apps/comments/management/commands/purge_old_comment_data.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from apps.comments.models import Comment + + +class Command(BaseCommand): + help = "Nullify comment personal data for comments older than the retention window." + + def add_arguments(self, parser): + parser.add_argument( + "--months", + type=int, + default=24, + help="Retention window in months before personal data is purged (default: 24).", + ) + + def handle(self, *args, **options): + months = options["months"] + cutoff = timezone.now() - timedelta(days=30 * months) + + purged = ( + Comment.objects.filter(created_at__lt=cutoff) + .exclude(author_email="") + .update(author_email="", ip_address=None) + ) + self.stdout.write(self.style.SUCCESS(f"Purged personal data for {purged} comment(s).")) diff --git a/apps/comments/migrations/0001_initial.py b/apps/comments/migrations/0001_initial.py new file mode 100644 index 0000000..af46b79 --- /dev/null +++ b/apps/comments/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.11 on 2026-02-28 11:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('blog', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('author_name', models.CharField(max_length=100)), + ('author_email', models.EmailField(max_length=254)), + ('body', models.TextField(max_length=2000)), + ('is_approved', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='blog.articlepage')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='comments.comment')), + ], + ), + ] diff --git a/apps/comments/migrations/__init__.py b/apps/comments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/comments/models.py b/apps/comments/models.py new file mode 100644 index 0000000..5689d97 --- /dev/null +++ b/apps/comments/models.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from django.core.exceptions import ValidationError +from django.db import models + + +class Comment(models.Model): + article = models.ForeignKey("blog.ArticlePage", on_delete=models.CASCADE, related_name="comments") + parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.CASCADE, related_name="replies") + author_name = models.CharField(max_length=100) + author_email = models.EmailField() + 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.") + + def get_absolute_url(self): + return f"{self.article.url}#comment-{self.pk}" + + def __str__(self) -> str: + return f"Comment by {self.author_name}" diff --git a/apps/comments/tests/__init__.py b/apps/comments/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/comments/tests/test_admin.py b/apps/comments/tests/test_admin.py new file mode 100644 index 0000000..653e6ee --- /dev/null +++ b/apps/comments/tests/test_admin.py @@ -0,0 +1,81 @@ +import pytest + +from apps.blog.models import ArticleIndexPage, ArticlePage +from apps.blog.tests.factories import AuthorFactory +from apps.comments.models import Comment +from apps.comments.wagtail_hooks import ApproveCommentBulkAction, CommentViewSet + + +@pytest.mark.django_db +def test_comment_viewset_annotates_pending_in_article(rf, home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "body
")]) + index.add_child(instance=article) + article.save_revision().publish() + + pending = Comment.objects.create( + article=article, + author_name="Pending", + author_email="pending@example.com", + body="Awaiting moderation", + is_approved=False, + ) + Comment.objects.create( + article=article, + author_name="Pending2", + author_email="pending2@example.com", + body="Awaiting moderation too", + is_approved=False, + ) + Comment.objects.create( + article=article, + author_name="Approved", + author_email="approved@example.com", + body="Already approved", + is_approved=True, + ) + + viewset = CommentViewSet() + qs = viewset.get_queryset(rf.get("/cms/snippets/comments/comment/")) + annotated = qs.get(pk=pending.pk) + + assert annotated.pending_in_article == 2 + + +@pytest.mark.django_db +def test_bulk_approve_action_marks_selected_pending_comments_as_approved(home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "body
")]) + index.add_child(instance=article) + article.save_revision().publish() + + pending = Comment.objects.create( + article=article, + author_name="Pending", + author_email="pending@example.com", + body="Awaiting moderation", + is_approved=False, + ) + approved = Comment.objects.create( + article=article, + author_name="Approved", + author_email="approved@example.com", + body="Already approved", + is_approved=True, + ) + + class _Context: + model = Comment + + updated, child_updates = ApproveCommentBulkAction.execute_action([pending, approved], self=_Context()) + pending.refresh_from_db() + approved.refresh_from_db() + + assert updated == 1 + assert child_updates == 0 + assert pending.is_approved is True + assert approved.is_approved is True diff --git a/apps/comments/tests/test_commands.py b/apps/comments/tests/test_commands.py new file mode 100644 index 0000000..7e87e44 --- /dev/null +++ b/apps/comments/tests/test_commands.py @@ -0,0 +1,40 @@ +from datetime import timedelta + +import pytest +from django.core.management import call_command +from django.utils import timezone + +from apps.blog.models import ArticleIndexPage, ArticlePage +from apps.blog.tests.factories import AuthorFactory +from apps.comments.models import Comment + + +@pytest.mark.django_db +def test_purge_old_comment_data_clears_personal_fields(home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="Article", + slug="article", + author=author, + summary="summary", + body=[("rich_text", "body
")], + ) + index.add_child(instance=article) + article.save_revision().publish() + + old_comment = Comment.objects.create( + article=article, + author_name="Old", + author_email="old@example.com", + body="legacy", + ip_address="127.0.0.1", + ) + Comment.objects.filter(pk=old_comment.pk).update(created_at=timezone.now() - timedelta(days=800)) + + call_command("purge_old_comment_data") + + old_comment.refresh_from_db() + assert old_comment.author_email == "" + assert old_comment.ip_address is None diff --git a/apps/comments/tests/test_models.py b/apps/comments/tests/test_models.py new file mode 100644 index 0000000..94e27b9 --- /dev/null +++ b/apps/comments/tests/test_models.py @@ -0,0 +1,40 @@ +import pytest +from django.core.exceptions import ValidationError + +from apps.blog.models import ArticleIndexPage, ArticlePage +from apps.blog.tests.factories import AuthorFactory +from apps.comments.models import Comment + + +def create_article(home): + index = ArticleIndexPage(title="Articles", slug="articles") + home.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "body
")]) + index.add_child(instance=article) + article.save_revision().publish() + return article + + +@pytest.mark.django_db +def test_comment_defaults_and_absolute_url(home_page): + article = create_article(home_page) + comment = Comment.objects.create(article=article, author_name="N", author_email="n@example.com", body="hello") + assert comment.is_approved is False + assert comment.get_absolute_url().endswith(f"#comment-{comment.id}") + + +@pytest.mark.django_db +def test_reply_depth_validation(home_page): + article = create_article(home_page) + parent = Comment.objects.create(article=article, author_name="P", author_email="p@example.com", body="p") + child = Comment.objects.create( + article=article, + author_name="C", + author_email="c@example.com", + body="c", + parent=parent, + ) + nested = Comment(article=article, author_name="X", author_email="x@example.com", body="x", parent=child) + with pytest.raises(ValidationError): + nested.clean() diff --git a/apps/comments/tests/test_more.py b/apps/comments/tests/test_more.py new file mode 100644 index 0000000..94fd369 --- /dev/null +++ b/apps/comments/tests/test_more.py @@ -0,0 +1,26 @@ +import pytest +from django.core.cache import cache + +from apps.comments.forms import CommentForm + + +@pytest.mark.django_db +def test_comment_form_rejects_blank_body(): + form = CommentForm(data={"author_name": "A", "author_email": "a@a.com", "body": " ", "article_id": 1}) + assert not form.is_valid() + + +@pytest.mark.django_db +def test_comment_rate_limit(client, article_page): + cache.clear() + payload = { + "article_id": article_page.id, + "author_name": "T", + "author_email": "t@example.com", + "body": "Hi", + "honeypot": "", + } + for _ in range(3): + client.post("/comments/post/", payload) + resp = client.post("/comments/post/", payload) + assert resp.status_code == 429 diff --git a/apps/comments/tests/test_views.py b/apps/comments/tests/test_views.py new file mode 100644 index 0000000..d55b8ba --- /dev/null +++ b/apps/comments/tests/test_views.py @@ -0,0 +1,160 @@ +import pytest +from django.core.cache import cache +from django.test import override_settings + +from apps.blog.models import ArticleIndexPage, ArticlePage +from apps.blog.tests.factories import AuthorFactory +from apps.comments.models import Comment + + +@pytest.mark.django_db +def test_comment_post_flow(client, home_page): + cache.clear() + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "body
")]) + index.add_child(instance=article) + article.save_revision().publish() + + resp = client.post( + "/comments/post/", + { + "article_id": article.id, + "author_name": "Test", + "author_email": "test@example.com", + "body": "Hello", + "honeypot": "", + }, + ) + assert resp.status_code == 302 + assert resp["Location"].endswith("?commented=1") + assert Comment.objects.count() == 1 + + +@pytest.mark.django_db +def test_comment_post_rejected_when_comments_disabled(client, home_page): + cache.clear() + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="A", + slug="a", + author=author, + summary="s", + body=[("rich_text", "body
")], + comments_enabled=False, + ) + index.add_child(instance=article) + article.save_revision().publish() + + resp = client.post( + "/comments/post/", + { + "article_id": article.id, + "author_name": "Test", + "author_email": "test@example.com", + "body": "Hello", + "honeypot": "", + }, + ) + assert resp.status_code == 404 + assert Comment.objects.count() == 0 + + +@pytest.mark.django_db +def test_invalid_comment_post_rerenders_form_with_errors(client, home_page): + cache.clear() + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "body
")]) + index.add_child(instance=article) + article.save_revision().publish() + + resp = client.post( + "/comments/post/", + { + "article_id": article.id, + "author_name": "Test", + "author_email": "test@example.com", + "body": " ", + "honeypot": "", + }, + ) + + assert resp.status_code == 200 + assert b'aria-label="Comment form errors"' in resp.content + assert b'value="Test"' in resp.content + assert b"test@example.com" in resp.content + assert Comment.objects.count() == 0 + + +@pytest.mark.django_db +def test_comment_reply_depth_is_enforced(client, home_page): + cache.clear() + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "body
")]) + index.add_child(instance=article) + article.save_revision().publish() + + parent = Comment.objects.create( + article=article, + author_name="Parent", + author_email="p@example.com", + body="Parent", + is_approved=True, + ) + child = Comment.objects.create( + article=article, + parent=parent, + author_name="Child", + author_email="c@example.com", + body="Child", + is_approved=True, + ) + + resp = client.post( + "/comments/post/", + { + "article_id": article.id, + "parent_id": child.id, + "author_name": "TooDeep", + "author_email": "deep@example.com", + "body": "Nope", + "honeypot": "", + }, + ) + assert resp.status_code == 200 + assert b"Reply depth exceeds the allowed limit" in resp.content + assert Comment.objects.count() == 2 + + +@pytest.mark.django_db +@override_settings(TRUSTED_PROXY_IPS=[]) +def test_comment_uses_remote_addr_when_proxy_untrusted(client, home_page): + cache.clear() + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "body
")]) + index.add_child(instance=article) + article.save_revision().publish() + + client.post( + "/comments/post/", + { + "article_id": article.id, + "author_name": "Test", + "author_email": "test@example.com", + "body": "Hello", + "honeypot": "", + }, + REMOTE_ADDR="10.0.0.1", + HTTP_X_FORWARDED_FOR="203.0.113.7", + ) + comment = Comment.objects.get() + assert comment.ip_address == "10.0.0.1" diff --git a/apps/comments/urls.py b/apps/comments/urls.py new file mode 100644 index 0000000..861e88c --- /dev/null +++ b/apps/comments/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from apps.comments.views import CommentCreateView + +urlpatterns = [ + path("post/", CommentCreateView.as_view(), name="comment_post"), +] diff --git a/apps/comments/views.py b/apps/comments/views.py new file mode 100644 index 0000000..5e908a2 --- /dev/null +++ b/apps/comments/views.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from django.conf import settings +from django.contrib import messages +from django.core.cache import cache +from django.core.exceptions import ValidationError +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.views import View + +from apps.blog.models import ArticlePage +from apps.comments.forms import CommentForm +from apps.comments.models import Comment + + +def client_ip_from_request(request) -> str: + remote_addr = request.META.get("REMOTE_ADDR", "").strip() + trusted_proxies = getattr(settings, "TRUSTED_PROXY_IPS", []) + x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "") + if remote_addr in trusted_proxies and x_forwarded_for: + return x_forwarded_for.split(",")[0].strip() + return remote_addr + + +class CommentCreateView(View): + def _render_article_with_errors(self, request, article, form): + context = article.get_context(request) + context["page"] = article + context["comment_form"] = form + return render(request, "blog/article_page.html", context, status=200) + + def post(self, request): + ip = client_ip_from_request(request) + key = f"comment-rate:{ip}" + count = cache.get(key, 0) + if count >= 3: + return HttpResponse(status=429) + cache.set(key, count + 1, timeout=60) + + form = CommentForm(request.POST) + article = get_object_or_404(ArticlePage, pk=request.POST.get("article_id")) + if not article.comments_enabled: + return HttpResponse(status=404) + + if form.is_valid(): + if form.cleaned_data.get("honeypot"): + return redirect(f"{article.url}?commented=1") + comment = form.save(commit=False) + comment.article = article + parent_id = form.cleaned_data.get("parent_id") + if parent_id: + comment.parent = Comment.objects.filter(pk=parent_id, article=article).first() + comment.ip_address = ip or None + try: + comment.full_clean() + except ValidationError: + form.add_error(None, "Reply depth exceeds the allowed limit") + return self._render_article_with_errors(request, article, form) + comment.save() + messages.success(request, "Your comment is awaiting moderation") + return redirect(f"{article.url}?commented=1") + + return self._render_article_with_errors(request, article, form) diff --git a/apps/comments/wagtail_hooks.py b/apps/comments/wagtail_hooks.py new file mode 100644 index 0000000..c30ac32 --- /dev/null +++ b/apps/comments/wagtail_hooks.py @@ -0,0 +1,72 @@ +from typing import Any, cast + +from django.db.models import Count, Q +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import ngettext +from wagtail import hooks +from wagtail.admin.ui.tables import BooleanColumn +from wagtail.snippets.bulk_actions.snippet_bulk_action import SnippetBulkAction +from wagtail.snippets.models import register_snippet +from wagtail.snippets.permissions import get_permission_name +from wagtail.snippets.views.snippets import SnippetViewSet + +from apps.comments.models import Comment + + +class ApproveCommentBulkAction(SnippetBulkAction): + display_name = _("Approve") + action_type = "approve" + aria_label = _("Approve selected comments") + template_name = "comments/confirm_bulk_approve.html" + action_priority = 20 + models = [Comment] + + def check_perm(self, snippet): + if getattr(self, "can_change_items", None) is None: + self.can_change_items = self.request.user.has_perm(get_permission_name("change", self.model)) + return self.can_change_items + + @classmethod + def execute_action(cls, objects, **kwargs): + updated = kwargs["self"].model.objects.filter(pk__in=[obj.pk for obj in objects], is_approved=False).update( + is_approved=True + ) + return updated, 0 + + def get_success_message(self, num_parent_objects, num_child_objects): + return ngettext( + "%(count)d comment approved.", + "%(count)d comments approved.", + num_parent_objects, + ) % {"count": num_parent_objects} + + +class CommentViewSet(SnippetViewSet): + model = Comment + queryset = Comment.objects.all() + icon = "comment" + list_display = ["author_name", "article", BooleanColumn("is_approved"), "pending_in_article", "created_at"] + list_filter = ["is_approved"] + search_fields = ["author_name", "body"] + add_to_admin_menu = True + + def get_queryset(self, request): + base_qs = self.model.objects.all().select_related("article", "parent") + # mypy-django-plugin currently crashes on QuerySet.annotate() in this file. + typed_qs = cast(Any, base_qs) + return typed_qs.annotate( + pending_in_article=Count( + "article__comments", + filter=Q(article__comments__is_approved=False), + distinct=True, + ) + ) + + def pending_in_article(self, obj): + return obj.pending_in_article + + pending_in_article.short_description = "Pending (article)" # type: ignore[attr-defined] + + +register_snippet(CommentViewSet) +hooks.register("register_bulk_action", ApproveCommentBulkAction) diff --git a/apps/core/__init__.py b/apps/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/apps.py b/apps/core/apps.py new file mode 100644 index 0000000..ab0051e --- /dev/null +++ b/apps/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.core" diff --git a/apps/core/consent.py b/apps/core/consent.py new file mode 100644 index 0000000..8737a1d --- /dev/null +++ b/apps/core/consent.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass +from urllib.parse import parse_qs, urlencode + +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: + 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() + + @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, + httponly=False, + samesite="Lax", + secure=not settings.DEBUG, + ) diff --git a/apps/core/context_processors.py b/apps/core/context_processors.py new file mode 100644 index 0000000..8ce6231 --- /dev/null +++ b/apps/core/context_processors.py @@ -0,0 +1,9 @@ +from wagtail.models import Site + +from apps.core.models import SiteSettings + + +def site_settings(request): + site = Site.find_for_request(request) + settings_obj = SiteSettings.for_site(site) if site else None + return {"site_settings": settings_obj} diff --git a/apps/core/management/__init__.py b/apps/core/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/core/management/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/core/management/commands/__init__.py b/apps/core/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/core/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/core/management/commands/check_content_integrity.py b/apps/core/management/commands/check_content_integrity.py new file mode 100644 index 0000000..e4088d6 --- /dev/null +++ b/apps/core/management/commands/check_content_integrity.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from django.core.management.base import BaseCommand, CommandError +from django.db.models.functions import Trim +from wagtail.models import Site + +from apps.blog.models import ArticlePage +from apps.core.models import SiteSettings + + +class Command(BaseCommand): + help = "Validate content-integrity constraints for live article pages." + + def handle(self, *args, **options): + errors: list[str] = [] + + missing_summary = ArticlePage.objects.live().annotate(summary_trimmed=Trim("summary")).filter( + summary_trimmed="" + ) + if missing_summary.exists(): + errors.append(f"{missing_summary.count()} live article(s) have an empty summary.") + + missing_author = ArticlePage.objects.live().filter(author__isnull=True) + if missing_author.exists(): + errors.append(f"{missing_author.count()} live article(s) have no author.") + + default_site = Site.objects.filter(is_default_site=True).first() + default_og_image = None + if default_site: + default_og_image = SiteSettings.for_site(default_site).default_og_image + + if default_og_image is None: + missing_hero = ArticlePage.objects.live().filter(hero_image__isnull=True) + if missing_hero.exists(): + errors.append( + f"{missing_hero.count()} live article(s) have no hero image and no site default OG image is set." + ) + + if errors: + raise CommandError("Content integrity check failed: " + " ".join(errors)) + + self.stdout.write(self.style.SUCCESS("Content integrity check passed.")) diff --git a/apps/core/management/commands/seed_e2e_content.py b/apps/core/management/commands/seed_e2e_content.py new file mode 100644 index 0000000..82d1f06 --- /dev/null +++ b/apps/core/management/commands/seed_e2e_content.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from django.core.management.base import BaseCommand +from wagtail.models import Page, Site + +from apps.authors.models import Author +from apps.blog.models import ArticleIndexPage, ArticlePage, HomePage + + +class Command(BaseCommand): + help = "Seed deterministic content for nightly Playwright E2E checks." + + def handle(self, *args, **options): + root = Page.get_first_root_node() + + home = HomePage.objects.child_of(root).first() + if home is None: + home = HomePage(title="Nightly Home", slug="nightly-home") + root.add_child(instance=home) + home.save_revision().publish() + + article_index = ArticleIndexPage.objects.child_of(home).filter(slug="articles").first() + if article_index is None: + article_index = ArticleIndexPage(title="Articles", slug="articles") + home.add_child(instance=article_index) + article_index.save_revision().publish() + + author, _ = Author.objects.get_or_create( + slug="e2e-author", + defaults={ + "name": "E2E Author", + "bio": "Seeded nightly test author.", + }, + ) + + article = ArticlePage.objects.child_of(article_index).filter(slug="nightly-playwright-journey").first() + if article is None: + article = ArticlePage( + title="Nightly Playwright Journey", + slug="nightly-playwright-journey", + author=author, + summary="Seeded article for nightly browser journey.", + body=[("rich_text", "Seeded article body for nightly browser checks.
")], + comments_enabled=True, + ) + article_index.add_child(instance=article) + article.save_revision().publish() + + site, _ = Site.objects.get_or_create( + hostname="127.0.0.1", + port=8000, + defaults={ + "root_page": home, + "is_default_site": True, + "site_name": "No Hype AI", + }, + ) + site.root_page = home + site.is_default_site = True + site.site_name = "No Hype AI" + site.save() + + self.stdout.write(self.style.SUCCESS("Seeded nightly E2E content.")) diff --git a/apps/core/middleware.py b/apps/core/middleware.py new file mode 100644 index 0000000..a04983f --- /dev/null +++ b/apps/core/middleware.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import secrets + +from .consent import ConsentService + + +class ConsentMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + request.consent = ConsentService.get_consent(request) + return self.get_response(request) + + +class SecurityHeadersMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + nonce = secrets.token_urlsafe(16) + request.csp_nonce = nonce + response = self.get_response(request) + response["Content-Security-Policy"] = ( + f"default-src 'self'; " + f"script-src 'self' 'nonce-{nonce}'; " + "style-src 'self'; " + "img-src 'self' data: blob:; " + "font-src 'self'; " + "connect-src 'self'; " + "object-src 'none'; " + "base-uri 'self'; " + "frame-ancestors 'self'" + ) + response["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" + return response diff --git a/apps/core/migrations/0001_initial.py b/apps/core/migrations/0001_initial.py new file mode 100644 index 0000000..229d6f6 --- /dev/null +++ b/apps/core/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.11 on 2026-02-28 11:42 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('wagtailcore', '0094_alter_page_locale'), + ('wagtailimages', '0027_image_description'), + ] + + operations = [ + migrations.CreateModel( + name='SiteSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('default_og_image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')), + ('privacy_policy_page', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.page')), + ('site', models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, to='wagtailcore.site')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/core/migrations/__init__.py b/apps/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/models.py b/apps/core/models.py new file mode 100644 index 0000000..68c3e2c --- /dev/null +++ b/apps/core/models.py @@ -0,0 +1,21 @@ +from django.db import models +from django.db.models import SET_NULL +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="+", + ) diff --git a/apps/core/templatetags/__init__.py b/apps/core/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/templatetags/core_tags.py b/apps/core/templatetags/core_tags.py new file mode 100644 index 0000000..213e3bb --- /dev/null +++ b/apps/core/templatetags/core_tags.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from django import template +from django.utils.safestring import mark_safe +from wagtail.models import Site + +from apps.blog.models import TagMetadata +from apps.legal.models import LegalPage + +register = template.Library() + + +@register.simple_tag(takes_context=True) +def get_legal_pages(context): + request = context.get("request") + site = Site.find_for_request(request) if request else None + pages = LegalPage.objects.live().filter(show_in_footer=True) + if site: + pages = pages.in_site(site) + return pages + + +@register.simple_tag +@register.filter +def get_tag_css(tag): + meta = getattr(tag, "metadata", None) + if meta is None: + meta = TagMetadata.objects.filter(tag=tag).first() + classes = meta.get_css_classes() if meta else TagMetadata.get_fallback_css() + return mark_safe(f"{classes['bg']} {classes['text']}") diff --git a/apps/core/templatetags/seo_tags.py b/apps/core/templatetags/seo_tags.py new file mode 100644 index 0000000..c5ddb36 --- /dev/null +++ b/apps/core/templatetags/seo_tags.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json + +from django import template +from django.utils.safestring import mark_safe +from wagtail.images.models import Image + +from apps.core.models import SiteSettings + +register = template.Library() + + +def _article_image_url(request, article) -> str: + site_settings = SiteSettings.for_request(request) + image = article.hero_image or site_settings.default_og_image + if isinstance(image, Image): + rendition = image.get_rendition("fill-1200x630") + return request.build_absolute_uri(rendition.url) + return "" + + +@register.simple_tag(takes_context=True) +def canonical_url(context, page=None) -> str: + request = context["request"] + target = page or context.get("page") + if target and hasattr(target, "get_full_url"): + return target.get_full_url(request) + return request.build_absolute_uri() + + +@register.simple_tag(takes_context=True) +def article_og_image_url(context, article) -> str: + return _article_image_url(context["request"], article) + + +@register.simple_tag(takes_context=True) +def article_json_ld(context, article): + request = context["request"] + nonce = getattr(request, "csp_nonce", "") + data = { + "@context": "https://schema.org", + "@type": "Article", + "headline": article.title, + "author": {"@type": "Person", "name": article.author.name}, + "datePublished": article.first_published_at.isoformat() if article.first_published_at else "", + "dateModified": article.last_published_at.isoformat() if article.last_published_at else "", + "description": article.search_description or article.summary, + "url": article.get_full_url(request), + "image": _article_image_url(request, article), + } + return mark_safe( + '" + ) diff --git a/apps/core/tests/__init__.py b/apps/core/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/tests/test_commands.py b/apps/core/tests/test_commands.py new file mode 100644 index 0000000..b7fedb8 --- /dev/null +++ b/apps/core/tests/test_commands.py @@ -0,0 +1,30 @@ +import pytest +from django.core.management import call_command +from django.core.management.base import CommandError + +from apps.blog.models import ArticleIndexPage, ArticlePage +from apps.blog.tests.factories import AuthorFactory + + +@pytest.mark.django_db +def test_check_content_integrity_passes_when_requirements_met(home_page): + call_command("check_content_integrity") + + +@pytest.mark.django_db +def test_check_content_integrity_fails_for_blank_summary(home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="Article", + slug="article", + author=author, + summary=" ", + body=[("rich_text", "body
")], + ) + index.add_child(instance=article) + article.save_revision().publish() + + with pytest.raises(CommandError, match="empty summary"): + call_command("check_content_integrity") diff --git a/apps/core/tests/test_consent.py b/apps/core/tests/test_consent.py new file mode 100644 index 0000000..1a13643 --- /dev/null +++ b/apps/core/tests/test_consent.py @@ -0,0 +1,67 @@ +import pytest +from django.http import HttpRequest, HttpResponse + +from apps.core.consent import CONSENT_COOKIE_NAME, ConsentService + + +@pytest.mark.django_db +def test_consent_round_trip(rf): + request = HttpRequest() + response = HttpResponse() + ConsentService.set_consent(response, analytics=True, advertising=False) + cookie = response.cookies[CONSENT_COOKIE_NAME].value + request.COOKIES[CONSENT_COOKIE_NAME] = cookie + state = ConsentService.get_consent(request) + assert state.analytics is True + assert state.advertising is False + + +@pytest.mark.django_db +def test_consent_post_view(client): + resp = client.post("/consent/", {"accept_all": "1"}, follow=False) + assert resp.status_code == 302 + assert CONSENT_COOKIE_NAME in resp.cookies + + +@pytest.mark.django_db +def test_consent_get_without_cookie_defaults_false(): + request = HttpRequest() + state = ConsentService.get_consent(request) + assert state.analytics is False + assert state.advertising is False + assert state.requires_prompt is True + + +@pytest.mark.django_db +def test_consent_malformed_cookie_returns_safe_default(): + request = HttpRequest() + request.COOKIES[CONSENT_COOKIE_NAME] = "not=a=valid%%%cookie" + state = ConsentService.get_consent(request) + assert state.analytics is False + assert state.advertising is False + + +@pytest.mark.django_db +def test_consent_post_preferences(client): + resp = client.post("/consent/", {"analytics": "1", "advertising": ""}) + assert resp.status_code == 302 + value = resp.cookies[CONSENT_COOKIE_NAME].value + assert "a=1" in value + assert "d=0" in value + + +@pytest.mark.django_db +def test_consent_get_method_not_allowed(client): + resp = client.get("/consent/") + assert resp.status_code == 405 + + +@pytest.mark.django_db +def test_cookie_banner_hides_after_consent(client, home_page): + first = client.get("/") + assert "id=\"cookie-banner\"" in first.content.decode() + consented = client.post("/consent/", {"accept_all": "1"}) + cookie_value = consented.cookies[CONSENT_COOKIE_NAME].value + client.cookies[CONSENT_COOKIE_NAME] = cookie_value + second = client.get("/") + assert "id=\"cookie-banner\"" not in second.content.decode() diff --git a/apps/core/tests/test_more.py b/apps/core/tests/test_more.py new file mode 100644 index 0000000..b430ead --- /dev/null +++ b/apps/core/tests/test_more.py @@ -0,0 +1,50 @@ + +import pytest +from django.template import Context +from django.test import RequestFactory +from taggit.models import Tag +from wagtail.models import Site + +from apps.core.context_processors import site_settings +from apps.core.templatetags import core_tags +from apps.core.templatetags.seo_tags import article_json_ld +from apps.legal.models import LegalIndexPage, LegalPage + + +@pytest.mark.django_db +def test_context_processor_returns_sitesettings(home_page): + rf = RequestFactory() + request = rf.get("/") + request.site = Site.find_for_request(request) + data = site_settings(request) + assert "site_settings" in data + + +@pytest.mark.django_db +def test_get_tag_css_fallback(): + tag = Tag.objects.create(name="x", slug="x") + value = core_tags.get_tag_css(tag) + assert "bg-zinc" in value + + +@pytest.mark.django_db +def test_get_legal_pages_tag_callable(home_page): + legal_index = LegalIndexPage(title="Legal", slug="legal") + home_page.add_child(instance=legal_index) + legal = LegalPage(title="Privacy", slug="privacy-policy", body="x
", last_updated="2026-01-01") + legal_index.add_child(instance=legal) + legal.save_revision().publish() + + rf = RequestFactory() + request = rf.get("/") + pages = core_tags.get_legal_pages({"request": request}) + assert pages.count() >= 1 + + +@pytest.mark.django_db +def test_article_json_ld_contains_headline(article_page, rf): + request = rf.get("/") + request.site = Site.objects.filter(is_default_site=True).first() + result = article_json_ld(Context({"request": request}), article_page) + assert "application/ld+json" in result + assert article_page.title in result diff --git a/apps/core/tests/test_nightly_e2e_playwright.py b/apps/core/tests/test_nightly_e2e_playwright.py new file mode 100644 index 0000000..af38e24 --- /dev/null +++ b/apps/core/tests/test_nightly_e2e_playwright.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import os + +import pytest +from playwright.sync_api import expect, sync_playwright + + +@pytest.mark.e2e +def test_nightly_playwright_journey() -> None: + base_url = os.getenv("E2E_BASE_URL") + if not base_url: + pytest.skip("E2E_BASE_URL is not set") + + base_url = base_url.rstrip("/") + + with sync_playwright() as pw: + browser = pw.chromium.launch() + page = browser.new_page() + + page.goto(f"{base_url}/", wait_until="networkidle") + expect(page.locator("#cookie-banner")).to_be_visible() + page.get_by_role("button", name="Toggle theme").click() + page.get_by_role("button", name="Accept all").first.click() + expect(page.locator("#cookie-banner")).to_have_count(0) + + page.goto(f"{base_url}/articles/", wait_until="networkidle") + first_article_link = page.locator("main article a").first + expect(first_article_link).to_be_visible() + article_href = first_article_link.get_attribute("href") + assert article_href + + article_url = article_href if article_href.startswith("http") else f"{base_url}{article_href}" + page.goto(article_url, wait_until="networkidle") + expect(page.get_by_role("heading", name="Comments")).to_be_visible() + expect(page.get_by_role("button", name="Post comment")).to_be_visible() + + page.goto(f"{base_url}/feed/", wait_until="networkidle") + feed_content = page.content() + assert ( + "" + "word " * 1000 + "
")] + article = ArticlePage(title="Bench", slug="bench", author=author, summary="summary", body=body) + + result = benchmark(article._compute_read_time) + assert result >= 1 + assert benchmark.stats.stats.mean < 0.05 diff --git a/apps/core/tests/test_security.py b/apps/core/tests/test_security.py new file mode 100644 index 0000000..3508317 --- /dev/null +++ b/apps/core/tests/test_security.py @@ -0,0 +1,101 @@ +import re + +import pytest + +from apps.blog.models import ArticleIndexPage, ArticlePage +from apps.blog.tests.factories import AuthorFactory + + +@pytest.mark.django_db +def test_security_headers_present(client, home_page): + resp = client.get("/") + assert resp.status_code == 200 + assert "Content-Security-Policy" in resp + assert "Permissions-Policy" in resp + assert "unsafe-inline" not in resp["Content-Security-Policy"] + assert "script-src" in resp["Content-Security-Policy"] + assert resp["X-Frame-Options"] == "SAMEORIGIN" + assert "strict-origin-when-cross-origin" in resp["Referrer-Policy"] + + +@pytest.mark.django_db +def test_csp_nonce_applied_to_inline_script(client, home_page): + resp = client.get("/") + csp = resp["Content-Security-Policy"] + match = re.search(r"nonce-([^' ;]+)", csp) + assert match + nonce = match.group(1) + html = resp.content.decode() + assert f'nonce="{nonce}"' in html + + +@pytest.mark.django_db +def test_robots_disallows_cms_and_contains_sitemap(client): + resp = client.get("/robots.txt") + body = resp.content.decode() + assert resp.status_code == 200 + assert "Disallow: /cms/" in body + assert "Sitemap:" in body + + +@pytest.mark.django_db +def test_admin_obscured_path_redirects_to_cms(client): + resp = client.get("/admin/") + assert resp.status_code == 302 + assert resp["Location"] == "/cms/" + + +@pytest.mark.django_db +def test_article_comment_form_contains_csrf_token(client, home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="CSRF Article", + slug="csrf-article", + author=author, + summary="summary", + body=[("rich_text", "Body
")], + ) + index.add_child(instance=article) + article.save_revision().publish() + + resp = client.get("/articles/csrf-article/") + html = resp.content.decode() + assert resp.status_code == 200 + assert "csrfmiddlewaretoken" in html + + +@pytest.mark.django_db +def test_consent_rejects_open_redirect(client, home_page): + resp = client.post( + "/consent/", + {"reject_all": "1"}, + HTTP_REFERER="https://evil.example.com/phish", + ) + assert resp.status_code == 302 + assert resp["Location"] == "/" + + +@pytest.mark.django_db +def test_article_json_ld_script_has_csp_nonce(client, home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="Nonce Article", + slug="nonce-article", + author=author, + summary="summary", + body=[("rich_text", "Body
")], + ) + index.add_child(instance=article) + article.save_revision().publish() + + resp = client.get("/articles/nonce-article/") + csp = resp["Content-Security-Policy"] + match = re.search(r"nonce-([^' ;]+)", csp) + assert match + nonce = match.group(1) + html = resp.content.decode() + assert f'type="application/ld+json" nonce="{nonce}"' in html diff --git a/apps/core/tests/test_smoke.py b/apps/core/tests/test_smoke.py new file mode 100644 index 0000000..353204d --- /dev/null +++ b/apps/core/tests/test_smoke.py @@ -0,0 +1,2 @@ +def test_smoke(): + assert 1 == 1 diff --git a/apps/core/tests/test_tags.py b/apps/core/tests/test_tags.py new file mode 100644 index 0000000..5ccabb5 --- /dev/null +++ b/apps/core/tests/test_tags.py @@ -0,0 +1,15 @@ +import pytest + +from apps.legal.models import LegalIndexPage, LegalPage + + +@pytest.mark.django_db +def test_get_legal_pages_tag(client, home_page): + legal_index = LegalIndexPage(title="Legal", slug="legal") + home_page.add_child(instance=legal_index) + legal = LegalPage(title="Privacy", slug="privacy-policy", last_updated="2026-01-01", body="x
") + legal_index.add_child(instance=legal) + legal.save_revision().publish() + + resp = client.get("/") + assert resp.status_code == 200 diff --git a/apps/core/views.py b/apps/core/views.py new file mode 100644 index 0000000..bbfeb4f --- /dev/null +++ b/apps/core/views.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed +from django.shortcuts import redirect, render +from django.utils.http import url_has_allowed_host_and_scheme + +from apps.core.consent import ConsentService + + +def consent_view(request: HttpRequest) -> HttpResponse: + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + + analytics = False + advertising = False + + if request.POST.get("accept_all"): + analytics = True + advertising = True + elif request.POST.get("reject_all"): + analytics = False + advertising = False + else: + analytics = request.POST.get("analytics") in {"true", "1", "on"} + advertising = request.POST.get("advertising") in {"true", "1", "on"} + + target = request.META.get("HTTP_REFERER", "/") + if not url_has_allowed_host_and_scheme( + url=target, + allowed_hosts={request.get_host()}, + require_https=request.is_secure(), + ): + target = "/" + response = redirect(target) + ConsentService.set_consent(response, analytics=analytics, advertising=advertising) + return response + + +def robots_txt(request: HttpRequest) -> HttpResponse: + return render(request, "core/robots.txt", content_type="text/plain") diff --git a/apps/legal/__init__.py b/apps/legal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/legal/apps.py b/apps/legal/apps.py new file mode 100644 index 0000000..8fea38e --- /dev/null +++ b/apps/legal/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LegalConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.legal" diff --git a/apps/legal/migrations/0001_initial.py b/apps/legal/migrations/0001_initial.py new file mode 100644 index 0000000..e72e2e1 --- /dev/null +++ b/apps/legal/migrations/0001_initial.py @@ -0,0 +1,40 @@ +# Generated by Django 5.2.11 on 2026-02-28 11:42 + +import django.db.models.deletion +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('wagtailcore', '0094_alter_page_locale'), + ] + + operations = [ + migrations.CreateModel( + name='LegalIndexPage', + fields=[ + ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')), + ], + options={ + 'abstract': False, + }, + bases=('wagtailcore.page',), + ), + migrations.CreateModel( + name='LegalPage', + fields=[ + ('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')), + ('body', wagtail.fields.RichTextField()), + ('last_updated', models.DateField()), + ('show_in_footer', models.BooleanField(default=True)), + ], + options={ + 'abstract': False, + }, + bases=('wagtailcore.page',), + ), + ] diff --git a/apps/legal/migrations/__init__.py b/apps/legal/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/legal/models.py b/apps/legal/models.py new file mode 100644 index 0000000..74abc93 --- /dev/null +++ b/apps/legal/models.py @@ -0,0 +1,25 @@ +from django.db import models +from wagtail.fields import RichTextField +from wagtail.models import Page + + +class LegalIndexPage(Page): + 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): + return [] + + +class LegalPage(Page): + body = RichTextField() + last_updated = models.DateField() + show_in_footer = models.BooleanField(default=True) + + parent_page_types = ["legal.LegalIndexPage"] + subpage_types = [] diff --git a/apps/legal/tests/__init__.py b/apps/legal/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/legal/tests/test_models.py b/apps/legal/tests/test_models.py new file mode 100644 index 0000000..ec82f02 --- /dev/null +++ b/apps/legal/tests/test_models.py @@ -0,0 +1,23 @@ +import pytest + +from apps.legal.models import LegalIndexPage, LegalPage + + +@pytest.mark.django_db +def test_legal_index_redirects(client, home_page): + legal_index = LegalIndexPage(title="Legal", slug="legal") + home_page.add_child(instance=legal_index) + legal_index.save_revision().publish() + resp = client.get("/legal/") + assert resp.status_code == 302 + + +@pytest.mark.django_db +def test_legal_page_render(client, home_page): + legal_index = LegalIndexPage(title="Legal", slug="legal") + home_page.add_child(instance=legal_index) + legal = LegalPage(title="Privacy", slug="privacy-policy", last_updated="2026-01-01", body="x
") + legal_index.add_child(instance=legal) + legal.save_revision().publish() + resp = client.get("/legal/privacy-policy/") + assert resp.status_code == 200 diff --git a/apps/legal/tests/test_more.py b/apps/legal/tests/test_more.py new file mode 100644 index 0000000..44e68ba --- /dev/null +++ b/apps/legal/tests/test_more.py @@ -0,0 +1,8 @@ +import pytest + +from apps.legal.models import LegalIndexPage + + +@pytest.mark.django_db +def test_legal_index_sitemap_urls_empty(): + assert LegalIndexPage().get_sitemap_urls() == [] diff --git a/apps/newsletter/__init__.py b/apps/newsletter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/newsletter/apps.py b/apps/newsletter/apps.py new file mode 100644 index 0000000..81151b7 --- /dev/null +++ b/apps/newsletter/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NewsletterConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.newsletter" diff --git a/apps/newsletter/forms.py b/apps/newsletter/forms.py new file mode 100644 index 0000000..4cdc56a --- /dev/null +++ b/apps/newsletter/forms.py @@ -0,0 +1,7 @@ +from django import forms + + +class SubscriptionForm(forms.Form): + email = forms.EmailField() + source = forms.CharField(required=False) + honeypot = forms.CharField(required=False) diff --git a/apps/newsletter/migrations/0001_initial.py b/apps/newsletter/migrations/0001_initial.py new file mode 100644 index 0000000..de2e42e --- /dev/null +++ b/apps/newsletter/migrations/0001_initial.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.11 on 2026-02-28 11:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='NewsletterSubscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254, unique=True)), + ('confirmed', models.BooleanField(default=False)), + ('source', models.CharField(default='unknown', max_length=100)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/apps/newsletter/migrations/__init__.py b/apps/newsletter/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/newsletter/models.py b/apps/newsletter/models.py new file mode 100644 index 0000000..7d39217 --- /dev/null +++ b/apps/newsletter/models.py @@ -0,0 +1,11 @@ +from django.db import models + + +class NewsletterSubscription(models.Model): + email = models.EmailField(unique=True) + confirmed = models.BooleanField(default=False) + source = models.CharField(max_length=100, default="unknown") + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return self.email diff --git a/apps/newsletter/services.py b/apps/newsletter/services.py new file mode 100644 index 0000000..3930c56 --- /dev/null +++ b/apps/newsletter/services.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import logging +import os + +import requests + +logger = logging.getLogger(__name__) + + +class ProviderSyncError(Exception): + pass + + +class ProviderSyncService: + def sync(self, subscription): + raise NotImplementedError + + +class ButtondownSyncService(ProviderSyncService): + endpoint = "https://api.buttondown.email/v1/subscribers" + + def sync(self, subscription): + api_key = os.getenv("BUTTONDOWN_API_KEY", "") + if not api_key: + raise ProviderSyncError("BUTTONDOWN_API_KEY is not configured") + + response = requests.post( + self.endpoint, + headers={"Authorization": f"Token {api_key}", "Content-Type": "application/json"}, + json={"email": subscription.email}, + timeout=10, + ) + if response.status_code >= 400: + raise ProviderSyncError(f"Buttondown sync failed: {response.status_code}") + logger.info("Synced subscription %s to Buttondown", subscription.email) + + +def get_provider_service() -> ProviderSyncService: + provider = os.getenv("NEWSLETTER_PROVIDER", "buttondown").lower().strip() + if provider != "buttondown": + raise ProviderSyncError(f"Unsupported newsletter provider: {provider}") + return ButtondownSyncService() diff --git a/apps/newsletter/tests/__init__.py b/apps/newsletter/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/newsletter/tests/test_more.py b/apps/newsletter/tests/test_more.py new file mode 100644 index 0000000..3ec368e --- /dev/null +++ b/apps/newsletter/tests/test_more.py @@ -0,0 +1,14 @@ +import pytest + +from apps.newsletter.services import ProviderSyncService +from apps.newsletter.views import confirmation_token + + +def test_confirmation_token_roundtrip(): + token = confirmation_token("x@example.com") + assert token + + +def test_provider_sync_not_implemented(): + with pytest.raises(NotImplementedError): + ProviderSyncService().sync(None) diff --git a/apps/newsletter/tests/test_views.py b/apps/newsletter/tests/test_views.py new file mode 100644 index 0000000..bc48202 --- /dev/null +++ b/apps/newsletter/tests/test_views.py @@ -0,0 +1,55 @@ +import pytest +from django.core import signing + +from apps.newsletter.models import NewsletterSubscription + + +@pytest.mark.django_db +def test_subscribe_ok(client): + resp = client.post("/newsletter/subscribe/", {"email": "a@example.com", "source": "nav"}) + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + assert NewsletterSubscription.objects.filter(email="a@example.com").exists() + + +@pytest.mark.django_db +def test_subscribe_sends_confirmation_email(client, mailoutbox): + resp = client.post("/newsletter/subscribe/", {"email": "new@example.com", "source": "nav"}) + assert resp.status_code == 200 + assert len(mailoutbox) == 1 + assert "Confirm your No Hype AI newsletter subscription" in mailoutbox[0].subject + + +@pytest.mark.django_db +def test_duplicate_subscribe_returns_ok_without_extra_email(client, mailoutbox): + client.post("/newsletter/subscribe/", {"email": "dupe@example.com", "source": "nav"}) + assert len(mailoutbox) == 1 + resp = client.post("/newsletter/subscribe/", {"email": "dupe@example.com", "source": "footer"}) + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + assert len(mailoutbox) == 1 + + +@pytest.mark.django_db +def test_subscribe_invalid(client): + resp = client.post("/newsletter/subscribe/", {"email": "bad"}) + assert resp.status_code == 400 + + +@pytest.mark.django_db +def test_confirm_endpoint(client): + sub = NewsletterSubscription.objects.create(email="b@example.com") + token = signing.dumps(sub.email, salt="newsletter-confirm") + resp = client.get(f"/newsletter/confirm/{token}/") + assert resp.status_code == 302 + sub.refresh_from_db() + assert sub.confirmed is True + + +@pytest.mark.django_db +def test_confirm_endpoint_with_expired_token(client, monkeypatch): + sub = NewsletterSubscription.objects.create(email="c@example.com") + token = signing.dumps(sub.email, salt="newsletter-confirm") + monkeypatch.setattr("apps.newsletter.views.CONFIRMATION_TOKEN_MAX_AGE_SECONDS", -1) + resp = client.get(f"/newsletter/confirm/{token}/") + assert resp.status_code == 404 diff --git a/apps/newsletter/urls.py b/apps/newsletter/urls.py new file mode 100644 index 0000000..a0e8e3f --- /dev/null +++ b/apps/newsletter/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from apps.newsletter.views import ConfirmView, SubscribeView + +urlpatterns = [ + path("subscribe/", SubscribeView.as_view(), name="newsletter_subscribe"), + path("confirm/body words
")], + ) + article_index.add_child(instance=article) + article.save_revision().publish() + return article diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml new file mode 100644 index 0000000..c89e948 --- /dev/null +++ b/docker-compose.ci.yml @@ -0,0 +1,7 @@ +services: + web: + volumes: [] + ports: [] + + db: + ports: [] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cd89585 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + web: + build: . + working_dir: /app + command: python manage.py runserver 0.0.0.0:8000 + volumes: + - .:/app + ports: + - "8035:8000" + environment: + SECRET_KEY: dev-secret-key + DEBUG: "1" + ALLOWED_HOSTS: localhost,127.0.0.1,web + WAGTAIL_SITE_NAME: No Hype AI + DATABASE_URL: postgres://nohype:nohype@db:5432/nohype + DJANGO_SETTINGS_MODULE: config.settings.development + WAGTAILADMIN_BASE_URL: http://localhost:8035 + CONSENT_POLICY_VERSION: "1" + EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend + DEFAULT_FROM_EMAIL: hello@nohypeai.com + NEWSLETTER_PROVIDER: buttondown + depends_on: + db: + condition: service_healthy + + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: nohype + POSTGRES_USER: nohype + POSTGRES_PASSWORD: nohype + volumes: + - nohype_pg:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U nohype -d nohype"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 5s + +volumes: + nohype_pg: diff --git a/implementation.md b/implementation.md index bd409e3..bb223f6 100644 --- a/implementation.md +++ b/implementation.md @@ -179,8 +179,8 @@ Every milestone follows the **Red → Green → Refactor** cycle. No production ### 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) +- 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 @@ -212,10 +212,10 @@ class ArticlePageFactory(wagtail_factories.PageFactory): # Note: no is_featured — featured article is set on HomePage.featured_article only ``` -### 3.6 CI Pipeline (GitHub Actions) +### 3.6 CI Pipeline (Gitea Actions) ``` -on: [push, pull_request] +on: [pull_request] jobs: test: @@ -232,6 +232,8 @@ jobs: - 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 @@ -242,7 +244,7 @@ jobs: - `./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 +- Gitea Actions workflow file committed and green ### M0 — Tasks @@ -271,7 +273,7 @@ jobs: - Add Prism.js and Alpine.js to `static/js/`; wire into `base.html` #### M0.5 — CI -- Create `.github/workflows/ci.yml` +- 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 @@ -1487,4 +1489,4 @@ A milestone is **Done** when all of the following are true: --- -*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 +*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.* diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..f7ba6cb --- /dev/null +++ b/manage.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +import os +import sys + + +def main() -> None: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5270ff4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[tool.ruff] +line-length = 120 +target-version = "py312" +exclude = ["migrations"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP"] + +[tool.ruff.lint.per-file-ignores] +"config/settings/development.py" = ["F403", "F405"] + +[tool.mypy] +python_version = "3.12" +plugins = ["mypy_django_plugin.main"] +warn_unused_configs = true +warn_redundant_casts = true +warn_unused_ignores = true +check_untyped_defs = true +exclude = ["migrations"] +disable_error_code = ["var-annotated", "override", "import-untyped", "arg-type"] +allow_untyped_globals = true + +[[tool.mypy.overrides]] +module = ["wagtail.*", "taggit.*", "modelcluster.*", "wagtailseo.*", "debug_toolbar"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["apps.authors.models"] +ignore_errors = true + +[tool.django-stubs] +django_settings_module = "config.settings.development" + +[tool.coverage.run] +source = ["apps"] +omit = [ + "*/migrations/*", + "*/tests/*", +] + +[tool.coverage.report] +omit = [ + "*/migrations/*", + "*/tests/*", +] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3ed55ca --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +DJANGO_SETTINGS_MODULE = config.settings.development +python_files = test_*.py +addopts = -q --cov=apps --cov-report=term-missing --cov-fail-under=90 +markers = + e2e: browser-based end-to-end test suite for nightly jobs diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 0000000..af13702 --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,24 @@ +Django~=5.2.0 +wagtail~=7.0.0 +wagtail-seo~=3.1.1 +psycopg2-binary~=2.9.0 +Pillow~=11.0.0 +django-taggit~=6.0.0 +whitenoise~=6.0.0 +gunicorn~=23.0.0 +python-dotenv~=1.0.0 +dj-database-url~=2.2.0 +django-tailwind~=3.8.0 +django-csp~=3.8.0 +pytest~=8.3.0 +pytest-django~=4.9.0 +pytest-cov~=5.0.0 +pytest-benchmark~=4.0.0 +factory-boy~=3.3.0 +wagtail-factories~=4.2.0 +feedparser~=6.0.0 +playwright~=1.52.0 +pytest-playwright~=0.7.0 +ruff~=0.6.0 +mypy~=1.11.0 +django-stubs~=5.1.0 diff --git a/requirements/production.txt b/requirements/production.txt new file mode 100644 index 0000000..c5217f5 --- /dev/null +++ b/requirements/production.txt @@ -0,0 +1,2 @@ +-r base.txt +sentry-sdk~=2.0.0 diff --git a/static/js/consent.js b/static/js/consent.js new file mode 100644 index 0000000..1a53c05 --- /dev/null +++ b/static/js/consent.js @@ -0,0 +1,13 @@ +(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 (_e) { + return {}; + } + } + const c = parseCookieValue('nhAiConsent'); + window.__nhConsent = { analytics: c.a === '1', advertising: c.d === '1' }; +})(); diff --git a/static/js/newsletter.js b/static/js/newsletter.js new file mode 100644 index 0000000..6562ae2 --- /dev/null +++ b/static/js/newsletter.js @@ -0,0 +1,54 @@ +(() => { + const setMessage = (form, text) => { + const target = form.querySelector("[data-newsletter-message]"); + if (target) { + target.textContent = text; + } + }; + + const bindNewsletterForms = () => { + const forms = document.querySelectorAll("form[data-newsletter-form]"); + forms.forEach((form) => { + form.addEventListener("submit", async (event) => { + event.preventDefault(); + const formData = new FormData(form); + try { + const response = await fetch(form.action, { + method: "POST", + body: formData, + }); + if (!response.ok) { + setMessage(form, "Please enter a valid email."); + return; + } + setMessage(form, "Check your email to confirm your subscription."); + form.reset(); + } catch (error) { + setMessage(form, "Subscription failed. Please try again."); + } + }); + }); + }; + + const bindCopyLink = () => { + const button = document.querySelector("[data-copy-link]"); + if (!button) { + return; + } + button.addEventListener("click", async () => { + const url = button.getAttribute("data-copy-url"); + if (!url) { + return; + } + try { + await navigator.clipboard.writeText(url); + button.textContent = "Copied"; + } catch (error) { + button.textContent = "Copy failed"; + } + }); + }; + + bindNewsletterForms(); + bindCopyLink(); +})(); diff --git a/static/js/prism.js b/static/js/prism.js new file mode 100644 index 0000000..bcd212e --- /dev/null +++ b/static/js/prism.js @@ -0,0 +1 @@ +/* placeholder for Prism.js bundle */ diff --git a/static/js/theme.js b/static/js/theme.js new file mode 100644 index 0000000..5fb7062 --- /dev/null +++ b/static/js/theme.js @@ -0,0 +1,14 @@ +(function () { + window.toggleTheme = function toggleTheme() { + const root = document.documentElement; + root.classList.toggle('dark'); + localStorage.setItem('theme', root.classList.contains('dark') ? 'dark' : 'light'); + }; + + document.addEventListener('DOMContentLoaded', function onReady() { + const toggle = document.querySelector('[data-theme-toggle]'); + if (toggle) { + toggle.addEventListener('click', window.toggleTheme); + } + }); +})(); diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..b802c1d --- /dev/null +++ b/templates/base.html @@ -0,0 +1,31 @@ +{% load static core_tags %} + + + + + +{{ message }}
+ {% endfor %} +{{ page.mission_statement }}
+{{ page.body|richtext }} +{% if page.featured_author %} +{{ page.featured_author.bio }}
+ {% if page.featured_author.avatar %} + {% image page.featured_author.avatar fill-200x200 %} + {% endif %} +{% endif %} +{% endblock %} diff --git a/templates/blog/article_index_page.html b/templates/blog/article_index_page.html new file mode 100644 index 0000000..edeafc4 --- /dev/null +++ b/templates/blog/article_index_page.html @@ -0,0 +1,35 @@ +{% extends 'base.html' %} +{% load core_tags seo_tags %} +{% block title %}Articles | No Hype AI{% endblock %} +{% block head_meta %} + {% canonical_url page as canonical %} + + + + + +{% endblock %} +{% block content %} +No articles found.
+{% endfor %} + +{% endblock %} diff --git a/templates/blog/article_page.html b/templates/blog/article_page.html new file mode 100644 index 0000000..76d47e3 --- /dev/null +++ b/templates/blog/article_page.html @@ -0,0 +1,91 @@ +{% extends 'base.html' %} +{% load wagtailcore_tags wagtailimages_tags seo_tags %} +{% block title %}{{ page.title }} | No Hype AI{% endblock %} +{% block head_meta %} + {% canonical_url page as canonical %} + {% article_og_image_url page as og_image %} + + + + + + + {% if og_image %}{% endif %} + + + + {% if og_image %}{% endif %} +{% endblock %} +{% block content %} +{{ page.read_time_mins }} min read
+ {% if page.hero_image %} + {% image page.hero_image fill-1200x630 %} + {% endif %} + {{ page.body }} + {% article_json_ld page %} +{{ comment.author_name }}
+{{ comment.body }}
+ {% for reply in comment.replies.all %} +{{ reply.author_name }}
+{{ reply.body }}
+No comments yet.
+ {% endfor %} + {% if comment_form and comment_form.errors %} +{{ value.raw_code }}
++diff --git a/templates/blog/home_page.html b/templates/blog/home_page.html new file mode 100644 index 0000000..08bae09 --- /dev/null +++ b/templates/blog/home_page.html @@ -0,0 +1,31 @@ +{% extends 'base.html' %} +{% load seo_tags %} +{% block title %}No Hype AI{% endblock %} +{% block head_meta %} + {% canonical_url page as canonical %} + + + + + + +{% endblock %} +{% block content %} +{{ value.quote }}
+ {% if value.attribution %}{{ value.attribution }}{% endif %} +
{{ featured_article.author.name }}
+{{ featured_article.read_time_mins }} min read
+ {% endif %} +{% blocktrans trimmed with snippet_type_name=model_opts.verbose_name %}Approve this {{ snippet_type_name }}?{% endblocktrans %}
+ {% else %} +{% blocktrans trimmed with count=items|length|intcomma %}Approve {{ count }} selected comments?{% endblocktrans %}
+{{ article.summary|truncatewords:20 }}
+ {% for tag in article.tags.all %} + {{ tag.name }} + {% endfor %} +Last updated: {{ page.last_updated|date:'F Y' }}
+{{ page.body|richtext }} +{% endblock %} diff --git a/templates/newsletter/email/confirmation_body.html b/templates/newsletter/email/confirmation_body.html new file mode 100644 index 0000000..4ccdaff --- /dev/null +++ b/templates/newsletter/email/confirmation_body.html @@ -0,0 +1,13 @@ + + + +Hi,
+Please confirm your newsletter subscription by clicking the button below:
+ +If you did not request this, you can ignore this email.
+ + diff --git a/templates/newsletter/email/confirmation_body.txt b/templates/newsletter/email/confirmation_body.txt new file mode 100644 index 0000000..c659687 --- /dev/null +++ b/templates/newsletter/email/confirmation_body.txt @@ -0,0 +1,7 @@ +Hi, + +Please confirm your newsletter subscription by visiting this link: + +{{ confirmation_url }} + +If you did not request this, you can ignore this email. diff --git a/templates/newsletter/email/confirmation_subject.txt b/templates/newsletter/email/confirmation_subject.txt new file mode 100644 index 0000000..ba47691 --- /dev/null +++ b/templates/newsletter/email/confirmation_subject.txt @@ -0,0 +1 @@ +Confirm your No Hype AI newsletter subscription diff --git a/theme/__init__.py b/theme/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/theme/apps.py b/theme/apps.py new file mode 100644 index 0000000..f656ab4 --- /dev/null +++ b/theme/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ThemeConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "theme" diff --git a/theme/static/css/styles.css b/theme/static/css/styles.css new file mode 100644 index 0000000..0798e68 --- /dev/null +++ b/theme/static/css/styles.css @@ -0,0 +1 @@ +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.static{position:static}.block{display:block}.hidden{display:none} \ No newline at end of file diff --git a/theme/static_src/package-lock.json b/theme/static_src/package-lock.json new file mode 100644 index 0000000..6af33a5 --- /dev/null +++ b/theme/static_src/package-lock.json @@ -0,0 +1,944 @@ +{ + "name": "nohype-theme", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nohype-theme", + "version": "1.0.0", + "devDependencies": { + "tailwindcss": "^3.4.17" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + } + } +} diff --git a/theme/static_src/package.json b/theme/static_src/package.json new file mode 100644 index 0000000..608e777 --- /dev/null +++ b/theme/static_src/package.json @@ -0,0 +1,12 @@ +{ + "name": "nohype-theme", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tailwindcss -i ./src/input.css -o ../static/css/styles.css --minify", + "dev": "tailwindcss -i ./src/input.css -o ../static/css/styles.css --watch" + }, + "devDependencies": { + "tailwindcss": "^3.4.17" + } +} diff --git a/theme/static_src/src/input.css b/theme/static_src/src/input.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/theme/static_src/src/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/theme/static_src/tailwind.config.js b/theme/static_src/tailwind.config.js new file mode 100644 index 0000000..dc5cbfc --- /dev/null +++ b/theme/static_src/tailwind.config.js @@ -0,0 +1,10 @@ +module.exports = { + content: [ + "../../templates/**/*.html", + "../../apps/**/templates/**/*.html" + ], + theme: { + extend: {} + }, + plugins: [] +};