52 Commits

Author SHA1 Message Date
codex_a
229c0f8b48 fix: cd to site dir before docker compose commands
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m7s
CI / ci (pull_request) Successful in 1m15s
docker compose stats the cwd when parsing compose files; if cwd is
not accessible to the deploy user the command fails.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 09:38:54 +00:00
codex_a
ec3e1ee1bf fix: remove sudo from deploy script, use docker compose directly
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Failing after 42s
CI / ci (pull_request) Successful in 1m1s
deploy user has no sudo for systemctl. Instead:
- Use 'docker compose up -d --force-recreate' to recreate the web
  container without needing systemctl
- Change Restart=always so systemd re-attaches after the container
  is recreated
- Replace 'sudo journalctl' with 'docker compose logs' in error path

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 22:15:02 +00:00
codex_a
c9dab3e93b fix: use correct Host header in deploy health check
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Failing after 44s
CI / ci (pull_request) Successful in 1m2s
ALLOWED_HOSTS doesn't include localhost, so curl with default Host
header always gets a 400 and the health check fails.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 21:55:58 +00:00
codex_a
349f1db721 fix: auto-set Wagtail site hostname from ALLOWED_HOSTS on startup
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m15s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 21:54:27 +00:00
de56b564c5 Merge pull request 'feat: production deploy pipeline' (#15) from feat/deploy-pipeline into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Failing after 1s
2026-02-28 21:50:04 +00:00
codex_a
833ff378ea fix: use entrypoint script for prod container startup
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m15s
YAML folded block scalar (>) was preserving newlines for more-indented
continuation lines, so gunicorn received no arguments and defaulted to
binding on 127.0.0.1:8000. Replace with an explicit entrypoint script
so all args are passed correctly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 21:47:02 +00:00
codex_a
754b0ca5f6 fix: set build context to app/ in prod compose
The compose file lives in /srv/sum/nohype/ while the code is in
/srv/sum/nohype/app/ so the Dockerfile is at app/Dockerfile.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 21:42:06 +00:00
03fcbdb5ad Merge pull request 'feat: production deploy pipeline' (#14) from feat/deploy-pipeline into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Failing after 5s
Reviewed-on: #14
2026-02-28 21:40:03 +00:00
codex_a
0cbac68ec1 feat: add production deploy pipeline and fix dev CSS
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m23s
Dev:
- Add tailwind install + build to docker-compose startup so CSS is built
  inside the container — not dependent on local filesystem

Production (docker-compose.prod.yml):
- Gunicorn on 127.0.0.1:8001, bind-mounted static/media to host paths
  so Caddy can serve them directly
- Runs migrate, tailwind build, collectstatic on startup

Settings (production.py):
- Disable SECURE_SSL_REDIRECT (Caddy handles redirects; Django would loop)
- Add CSRF_TRUSTED_ORIGINS for nohypeai.net

CI (.gitea/workflows/ci.yml):
- Add push-to-main trigger
- Add deploy job: SSHes to lintel-prod-01 as deploy, runs deploy/deploy.sh

Server config (deploy/):
- deploy/caddy/nohype.caddy — Caddy site config for nohypeai.net
- deploy/sum-nohype.service — systemd unit for the compose stack
- deploy/deploy.sh — deploy script (pull, build, restart)

One-time manual steps required on lintel-prod-01 (need root):
  sudo cp deploy/sum-nohype.service /etc/systemd/system/
  sudo cp deploy/caddy/nohype.caddy /etc/caddy/sites-enabled/
  sudo systemctl daemon-reload && sudo systemctl enable sum-nohype
  sudo systemctl reload caddy

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 21:34:13 +00:00
4c27cfe1dd Merge pull request 'fix: make docker compose up work out of the box' (#13) from fix/dev-setup into main
Reviewed-on: #13
2026-02-28 21:00:10 +00:00
codex_a
a598727888 fix: fix all dev static/media serving issues
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m22s
- Remove WhiteNoise from MIDDLEWARE in development: it intercepts /static/
  requests and serves from STATIC_ROOT, which is empty without collectstatic.
  Django's runserver serves static files natively with DEBUG=True.
- Switch to StaticFilesStorage in dev: no manifest required.
- Add media URL pattern in DEBUG mode: runserver does not serve MEDIA_ROOT
  automatically, so uploaded images were 404ing in local dev.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 20:57:54 +00:00
36eb0f1dd2 Merge pull request 'fix: use plain StaticFilesStorage in dev settings' (#10) from fix/seed-default-site into main
Reviewed-on: #10
2026-02-28 20:48:44 +00:00
codex_a
f950e3cd5e fix: use plain StaticFilesStorage in dev settings
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m24s
CompressedManifestStaticFilesStorage requires collectstatic to generate a
manifest before it can serve anything. Dev containers never run collectstatic
so every static asset 404s. Override to StaticFilesStorage in dev so Django
serves files directly from STATICFILES_DIRS and app static directories.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 20:46:23 +00:00
311ad80320 Merge pull request 'fix: update ALL site records in seed, not just is_default_site' (#9) from fix/seed-default-site into main
Reviewed-on: #9
2026-02-28 20:42:24 +00:00
codex_a
08e003e165 fix: update ALL site records in seed, not just is_default_site
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m22s
Wagtail's initial migration creates a localhost:80 site. Wagtail matches
incoming requests by hostname before ever checking is_default_site, so
updating only the is_default_site record left localhost:80 still pointing
at the Welcome page. Fix by updating root_page on every site.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 20:40:20 +00:00
076aaa0b9e Merge pull request 'fix: update existing default site in seed command' (#8) from fix/seed-default-site into main
Reviewed-on: #8
2026-02-28 20:35:15 +00:00
codex_a
7b2ad4cfe5 fix: remove server-specific playwright volume from dev compose, auto-seed on startup
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m6s
CI / ci (pull_request) Successful in 1m23s
- /opt/playwright-tools/browsers only exists on agent-workspace; mounting it
  in docker-compose.yml breaks local dev for everyone else
- seed_e2e_content is idempotent so safe to run on every startup; removes the
  manual step that nobody knew about

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 20:33:40 +00:00
codex_a
56e53478ea fix: update existing default site in seed command instead of hardcoding 127.0.0.1
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m10s
CI / ci (pull_request) Successful in 1m22s
Wagtail initialises the default site with hostname 'localhost'. The previous
get_or_create on '127.0.0.1' left the localhost site intact (still pointing
to the Welcome page), so browsers got the wrong root page.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 20:31:20 +00:00
96f9eca19d Merge pull request 'feat: comprehensive Playwright E2E test suite' (#7) from tests/e2e into main
Reviewed-on: #7
2026-02-28 20:25:29 +00:00
codex_a
f6edcadd46 fix: run E2E tests properly with mounted browsers and real postgres
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m33s
CI / ci (pull_request) Successful in 2m18s
- Mount /opt/playwright-tools/browsers into web container (docker-compose.yml
  and CI docker run) — never download browsers, use the ones on this host
- Set PLAYWRIGHT_BROWSERS_PATH in all container envs (compose + CI)
- Drop 'playwright install chromium' steps from pr-e2e and nightly-e2e jobs
- Bump playwright requirement to ~1.57.0 to match the installed browser builds
- Fix seed_e2e_content: de-duplicate default Site entries left by unit test
  fixtures so Wagtail always routes to the seeded home page
- Fix test_comments_section_absent_when_disabled: use exact=True on heading
  locator to avoid matching 'No Comments Article' h1 as 'Comments' heading
- Fix test_copy_link_button_updates_text: use [data-copy-link] data-attr
  locator (stable across text change) and force-override clipboard.writeText
  via page.evaluate() rather than relying on init_script polyfill

E2E suite verified locally: 34 passed via docker compose exec

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 20:20:18 +00:00
codex_a
4992b0cb9d fix: resolve 5 E2E test failures from first CI run
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m22s
CI / pr-e2e (pull_request) Failing after 1m32s
- test_homepage_title_contains_brand: to_have_title() requires a string or
  regex, not a lambda; switch to re.compile('No Hype AI')
- test_granular_preferences_save_dismisses_banner: wrong element clicked to
  open <details>; use 'details summary' locator directly
- test_subscribe_invalid_email_shows_error: browser HTML5 email validation
  swallows the submit event before the JS handler fires; add 'novalidate' via
  evaluate() so the fetch still runs and the server returns 400
- test_copy_link_button_updates_text: clipboard API unavailable in headless
  Docker; add polyfill + pre-grant permissions in conftest page fixture so
  the JS success path runs and button text becomes 'Copied'
- test_comments_section_absent_when_disabled: guard against Wagtail's
  add_child() resetting BooleanField defaults by calling an explicit
  .update(comments_enabled=False) + re-setting on the instance before
  save_revision().publish(); also tighten test to assert 200 + correct title

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 19:47:13 +00:00
codex_a
9d323d2040 feat: add comprehensive Playwright E2E test suite
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m22s
CI / pr-e2e (pull_request) Failing after 3m28s
- Create e2e/ directory with 7 test modules covering:
  - Home page: title, nav links, theme toggle, newsletter form
  - Cookie consent: accept all, reject all, granular prefs, persistence
  - Article index: loads, tag filter, click-through navigation
  - Article detail: title/read-time, share section, comments, newsletter aside, related
  - Comments: valid submit → redirect, empty body → error display, disabled check
  - Newsletter: JS confirmation message, invalid email error, aside form, duplicate
  - Feeds: RSS/sitemap/robots.txt validity, tag feed, seeded content present
- Extend seed_e2e_content management command with tagged article, about page,
  no-comments article, and legal pages for richer test coverage
- Add seed command tests (create + idempotency) to keep coverage ≥ 90%
- Add pr-e2e CI job (runs on pull_request): builds image, starts postgres + app,
  installs playwright, runs pytest e2e/
- Update nightly-e2e to run full e2e/ suite alongside legacy journey test
- Add --ignore=e2e to unit-test pytest step (coverage must not include browser tests)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 19:30:43 +00:00
aeb0afb2ea Merge pull request 'Run migrations before starting dev server' (#6) from fix/run-migrations-on-startup into main
Reviewed-on: #6
2026-02-28 19:13:57 +00:00
codex_a
b73a2d4d72 Run migrations before starting dev server
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m21s
Chains 'migrate --noinput' before 'runserver' in the web
service command so migrations are applied automatically on
'docker compose up'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 18:53:32 +00:00
7d226bc4ef Merge pull request 'fix: docker fix' (#5) from fix/docker-apt-retries into main
Reviewed-on: #5
2026-02-28 18:32:29 +00:00
Mark
9b677741cb fix: docker fix
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 3m27s
2026-02-28 18:27:56 +00:00
c5f2902417 Merge pull request 'Harden Docker apt install against transient mirror failures' (#4) from fix/docker-apt-retries into main
Reviewed-on: #4
2026-02-28 18:15:52 +00:00
Mark
5d1c5f43bc Harden Docker apt install with retry logic
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 3m48s
2026-02-28 18:09:24 +00:00
027dff20e3 Merge pull request 'Corrective implementation of implementation.md (containerized Django/Wagtail)' (#3) from codex_b/implementation-e2e into main
Reviewed-on: #3
Reviewed-by: codex_a <codex_a@linteldigital.com>
2026-02-28 17:55:13 +00:00
Mark
c4fde90a9c fix(spec): enforce read-time budget and re-render invalid comment submissions
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m1s
2026-02-28 17:36:34 +00:00
Mark
cfe0cbca62 fix(mypy): work around django-plugin annotate crash in comment viewset
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m20s
2026-02-28 17:21:29 +00:00
Mark
5adff60d4b docs+comments: align plan with gitea PR-only CI and close remaining blockers
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Failing after 20s
2026-02-28 17:17:19 +00:00
Mark
0c9340d279 chore(ci): remove github workflow mirror and use gitea as source
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m1s
2026-02-28 16:49:34 +00:00
Mark
ebddb6c904 fix(ci): validate tailwind output without host bind mount
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m21s
2026-02-28 16:47:58 +00:00
Mark
29e3589b1a fix(ci): avoid docker subnet exhaustion and harden nightly feed check
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Failing after 3m40s
2026-02-28 16:43:20 +00:00
Mark
14db1bb57e fix(ci): address PR blockers and move CI/nightly off sqlite
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Failing after 35s
2026-02-28 16:38:37 +00:00
Codex_B
36ac487cbd Resolve PR review gaps across comments, security, feeds, and UX
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / ci (pull_request) Successful in 48s
2026-02-28 13:47:21 +00:00
Codex_B
932b05cc02 Add performance regression tests for core page flows
All checks were successful
CI / ci (pull_request) Successful in 48s
2026-02-28 13:23:24 +00:00
Codex_B
683cba4280 Complete missing UX flows and production integrity commands
All checks were successful
CI / ci (pull_request) Successful in 32s
2026-02-28 13:20:25 +00:00
Codex_B
2cb1e622e2 Run PR CI via docker build/run without compose networks
All checks were successful
CI / ci (pull_request) Successful in 43s
2026-02-28 13:05:28 +00:00
Codex_B
11b89e9e1c Stabilize PR CI and harden compose startup
Some checks failed
CI / ci (pull_request) Failing after 6s
2026-02-28 13:01:27 +00:00
Codex_B
06be5d6752 CI: isolate compose projects and remove runner container conflicts
Some checks failed
CI / lint (pull_request) Failing after 6s
CI / typecheck (pull_request) Failing after 6s
CI / tests (pull_request) Failing after 8s
2026-02-28 12:56:44 +00:00
Codex_B
ebdf20e708 CI: remove buildx action dependency for runner compatibility
Some checks failed
CI / typecheck (pull_request) Failing after 31s
CI / lint (pull_request) Successful in 2m8s
CI / tests (pull_request) Failing after 2m8s
2026-02-28 12:51:24 +00:00
Codex_B
47e8afea18 CI: use Docker Compose checks for runner compatibility
Some checks failed
CI / typecheck (pull_request) Failing after 2m11s
CI / lint (pull_request) Failing after 2m51s
CI / tests (pull_request) Failing after 2m56s
2026-02-28 12:47:54 +00:00
Codex_B
630c86221f CI: mirror workflow under .gitea/workflows for Gitea Actions
Some checks failed
CI / lint (pull_request) Failing after 6s
CI / tests (pull_request) Failing after 6s
CI / typecheck (pull_request) Failing after 7s
2026-02-28 12:46:43 +00:00
Codex_B
2d2edd8605 Make Docker workflows independent of local .env file
Some checks failed
CI / lint (pull_request) Failing after 6s
CI / tests (pull_request) Failing after 6s
CI / typecheck (pull_request) Failing after 9s
2026-02-28 12:44:41 +00:00
Codex_B
0b5fca3be6 CI: switch to uv with caching and cancel in-progress PR runs
Some checks failed
CI / lint (pull_request) Failing after 12s
CI / tests (pull_request) Failing after 10s
CI / typecheck (pull_request) Failing after 39s
2026-02-28 12:43:40 +00:00
Codex_B
eb2cdfc5f2 Add granular consent preference flow and regression tests
Some checks failed
CI / typecheck (pull_request) Failing after 2m13s
CI / lint (pull_request) Failing after 2m20s
CI / tests (pull_request) Failing after 2m41s
2026-02-28 12:41:26 +00:00
Codex_B
82e6bc2ee0 Add security regression tests for headers, robots and CSRF forms
Some checks failed
CI / typecheck (pull_request) Failing after 2m21s
CI / tests (pull_request) Failing after 3m14s
CI / lint (pull_request) Failing after 3m16s
2026-02-28 12:40:20 +00:00
Codex_B
e279e15c9c Add canonical and social SEO meta tags for core page templates
Some checks failed
CI / typecheck (pull_request) Failing after 2m20s
CI / lint (pull_request) Failing after 3m3s
CI / tests (pull_request) Failing after 3m7s
2026-02-28 12:39:12 +00:00
Codex_B
6fc28f9d9a Implement newsletter double opt-in email flow and CSP nonce headers
Some checks failed
CI / lint (pull_request) Failing after 2m13s
CI / tests (pull_request) Failing after 2m18s
CI / typecheck (pull_request) Failing after 2m39s
2026-02-28 12:37:32 +00:00
Codex_B
ca211c14e9 CI: run lint/typecheck/tests on pull requests only
Some checks failed
CI / typecheck (pull_request) Failing after 2m11s
CI / lint (pull_request) Failing after 2m47s
CI / tests (pull_request) Failing after 2m57s
2026-02-28 12:32:38 +00:00
78 changed files with 3383 additions and 83 deletions

15
.dockerignore Normal file
View 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
View 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

View File

@@ -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

View File

@@ -10,3 +10,6 @@
- Added newsletter subscription + confirmation flow with provider sync abstraction. - Added newsletter subscription + confirmation flow with provider sync abstraction.
- Added templates/static assets baseline for homepage, article index/read, legal, about. - 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 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.

View File

@@ -1,16 +1,46 @@
FROM python:3.12-slim FROM python:3.12-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 PIP_NO_CACHE_DIR=1
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN set -eux; \
build-essential \ sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources; \
libpq-dev \ printf '%s\n' \
curl \ 'Acquire::Retries "8";' \
nodejs \ 'Acquire::http::No-Cache "true";' \
npm \ 'Acquire::https::No-Cache "true";' \
&& rm -rf /var/lib/apt/lists/* '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 WORKDIR /app

View File

@@ -48,6 +48,8 @@ git pull origin main
pip install -r requirements/production.txt pip install -r requirements/production.txt
python manage.py migrate --run-syncdb python manage.py migrate --run-syncdb
python manage.py collectstatic --noinput python manage.py collectstatic --noinput
python manage.py tailwind build
python manage.py check_content_integrity
sudo systemctl reload gunicorn 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` - PostgreSQL dump daily: `pg_dump | gzip > backup-$(date +%Y%m%d).sql.gz`
- `MEDIA_ROOT` rsynced offsite daily - `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.

View File

@@ -11,6 +11,10 @@ class AllArticlesFeed(Feed):
link = "/articles/" link = "/articles/"
description = "Honest AI coding tool reviews for developers." description = "Honest AI coding tool reviews for developers."
def get_object(self, request):
self.request = request
return None
def items(self): def items(self):
return ArticlePage.objects.live().order_by("-first_published_at")[:20] return ArticlePage.objects.live().order_by("-first_published_at")[:20]
@@ -27,11 +31,16 @@ class AllArticlesFeed(Feed):
return item.author.name return item.author.name
def item_link(self, item: ArticlePage): 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): class TagArticlesFeed(AllArticlesFeed):
def get_object(self, request, tag_slug: str): def get_object(self, request, tag_slug: str):
self.request = request
return get_object_or_404(Tag, slug=tag_slug) return get_object_or_404(Tag, slug=tag_slug)
def title(self, obj): def title(self, obj):

View File

@@ -6,10 +6,10 @@ from typing import Any
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db import models 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.contrib.taggit import ClusterTaggableManager
from modelcluster.fields import ParentalKey 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.admin.panels import FieldPanel, PageChooserPanel
from wagtail.fields import RichTextField, StreamField from wagtail.fields import RichTextField, StreamField
from wagtail.models import Page from wagtail.models import Page
@@ -31,15 +31,16 @@ class HomePage(Page):
def get_context(self, request, *args, **kwargs): def get_context(self, request, *args, **kwargs):
ctx = super().get_context(request, *args, **kwargs) ctx = super().get_context(request, *args, **kwargs)
articles = ( articles_qs = (
ArticlePage.objects.live() ArticlePage.objects.live()
.public() .public()
.select_related("author") .select_related("author")
.prefetch_related("tags__metadata") .prefetch_related("tags__metadata")
.order_by("-first_published_at") .order_by("-first_published_at")
) )
articles = list(articles_qs[:5])
ctx["featured_article"] = self.featured_article ctx["featured_article"] = self.featured_article
ctx["latest_articles"] = articles[:5] ctx["latest_articles"] = articles
ctx["more_articles"] = articles[:3] ctx["more_articles"] = articles[:3]
return ctx return ctx
@@ -62,6 +63,9 @@ class ArticleIndexPage(Page):
ctx = super().get_context(request, *args, **kwargs) ctx = super().get_context(request, *args, **kwargs)
tag_slug = request.GET.get("tag") tag_slug = request.GET.get("tag")
articles = self.get_articles() 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: if tag_slug:
articles = articles.filter(tags__slug=tag_slug) articles = articles.filter(tags__slug=tag_slug)
paginator = Paginator(articles, self.ARTICLES_PER_PAGE) paginator = Paginator(articles, self.ARTICLES_PER_PAGE)
@@ -75,6 +79,7 @@ class ArticleIndexPage(Page):
ctx["articles"] = page_obj ctx["articles"] = page_obj
ctx["paginator"] = paginator ctx["paginator"] = paginator
ctx["active_tag"] = tag_slug ctx["active_tag"] = tag_slug
ctx["available_tags"] = available_tags
return ctx return ctx
@@ -168,8 +173,11 @@ class ArticlePage(SeoMixin, Page):
def get_context(self, request, *args, **kwargs): def get_context(self, request, *args, **kwargs):
ctx = super().get_context(request, *args, **kwargs) ctx = super().get_context(request, *args, **kwargs)
ctx["related_articles"] = self.get_related_articles() ctx["related_articles"] = self.get_related_articles()
ctx["approved_comments"] = self.comments.filter(is_approved=True, parent__isnull=True).select_related( from apps.comments.models import Comment
"parent"
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 return ctx

View File

@@ -1,4 +1,8 @@
import pytest 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 @pytest.mark.django_db
@@ -6,3 +10,25 @@ def test_feed_endpoint(client):
resp = client.get("/feed/") resp = client.get("/feed/")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp["Content-Type"].startswith("application/rss+xml") 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

View 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

View File

@@ -1,7 +1,9 @@
import pytest import pytest
from taggit.models import Tag
from apps.blog.models import ArticleIndexPage, ArticlePage from apps.blog.models import ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory from apps.blog.tests.factories import AuthorFactory
from apps.comments.models import Comment
@pytest.mark.django_db @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") resp = client.get("/articles/?page=2")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.context["articles"].number == 2 assert resp.context["articles"].number == 2
assert "Pagination" in resp.content.decode()
@pytest.mark.django_db @pytest.mark.django_db
@@ -59,3 +62,98 @@ def test_article_page_related_context(client, home_page):
resp = client.get("/articles/main/") resp = client.get("/articles/main/")
assert resp.status_code == 200 assert resp.status_code == 200
assert "related_articles" in resp.context 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

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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)."))

View 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

View 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

View File

@@ -1,5 +1,6 @@
import pytest import pytest
from django.core.cache import cache from django.core.cache import cache
from django.test import override_settings
from apps.blog.models import ArticleIndexPage, ArticlePage from apps.blog.models import ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory 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.status_code == 302
assert resp["Location"].endswith("?commented=1")
assert Comment.objects.count() == 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 resp.status_code == 404
assert Comment.objects.count() == 0 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"

View File

@@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.http import HttpResponse 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 django.views import View
from apps.blog.models import ArticlePage from apps.blog.models import ArticlePage
@@ -11,9 +13,24 @@ from apps.comments.forms import CommentForm
from apps.comments.models import Comment 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): 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): 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}" key = f"comment-rate:{ip}"
count = cache.get(key, 0) count = cache.get(key, 0)
if count >= 3: if count >= 3:
@@ -34,9 +51,13 @@ class CommentCreateView(View):
if parent_id: if parent_id:
comment.parent = Comment.objects.filter(pk=parent_id, article=article).first() comment.parent = Comment.objects.filter(pk=parent_id, article=article).first()
comment.ip_address = ip or None 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() comment.save()
messages.success(request, "Your comment is awaiting moderation") messages.success(request, "Your comment is awaiting moderation")
return redirect(f"{article.url}?commented=1") return redirect(f"{article.url}?commented=1")
messages.error(request, "Please correct the form errors") return self._render_article_with_errors(request, article, form)
return redirect(article.url)

View File

@@ -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.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.models import register_snippet
from wagtail.snippets.permissions import get_permission_name
from wagtail.snippets.views.snippets import SnippetViewSet from wagtail.snippets.views.snippets import SnippetViewSet
from apps.comments.models import Comment 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): class CommentViewSet(SnippetViewSet):
model = Comment model = Comment
queryset = Comment.objects.all()
icon = "comment" 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"] list_filter = ["is_approved"]
search_fields = ["author_name", "body"] search_fields = ["author_name", "body"]
add_to_admin_menu = True add_to_admin_menu = True
def get_queryset(self, request): 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) register_snippet(CommentViewSet)
hooks.register("register_bulk_action", ApproveCommentBulkAction)

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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."))

View 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."))

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import secrets
from .consent import ConsentService from .consent import ConsentService
@@ -10,3 +12,26 @@ class ConsentMiddleware:
def __call__(self, request): def __call__(self, request):
request.consent = ConsentService.get_consent(request) request.consent = ConsentService.get_consent(request)
return self.get_response(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

View File

@@ -11,16 +11,33 @@ from apps.core.models import SiteSettings
register = template.Library() 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) @register.simple_tag(takes_context=True)
def article_json_ld(context, article): def article_json_ld(context, article):
request = context["request"] request = context["request"]
site_settings = SiteSettings.for_request(request) nonce = getattr(request, "csp_nonce", "")
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)
data = { data = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Article", "@type": "Article",
@@ -30,8 +47,12 @@ def article_json_ld(context, article):
"dateModified": article.last_published_at.isoformat() if article.last_published_at else "", "dateModified": article.last_published_at.isoformat() if article.last_published_at else "",
"description": article.search_description or article.summary, "description": article.search_description or article.summary,
"url": article.get_full_url(request), "url": article.get_full_url(request),
"image": image_url, "image": _article_image_url(request, article),
} }
return mark_safe( 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>"
) )

View 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

View File

@@ -21,3 +21,47 @@ def test_consent_post_view(client):
resp = client.post("/consent/", {"accept_all": "1"}, follow=False) resp = client.post("/consent/", {"accept_all": "1"}, follow=False)
assert resp.status_code == 302 assert resp.status_code == 302
assert CONSENT_COOKIE_NAME in resp.cookies 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()

View 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 "&lt;rss" in feed_content
or "&lt;feed" in feed_content
)
browser.close()

View 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

View 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

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.utils.http import url_has_allowed_host_and_scheme
from apps.core.consent import ConsentService 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"} advertising = request.POST.get("advertising") in {"true", "1", "on"}
target = request.META.get("HTTP_REFERER", "/") 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) response = redirect(target)
ConsentService.set_consent(response, analytics=analytics, advertising=advertising) ConsentService.set_consent(response, analytics=analytics, advertising=advertising)
return response return response

View File

@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import requests
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -15,9 +18,26 @@ class ProviderSyncService:
class ButtondownSyncService(ProviderSyncService): class ButtondownSyncService(ProviderSyncService):
endpoint = "https://api.buttondown.email/v1/subscribers"
def sync(self, subscription): 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: 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() return ButtondownSyncService()

View File

@@ -12,6 +12,24 @@ def test_subscribe_ok(client):
assert NewsletterSubscription.objects.filter(email="a@example.com").exists() 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 @pytest.mark.django_db
def test_subscribe_invalid(client): def test_subscribe_invalid(client):
resp = client.post("/newsletter/subscribe/", {"email": "bad"}) resp = client.post("/newsletter/subscribe/", {"email": "bad"})

View File

@@ -1,8 +1,13 @@
from __future__ import annotations from __future__ import annotations
import logging
from django.core import signing from django.core import signing
from django.core.mail import EmailMultiAlternatives
from django.http import Http404, JsonResponse from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect 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 django.views import View
from apps.newsletter.forms import SubscriptionForm 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 from apps.newsletter.services import ProviderSyncError, get_provider_service
CONFIRMATION_TOKEN_MAX_AGE_SECONDS = 60 * 60 * 24 * 2 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): class SubscribeView(View):
@@ -20,9 +46,14 @@ class SubscribeView(View):
if form.cleaned_data.get("honeypot"): if form.cleaned_data.get("honeypot"):
return JsonResponse({"status": "ok"}) return JsonResponse({"status": "ok"})
email = form.cleaned_data["email"] email = form.cleaned_data["email"].lower().strip()
source = form.cleaned_data.get("source") or "unknown" 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"}) return JsonResponse({"status": "ok"})
@@ -42,10 +73,6 @@ class ConfirmView(View):
service = get_provider_service() service = get_provider_service()
try: try:
service.sync(subscription) service.sync(subscription)
except ProviderSyncError: except ProviderSyncError as exc:
pass logger.exception("Newsletter provider sync failed: %s", exc)
return redirect("/") return redirect("/")
def confirmation_token(email: str) -> str:
return signing.dumps(email, salt="newsletter-confirm")

View File

@@ -4,13 +4,20 @@ import os
from pathlib import Path from pathlib import Path
import dj_database_url import dj_database_url
from django.core.exceptions import ImproperlyConfigured
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
BASE_DIR = Path(__file__).resolve().parents[2] 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" 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()] 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", "wagtail",
"wagtailseo", "wagtailseo",
"tailwind", "tailwind",
"theme",
"apps.core", "apps.core",
"apps.blog", "apps.blog",
"apps.authors", "apps.authors",
@@ -49,6 +57,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"apps.core.middleware.SecurityHeadersMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
@@ -80,9 +89,7 @@ TEMPLATES = [
WSGI_APPLICATION = "config.wsgi.application" WSGI_APPLICATION = "config.wsgi.application"
DATABASES = { DATABASES = {"default": dj_database_url.parse(DATABASE_URL)}
"default": dj_database_url.parse(os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR / 'db.sqlite3'}"))
}
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
@@ -130,7 +137,11 @@ CACHES = {
X_FRAME_OPTIONS = "SAMEORIGIN" X_FRAME_OPTIONS = "SAMEORIGIN"
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin" SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
SECURE_CONTENT_TYPE_NOSNIFF = True 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] 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" STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
TAILWIND_APP_NAME = "theme"

View File

@@ -4,6 +4,13 @@ DEBUG = True
INTERNAL_IPS = ["127.0.0.1"] 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: try:
import debug_toolbar # noqa: F401 import debug_toolbar # noqa: F401

View File

@@ -2,8 +2,16 @@ from .base import * # noqa
DEBUG = False 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") SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True USE_X_FORWARDED_HOST = True
SECURE_SSL_REDIRECT = True SECURE_SSL_REDIRECT = False
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True
CSRF_TRUSTED_ORIGINS = [
"https://nohypeai.net",
"https://www.nohypeai.net",
]

View File

@@ -1,3 +1,5 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from django.views.generic import RedirectView from django.views.generic import RedirectView
@@ -21,3 +23,6 @@ urlpatterns = [
path("admin/", RedirectView.as_view(url="/cms/", permanent=False)), path("admin/", RedirectView.as_view(url="/cms/", permanent=False)),
path("", include(wagtail_urls)), path("", include(wagtail_urls)),
] ]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

23
deploy/caddy/nohype.caddy Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
services:
web:
volumes: []
ports: []
db:
ports: []

36
docker-compose.prod.yml Normal file
View 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:

View File

@@ -1,31 +1,47 @@
services: services:
web: web:
build: . build: .
container_name: nohype-web working_dir: /app
command: python manage.py runserver 0.0.0.0:8000 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: volumes:
- .:/app - .:/app
ports: ports:
- "8035:8000" - "8035:8000"
env_file:
- .env
environment: 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 DATABASE_URL: postgres://nohype:nohype@db:5432/nohype
DJANGO_SETTINGS_MODULE: config.settings.development 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: depends_on:
- db db:
condition: service_healthy
db: db:
image: postgres:16-alpine image: postgres:16-alpine
container_name: nohype-db
environment: environment:
POSTGRES_DB: nohype POSTGRES_DB: nohype
POSTGRES_USER: nohype POSTGRES_USER: nohype
POSTGRES_PASSWORD: nohype POSTGRES_PASSWORD: nohype
ports:
- "5545:5432"
volumes: volumes:
- nohype_pg:/var/lib/postgresql/data - 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: volumes:
nohype_pg: nohype_pg:

0
e2e/__init__.py Normal file
View File

57
e2e/conftest.py Normal file
View 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()

View 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
View 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
View 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)

View 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
View 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 "&lt;rss" in content or "&lt;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 "&lt;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 "&lt;rss" in content or "&lt;feed" in content

54
e2e/test_home.py Normal file
View 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
View 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)

View File

@@ -179,8 +179,8 @@ Every milestone follows the **Red → Green → Refactor** cycle. No production
### 3.3 Coverage Requirements ### 3.3 Coverage Requirements
- **Minimum 90% line coverage** on all `apps/` code, enforced via `pytest-cov` in CI - **Minimum 90% line coverage** on all `apps/` code, enforced via `pytest-cov` in CI
- Coverage reports generated on every push; PRs blocked below threshold - Coverage reports generated on every pull request; PRs blocked below threshold
- E2E tests run nightly, not on every push (they are slow) - E2E tests run nightly, not on every pull request (they are slow)
### 3.4 Test Organisation ### 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 # 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: jobs:
test: test:
@@ -232,6 +232,8 @@ jobs:
- Run Playwright suite - 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 ## Milestone 0 — Project Scaffold & Tooling
@@ -242,7 +244,7 @@ jobs:
- `./manage.py runserver` starts without errors - `./manage.py runserver` starts without errors
- `pytest` runs and exits 0 (no tests yet = trivially passing) - `pytest` runs and exits 0 (no tests yet = trivially passing)
- `ruff` and `mypy` pass on an empty codebase - `ruff` and `mypy` pass on an empty codebase
- GitHub Actions workflow file committed and green - Gitea Actions workflow file committed and green
### M0 — Tasks ### M0 — Tasks
@@ -271,7 +273,7 @@ jobs:
- Add Prism.js and Alpine.js to `static/js/`; wire into `base.html` - Add Prism.js and Alpine.js to `static/js/`; wire into `base.html`
#### M0.5 — CI #### M0.5 — CI
- Create `.github/workflows/ci.yml` - Create `.gitea/workflows/ci.yml`
- Install `pytest-django`, `pytest-cov`, `ruff`, `mypy`, `factory_boy`, `wagtail-factories` - Install `pytest-django`, `pytest-cov`, `ruff`, `mypy`, `factory_boy`, `wagtail-factories`
- Create `pytest.ini` / `pyproject.toml` config pointing at `config.settings.development` - 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 - 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.*

View File

@@ -2,3 +2,5 @@
DJANGO_SETTINGS_MODULE = config.settings.development DJANGO_SETTINGS_MODULE = config.settings.development
python_files = test_*.py python_files = test_*.py
addopts = -q --cov=apps --cov-report=term-missing --cov-fail-under=90 addopts = -q --cov=apps --cov-report=term-missing --cov-fail-under=90
markers =
e2e: browser-based end-to-end test suite for nightly jobs

View File

@@ -17,6 +17,8 @@ pytest-benchmark~=4.0.0
factory-boy~=3.3.0 factory-boy~=3.3.0
wagtail-factories~=4.2.0 wagtail-factories~=4.2.0
feedparser~=6.0.0 feedparser~=6.0.0
playwright~=1.57.0
pytest-playwright~=0.7.0
ruff~=0.6.0 ruff~=0.6.0
mypy~=1.11.0 mypy~=1.11.0
django-stubs~=5.1.0 django-stubs~=5.1.0

54
static/js/newsletter.js Normal file
View 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();
})();

View File

@@ -4,4 +4,11 @@
root.classList.toggle('dark'); root.classList.toggle('dark');
localStorage.setItem('theme', root.classList.contains('dark') ? 'dark' : 'light'); 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);
}
});
})(); })();

View File

@@ -5,16 +5,26 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}No Hype AI{% endblock %}</title> <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:'' }}"> <script nonce="{{ request.csp_nonce|default:'' }}">
(function(){try{if(localStorage.getItem('theme')==='light'){document.documentElement.classList.remove('dark');}}catch(e){}})(); (function(){try{if(localStorage.getItem('theme')==='light'){document.documentElement.classList.remove('dark');}}catch(e){}})();
</script> </script>
<script src="{% static 'js/consent.js' %}" nonce="{{ request.csp_nonce|default:'' }}"></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/theme.js' %}" defer></script>
<script src="{% static 'js/prism.js' %}" defer></script> <script src="{% static 'js/prism.js' %}" defer></script>
<script src="{% static 'js/newsletter.js' %}" defer></script>
</head> </head>
<body> <body>
{% include 'components/nav.html' %} {% include 'components/nav.html' %}
{% include 'components/cookie_banner.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> <main>{% block content %}{% endblock %}</main>
{% include 'components/footer.html' %} {% include 'components/footer.html' %}
</body> </body>

View File

@@ -1,11 +1,35 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load core_tags %} {% load core_tags seo_tags %}
{% block title %}Articles | No Hype AI{% endblock %} {% 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 %} {% block content %}
<h1>{{ page.title }}</h1> <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 %} {% for article in articles %}
{% include 'components/article_card.html' with article=article %} {% include 'components/article_card.html' with article=article %}
{% empty %} {% empty %}
<p>No articles found.</p> <p>No articles found.</p>
{% endfor %} {% 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 %} {% endblock %}

View File

@@ -1,6 +1,21 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load wagtailcore_tags wagtailimages_tags seo_tags %} {% load wagtailcore_tags wagtailimages_tags seo_tags %}
{% block title %}{{ page.title }} | No Hype AI{% endblock %} {% 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 %} {% block content %}
<article> <article>
<h1>{{ page.title }}</h1> <h1>{{ page.title }}</h1>
@@ -11,20 +26,63 @@
{{ page.body }} {{ page.body }}
{% article_json_ld page %} {% article_json_ld page %}
</article> </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> <section>
<h2>Related</h2> <h2>Related</h2>
{% for article in related_articles %} {% for article in related_articles %}
<a href="{{ article.url }}">{{ article.title }}</a> <a href="{{ article.url }}">{{ article.title }}</a>
{% endfor %} {% endfor %}
</section> </section>
<aside>
<h2>Newsletter</h2>
{% include 'components/newsletter_form.html' with source='article' label='Never miss a post' %}
</aside>
{% if page.comments_enabled %} {% if page.comments_enabled %}
<section> <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' %}"> <form method="post" action="{% url 'comment_post' %}">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="article_id" value="{{ page.id }}" /> <input type="hidden" name="article_id" value="{{ page.id }}" />
<input type="text" name="author_name" required /> <input type="text" name="author_name" value="{% if comment_form %}{{ comment_form.author_name.value|default:'' }}{% endif %}" required />
<input type="email" name="author_email" required /> <input type="email" name="author_email" value="{% if comment_form %}{{ comment_form.author_email.value|default:'' }}{% endif %}" required />
<textarea name="body" required></textarea> <textarea name="body" required>{% if comment_form %}{{ comment_form.body.value|default:'' }}{% endif %}</textarea>
<input type="text" name="honeypot" style="display:none" /> <input type="text" name="honeypot" style="display:none" />
<button type="submit">Post comment</button> <button type="submit">Post comment</button>
</form> </form>

View File

@@ -1,5 +1,15 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load seo_tags %}
{% block title %}No Hype AI{% endblock %} {% 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 %} {% block content %}
<section> <section>
{% if featured_article %} {% if featured_article %}

View 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 %}

View File

@@ -5,6 +5,21 @@
<button type="submit" name="accept_all" value="1">Accept all</button> <button type="submit" name="accept_all" value="1">Accept all</button>
<button type="submit" name="reject_all" value="1">Reject all</button> <button type="submit" name="reject_all" value="1">Reject all</button>
</form> </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 %} {% if site_settings and site_settings.privacy_policy_page %}
<a href="{{ site_settings.privacy_policy_page.url }}">Privacy Policy</a> <a href="{{ site_settings.privacy_policy_page.url }}">Privacy Policy</a>
{% endif %} {% endif %}

View File

@@ -1,6 +1,7 @@
{% load core_tags %} {% load core_tags %}
<footer> <footer>
{% get_legal_pages as legal_pages %} {% get_legal_pages as legal_pages %}
{% include 'components/newsletter_form.html' with source='footer' label='Newsletter' %}
{% for page in legal_pages %} {% for page in legal_pages %}
<a href="{{ page.url }}">{{ page.title }}</a> <a href="{{ page.url }}">{{ page.title }}</a>
{% endfor %} {% endfor %}

View File

@@ -2,4 +2,6 @@
<a href="/">Home</a> <a href="/">Home</a>
<a href="/articles/">Articles</a> <a href="/articles/">Articles</a>
<a href="/about/">About</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> </nav>

View 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>

View 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>

View 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.

View File

@@ -0,0 +1 @@
Confirm your No Hype AI newsletter subscription

0
theme/__init__.py Normal file
View File

6
theme/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ThemeConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "theme"

View 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
View 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
}
}
}

View 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"
}
}

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,10 @@
module.exports = {
content: [
"../../templates/**/*.html",
"../../apps/**/templates/**/*.html"
],
theme: {
extend: {}
},
plugins: []
};