/", TagArticlesFeed(), name="rss_feed_by_tag"),
+ path("sitemap.xml", sitemap),
+ path("admin/", RedirectView.as_view(url="/cms/", permanent=False)),
+ path("", include(wagtail_urls)),
+]
diff --git a/config/wsgi.py b/config/wsgi.py
new file mode 100644
index 0000000..ee192cf
--- /dev/null
+++ b/config/wsgi.py
@@ -0,0 +1,7 @@
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
+
+application = get_wsgi_application()
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..e744998
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,31 @@
+services:
+ web:
+ build: .
+ container_name: nohype-web
+ command: python manage.py runserver 0.0.0.0:8000
+ volumes:
+ - .:/app
+ ports:
+ - "8035:8000"
+ env_file:
+ - .env
+ environment:
+ DATABASE_URL: postgres://nohype:nohype@db:5432/nohype
+ DJANGO_SETTINGS_MODULE: config.settings.development
+ depends_on:
+ - db
+
+ db:
+ image: postgres:16-alpine
+ container_name: nohype-db
+ environment:
+ POSTGRES_DB: nohype
+ POSTGRES_USER: nohype
+ POSTGRES_PASSWORD: nohype
+ ports:
+ - "5545:5432"
+ volumes:
+ - nohype_pg:/var/lib/postgresql/data
+
+volumes:
+ nohype_pg:
diff --git a/manage.py b/manage.py
new file mode 100755
index 0000000..f7ba6cb
--- /dev/null
+++ b/manage.py
@@ -0,0 +1,14 @@
+#!/usr/bin/env python
+import os
+import sys
+
+
+def main() -> None:
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development")
+ from django.core.management import execute_from_command_line
+
+ execute_from_command_line(sys.argv)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..5270ff4
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,45 @@
+[tool.ruff]
+line-length = 120
+target-version = "py312"
+exclude = ["migrations"]
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "UP"]
+
+[tool.ruff.lint.per-file-ignores]
+"config/settings/development.py" = ["F403", "F405"]
+
+[tool.mypy]
+python_version = "3.12"
+plugins = ["mypy_django_plugin.main"]
+warn_unused_configs = true
+warn_redundant_casts = true
+warn_unused_ignores = true
+check_untyped_defs = true
+exclude = ["migrations"]
+disable_error_code = ["var-annotated", "override", "import-untyped", "arg-type"]
+allow_untyped_globals = true
+
+[[tool.mypy.overrides]]
+module = ["wagtail.*", "taggit.*", "modelcluster.*", "wagtailseo.*", "debug_toolbar"]
+ignore_missing_imports = true
+
+[[tool.mypy.overrides]]
+module = ["apps.authors.models"]
+ignore_errors = true
+
+[tool.django-stubs]
+django_settings_module = "config.settings.development"
+
+[tool.coverage.run]
+source = ["apps"]
+omit = [
+ "*/migrations/*",
+ "*/tests/*",
+]
+
+[tool.coverage.report]
+omit = [
+ "*/migrations/*",
+ "*/tests/*",
+]
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..f33c45a
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,4 @@
+[pytest]
+DJANGO_SETTINGS_MODULE = config.settings.development
+python_files = test_*.py
+addopts = -q --cov=apps --cov-report=term-missing --cov-fail-under=90
diff --git a/requirements/base.txt b/requirements/base.txt
new file mode 100644
index 0000000..7488476
--- /dev/null
+++ b/requirements/base.txt
@@ -0,0 +1,22 @@
+Django~=5.2.0
+wagtail~=7.0.0
+wagtail-seo~=3.1.1
+psycopg2-binary~=2.9.0
+Pillow~=11.0.0
+django-taggit~=6.0.0
+whitenoise~=6.0.0
+gunicorn~=23.0.0
+python-dotenv~=1.0.0
+dj-database-url~=2.2.0
+django-tailwind~=3.8.0
+django-csp~=3.8.0
+pytest~=8.3.0
+pytest-django~=4.9.0
+pytest-cov~=5.0.0
+pytest-benchmark~=4.0.0
+factory-boy~=3.3.0
+wagtail-factories~=4.2.0
+feedparser~=6.0.0
+ruff~=0.6.0
+mypy~=1.11.0
+django-stubs~=5.1.0
diff --git a/requirements/production.txt b/requirements/production.txt
new file mode 100644
index 0000000..c5217f5
--- /dev/null
+++ b/requirements/production.txt
@@ -0,0 +1,2 @@
+-r base.txt
+sentry-sdk~=2.0.0
diff --git a/static/js/consent.js b/static/js/consent.js
new file mode 100644
index 0000000..1a53c05
--- /dev/null
+++ b/static/js/consent.js
@@ -0,0 +1,13 @@
+(function () {
+ function parseCookieValue(name) {
+ const match = document.cookie.match(new RegExp('(?:^|;)\\s*' + name + '\\s*=\\s*([^;]+)'));
+ if (!match) return {};
+ try {
+ return Object.fromEntries(new URLSearchParams(match[1]));
+ } catch (_e) {
+ return {};
+ }
+ }
+ const c = parseCookieValue('nhAiConsent');
+ window.__nhConsent = { analytics: c.a === '1', advertising: c.d === '1' };
+})();
diff --git a/static/js/prism.js b/static/js/prism.js
new file mode 100644
index 0000000..bcd212e
--- /dev/null
+++ b/static/js/prism.js
@@ -0,0 +1 @@
+/* placeholder for Prism.js bundle */
diff --git a/static/js/theme.js b/static/js/theme.js
new file mode 100644
index 0000000..f9abe1f
--- /dev/null
+++ b/static/js/theme.js
@@ -0,0 +1,7 @@
+(function () {
+ window.toggleTheme = function toggleTheme() {
+ const root = document.documentElement;
+ root.classList.toggle('dark');
+ localStorage.setItem('theme', root.classList.contains('dark') ? 'dark' : 'light');
+ };
+})();
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000..3f1963b
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,21 @@
+{% load static core_tags %}
+
+
+
+
+
+ {% block title %}No Hype AI{% endblock %}
+
+
+
+
+
+
+ {% include 'components/nav.html' %}
+ {% include 'components/cookie_banner.html' %}
+ {% block content %}{% endblock %}
+ {% include 'components/footer.html' %}
+
+
diff --git a/templates/blog/about_page.html b/templates/blog/about_page.html
new file mode 100644
index 0000000..268074a
--- /dev/null
+++ b/templates/blog/about_page.html
@@ -0,0 +1,14 @@
+{% extends 'base.html' %}
+{% load wagtailimages_tags wagtailcore_tags %}
+{% block content %}
+{{ page.title }}
+{{ page.mission_statement }}
+{{ page.body|richtext }}
+{% if page.featured_author %}
+ {{ page.featured_author.name }}
+ {{ page.featured_author.bio }}
+ {% if page.featured_author.avatar %}
+ {% image page.featured_author.avatar fill-200x200 %}
+ {% endif %}
+{% endif %}
+{% endblock %}
diff --git a/templates/blog/article_index_page.html b/templates/blog/article_index_page.html
new file mode 100644
index 0000000..3a67743
--- /dev/null
+++ b/templates/blog/article_index_page.html
@@ -0,0 +1,11 @@
+{% extends 'base.html' %}
+{% load core_tags %}
+{% block title %}Articles | No Hype AI{% endblock %}
+{% block content %}
+{{ page.title }}
+{% for article in articles %}
+ {% include 'components/article_card.html' with article=article %}
+{% empty %}
+ No articles found.
+{% endfor %}
+{% endblock %}
diff --git a/templates/blog/article_page.html b/templates/blog/article_page.html
new file mode 100644
index 0000000..ff661da
--- /dev/null
+++ b/templates/blog/article_page.html
@@ -0,0 +1,33 @@
+{% extends 'base.html' %}
+{% load wagtailcore_tags wagtailimages_tags seo_tags %}
+{% block title %}{{ page.title }} | No Hype AI{% endblock %}
+{% block content %}
+
+ {{ page.title }}
+ {{ page.read_time_mins }} min read
+ {% if page.hero_image %}
+ {% image page.hero_image fill-1200x630 %}
+ {% endif %}
+ {{ page.body }}
+ {% article_json_ld page %}
+
+
+{% if page.comments_enabled %}
+
+{% endif %}
+{% endblock %}
diff --git a/templates/blog/blocks/callout_block.html b/templates/blog/blocks/callout_block.html
new file mode 100644
index 0000000..acc772f
--- /dev/null
+++ b/templates/blog/blocks/callout_block.html
@@ -0,0 +1,4 @@
+
+
{{ value.heading }}
+ {{ value.body }}
+
diff --git a/templates/blog/blocks/code_block.html b/templates/blog/blocks/code_block.html
new file mode 100644
index 0000000..7beb73e
--- /dev/null
+++ b/templates/blog/blocks/code_block.html
@@ -0,0 +1,5 @@
+{% load wagtailcore_tags %}
+
+ {% if value.filename %}
{{ value.filename }}
{% endif %}
+
{{ value.raw_code }}
+
diff --git a/templates/blog/blocks/image_block.html b/templates/blog/blocks/image_block.html
new file mode 100644
index 0000000..081fedf
--- /dev/null
+++ b/templates/blog/blocks/image_block.html
@@ -0,0 +1,5 @@
+{% load wagtailimages_tags %}
+
+ {% image value.image width-1024 alt=value.alt %}
+ {% if value.caption %}{{ value.caption }}{% endif %}
+
diff --git a/templates/blog/blocks/pull_quote_block.html b/templates/blog/blocks/pull_quote_block.html
new file mode 100644
index 0000000..f971c12
--- /dev/null
+++ b/templates/blog/blocks/pull_quote_block.html
@@ -0,0 +1,4 @@
+
+ {{ value.quote }}
+ {% if value.attribution %}{{ value.attribution }}{% endif %}
+
diff --git a/templates/blog/home_page.html b/templates/blog/home_page.html
new file mode 100644
index 0000000..cfd06f6
--- /dev/null
+++ b/templates/blog/home_page.html
@@ -0,0 +1,21 @@
+{% extends 'base.html' %}
+{% block title %}No Hype AI{% endblock %}
+{% block content %}
+
+ {% if featured_article %}
+ {{ featured_article.title }}
+ {{ featured_article.author.name }}
+ {{ featured_article.read_time_mins }} min read
+ {% endif %}
+
+
+ {% for article in latest_articles %}
+ {% include 'components/article_card.html' with article=article %}
+ {% endfor %}
+
+
+ {% for article in more_articles %}
+ {% include 'components/article_card.html' with article=article %}
+ {% endfor %}
+
+{% endblock %}
diff --git a/templates/components/article_card.html b/templates/components/article_card.html
new file mode 100644
index 0000000..3d0501f
--- /dev/null
+++ b/templates/components/article_card.html
@@ -0,0 +1,8 @@
+{% load core_tags %}
+
+ {{ article.title }}
+ {{ article.summary|truncatewords:20 }}
+ {% for tag in article.tags.all %}
+ {{ tag.name }}
+ {% endfor %}
+
diff --git a/templates/components/cookie_banner.html b/templates/components/cookie_banner.html
new file mode 100644
index 0000000..5623737
--- /dev/null
+++ b/templates/components/cookie_banner.html
@@ -0,0 +1,12 @@
+{% if request.consent.requires_prompt %}
+
+
+ {% if site_settings and site_settings.privacy_policy_page %}
+
Privacy Policy
+ {% endif %}
+
+{% endif %}
diff --git a/templates/components/footer.html b/templates/components/footer.html
new file mode 100644
index 0000000..f2ab9b5
--- /dev/null
+++ b/templates/components/footer.html
@@ -0,0 +1,7 @@
+{% load core_tags %}
+
diff --git a/templates/components/nav.html b/templates/components/nav.html
new file mode 100644
index 0000000..7027f23
--- /dev/null
+++ b/templates/components/nav.html
@@ -0,0 +1,5 @@
+
diff --git a/templates/core/robots.txt b/templates/core/robots.txt
new file mode 100644
index 0000000..d8b232d
--- /dev/null
+++ b/templates/core/robots.txt
@@ -0,0 +1,3 @@
+User-agent: *
+Disallow: /cms/
+Sitemap: {{ request.scheme }}://{{ request.get_host }}/sitemap.xml
diff --git a/templates/legal/legal_page.html b/templates/legal/legal_page.html
new file mode 100644
index 0000000..b472096
--- /dev/null
+++ b/templates/legal/legal_page.html
@@ -0,0 +1,7 @@
+{% extends 'base.html' %}
+{% load wagtailcore_tags %}
+{% block content %}
+{{ page.title }}
+Last updated: {{ page.last_updated|date:'F Y' }}
+{{ page.body|richtext }}
+{% endblock %}