Compare commits
52 Commits
938ff5b0d2
...
fix/deploy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
229c0f8b48
|
||
|
|
ec3e1ee1bf
|
||
|
|
c9dab3e93b
|
||
|
|
349f1db721
|
||
| de56b564c5 | |||
|
|
833ff378ea
|
||
|
|
754b0ca5f6
|
||
| 03fcbdb5ad | |||
|
|
0cbac68ec1
|
||
| 4c27cfe1dd | |||
|
|
a598727888
|
||
| 36eb0f1dd2 | |||
|
|
f950e3cd5e
|
||
| 311ad80320 | |||
|
|
08e003e165
|
||
| 076aaa0b9e | |||
|
|
7b2ad4cfe5
|
||
|
|
56e53478ea
|
||
| 96f9eca19d | |||
|
|
f6edcadd46
|
||
|
|
4992b0cb9d
|
||
|
|
9d323d2040
|
||
| aeb0afb2ea | |||
|
|
b73a2d4d72
|
||
| 7d226bc4ef | |||
|
|
9b677741cb | ||
| c5f2902417 | |||
|
|
5d1c5f43bc | ||
| 027dff20e3 | |||
|
|
c4fde90a9c | ||
|
|
cfe0cbca62 | ||
|
|
5adff60d4b | ||
|
|
0c9340d279 | ||
|
|
ebddb6c904 | ||
|
|
29e3589b1a | ||
|
|
14db1bb57e | ||
|
|
36ac487cbd
|
||
|
|
932b05cc02
|
||
|
|
683cba4280
|
||
|
|
2cb1e622e2
|
||
|
|
11b89e9e1c
|
||
|
|
06be5d6752
|
||
|
|
ebdf20e708
|
||
|
|
47e8afea18
|
||
|
|
630c86221f
|
||
|
|
2d2edd8605
|
||
|
|
0b5fca3be6
|
||
|
|
eb2cdfc5f2
|
||
|
|
82e6bc2ee0
|
||
|
|
e279e15c9c
|
||
|
|
6fc28f9d9a
|
||
|
|
ca211c14e9
|
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
.gitea
|
||||
.github
|
||||
.venv
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
.benchmarks/
|
||||
media/
|
||||
staticfiles/
|
||||
205
.gitea/workflows/ci.yml
Normal file
205
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,205 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
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 --ignore=e2e
|
||||
|
||||
- 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
|
||||
|
||||
pr-e2e:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_IMAGE: nohype-ci-e2e:${{ github.run_id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build
|
||||
run: docker build -t "$CI_IMAGE" .
|
||||
|
||||
- name: Start PostgreSQL
|
||||
run: |
|
||||
docker run -d --name pr-e2e-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 pr-e2e-postgres pg_isready -U nohype -d nohype >/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
docker logs pr-e2e-postgres || true
|
||||
exit 1
|
||||
|
||||
- name: Start app with seeded content
|
||||
run: |
|
||||
docker run -d --name pr-e2e-app --network container:pr-e2e-postgres \
|
||||
-v /opt/playwright-tools/browsers:/opt/playwright-tools/browsers:ro \
|
||||
-e SECRET_KEY=ci-secret-key \
|
||||
-e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \
|
||||
-e CONSENT_POLICY_VERSION=1 \
|
||||
-e EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend \
|
||||
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
|
||||
-e NEWSLETTER_PROVIDER=buttondown \
|
||||
-e PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-tools/browsers \
|
||||
"$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 pr-e2e-app curl -fsS http://127.0.0.1:8000/ >/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
docker logs pr-e2e-app || true
|
||||
exit 1
|
||||
|
||||
- name: Run E2E tests
|
||||
run: |
|
||||
docker exec -e E2E_BASE_URL=http://127.0.0.1:8000 pr-e2e-app \
|
||||
pytest e2e/ -o addopts='' -q --tb=short
|
||||
|
||||
- name: Remove containers
|
||||
if: always()
|
||||
run: |
|
||||
docker rm -f pr-e2e-app || true
|
||||
docker rm -f pr-e2e-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 \
|
||||
-v /opt/playwright-tools/browsers:/opt/playwright-tools/browsers:ro \
|
||||
-e SECRET_KEY=ci-secret-key \
|
||||
-e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \
|
||||
-e CONSENT_POLICY_VERSION=1 \
|
||||
-e EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend \
|
||||
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
|
||||
-e NEWSLETTER_PROVIDER=buttondown \
|
||||
-e PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-tools/browsers \
|
||||
"$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 Playwright E2E tests
|
||||
run: |
|
||||
docker exec -e E2E_BASE_URL=http://127.0.0.1:8000 nightly-e2e \
|
||||
pytest e2e/ apps/core/tests/test_nightly_e2e_playwright.py -o addopts='' -q --tb=short
|
||||
- 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
|
||||
|
||||
deploy:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy to lintel-prod-01
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.PROD_SSH_HOST }}
|
||||
username: deploy
|
||||
key: ${{ secrets.PROD_SSH_KEY }}
|
||||
script: bash /srv/sum/nohype/app/deploy/deploy.sh
|
||||
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -1,20 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Build
|
||||
run: docker compose build
|
||||
- name: Pytest
|
||||
run: docker compose run --rm web pytest
|
||||
- name: Ruff
|
||||
run: docker compose run --rm web ruff check .
|
||||
- name: Mypy
|
||||
run: docker compose run --rm web mypy apps config
|
||||
@@ -10,3 +10,6 @@
|
||||
- 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.
|
||||
|
||||
46
Dockerfile
46
Dockerfile
@@ -1,16 +1,46 @@
|
||||
FROM python:3.12-slim
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
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 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN set -eux; \
|
||||
sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources; \
|
||||
printf '%s\n' \
|
||||
'Acquire::Retries "8";' \
|
||||
'Acquire::http::No-Cache "true";' \
|
||||
'Acquire::https::No-Cache "true";' \
|
||||
'Acquire::http::Pipeline-Depth "0";' \
|
||||
'Acquire::BrokenProxy "true";' \
|
||||
> /etc/apt/apt.conf.d/99docker-hardening; \
|
||||
apt-get update; \
|
||||
for attempt in 1 2 3; do \
|
||||
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 \
|
||||
&& break; \
|
||||
if [ "$attempt" -eq 3 ]; then exit 1; fi; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
sleep "$((attempt * 5))"; \
|
||||
apt-get update; \
|
||||
done; \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
10
README.md
10
README.md
@@ -48,6 +48,8 @@ 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
|
||||
```
|
||||
|
||||
@@ -55,3 +57,11 @@ sudo systemctl reload gunicorn
|
||||
|
||||
- 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 <backup-host>:/path/to/media/ /srv/nohypeai/media/`
|
||||
|
||||
## Runtime Notes
|
||||
|
||||
- Keep Caddy serving `/static/` and `/media/` directly in production.
|
||||
- Keep Gunicorn behind Caddy and run from a systemd service/socket pair.
|
||||
- Use `python manage.py purge_old_comment_data --months 24` in cron for comment-data retention.
|
||||
|
||||
@@ -11,6 +11,10 @@ class AllArticlesFeed(Feed):
|
||||
link = "/articles/"
|
||||
description = "Honest AI coding tool reviews for developers."
|
||||
|
||||
def get_object(self, request):
|
||||
self.request = request
|
||||
return None
|
||||
|
||||
def items(self):
|
||||
return ArticlePage.objects.live().order_by("-first_published_at")[:20]
|
||||
|
||||
@@ -27,11 +31,16 @@ class AllArticlesFeed(Feed):
|
||||
return item.author.name
|
||||
|
||||
def item_link(self, item: ArticlePage):
|
||||
return f"{settings.WAGTAILADMIN_BASE_URL}{item.url}"
|
||||
if hasattr(self, "request") and self.request is not None:
|
||||
full_url = item.get_full_url(self.request)
|
||||
if full_url:
|
||||
return full_url
|
||||
return f"{settings.WAGTAILADMIN_BASE_URL.rstrip('/')}{item.url}"
|
||||
|
||||
|
||||
class TagArticlesFeed(AllArticlesFeed):
|
||||
def get_object(self, request, tag_slug: str):
|
||||
self.request = request
|
||||
return get_object_or_404(Tag, slug=tag_slug)
|
||||
|
||||
def title(self, obj):
|
||||
|
||||
@@ -6,10 +6,10 @@ from typing import Any
|
||||
|
||||
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
||||
from django.db import models
|
||||
from django.db.models import CASCADE, PROTECT, SET_NULL
|
||||
from django.db.models import CASCADE, PROTECT, SET_NULL, Prefetch
|
||||
from modelcluster.contrib.taggit import ClusterTaggableManager
|
||||
from modelcluster.fields import ParentalKey
|
||||
from taggit.models import TaggedItemBase
|
||||
from taggit.models import Tag, TaggedItemBase
|
||||
from wagtail.admin.panels import FieldPanel, PageChooserPanel
|
||||
from wagtail.fields import RichTextField, StreamField
|
||||
from wagtail.models import Page
|
||||
@@ -31,15 +31,16 @@ class HomePage(Page):
|
||||
|
||||
def get_context(self, request, *args, **kwargs):
|
||||
ctx = super().get_context(request, *args, **kwargs)
|
||||
articles = (
|
||||
articles_qs = (
|
||||
ArticlePage.objects.live()
|
||||
.public()
|
||||
.select_related("author")
|
||||
.prefetch_related("tags__metadata")
|
||||
.order_by("-first_published_at")
|
||||
)
|
||||
articles = list(articles_qs[:5])
|
||||
ctx["featured_article"] = self.featured_article
|
||||
ctx["latest_articles"] = articles[:5]
|
||||
ctx["latest_articles"] = articles
|
||||
ctx["more_articles"] = articles[:3]
|
||||
return ctx
|
||||
|
||||
@@ -62,6 +63,9 @@ class ArticleIndexPage(Page):
|
||||
ctx = super().get_context(request, *args, **kwargs)
|
||||
tag_slug = request.GET.get("tag")
|
||||
articles = self.get_articles()
|
||||
available_tags = (
|
||||
Tag.objects.filter(id__in=articles.values_list("tags__id", flat=True)).distinct().order_by("name")
|
||||
)
|
||||
if tag_slug:
|
||||
articles = articles.filter(tags__slug=tag_slug)
|
||||
paginator = Paginator(articles, self.ARTICLES_PER_PAGE)
|
||||
@@ -75,6 +79,7 @@ class ArticleIndexPage(Page):
|
||||
ctx["articles"] = page_obj
|
||||
ctx["paginator"] = paginator
|
||||
ctx["active_tag"] = tag_slug
|
||||
ctx["available_tags"] = available_tags
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -168,8 +173,11 @@ class ArticlePage(SeoMixin, Page):
|
||||
def get_context(self, request, *args, **kwargs):
|
||||
ctx = super().get_context(request, *args, **kwargs)
|
||||
ctx["related_articles"] = self.get_related_articles()
|
||||
ctx["approved_comments"] = self.comments.filter(is_approved=True, parent__isnull=True).select_related(
|
||||
"parent"
|
||||
from apps.comments.models import Comment
|
||||
|
||||
approved_replies = Comment.objects.filter(is_approved=True).select_related("parent")
|
||||
ctx["approved_comments"] = self.comments.filter(is_approved=True, parent__isnull=True).prefetch_related(
|
||||
Prefetch("replies", queryset=approved_replies)
|
||||
)
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
|
||||
from apps.blog.models import ArticleIndexPage, ArticlePage
|
||||
from apps.blog.tests.factories import AuthorFactory
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -6,3 +10,25 @@ def test_feed_endpoint(client):
|
||||
resp = client.get("/feed/")
|
||||
assert resp.status_code == 200
|
||||
assert resp["Content-Type"].startswith("application/rss+xml")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(WAGTAILADMIN_BASE_URL="http://wrong-host.example")
|
||||
def test_feed_uses_request_host_for_item_links(client, home_page):
|
||||
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||
home_page.add_child(instance=index)
|
||||
author = AuthorFactory()
|
||||
article = ArticlePage(
|
||||
title="Feed Article",
|
||||
slug="feed-article",
|
||||
author=author,
|
||||
summary="summary",
|
||||
body=[("rich_text", "<p>Body</p>")],
|
||||
)
|
||||
index.add_child(instance=article)
|
||||
article.save_revision().publish()
|
||||
|
||||
resp = client.get("/feed/")
|
||||
body = resp.content.decode()
|
||||
assert resp.status_code == 200
|
||||
assert "http://localhost/articles/feed-article/" in body
|
||||
|
||||
35
apps/blog/tests/test_seo.py
Normal file
35
apps/blog/tests/test_seo.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import pytest
|
||||
|
||||
from apps.blog.models import ArticleIndexPage, ArticlePage
|
||||
from apps.blog.tests.factories import AuthorFactory
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_article_page_renders_core_seo_meta(client, home_page):
|
||||
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||
home_page.add_child(instance=index)
|
||||
author = AuthorFactory()
|
||||
article = ArticlePage(
|
||||
title="SEO Article",
|
||||
slug="seo-article",
|
||||
author=author,
|
||||
summary="Summary content",
|
||||
body=[("rich_text", "<p>Body</p>")],
|
||||
)
|
||||
index.add_child(instance=article)
|
||||
article.save_revision().publish()
|
||||
|
||||
resp = client.get("/articles/seo-article/")
|
||||
html = resp.content.decode()
|
||||
assert resp.status_code == 200
|
||||
assert '<link rel="canonical" href="http' in html
|
||||
assert 'property="og:type" content="article"' in html
|
||||
assert 'name="twitter:card" content="summary_large_image"' in html
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_homepage_renders_website_og_type(client, home_page):
|
||||
resp = client.get("/")
|
||||
html = resp.content.decode()
|
||||
assert resp.status_code == 200
|
||||
assert 'property="og:type" content="website"' in html
|
||||
@@ -1,7 +1,9 @@
|
||||
import pytest
|
||||
from taggit.models import Tag
|
||||
|
||||
from apps.blog.models import ArticleIndexPage, ArticlePage
|
||||
from apps.blog.tests.factories import AuthorFactory
|
||||
from apps.comments.models import Comment
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -29,6 +31,7 @@ def test_article_index_pagination_and_tag_filter(client, home_page):
|
||||
resp = client.get("/articles/?page=2")
|
||||
assert resp.status_code == 200
|
||||
assert resp.context["articles"].number == 2
|
||||
assert "Pagination" in resp.content.decode()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -59,3 +62,98 @@ def test_article_page_related_context(client, home_page):
|
||||
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", "<p>body</p>")],
|
||||
)
|
||||
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", "<p>body</p>")],
|
||||
)
|
||||
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", "<p>body</p>")],
|
||||
)
|
||||
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
|
||||
|
||||
1
apps/comments/management/__init__.py
Normal file
1
apps/comments/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
apps/comments/management/commands/__init__.py
Normal file
1
apps/comments/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
31
apps/comments/management/commands/purge_old_comment_data.py
Normal file
31
apps/comments/management/commands/purge_old_comment_data.py
Normal file
@@ -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)."))
|
||||
81
apps/comments/tests/test_admin.py
Normal file
81
apps/comments/tests/test_admin.py
Normal file
@@ -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", "<p>body</p>")])
|
||||
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", "<p>body</p>")])
|
||||
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
|
||||
40
apps/comments/tests/test_commands.py
Normal file
40
apps/comments/tests/test_commands.py
Normal file
@@ -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", "<p>body</p>")],
|
||||
)
|
||||
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
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -27,6 +28,7 @@ def test_comment_post_flow(client, home_page):
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert resp["Location"].endswith("?commented=1")
|
||||
assert Comment.objects.count() == 1
|
||||
|
||||
|
||||
@@ -59,3 +61,100 @@ def test_comment_post_rejected_when_comments_disabled(client, home_page):
|
||||
)
|
||||
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", "<p>body</p>")])
|
||||
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", "<p>body</p>")])
|
||||
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", "<p>body</p>")])
|
||||
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"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views import View
|
||||
|
||||
from apps.blog.models import ArticlePage
|
||||
@@ -11,9 +13,24 @@ 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 = (request.META.get("HTTP_X_FORWARDED_FOR") or request.META.get("REMOTE_ADDR", "")).split(",")[0].strip()
|
||||
ip = client_ip_from_request(request)
|
||||
key = f"comment-rate:{ip}"
|
||||
count = cache.get(key, 0)
|
||||
if count >= 3:
|
||||
@@ -34,9 +51,13 @@ class CommentCreateView(View):
|
||||
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")
|
||||
|
||||
messages.error(request, "Please correct the form errors")
|
||||
return redirect(article.url)
|
||||
return self._render_article_with_errors(request, article, form)
|
||||
|
||||
@@ -1,20 +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"), "created_at"]
|
||||
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):
|
||||
return super().get_queryset(request).select_related("article", "parent")
|
||||
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)
|
||||
|
||||
1
apps/core/management/__init__.py
Normal file
1
apps/core/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
apps/core/management/commands/__init__.py
Normal file
1
apps/core/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
42
apps/core/management/commands/check_content_integrity.py
Normal file
42
apps/core/management/commands/check_content_integrity.py
Normal file
@@ -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."))
|
||||
131
apps/core/management/commands/seed_e2e_content.py
Normal file
131
apps/core/management/commands/seed_e2e_content.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from taggit.models import Tag
|
||||
from wagtail.models import Page, Site
|
||||
|
||||
from apps.authors.models import Author
|
||||
from apps.blog.models import AboutPage, ArticleIndexPage, ArticlePage, HomePage, TagMetadata
|
||||
from apps.legal.models import LegalIndexPage, LegalPage
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed deterministic content for E2E checks."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
import datetime
|
||||
|
||||
root = Page.get_first_root_node()
|
||||
|
||||
home = HomePage.objects.child_of(root).first()
|
||||
if home is None:
|
||||
home = HomePage(title="No Hype AI", slug="nohype-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.",
|
||||
},
|
||||
)
|
||||
|
||||
# Primary article — comments enabled, used by nightly journey test
|
||||
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", "<p>Seeded article body for nightly browser checks.</p>")],
|
||||
comments_enabled=True,
|
||||
)
|
||||
article_index.add_child(instance=article)
|
||||
article.save_revision().publish()
|
||||
|
||||
# Tagged article — used by tag-filter E2E tests
|
||||
tag, _ = Tag.objects.get_or_create(name="AI Tools", slug="ai-tools")
|
||||
TagMetadata.objects.get_or_create(tag=tag, defaults={"colour": "cyan"})
|
||||
tagged_article = ArticlePage.objects.child_of(article_index).filter(slug="e2e-tagged-article").first()
|
||||
if tagged_article is None:
|
||||
tagged_article = ArticlePage(
|
||||
title="Tagged Article",
|
||||
slug="e2e-tagged-article",
|
||||
author=author,
|
||||
summary="An article with tags for E2E filter tests.",
|
||||
body=[("rich_text", "<p>This article is tagged with AI Tools.</p>")],
|
||||
comments_enabled=True,
|
||||
)
|
||||
article_index.add_child(instance=tagged_article)
|
||||
tagged_article.save_revision().publish()
|
||||
tagged_article.tags.add(tag)
|
||||
tagged_article.save()
|
||||
|
||||
# Third article — comments disabled
|
||||
no_comments_article = ArticlePage.objects.child_of(article_index).filter(slug="e2e-no-comments").first()
|
||||
if no_comments_article is None:
|
||||
no_comments_article = ArticlePage(
|
||||
title="No Comments Article",
|
||||
slug="e2e-no-comments",
|
||||
author=author,
|
||||
summary="An article with comments disabled.",
|
||||
body=[("rich_text", "<p>Comments are disabled on this one.</p>")],
|
||||
comments_enabled=False,
|
||||
)
|
||||
article_index.add_child(instance=no_comments_article)
|
||||
# Explicitly persist False after add_child (which internally calls save())
|
||||
# to guard against any field reset in the page tree insertion path.
|
||||
ArticlePage.objects.filter(pk=no_comments_article.pk).update(comments_enabled=False)
|
||||
no_comments_article.comments_enabled = False
|
||||
no_comments_article.save_revision().publish()
|
||||
|
||||
# About page
|
||||
if not AboutPage.objects.child_of(home).filter(slug="about").exists():
|
||||
about = AboutPage(
|
||||
title="About",
|
||||
slug="about",
|
||||
mission_statement="Honest AI coding tool reviews for developers.",
|
||||
body="<p>We benchmark, so you don't have to.</p>",
|
||||
)
|
||||
home.add_child(instance=about)
|
||||
about.save_revision().publish()
|
||||
|
||||
# Legal pages
|
||||
legal_index = LegalIndexPage.objects.child_of(home).filter(slug="legal").first()
|
||||
if legal_index is None:
|
||||
legal_index = LegalIndexPage(title="Legal", slug="legal")
|
||||
home.add_child(instance=legal_index)
|
||||
legal_index.save_revision().publish()
|
||||
|
||||
if not LegalPage.objects.child_of(legal_index).filter(slug="privacy-policy").exists():
|
||||
privacy = LegalPage(
|
||||
title="Privacy Policy",
|
||||
slug="privacy-policy",
|
||||
body="<p>We take your privacy seriously.</p>",
|
||||
last_updated=datetime.date.today(),
|
||||
show_in_footer=True,
|
||||
)
|
||||
legal_index.add_child(instance=privacy)
|
||||
privacy.save_revision().publish()
|
||||
|
||||
# Point every existing Site at the real home page and mark exactly one
|
||||
# as the default. Wagtail's initial migration creates a localhost:80
|
||||
# site that matches incoming requests by hostname before the
|
||||
# is_default_site fallback is ever reached, so we must update *all*
|
||||
# sites, not just the is_default_site one.
|
||||
Site.objects.all().update(root_page=home, site_name="No Hype AI", is_default_site=False)
|
||||
site = Site.objects.first()
|
||||
if site is None:
|
||||
site = Site(hostname="localhost", port=80)
|
||||
site.is_default_site = True
|
||||
site.save()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Seeded E2E content."))
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from .consent import ConsentService
|
||||
|
||||
|
||||
@@ -10,3 +12,26 @@ class ConsentMiddleware:
|
||||
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
|
||||
|
||||
@@ -11,16 +11,33 @@ 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"]
|
||||
site_settings = SiteSettings.for_request(request)
|
||||
image = article.hero_image or site_settings.default_og_image
|
||||
image_url = ""
|
||||
if isinstance(image, Image):
|
||||
rendition = image.get_rendition("fill-1200x630")
|
||||
image_url = request.build_absolute_uri(rendition.url)
|
||||
|
||||
nonce = getattr(request, "csp_nonce", "")
|
||||
data = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
@@ -30,8 +47,12 @@ def article_json_ld(context, article):
|
||||
"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": image_url,
|
||||
"image": _article_image_url(request, article),
|
||||
}
|
||||
return mark_safe(
|
||||
'<script type="application/ld+json">' + json.dumps(data, ensure_ascii=True) + "</script>"
|
||||
'<script type="application/ld+json" nonce="'
|
||||
+ nonce
|
||||
+ '">'
|
||||
+ json.dumps(data, ensure_ascii=True)
|
||||
+ "</script>"
|
||||
)
|
||||
|
||||
56
apps/core/tests/test_commands.py
Normal file
56
apps/core/tests/test_commands.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
|
||||
from apps.blog.models import AboutPage, 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", "<p>body</p>")],
|
||||
)
|
||||
index.add_child(instance=article)
|
||||
article.save_revision().publish()
|
||||
|
||||
with pytest.raises(CommandError, match="empty summary"):
|
||||
call_command("check_content_integrity")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_seed_e2e_content_creates_expected_pages():
|
||||
call_command("seed_e2e_content")
|
||||
|
||||
assert ArticlePage.objects.filter(slug="nightly-playwright-journey").exists()
|
||||
assert ArticlePage.objects.filter(slug="e2e-tagged-article").exists()
|
||||
assert ArticlePage.objects.filter(slug="e2e-no-comments").exists()
|
||||
assert AboutPage.objects.filter(slug="about").exists()
|
||||
|
||||
# Tagged article must carry the seeded tag
|
||||
tagged = ArticlePage.objects.get(slug="e2e-tagged-article")
|
||||
assert tagged.tags.filter(slug="ai-tools").exists()
|
||||
|
||||
# No-comments article must have comments disabled
|
||||
no_comments = ArticlePage.objects.get(slug="e2e-no-comments")
|
||||
assert no_comments.comments_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_seed_e2e_content_is_idempotent():
|
||||
"""Running the command twice must not raise or create duplicates."""
|
||||
call_command("seed_e2e_content")
|
||||
call_command("seed_e2e_content")
|
||||
assert ArticlePage.objects.filter(slug="nightly-playwright-journey").count() == 1
|
||||
@@ -21,3 +21,47 @@ 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()
|
||||
|
||||
47
apps/core/tests/test_nightly_e2e_playwright.py
Normal file
47
apps/core/tests/test_nightly_e2e_playwright.py
Normal file
@@ -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 (
|
||||
"<rss" in feed_content
|
||||
or "<feed" in feed_content
|
||||
or "<rss" in feed_content
|
||||
or "<feed" in feed_content
|
||||
)
|
||||
|
||||
browser.close()
|
||||
75
apps/core/tests/test_performance.py
Normal file
75
apps/core/tests/test_performance.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import pytest
|
||||
from taggit.models import Tag
|
||||
from wagtail.models import Site
|
||||
|
||||
from apps.blog.models import ArticleIndexPage, ArticlePage, HomePage, TagMetadata
|
||||
from apps.blog.tests.factories import AuthorFactory
|
||||
|
||||
|
||||
def _build_article_tree(home_page: HomePage, count: int = 12):
|
||||
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||
home_page.add_child(instance=index)
|
||||
author = AuthorFactory()
|
||||
tag = Tag.objects.create(name="Bench", slug="bench")
|
||||
TagMetadata.objects.create(tag=tag, colour="cyan")
|
||||
|
||||
for n in range(count):
|
||||
article = ArticlePage(
|
||||
title=f"Article {n}",
|
||||
slug=f"article-{n}",
|
||||
author=author,
|
||||
summary="summary",
|
||||
body=[("rich_text", "<p>body words</p>")],
|
||||
)
|
||||
index.add_child(instance=article)
|
||||
article.tags.add(tag)
|
||||
article.save_revision().publish()
|
||||
return index
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_homepage_query_budget(rf, home_page, django_assert_num_queries):
|
||||
_build_article_tree(home_page, count=8)
|
||||
request = rf.get("/")
|
||||
request.site = Site.objects.get(is_default_site=True)
|
||||
with django_assert_num_queries(10, exact=False):
|
||||
context = home_page.get_context(request)
|
||||
list(context["latest_articles"])
|
||||
list(context["more_articles"])
|
||||
assert len(context["latest_articles"]) <= 5
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_article_index_query_budget(rf, home_page, django_assert_num_queries):
|
||||
index = _build_article_tree(home_page, count=12)
|
||||
request = rf.get("/articles/")
|
||||
request.site = Site.objects.get(is_default_site=True)
|
||||
with django_assert_num_queries(12, exact=False):
|
||||
context = index.get_context(request)
|
||||
list(context["articles"])
|
||||
list(context["available_tags"])
|
||||
assert context["paginator"].count == 12
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_article_read_query_budget(rf, home_page, django_assert_num_queries):
|
||||
index = _build_article_tree(home_page, count=4)
|
||||
article = ArticlePage.objects.child_of(index).live().first()
|
||||
assert article is not None
|
||||
request = rf.get(article.url)
|
||||
request.site = Site.objects.get(is_default_site=True)
|
||||
with django_assert_num_queries(8, exact=False):
|
||||
context = article.get_context(request)
|
||||
list(context["related_articles"])
|
||||
list(context["approved_comments"])
|
||||
assert context["related_articles"] is not None
|
||||
|
||||
|
||||
def test_read_time_benchmark(benchmark):
|
||||
author = AuthorFactory.build()
|
||||
body = [("rich_text", "<p>" + "word " * 1000 + "</p>")]
|
||||
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
|
||||
101
apps/core/tests/test_security.py
Normal file
101
apps/core/tests/test_security.py
Normal file
@@ -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", "<p>Body</p>")],
|
||||
)
|
||||
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", "<p>Body</p>")],
|
||||
)
|
||||
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
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
|
||||
@@ -24,6 +25,12 @@ def consent_view(request: HttpRequest) -> HttpResponse:
|
||||
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
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -15,9 +18,26 @@ class ProviderSyncService:
|
||||
|
||||
|
||||
class ButtondownSyncService(ProviderSyncService):
|
||||
endpoint = "https://api.buttondown.email/v1/subscribers"
|
||||
|
||||
def sync(self, subscription):
|
||||
logger.info("Synced subscription %s", subscription.email)
|
||||
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()
|
||||
|
||||
@@ -12,6 +12,24 @@ def test_subscribe_ok(client):
|
||||
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"})
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from django.core import signing
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.http import Http404, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
from django.views import View
|
||||
|
||||
from apps.newsletter.forms import SubscriptionForm
|
||||
@@ -10,6 +15,27 @@ from apps.newsletter.models import NewsletterSubscription
|
||||
from apps.newsletter.services import ProviderSyncError, get_provider_service
|
||||
|
||||
CONFIRMATION_TOKEN_MAX_AGE_SECONDS = 60 * 60 * 24 * 2
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def confirmation_token(email: str) -> str:
|
||||
return signing.dumps(email, salt="newsletter-confirm")
|
||||
|
||||
|
||||
def send_confirmation_email(request, subscription: NewsletterSubscription) -> None:
|
||||
token = confirmation_token(subscription.email)
|
||||
confirm_url = request.build_absolute_uri(reverse("newsletter_confirm", args=[token]))
|
||||
context = {"confirmation_url": confirm_url, "subscription": subscription}
|
||||
subject = render_to_string("newsletter/email/confirmation_subject.txt", context).strip()
|
||||
text_body = render_to_string("newsletter/email/confirmation_body.txt", context)
|
||||
html_body = render_to_string("newsletter/email/confirmation_body.html", context)
|
||||
message = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_body,
|
||||
to=[subscription.email],
|
||||
)
|
||||
message.attach_alternative(html_body, "text/html")
|
||||
message.send()
|
||||
|
||||
|
||||
class SubscribeView(View):
|
||||
@@ -20,9 +46,14 @@ class SubscribeView(View):
|
||||
if form.cleaned_data.get("honeypot"):
|
||||
return JsonResponse({"status": "ok"})
|
||||
|
||||
email = form.cleaned_data["email"]
|
||||
email = form.cleaned_data["email"].lower().strip()
|
||||
source = form.cleaned_data.get("source") or "unknown"
|
||||
NewsletterSubscription.objects.get_or_create(email=email, defaults={"source": source})
|
||||
subscription, created = NewsletterSubscription.objects.get_or_create(
|
||||
email=email,
|
||||
defaults={"source": source},
|
||||
)
|
||||
if created and not subscription.confirmed:
|
||||
send_confirmation_email(request, subscription)
|
||||
return JsonResponse({"status": "ok"})
|
||||
|
||||
|
||||
@@ -42,10 +73,6 @@ class ConfirmView(View):
|
||||
service = get_provider_service()
|
||||
try:
|
||||
service.sync(subscription)
|
||||
except ProviderSyncError:
|
||||
pass
|
||||
except ProviderSyncError as exc:
|
||||
logger.exception("Newsletter provider sync failed: %s", exc)
|
||||
return redirect("/")
|
||||
|
||||
|
||||
def confirmation_token(email: str) -> str:
|
||||
return signing.dumps(email, salt="newsletter-confirm")
|
||||
|
||||
@@ -4,13 +4,20 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
import dj_database_url
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "unsafe-dev-secret")
|
||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||
if not SECRET_KEY:
|
||||
raise ImproperlyConfigured("SECRET_KEY environment variable is required.")
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
if not DATABASE_URL:
|
||||
raise ImproperlyConfigured("DATABASE_URL environment variable is required.")
|
||||
DEBUG = os.getenv("DEBUG", "0") == "1"
|
||||
ALLOWED_HOSTS = [h.strip() for h in os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") if h.strip()]
|
||||
|
||||
@@ -39,6 +46,7 @@ INSTALLED_APPS = [
|
||||
"wagtail",
|
||||
"wagtailseo",
|
||||
"tailwind",
|
||||
"theme",
|
||||
"apps.core",
|
||||
"apps.blog",
|
||||
"apps.authors",
|
||||
@@ -49,6 +57,7 @@ INSTALLED_APPS = [
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"apps.core.middleware.SecurityHeadersMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
@@ -80,9 +89,7 @@ TEMPLATES = [
|
||||
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
DATABASES = {
|
||||
"default": dj_database_url.parse(os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR / 'db.sqlite3'}"))
|
||||
}
|
||||
DATABASES = {"default": dj_database_url.parse(DATABASE_URL)}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||
@@ -130,7 +137,11 @@ CACHES = {
|
||||
X_FRAME_OPTIONS = "SAMEORIGIN"
|
||||
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
X_CONTENT_TYPE_OPTIONS = "nosniff"
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = [u for u in os.getenv("CSRF_TRUSTED_ORIGINS", "http://localhost:8035").split(",") if u]
|
||||
TRUSTED_PROXY_IPS = [ip.strip() for ip in os.getenv("TRUSTED_PROXY_IPS", "").split(",") if ip.strip()]
|
||||
|
||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||
|
||||
TAILWIND_APP_NAME = "theme"
|
||||
|
||||
@@ -4,6 +4,13 @@ DEBUG = True
|
||||
|
||||
INTERNAL_IPS = ["127.0.0.1"]
|
||||
|
||||
# Drop WhiteNoise in dev — it serves from STATIC_ROOT which is empty without
|
||||
# collectstatic, so it 404s every asset. Django's runserver serves static and
|
||||
# media files natively when DEBUG=True (via django.contrib.staticfiles + the
|
||||
# media URL pattern in urls.py).
|
||||
MIDDLEWARE = [m for m in MIDDLEWARE if m != "whitenoise.middleware.WhiteNoiseMiddleware"]
|
||||
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
|
||||
try:
|
||||
import debug_toolbar # noqa: F401
|
||||
|
||||
|
||||
@@ -2,8 +2,16 @@ from .base import * # noqa
|
||||
|
||||
DEBUG = False
|
||||
|
||||
# Behind Caddy: trust the forwarded proto header so Django knows it's HTTPS.
|
||||
# SECURE_SSL_REDIRECT is intentionally off — Caddy handles HTTPS redirects
|
||||
# before the request reaches Django; enabling it here causes redirect loops.
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
USE_X_FORWARDED_HOST = True
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SECURE_SSL_REDIRECT = False
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
"https://nohypeai.net",
|
||||
"https://www.nohypeai.net",
|
||||
]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
from django.views.generic import RedirectView
|
||||
@@ -21,3 +23,6 @@ urlpatterns = [
|
||||
path("admin/", RedirectView.as_view(url="/cms/", permanent=False)),
|
||||
path("", include(wagtail_urls)),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
23
deploy/caddy/nohype.caddy
Normal file
23
deploy/caddy/nohype.caddy
Normal file
@@ -0,0 +1,23 @@
|
||||
nohypeai.net, www.nohypeai.net {
|
||||
encode gzip zstd
|
||||
|
||||
header {
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options DENY
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||
X-Forwarded-Proto https
|
||||
}
|
||||
|
||||
handle_path /static/* {
|
||||
root * /srv/sum/nohype/static
|
||||
file_server
|
||||
}
|
||||
|
||||
handle_path /media/* {
|
||||
root * /srv/sum/nohype/media
|
||||
file_server
|
||||
}
|
||||
|
||||
reverse_proxy localhost:8001
|
||||
}
|
||||
33
deploy/deploy.sh
Executable file
33
deploy/deploy.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy script for No Hype AI — runs on lintel-prod-01 as deploy user.
|
||||
# Called by CI after a successful push to main.
|
||||
set -euo pipefail
|
||||
|
||||
SITE_DIR=/srv/sum/nohype
|
||||
APP_DIR=${SITE_DIR}/app
|
||||
|
||||
cd "${SITE_DIR}"
|
||||
|
||||
echo "==> Pulling latest code"
|
||||
git -C "${APP_DIR}" pull origin main
|
||||
|
||||
echo "==> Updating compose file"
|
||||
cp "${APP_DIR}/docker-compose.prod.yml" "${SITE_DIR}/docker-compose.prod.yml"
|
||||
|
||||
echo "==> Ensuring static/media directories exist"
|
||||
mkdir -p "${SITE_DIR}/static" "${SITE_DIR}/media"
|
||||
|
||||
echo "==> Rebuilding and recreating web container"
|
||||
docker compose -f "${SITE_DIR}/docker-compose.prod.yml" up -d --no-deps --build --force-recreate web
|
||||
|
||||
echo "==> Waiting for health check"
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS -H "Host: nohypeai.net" http://localhost:8001/ >/dev/null 2>&1; then
|
||||
echo "==> Site is up"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo "ERROR: site did not come up after 90s" >&2
|
||||
docker compose -f "${SITE_DIR}/docker-compose.prod.yml" logs --tail=50 web
|
||||
exit 1
|
||||
22
deploy/entrypoint.prod.sh
Executable file
22
deploy/entrypoint.prod.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
python manage.py tailwind install --no-input
|
||||
python manage.py tailwind build
|
||||
python manage.py migrate --noinput
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
# Set Wagtail site hostname from first entry in ALLOWED_HOSTS
|
||||
python manage.py shell -c "
|
||||
from wagtail.models import Site
|
||||
import os
|
||||
hostname = os.environ.get('ALLOWED_HOSTS', 'localhost').split(',')[0].strip()
|
||||
Site.objects.update(hostname=hostname, port=443, site_name='No Hype AI')
|
||||
"
|
||||
|
||||
exec gunicorn config.wsgi:application \
|
||||
--workers 3 \
|
||||
--bind 0.0.0.0:8000 \
|
||||
--access-logfile - \
|
||||
--error-logfile - \
|
||||
--capture-output
|
||||
26
deploy/sum-nohype.service
Normal file
26
deploy/sum-nohype.service
Normal file
@@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=No Hype AI (Docker Compose)
|
||||
Requires=docker.service
|
||||
After=docker.service network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=deploy
|
||||
Group=www-data
|
||||
WorkingDirectory=/srv/sum/nohype
|
||||
|
||||
ExecStartPre=docker compose -f docker-compose.prod.yml pull --ignore-pull-failures
|
||||
ExecStart=docker compose -f docker-compose.prod.yml up --build
|
||||
ExecStop=docker compose -f docker-compose.prod.yml down
|
||||
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
TimeoutStartSec=300
|
||||
TimeoutStopSec=30
|
||||
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=sum-nohype
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
7
docker-compose.ci.yml
Normal file
7
docker-compose.ci.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
web:
|
||||
volumes: []
|
||||
ports: []
|
||||
|
||||
db:
|
||||
ports: []
|
||||
36
docker-compose.prod.yml
Normal file
36
docker-compose.prod.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
web:
|
||||
build: app
|
||||
working_dir: /app
|
||||
command: /app/deploy/entrypoint.prod.sh
|
||||
env_file: .env
|
||||
environment:
|
||||
DJANGO_SETTINGS_MODULE: config.settings.production
|
||||
volumes:
|
||||
- /srv/sum/nohype/static:/app/staticfiles
|
||||
- /srv/sum/nohype/media:/app/media
|
||||
ports:
|
||||
- "127.0.0.1:8001:8000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
env_file: .env
|
||||
environment:
|
||||
POSTGRES_DB: nohype
|
||||
POSTGRES_USER: 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: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
nohype_pg:
|
||||
@@ -1,31 +1,47 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
container_name: nohype-web
|
||||
command: python manage.py runserver 0.0.0.0:8000
|
||||
working_dir: /app
|
||||
command: >
|
||||
sh -c "python manage.py tailwind install --no-input &&
|
||||
python manage.py tailwind build &&
|
||||
python manage.py migrate --noinput &&
|
||||
python manage.py seed_e2e_content &&
|
||||
python manage.py runserver 0.0.0.0:8000"
|
||||
volumes:
|
||||
- .:/app
|
||||
ports:
|
||||
- "8035:8000"
|
||||
env_file:
|
||||
- .env
|
||||
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
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: nohype-db
|
||||
environment:
|
||||
POSTGRES_DB: nohype
|
||||
POSTGRES_USER: nohype
|
||||
POSTGRES_PASSWORD: nohype
|
||||
ports:
|
||||
- "5545:5432"
|
||||
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:
|
||||
|
||||
0
e2e/__init__.py
Normal file
0
e2e/__init__.py
Normal file
57
e2e/conftest.py
Normal file
57
e2e/conftest.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Shared fixtures for E2E Playwright tests.
|
||||
|
||||
All tests in this directory require a running application server pointed to by
|
||||
the E2E_BASE_URL environment variable. Tests are automatically skipped when
|
||||
the variable is absent, making them safe to collect in any environment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Browser, BrowserContext, Page, sync_playwright
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def base_url() -> str:
|
||||
url = os.getenv("E2E_BASE_URL", "").rstrip("/")
|
||||
if not url:
|
||||
pytest.skip("E2E_BASE_URL not set – start a server and export E2E_BASE_URL to run E2E tests")
|
||||
return url
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def _browser(base_url: str) -> Generator[Browser, None, None]: # noqa: ARG001
|
||||
"""Session-scoped Chromium instance (headless)."""
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(headless=True)
|
||||
yield browser
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def page(_browser: Browser) -> Generator[Page, None, None]:
|
||||
"""Fresh browser context + page per test — no shared state between tests.
|
||||
|
||||
Clipboard permissions are pre-granted so copy-link and similar interactions
|
||||
work in headless Chromium without triggering the permissions dialog.
|
||||
"""
|
||||
ctx: BrowserContext = _browser.new_context(
|
||||
permissions=["clipboard-read", "clipboard-write"],
|
||||
)
|
||||
# Polyfill clipboard in environments where the native API is unavailable
|
||||
# (e.g. non-HTTPS Docker CI). The polyfill stores writes in a variable so
|
||||
# the JS success path still runs and button text updates as expected.
|
||||
ctx.add_init_script("""
|
||||
if (!navigator.clipboard || !navigator.clipboard.writeText) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: () => Promise.resolve() },
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
""")
|
||||
pg: Page = ctx.new_page()
|
||||
yield pg
|
||||
ctx.close()
|
||||
72
e2e/test_article_detail.py
Normal file
72
e2e/test_article_detail.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""E2E tests for article detail pages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
ARTICLE_SLUG = "nightly-playwright-journey"
|
||||
|
||||
|
||||
def _go_to_article(page: Page, base_url: str) -> None:
|
||||
page.goto(f"{base_url}/articles/{ARTICLE_SLUG}/", wait_until="networkidle")
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_article_title_visible(page: Page, base_url: str) -> None:
|
||||
_go_to_article(page, base_url)
|
||||
h1 = page.get_by_role("heading", level=1)
|
||||
expect(h1).to_be_visible()
|
||||
assert h1.inner_text().strip() != ""
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_article_read_time_visible(page: Page, base_url: str) -> None:
|
||||
_go_to_article(page, base_url)
|
||||
# Read time is rendered as "N min read"
|
||||
expect(page.get_by_text("min read")).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_article_share_section_present(page: Page, base_url: str) -> None:
|
||||
_go_to_article(page, base_url)
|
||||
share_section = page.get_by_role("region", name="Share this article")
|
||||
expect(share_section).to_be_visible()
|
||||
expect(share_section.get_by_role("link", name="Share on X")).to_be_visible()
|
||||
expect(share_section.get_by_role("link", name="Share on LinkedIn")).to_be_visible()
|
||||
expect(share_section.get_by_role("button", name="Copy link")).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_article_comments_section_present(page: Page, base_url: str) -> None:
|
||||
_go_to_article(page, base_url)
|
||||
# The article has comments_enabled=True
|
||||
expect(page.get_by_role("heading", name="Comments", exact=True)).to_be_visible()
|
||||
expect(page.get_by_role("button", name="Post comment")).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_article_newsletter_aside_present(page: Page, base_url: str) -> None:
|
||||
_go_to_article(page, base_url)
|
||||
# There's a Newsletter aside within the article page
|
||||
aside = page.locator("aside")
|
||||
expect(aside).to_be_visible()
|
||||
expect(aside.locator('input[type="email"]')).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_article_related_section_present(page: Page, base_url: str) -> None:
|
||||
_go_to_article(page, base_url)
|
||||
# Related section heading
|
||||
expect(page.get_by_role("heading", name="Related")).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_copy_link_button_updates_text(page: Page, base_url: str) -> None:
|
||||
_go_to_article(page, base_url)
|
||||
copy_btn = page.locator("[data-copy-link]")
|
||||
expect(copy_btn).to_be_visible()
|
||||
# Force-override clipboard so writeText always resolves, even in non-HTTPS headless context
|
||||
page.evaluate("navigator.clipboard.writeText = () => Promise.resolve()")
|
||||
copy_btn.click()
|
||||
expect(copy_btn).to_have_text("Copied")
|
||||
59
e2e/test_articles.py
Normal file
59
e2e/test_articles.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""E2E tests for the article index page."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_article_index_loads(page: Page, base_url: str) -> None:
|
||||
page.goto(f"{base_url}/articles/", wait_until="networkidle")
|
||||
expect(page.get_by_role("heading", level=1)).to_be_visible()
|
||||
# At least one article card must be present after seeding
|
||||
expect(page.locator("main article").first).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_tag_filter_shows_tagged_articles(page: Page, base_url: str) -> None:
|
||||
page.goto(f"{base_url}/articles/", wait_until="networkidle")
|
||||
# The seeded "AI Tools" tag link must be present
|
||||
tag_link = page.get_by_role("link", name="AI Tools")
|
||||
expect(tag_link).to_be_visible()
|
||||
tag_link.click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# URL should now contain ?tag=ai-tools
|
||||
assert "tag=ai-tools" in page.url
|
||||
|
||||
# The tagged article must appear; no-tag articles may be absent
|
||||
expect(page.get_by_text("Tagged Article")).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_all_tag_clears_filter(page: Page, base_url: str) -> None:
|
||||
# Start with the tag filter applied
|
||||
page.goto(f"{base_url}/articles/?tag=ai-tools", wait_until="networkidle")
|
||||
|
||||
# Clicking "All" should return to unfiltered list
|
||||
page.get_by_role("link", name="All").click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
assert "tag=" not in page.url
|
||||
# All seeded articles should now be visible
|
||||
expect(page.locator("main article").first).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_article_card_navigates_to_detail(page: Page, base_url: str) -> None:
|
||||
page.goto(f"{base_url}/articles/", wait_until="networkidle")
|
||||
first_link = page.locator("main article a").first
|
||||
expect(first_link).to_be_visible()
|
||||
|
||||
href = first_link.get_attribute("href")
|
||||
assert href, "Article card must have an href"
|
||||
|
||||
first_link.click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# We should be on an article detail page
|
||||
expect(page.get_by_role("heading", level=1)).to_be_visible()
|
||||
59
e2e/test_comments.py
Normal file
59
e2e/test_comments.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""E2E tests for the comment submission flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
ARTICLE_SLUG = "nightly-playwright-journey"
|
||||
|
||||
|
||||
def _go_to_article(page: Page, base_url: str) -> None:
|
||||
page.goto(f"{base_url}/articles/{ARTICLE_SLUG}/", wait_until="networkidle")
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_valid_comment_submission_redirects(page: Page, base_url: str) -> None:
|
||||
_go_to_article(page, base_url)
|
||||
|
||||
# Fill the main comment form (not a reply form)
|
||||
form = page.locator("form[action]").filter(has=page.get_by_role("button", name="Post comment"))
|
||||
form.locator('input[name="author_name"]').fill("E2E Tester")
|
||||
form.locator('input[name="author_email"]').fill("e2e@example.com")
|
||||
form.locator('textarea[name="body"]').fill("This is a test comment from Playwright.")
|
||||
form.get_by_role("button", name="Post comment").click()
|
||||
|
||||
# Successful submission redirects back to the article with ?commented=1
|
||||
page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000)
|
||||
assert "commented=1" in page.url
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_empty_body_shows_form_errors(page: Page, base_url: str) -> None:
|
||||
_go_to_article(page, base_url)
|
||||
|
||||
form = page.locator("form[action]").filter(has=page.get_by_role("button", name="Post comment"))
|
||||
form.locator('input[name="author_name"]').fill("E2E Tester")
|
||||
form.locator('input[name="author_email"]').fill("e2e@example.com")
|
||||
form.locator('textarea[name="body"]').fill(" ") # whitespace-only body
|
||||
form.get_by_role("button", name="Post comment").click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# The page re-renders with the error summary visible
|
||||
expect(page.locator('[aria-label="Comment form errors"]')).to_be_visible()
|
||||
# URL must NOT have ?commented=1 — form was not accepted
|
||||
assert "commented=1" not in page.url
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_comments_section_absent_when_disabled(page: Page, base_url: str) -> None:
|
||||
"""Article with comments_enabled=False must not show the comments section."""
|
||||
response = page.goto(f"{base_url}/articles/e2e-no-comments/", wait_until="networkidle")
|
||||
assert response is not None and response.status == 200, (
|
||||
f"Expected 200 for e2e-no-comments article, got {response and response.status}"
|
||||
)
|
||||
# Confirm we're on the right page
|
||||
expect(page.get_by_role("heading", level=1)).to_have_text("No Comments Article")
|
||||
# Comments section must be absent — exact=True prevents matching "No Comments Article" h1
|
||||
expect(page.get_by_role("heading", name="Comments", exact=True)).to_have_count(0)
|
||||
expect(page.get_by_role("button", name="Post comment")).to_have_count(0)
|
||||
70
e2e/test_cookie_consent.py
Normal file
70
e2e/test_cookie_consent.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""E2E tests for the cookie consent banner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
def _open_fresh_page(page: Page, url: str) -> None:
|
||||
"""Navigate to URL with no existing consent cookie (fresh context guarantees this)."""
|
||||
page.goto(url, wait_until="networkidle")
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_banner_visible_on_first_visit(page: Page, base_url: str) -> None:
|
||||
_open_fresh_page(page, f"{base_url}/")
|
||||
expect(page.locator("#cookie-banner")).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_accept_all_dismisses_banner(page: Page, base_url: str) -> None:
|
||||
_open_fresh_page(page, f"{base_url}/")
|
||||
banner = page.locator("#cookie-banner")
|
||||
expect(banner).to_be_visible()
|
||||
page.get_by_role("button", name="Accept all").first.click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
expect(banner).to_have_count(0)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_reject_all_dismisses_banner(page: Page, base_url: str) -> None:
|
||||
_open_fresh_page(page, f"{base_url}/")
|
||||
banner = page.locator("#cookie-banner")
|
||||
expect(banner).to_be_visible()
|
||||
page.get_by_role("button", name="Reject all").first.click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
expect(banner).to_have_count(0)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_granular_preferences_save_dismisses_banner(page: Page, base_url: str) -> None:
|
||||
_open_fresh_page(page, f"{base_url}/")
|
||||
banner = page.locator("#cookie-banner")
|
||||
expect(banner).to_be_visible()
|
||||
|
||||
# Click the <summary> element to expand <details> inside the banner
|
||||
banner.locator("details summary").click()
|
||||
|
||||
# Analytics checkbox is now revealed; check it and save
|
||||
analytics_checkbox = banner.locator('input[name="analytics"]')
|
||||
expect(analytics_checkbox).to_be_visible()
|
||||
analytics_checkbox.check()
|
||||
|
||||
# Submit granular preferences
|
||||
page.get_by_role("button", name="Save preferences").click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
expect(banner).to_have_count(0)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_banner_absent_after_consent_cookie_set(page: Page, base_url: str) -> None:
|
||||
"""After accepting consent, subsequent page loads must not show the banner."""
|
||||
_open_fresh_page(page, f"{base_url}/")
|
||||
# Accept consent
|
||||
page.get_by_role("button", name="Accept all").first.click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
# Navigate to another page in the same context — cookie should persist
|
||||
page.goto(f"{base_url}/articles/", wait_until="networkidle")
|
||||
expect(page.locator("#cookie-banner")).to_have_count(0)
|
||||
61
e2e/test_feeds.py
Normal file
61
e2e/test_feeds.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""E2E tests for RSS feed, sitemap, and robots.txt."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_rss_feed_returns_valid_xml(page: Page, base_url: str) -> None:
|
||||
response = page.goto(f"{base_url}/feed/", wait_until="networkidle")
|
||||
assert response is not None
|
||||
assert response.status == 200
|
||||
content = page.content()
|
||||
assert "<rss" in content or "<feed" in content or "<rss" in content or "<feed" in content, (
|
||||
"RSS feed response must contain a <rss or <feed root element"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_rss_feed_contains_seeded_article(page: Page, base_url: str) -> None:
|
||||
response = page.goto(f"{base_url}/feed/", wait_until="networkidle")
|
||||
assert response is not None and response.status == 200
|
||||
content = page.content()
|
||||
assert "Nightly Playwright Journey" in content, "Seeded article title must appear in the feed"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_sitemap_returns_valid_xml(page: Page, base_url: str) -> None:
|
||||
response = page.goto(f"{base_url}/sitemap.xml", wait_until="networkidle")
|
||||
assert response is not None
|
||||
assert response.status == 200
|
||||
content = page.content()
|
||||
assert "urlset" in content or "<urlset" in content, "Sitemap must contain urlset element"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_sitemap_contains_article_url(page: Page, base_url: str) -> None:
|
||||
response = page.goto(f"{base_url}/sitemap.xml", wait_until="networkidle")
|
||||
assert response is not None and response.status == 200
|
||||
content = page.content()
|
||||
assert "nightly-playwright-journey" in content, "Seeded article URL must appear in sitemap"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_robots_txt_is_accessible(page: Page, base_url: str) -> None:
|
||||
response = page.goto(f"{base_url}/robots.txt", wait_until="networkidle")
|
||||
assert response is not None
|
||||
assert response.status == 200
|
||||
content = page.content()
|
||||
assert "User-agent" in content, "robots.txt must contain User-agent directive"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_tag_rss_feed(page: Page, base_url: str) -> None:
|
||||
"""Tag-specific feed must return 200 and valid XML for a seeded tag."""
|
||||
response = page.goto(f"{base_url}/feed/tag/ai-tools/", wait_until="networkidle")
|
||||
assert response is not None
|
||||
assert response.status == 200
|
||||
content = page.content()
|
||||
assert "<rss" in content or "<feed" in content or "<rss" in content or "<feed" in content
|
||||
54
e2e/test_home.py
Normal file
54
e2e/test_home.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""E2E tests for the home page."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_homepage_title_contains_brand(page: Page, base_url: str) -> None:
|
||||
page.goto(f"{base_url}/", wait_until="networkidle")
|
||||
expect(page).to_have_title(re.compile("No Hype AI"))
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_nav_links_present(page: Page, base_url: str) -> None:
|
||||
page.goto(f"{base_url}/", wait_until="networkidle")
|
||||
nav = page.locator("nav")
|
||||
expect(nav.get_by_role("link", name="Home")).to_be_visible()
|
||||
expect(nav.get_by_role("link", name="Articles")).to_be_visible()
|
||||
expect(nav.get_by_role("link", name="About")).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_theme_toggle_adds_dark_class(page: Page, base_url: str) -> None:
|
||||
page.goto(f"{base_url}/", wait_until="networkidle")
|
||||
toggle = page.get_by_role("button", name="Toggle theme")
|
||||
expect(toggle).to_be_visible()
|
||||
# Initial state: html may or may not have dark class
|
||||
html = page.locator("html")
|
||||
before = "dark" in (html.get_attribute("class") or "")
|
||||
toggle.click()
|
||||
after = "dark" in (html.get_attribute("class") or "")
|
||||
assert before != after, "Theme toggle must flip the dark class on <html>"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_newsletter_form_in_nav(page: Page, base_url: str) -> None:
|
||||
page.goto(f"{base_url}/", wait_until="networkidle")
|
||||
# The nav contains a newsletter form with an email input
|
||||
nav = page.locator("nav")
|
||||
expect(nav.locator('input[type="email"]')).to_be_visible()
|
||||
expect(nav.get_by_role("button", name="Subscribe")).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_home_shows_articles(page: Page, base_url: str) -> None:
|
||||
"""Latest articles section is populated after seeding."""
|
||||
page.goto(f"{base_url}/", wait_until="networkidle")
|
||||
# Seeded content means there should be at least one article card link
|
||||
article_links = page.locator("main article a")
|
||||
expect(article_links.first).to_be_visible()
|
||||
65
e2e/test_newsletter.py
Normal file
65
e2e/test_newsletter.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""E2E tests for the newsletter subscription form."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
def _nav_newsletter_form(page: Page):
|
||||
return page.locator("nav").locator("form[data-newsletter-form]")
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_subscribe_valid_email_shows_confirmation(page: Page, base_url: str) -> None:
|
||||
page.goto(f"{base_url}/", wait_until="networkidle")
|
||||
form = _nav_newsletter_form(page)
|
||||
form.locator('input[type="email"]').fill("playwright-test@example.com")
|
||||
form.get_by_role("button", name="Subscribe").click()
|
||||
|
||||
# JS sets the data-newsletter-message text on success
|
||||
message = form.locator("[data-newsletter-message]")
|
||||
expect(message).to_have_text("Check your email to confirm your subscription.", timeout=5_000)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_subscribe_invalid_email_shows_error(page: Page, base_url: str) -> None:
|
||||
page.goto(f"{base_url}/", wait_until="networkidle")
|
||||
form = _nav_newsletter_form(page)
|
||||
# Disable the browser's native HTML5 email validation so the JS handler
|
||||
# fires and sends the bad value to the server (which returns 400).
|
||||
page.evaluate("document.querySelector('nav form[data-newsletter-form]').setAttribute('novalidate', '')")
|
||||
form.locator('input[type="email"]').fill("not-an-email")
|
||||
form.get_by_role("button", name="Subscribe").click()
|
||||
|
||||
message = form.locator("[data-newsletter-message]")
|
||||
expect(message).to_have_text("Please enter a valid email.", timeout=5_000)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_subscribe_from_article_aside(page: Page, base_url: str) -> None:
|
||||
"""Newsletter form embedded in the article aside also works."""
|
||||
page.goto(f"{base_url}/articles/nightly-playwright-journey/", wait_until="networkidle")
|
||||
aside_form = page.locator("aside").locator("form[data-newsletter-form]")
|
||||
aside_form.locator('input[type="email"]').fill("aside-test@example.com")
|
||||
aside_form.get_by_role("button", name="Subscribe").click()
|
||||
|
||||
message = aside_form.locator("[data-newsletter-message]")
|
||||
expect(message).to_have_text("Check your email to confirm your subscription.", timeout=5_000)
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_subscribe_duplicate_email_still_shows_confirmation(page: Page, base_url: str) -> None:
|
||||
"""Submitting the same address twice must not expose an error to the user."""
|
||||
email = "dupe-e2e@example.com"
|
||||
page.goto(f"{base_url}/", wait_until="networkidle")
|
||||
form = _nav_newsletter_form(page)
|
||||
form.locator('input[type="email"]').fill(email)
|
||||
form.get_by_role("button", name="Subscribe").click()
|
||||
message = form.locator("[data-newsletter-message]")
|
||||
expect(message).to_have_text("Check your email to confirm your subscription.", timeout=5_000)
|
||||
|
||||
# Second submission — form resets after first, so fill again
|
||||
form.locator('input[type="email"]').fill(email)
|
||||
form.get_by_role("button", name="Subscribe").click()
|
||||
expect(message).to_have_text("Check your email to confirm your subscription.", timeout=5_000)
|
||||
@@ -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.*
|
||||
*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.*
|
||||
|
||||
@@ -2,3 +2,5 @@
|
||||
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
|
||||
|
||||
@@ -17,6 +17,8 @@ pytest-benchmark~=4.0.0
|
||||
factory-boy~=3.3.0
|
||||
wagtail-factories~=4.2.0
|
||||
feedparser~=6.0.0
|
||||
playwright~=1.57.0
|
||||
pytest-playwright~=0.7.0
|
||||
ruff~=0.6.0
|
||||
mypy~=1.11.0
|
||||
django-stubs~=5.1.0
|
||||
|
||||
54
static/js/newsletter.js
Normal file
54
static/js/newsletter.js
Normal file
@@ -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();
|
||||
})();
|
||||
@@ -4,4 +4,11 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -5,16 +5,26 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{% block title %}No Hype AI{% endblock %}</title>
|
||||
{% block head_meta %}{% endblock %}
|
||||
<link rel="stylesheet" href="{% static 'css/styles.css' %}" />
|
||||
<script nonce="{{ request.csp_nonce|default:'' }}">
|
||||
(function(){try{if(localStorage.getItem('theme')==='light'){document.documentElement.classList.remove('dark');}}catch(e){}})();
|
||||
</script>
|
||||
<script src="{% static 'js/consent.js' %}" nonce="{{ request.csp_nonce|default:'' }}"></script>
|
||||
<script src="{% static 'js/theme.js' %}" defer></script>
|
||||
<script src="{% static 'js/prism.js' %}" defer></script>
|
||||
<script src="{% static 'js/newsletter.js' %}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
{% include 'components/nav.html' %}
|
||||
{% include 'components/cookie_banner.html' %}
|
||||
{% if messages %}
|
||||
<section aria-label="Messages">
|
||||
{% for message in messages %}
|
||||
<p>{{ message }}</p>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
<main>{% block content %}{% endblock %}</main>
|
||||
{% include 'components/footer.html' %}
|
||||
</body>
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load core_tags %}
|
||||
{% load core_tags seo_tags %}
|
||||
{% block title %}Articles | No Hype AI{% endblock %}
|
||||
{% block head_meta %}
|
||||
{% canonical_url page as canonical %}
|
||||
<link rel="canonical" href="{{ canonical }}" />
|
||||
<meta name="description" content="Latest No Hype AI articles and benchmark-driven reviews." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Articles | No Hype AI" />
|
||||
<meta property="og:url" content="{{ canonical }}" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ page.title }}</h1>
|
||||
<section>
|
||||
<h2>Filter by tag</h2>
|
||||
<a href="/articles/" {% if not active_tag %}aria-current="page"{% endif %}>All</a>
|
||||
{% for tag in available_tags %}
|
||||
<a href="/articles/?tag={{ tag.slug }}" {% if active_tag == tag.slug %}aria-current="page"{% endif %}>{{ tag.name }}</a>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% for article in articles %}
|
||||
{% include 'components/article_card.html' with article=article %}
|
||||
{% empty %}
|
||||
<p>No articles found.</p>
|
||||
{% endfor %}
|
||||
<nav aria-label="Pagination">
|
||||
{% if articles.has_previous %}
|
||||
<a href="?page={{ articles.previous_page_number }}{% if active_tag %}&tag={{ active_tag }}{% endif %}">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ articles.number }} of {{ paginator.num_pages }}</span>
|
||||
{% if articles.has_next %}
|
||||
<a href="?page={{ articles.next_page_number }}{% if active_tag %}&tag={{ active_tag }}{% endif %}">Next</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
{% 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 %}
|
||||
<link rel="canonical" href="{{ canonical }}" />
|
||||
<meta name="description" content="{{ page.search_description|default:page.summary }}" />
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:title" content="{{ page.title }} | No Hype AI" />
|
||||
<meta property="og:description" content="{{ page.search_description|default:page.summary }}" />
|
||||
<meta property="og:url" content="{{ canonical }}" />
|
||||
{% if og_image %}<meta property="og:image" content="{{ og_image }}" />{% endif %}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="{{ page.title }} | No Hype AI" />
|
||||
<meta name="twitter:description" content="{{ page.search_description|default:page.summary }}" />
|
||||
{% if og_image %}<meta name="twitter:image" content="{{ og_image }}" />{% endif %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<article>
|
||||
<h1>{{ page.title }}</h1>
|
||||
@@ -11,20 +26,63 @@
|
||||
{{ page.body }}
|
||||
{% article_json_ld page %}
|
||||
</article>
|
||||
<section aria-label="Share this article">
|
||||
<h2>Share</h2>
|
||||
<a href="https://x.com/intent/post?url={{ request.build_absolute_uri|urlencode }}&text={{ page.title|urlencode }}" target="_blank" rel="noopener noreferrer">Share on X</a>
|
||||
<a href="https://www.linkedin.com/sharing/share-offsite/?url={{ request.build_absolute_uri|urlencode }}" target="_blank" rel="noopener noreferrer">Share on LinkedIn</a>
|
||||
<button type="button" data-copy-link data-copy-url="{{ request.build_absolute_uri }}">Copy link</button>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Related</h2>
|
||||
{% for article in related_articles %}
|
||||
<a href="{{ article.url }}">{{ article.title }}</a>
|
||||
{% endfor %}
|
||||
</section>
|
||||
<aside>
|
||||
<h2>Newsletter</h2>
|
||||
{% include 'components/newsletter_form.html' with source='article' label='Never miss a post' %}
|
||||
</aside>
|
||||
{% if page.comments_enabled %}
|
||||
<section>
|
||||
<h2>Comments</h2>
|
||||
{% for comment in approved_comments %}
|
||||
<article id="comment-{{ comment.id }}">
|
||||
<p><strong>{{ comment.author_name }}</strong></p>
|
||||
<p>{{ comment.body }}</p>
|
||||
{% for reply in comment.replies.all %}
|
||||
<article id="comment-{{ reply.id }}">
|
||||
<p><strong>{{ reply.author_name }}</strong></p>
|
||||
<p>{{ reply.body }}</p>
|
||||
</article>
|
||||
{% endfor %}
|
||||
<form method="post" action="{% url 'comment_post' %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
||||
<input type="hidden" name="parent_id" value="{{ comment.id }}" />
|
||||
<input type="text" name="author_name" required />
|
||||
<input type="email" name="author_email" required />
|
||||
<textarea name="body" required></textarea>
|
||||
<input type="text" name="honeypot" style="display:none" />
|
||||
<button type="submit">Reply</button>
|
||||
</form>
|
||||
</article>
|
||||
{% empty %}
|
||||
<p>No comments yet.</p>
|
||||
{% endfor %}
|
||||
{% if comment_form and comment_form.errors %}
|
||||
<div aria-label="Comment form errors">
|
||||
{{ comment_form.non_field_errors }}
|
||||
{% for field in comment_form %}
|
||||
{{ field.errors }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'comment_post' %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
||||
<input type="text" name="author_name" required />
|
||||
<input type="email" name="author_email" required />
|
||||
<textarea name="body" required></textarea>
|
||||
<input type="text" name="author_name" value="{% if comment_form %}{{ comment_form.author_name.value|default:'' }}{% endif %}" required />
|
||||
<input type="email" name="author_email" value="{% if comment_form %}{{ comment_form.author_email.value|default:'' }}{% endif %}" required />
|
||||
<textarea name="body" required>{% if comment_form %}{{ comment_form.body.value|default:'' }}{% endif %}</textarea>
|
||||
<input type="text" name="honeypot" style="display:none" />
|
||||
<button type="submit">Post comment</button>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load seo_tags %}
|
||||
{% block title %}No Hype AI{% endblock %}
|
||||
{% block head_meta %}
|
||||
{% canonical_url page as canonical %}
|
||||
<link rel="canonical" href="{{ canonical }}" />
|
||||
<meta name="description" content="Honest AI coding tool reviews for developers." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="No Hype AI" />
|
||||
<meta property="og:description" content="Honest AI coding tool reviews for developers." />
|
||||
<meta property="og:url" content="{{ canonical }}" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<section>
|
||||
{% if featured_article %}
|
||||
|
||||
53
templates/comments/confirm_bulk_approve.html
Normal file
53
templates/comments/confirm_bulk_approve.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{% extends 'wagtailadmin/bulk_actions/confirmation/base.html' %}
|
||||
{% load i18n wagtailusers_tags wagtailadmin_tags %}
|
||||
|
||||
{% block titletag %}
|
||||
{% if items|length == 1 %}
|
||||
{% blocktrans trimmed with snippet_type_name=model_opts.verbose_name %}Approve {{ snippet_type_name }}{% endblocktrans %} - {{ items.0.item }}
|
||||
{% else %}
|
||||
{% blocktrans trimmed with count=items|length|intcomma %}Approve {{ count }} comments{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
{% trans "Approve" as approve_str %}
|
||||
{% if items|length == 1 %}
|
||||
{% include "wagtailadmin/shared/header.html" with title=approve_str subtitle=items.0.item icon=header_icon only %}
|
||||
{% else %}
|
||||
{% include "wagtailadmin/shared/header.html" with title=approve_str subtitle=model_opts.verbose_name_plural|capfirst icon=header_icon only %}
|
||||
{% endif %}
|
||||
{% endblock header %}
|
||||
|
||||
{% block items_with_access %}
|
||||
{% if items %}
|
||||
{% if items|length == 1 %}
|
||||
<p>{% blocktrans trimmed with snippet_type_name=model_opts.verbose_name %}Approve this {{ snippet_type_name }}?{% endblocktrans %}</p>
|
||||
{% else %}
|
||||
<p>{% blocktrans trimmed with count=items|length|intcomma %}Approve {{ count }} selected comments?{% endblocktrans %}</p>
|
||||
<ul>
|
||||
{% for snippet in items %}
|
||||
<li><a href="{{ snippet.edit_url }}" target="_blank" rel="noreferrer">{{ snippet.item }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock items_with_access %}
|
||||
|
||||
{% block items_with_no_access %}
|
||||
{% if items_with_no_access|length == 1 %}
|
||||
{% trans "You don't have permission to approve this comment" as no_access_msg %}
|
||||
{% else %}
|
||||
{% trans "You don't have permission to approve these comments" as no_access_msg %}
|
||||
{% endif %}
|
||||
{% include 'wagtailsnippets/bulk_actions/list_items_with_no_access.html' with items=items_with_no_access no_access_msg=no_access_msg %}
|
||||
{% endblock items_with_no_access %}
|
||||
|
||||
{% block form_section %}
|
||||
{% if items %}
|
||||
{% trans "Yes, approve" as action_button_text %}
|
||||
{% trans "No, go back" as no_action_button_text %}
|
||||
{% include 'wagtailadmin/bulk_actions/confirmation/form.html' %}
|
||||
{% else %}
|
||||
{% include 'wagtailadmin/bulk_actions/confirmation/go_back.html' %}
|
||||
{% endif %}
|
||||
{% endblock form_section %}
|
||||
@@ -5,6 +5,21 @@
|
||||
<button type="submit" name="accept_all" value="1">Accept all</button>
|
||||
<button type="submit" name="reject_all" value="1">Reject all</button>
|
||||
</form>
|
||||
<details>
|
||||
<summary>Manage preferences</summary>
|
||||
<form method="post" action="{% url 'consent' %}">
|
||||
{% csrf_token %}
|
||||
<label>
|
||||
<input type="checkbox" name="analytics" value="1" />
|
||||
Analytics cookies
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" name="advertising" value="1" />
|
||||
Advertising cookies
|
||||
</label>
|
||||
<button type="submit">Save preferences</button>
|
||||
</form>
|
||||
</details>
|
||||
{% if site_settings and site_settings.privacy_policy_page %}
|
||||
<a href="{{ site_settings.privacy_policy_page.url }}">Privacy Policy</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% load core_tags %}
|
||||
<footer>
|
||||
{% get_legal_pages as legal_pages %}
|
||||
{% include 'components/newsletter_form.html' with source='footer' label='Newsletter' %}
|
||||
{% for page in legal_pages %}
|
||||
<a href="{{ page.url }}">{{ page.title }}</a>
|
||||
{% endfor %}
|
||||
|
||||
@@ -2,4 +2,6 @@
|
||||
<a href="/">Home</a>
|
||||
<a href="/articles/">Articles</a>
|
||||
<a href="/about/">About</a>
|
||||
<button type="button" data-theme-toggle>Toggle theme</button>
|
||||
{% include 'components/newsletter_form.html' with source='nav' label='Get updates' %}
|
||||
</nav>
|
||||
|
||||
11
templates/components/newsletter_form.html
Normal file
11
templates/components/newsletter_form.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<form method="post" action="/newsletter/subscribe/" data-newsletter-form>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="source" value="{{ source|default:'unknown' }}" />
|
||||
<label>
|
||||
<span>{{ label|default:"Newsletter" }}</span>
|
||||
<input type="email" name="email" required />
|
||||
</label>
|
||||
<input type="text" name="honeypot" style="display:none" />
|
||||
<button type="submit">Subscribe</button>
|
||||
<p data-newsletter-message aria-live="polite"></p>
|
||||
</form>
|
||||
13
templates/newsletter/email/confirmation_body.html
Normal file
13
templates/newsletter/email/confirmation_body.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.5;">
|
||||
<p>Hi,</p>
|
||||
<p>Please confirm your newsletter subscription by clicking the button below:</p>
|
||||
<p>
|
||||
<a href="{{ confirmation_url }}" style="display:inline-block;padding:10px 14px;background:#111;color:#fff;text-decoration:none;">
|
||||
Confirm Subscription
|
||||
</a>
|
||||
</p>
|
||||
<p>If you did not request this, you can ignore this email.</p>
|
||||
</body>
|
||||
</html>
|
||||
7
templates/newsletter/email/confirmation_body.txt
Normal file
7
templates/newsletter/email/confirmation_body.txt
Normal file
@@ -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.
|
||||
1
templates/newsletter/email/confirmation_subject.txt
Normal file
1
templates/newsletter/email/confirmation_subject.txt
Normal file
@@ -0,0 +1 @@
|
||||
Confirm your No Hype AI newsletter subscription
|
||||
0
theme/__init__.py
Normal file
0
theme/__init__.py
Normal file
6
theme/apps.py
Normal file
6
theme/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ThemeConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "theme"
|
||||
1
theme/static/css/styles.css
Normal file
1
theme/static/css/styles.css
Normal file
@@ -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}
|
||||
944
theme/static_src/package-lock.json
generated
Normal file
944
theme/static_src/package-lock.json
generated
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
12
theme/static_src/package.json
Normal file
12
theme/static_src/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
3
theme/static_src/src/input.css
Normal file
3
theme/static_src/src/input.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
10
theme/static_src/tailwind.config.js
Normal file
10
theme/static_src/tailwind.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
"../../templates/**/*.html",
|
||||
"../../apps/**/templates/**/*.html"
|
||||
],
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
Reference in New Issue
Block a user