From 452788fb18ac8dc3ff95960a3bf48d9b3d223041 Mon Sep 17 00:00:00 2001 From: emi Date: Tue, 21 Jul 2026 22:48:59 +0000 Subject: [PATCH] Move static asset + SPA-shell ownership from backend to frontend (repo-split Stage 7a) (#22) --- paths.py | 14 +-- requirements.txt | 7 +- templates/about.html.j2 | 36 ------ templates/base.html.j2 | 113 ------------------ templates/city.html.j2 | 111 ----------------- templates/glossary.html.j2 | 19 --- templates/glossary_term.html.j2 | 21 ---- templates/home.html.j2 | 205 -------------------------------- templates/hub.html.j2 | 89 -------------- templates/month.html.j2 | 63 ---------- templates/privacy.html.j2 | 39 ------ templates/records.html.j2 | 96 --------------- tests/web/test_api.py | 12 -- tests/web/test_homepage.py | 18 --- web/app.py | 114 ++++-------------- 15 files changed, 29 insertions(+), 928 deletions(-) delete mode 100644 templates/about.html.j2 delete mode 100644 templates/base.html.j2 delete mode 100644 templates/city.html.j2 delete mode 100644 templates/glossary.html.j2 delete mode 100644 templates/glossary_term.html.j2 delete mode 100644 templates/home.html.j2 delete mode 100644 templates/hub.html.j2 delete mode 100644 templates/month.html.j2 delete mode 100644 templates/privacy.html.j2 delete mode 100644 templates/records.html.j2 diff --git a/paths.py b/paths.py index 864faad..f5dc078 100644 --- a/paths.py +++ b/paths.py @@ -1,10 +1,10 @@ """Canonical filesystem locations, resolved once from the repo root. Every path the backend needs (the climate cache, the accounts DB, logs, the -Jinja templates, the frontend static dir, the bundled city data) is derived -here from the repository root rather than from each module's own ``__file__``. -That keeps the locations stable no matter how deep in the package tree a module -lives, so moving a module never silently re-points the cache or the accounts DB. +bundled city data) is derived here from the repository root rather than from +each module's own ``__file__``. That keeps the locations stable no matter how +deep in the package tree a module lives, so moving a module never silently +re-points the cache or the accounts DB. Callers that support an environment override (e.g. ``THERMOGRAPH_ACCOUNTS_DB``) keep doing so; these are only the defaults. @@ -33,12 +33,6 @@ REPO_DIR = _repo_root() BACKEND_DIR = os.path.join(REPO_DIR, "backend") DATA_DIR = os.path.join(REPO_DIR, "data") LOGS_DIR = os.path.join(REPO_DIR, "logs") -FRONTEND_DIR = os.path.join(REPO_DIR, "frontend") -TEMPLATES_DIR = os.path.join(BACKEND_DIR, "templates") -# Structured SSR copy (glossary, static-page SEO meta) — see -# backend/web/content_loader.py and docs/README.md. A sibling of backend/ and -# frontend/, not under either: it's the seed of a future thermograph-copy repo. -CONTENT_DIR = os.path.join(REPO_DIR, "content") # Bundled reference data shipped alongside the code (generated by gen_cities / # gen_flavor), not runtime state — hence under backend/, not data/. diff --git a/requirements.txt b/requirements.txt index d609f17..df710aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,10 +17,13 @@ alembic==1.14.0 pywebpush==2.0.0 # Ed25519 verification of Discord interaction webhooks (see backend/discord_interactions.py). PyNaCl==1.5.0 -# Server-rendered SEO content pages (see backend/web/content.py, templates/). +# Not used by backend itself (its own content.py/content_loader.py were +# deleted in repo-split Stage 4) -- installed here because frontend_ssr/ +# shares this requirements.txt in the current shared Dockerfile. See +# frontend_ssr/content.py (server-rendered SEO content pages). jinja2==3.1.6 # Structured SSR copy (glossary, static-page SEO meta) — see -# backend/web/content_loader.py and content/. +# frontend_ssr/content_loader.py and content/. PyYAML==6.0.3 # Recurring worker-tier jobs — city warming, IndexNow pings (see # notifications/scheduler.py). In-process; no broker/queue needed at this scale. diff --git a/templates/about.html.j2 b/templates/about.html.j2 deleted file mode 100644 index 1c38215..0000000 --- a/templates/about.html.j2 +++ /dev/null @@ -1,36 +0,0 @@ -{% extends "base.html.j2" %} -{% block title %}{{ page_title }}{% endblock %} -{% block description %}{{ page_description }}{% endblock %} -{% block canonical_path %}{{ canonical_path }}{% endblock %} -{% block content %} - -
-

About Thermograph

-

Thermograph answers one question for any point on Earth: how unusual is this - weather? Instead of an absolute temperature, it shows where a day falls in that exact - location's own climate history.

- -

Where the data comes from

-

The ~45-year daily record (high, low, feels-like, humidity, wind, gust and precipitation) - comes from the ERA5 reanalysis (ECMWF), served via Open-Meteo. - Because reanalysis is a gridded, model-consistent record, Thermograph works even where there's - no weather station, anywhere on the ~4 sq mi grid.

- -

How a day is graded

-

For each metric and date, Thermograph builds a reference distribution from every historical day - within ±7 days of that day-of-year, a 15-day seasonal window across all years. The - observed value is placed on that distribution as an empirical - percentile, then mapped to a grade from “Below Normal” through “High” to “Near Record”.

-

Everything is relative to the location: a {{ temp(60) }} day can be “Above Normal” in one place and - “Below Normal” in another. Precipitation is graded only among days that actually rained, so “Heavy” - means heavy for a rainy day here. See the grade guide for the full scale.

- -

Freshness

-

The historical archive updates as new days are added; recent and forecast conditions refresh hourly. - City climate pages show the latest recorded day graded against the local normal.

- -

Open the weather grader →

-
-{% endblock %} diff --git a/templates/base.html.j2 b/templates/base.html.j2 deleted file mode 100644 index c5ed3b4..0000000 --- a/templates/base.html.j2 +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - {{ head_verify() }} - {% block title %}Thermograph{% endblock %} - - - - - - - - - - - - - - - - - - - - - - - - - {% block head_extra %}{% endblock %} - - {% block jsonld %}{% endblock %} - - -
-
-
- {# The brand is the page's h1 everywhere EXCEPT the homepage, where the - hero headline owns the sole h1 and the brand degrades to a

. The - .brand h1, .brand .site-name selector pair in style.css keeps the - lockup looking identical either way. #} - <{{ brand_tag|default('h1') }} class="site-name">Thermograph -

How unusual is the weather? Graded against ~45 years of local climate history.

-
- -
-
- - - {% block content %}{% endblock %} - - -
- {# The compact digest signup is the site-wide footer pattern, not a - homepage-only surface: every page is a possible entry point. - Hidden for now; restore by uncommenting the form block below. #} - {# -
- -
- - -
-

No spam, no sharing your address, unsubscribe anytime.

- -
- #} - -

Free. No ads. No tracking. No account needed. Built by one person in the open.

-

Thermograph grades weather by its percentile against ~45 years of local - climate history. Data: ERA5 via Open-Meteo · NASA POWER · MET Norway.

-

Made by emigriffith.dev - @

-
- {% block body_scripts %} - - - - - {% endblock %} - - - - diff --git a/templates/city.html.j2 b/templates/city.html.j2 deleted file mode 100644 index 969ae93..0000000 --- a/templates/city.html.j2 +++ /dev/null @@ -1,111 +0,0 @@ -{% extends "base.html.j2" %} -{% block title %}{{ page_title }}{% endblock %} -{% block description %}{{ page_description }}{% endblock %} -{% block canonical_path %}{{ canonical_path }}{% endblock %} -{% block jsonld %}{% endblock %} -{% block content %} - - -
-

{{ display }} climate

-

Average temperatures, precipitation and all-time records for {{ display }}, - with every day graded against ~{{ n_years }} years of local climate history - ({{ year_range[0] }}–{{ year_range[1] }}).{% if warmest and coldest %} Its warmest month is - {{ warmest.name }}, averaging a high of {{ warmest.high }}, and its coldest is - {{ coldest.name }}, with an average low of {{ coldest.low }}.{% endif %}

- - {% if flavor %} -

{{ flavor.extract }}{% if flavor.url %} - via Wikipedia{% endif %}

- {% endif %} - - {% if today %} -
-

How today compares

-

Latest recorded day: {{ today.date }}. Each reading placed on {{ display }}'s - own ±7-day seasonal distribution.

-
- {% for c in today.cards %} -
- {{ c.label }} - {{ c.value }} - {% if c.percentile is not none %}{{ c.grade }} · {{ c.percentile|ordinal }} pct - {% else %}{{ c.grade }}{% endif %} -
- {% endfor %} -
-

Open {{ name }} in the live weather grader →

-
- {% endif %} - - {% if event %} -
-

Notable weather in {{ name }}

-

{{ event.text }}{% if event.url %} Read more →{% endif %}

-
- {% endif %} - -
-

{{ name }} average temperatures by month

-

Typical daily high and low, and average precipitation, for each month in {{ display }}, - based on {{ year_range[0] }}–{{ year_range[1] }} records. - {% if wettest %}The wettest month is {{ wettest.name }} ({{ wettest.precip }} on an average day).{% endif %}

-
- - - - {% for m in months %} - - - - - - - {% endfor %} - -
MonthAvg highAvg lowAvg precip
{{ m.name }}{{ m.high }}{{ m.low }}{{ m.precip }}
-
- - -

Each bar spans the 10th-percentile daily low to the 90th-percentile daily - high (the range most days fall within), coloured cold → hot on a shared - {{ temp(-10) }} to {{ temp(115) }} scale, so the whole year's rhythm reads at a glance.

-
- -
-

Thinking of visiting {{ name }}?

-

Trips live and die by the weather. See how {{ name }}'s comfort stacks up against where - you live. {{ name }} is pre-loaded, just add your city.

-

Compare {{ name }}'s comfort with your city →

-
- - {% if records.tmax or records.tmin %} -
-

Record extremes

-
    - {% if records.tmax %}
  • Hottest day on record: {{ temp(records.tmax.max) }} on {{ records.tmax.max_date }}
  • {% endif %} - {% if records.tmin %}
  • Coldest day on record: {{ temp(records.tmin.min) }} on {{ records.tmin.min_date }}
  • {% endif %} -
-

All {{ name }} weather records →

-
- {% endif %} - -

Percentiles compare each day to {{ display }}'s own history, so grades are - relative to this location: a “Near Record” day here isn't the same temperature as elsewhere. - How the grades work →

-
-{% endblock %} diff --git a/templates/glossary.html.j2 b/templates/glossary.html.j2 deleted file mode 100644 index 65a7c71..0000000 --- a/templates/glossary.html.j2 +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "base.html.j2" %} -{% block title %}{{ page_title }}{% endblock %} -{% block description %}{{ page_description }}{% endblock %} -{% block canonical_path %}{{ canonical_path }}{% endblock %} -{% block content %} - -
-

Weather & climate glossary

-

Plain-language definitions of the terms Thermograph uses to grade the weather.

- {% for t in terms %} -
-

{{ t.term }}

-

{{ t.short }}

-
- {% endfor %} -
-{% endblock %} diff --git a/templates/glossary_term.html.j2 b/templates/glossary_term.html.j2 deleted file mode 100644 index e140efe..0000000 --- a/templates/glossary_term.html.j2 +++ /dev/null @@ -1,21 +0,0 @@ -{% extends "base.html.j2" %} -{% block title %}{{ page_title }}{% endblock %} -{% block description %}{{ page_description }}{% endblock %} -{% block canonical_path %}{{ canonical_path }}{% endblock %} -{% block jsonld %}{% endblock %} -{% block content %} - - -{% endblock %} diff --git a/templates/home.html.j2 b/templates/home.html.j2 deleted file mode 100644 index 7d7f88e..0000000 --- a/templates/home.html.j2 +++ /dev/null @@ -1,205 +0,0 @@ -{% extends "base.html.j2" %} -{# The Weekly view — the product itself — with the distribution surfaces built - around it. Everything structural is server-rendered so crawlers and no-JS - readers get a complete page; app.js hydrates the live numbers in place. - - The interactive controls below (#find-btn, #date-input, #panel, #results, …) - are app.js's DOM contract, carried over verbatim from the old static - frontend/index.html. Do not rename these ids. - - section / brand_tag / main_class come from the route's render context - (content.home_page), not a child-template {% set %} — the SEO pages already - pass `section` that way. #} - -{% block title %}Thermograph: how unusual is your weather?{% endblock %} -{% block description %}See how today's weather compares to decades of local climate history for any place on Earth. Daily high, low, feels-like, humidity, wind and rain, each graded by percentile.{% endblock %} -{% block canonical_path %}/{% endblock %} - -{% block head_extra %} - -{% endblock %} - -{% block jsonld %} - -{% endblock %} - -{% block content %} - {# --- Hero ------------------------------------------------------------ - The grade card is the LCP element: its frame is server-rendered and its - slots are sized here, so hydrating the live numbers causes no layout - shift. Band colour is always paired with the band word — colour is never - the only signal. #} -
-
-

How unusual is your weather?

-

Any day, anywhere on Earth, graded against 45 years of that place's own history.

-
- -
- {# --cat carries the band colour, the same convention .normal-card uses, - so the band token stays the single source of truth. #} -
-

- {%- if unusual -%} - Today in {{ unusual.display }} - {%- else -%} - Today - {%- endif -%} -

-

{{ unusual.grade if unusual else '—' }}

-

- {%- if unusual -%} - {{ unusual.metric_label }} in the {{ unusual.percentile|ordinal }} percentile for {{ unusual.window_label }} - {%- else -%} - Pick a place to see how unusual its weather is today. - {%- endif -%} -

- {% if unusual and unusual.is_default %} -

the most unusual weather we're tracking{% if stale %} · as of {{ unusual.date }}{% endif %}

- {% endif %} -
-
- {# app.js hides the locate button outside a secure context, where the - browser refuses geolocation and it could only ever fail. #} - - -
- -

See your week ↓

-
-
- -
-
- - -
-
- - - What do the grades mean? → -
-
- -
-
-

Find a location to begin.

-

Thermograph fetches decades of daily highs, lows, and precipitation - for your ~4 sq mi cell, builds a ±7-day seasonal distribution for each - day of the year, and grades recent weather by where it falls on that distribution.

-
- -
- - {# Sits outside #panel on purpose: app.js rewrites results.innerHTML - wholesale on every grade, so anything inside it would be destroyed. #} -

- Free. No ads. No tracking. No account needed. - How we work → -

- - {# --- Unusual right now ---------------------------------------------- - Scroll-snap row, no JS carousel. Both tails are represented whenever a - cold-tail city qualifies — two-sided honesty is product identity. #} - {% if ranked %} -
-

Unusual right now

- -

See all records →

-
- {% endif %} - - {# --- How it works ---------------------------------------------------- #} -
-

How it works

-
    -
  1. We keep 45 years of climate history (ERA5 reanalysis) for your exact ~2-mile grid square.
  2. -
  3. Today gets compared to the same two weeks of the year, every year back to 1980.
  4. -
  5. The result is a percentile and a plain-language grade, from Near Record cold to Near Record hot.
  6. -
-

- Full methodology → - · Data: ERA5 via Open-Meteo · NASA POWER · MET Norway -

-
- - {# --- Explore --------------------------------------------------------- #} -
-

Explore

- {# Each card takes a colour from the grade ramp rather than a decorative - palette, so the section reads as part of the product's own scale. - Calendar carries the ramp itself — it IS the heatmap. #} - -
- - {# --- City hub links -------------------------------------------------- - Real HTML links funnelling homepage authority into the ~1000-page - /climate surface. #} -
-

Climate context for 1,000 cities

- -

Browse all cities →

-
-{% endblock %} - -{% block body_scripts %} - - - {# The records strip renders real temperatures server-side as .temp spans, so - it needs the same converter the SEO pages use or they'd stay in °F while - the toggle says °C. Both import units.js, which the module loader dedupes. #} - -{% endblock %} diff --git a/templates/hub.html.j2 b/templates/hub.html.j2 deleted file mode 100644 index 9352b66..0000000 --- a/templates/hub.html.j2 +++ /dev/null @@ -1,89 +0,0 @@ -{% extends "base.html.j2" %} -{% block title %}{{ page_title }}{% endblock %} -{% block description %}{{ page_description }}{% endblock %} -{% block canonical_path %}{{ canonical_path }}{% endblock %} -{% block content %} - -
-

City climates

-

Average temperatures by month, all-time records, and how today compares, for - {{ n_cities }} cities across {{ n_countries }} countries. Every day is graded against that - location's own ~45 years of climate history.

- - - - -
- {% for country, cs in groups.items() %} -
- {{ country }} {{ cs | length }} - -
- {% endfor %} -
-
- -{% raw %} - -{% endraw %} -{% endblock %} diff --git a/templates/month.html.j2 b/templates/month.html.j2 deleted file mode 100644 index 6af2118..0000000 --- a/templates/month.html.j2 +++ /dev/null @@ -1,63 +0,0 @@ -{% extends "base.html.j2" %} -{% block title %}{{ page_title }}{% endblock %} -{% block description %}{{ page_description }}{% endblock %} -{% block canonical_path %}{{ canonical_path }}{% endblock %} -{% block content %} - - -
-

Weather in {{ display }} in {{ month_name }}

-

In an average {{ month_name }}, {{ name }} sees a daily high around - {{ avg_high }} and a low around - {{ avg_low }}, based on - {{ year_range[0] }}–{{ year_range[1] }} of local climate records.

- - - - {% for label, value in stats %} - - {% endfor %} - -
{{ label }}{{ value }}
- - {% if records %} -

{{ month_name }} records

-

The most extreme {{ month_name }} on record for each metric, across - {{ year_range[0] }}–{{ year_range[1] }}. For rainfall, the wettest and driest are by - total {{ month_name }} accumulation.

-
- {% for r in records %} -
-

{{ r.label }}

-
- {{ r.high_tag }} - {{ r.high }} - {{ r.high_date }} -
-
- {{ r.low_tag }} - {{ r.low }} - {{ r.low_date }} -
-
- {% endfor %} -
- {% endif %} - -

See {{ name }}'s live weather grade →

- -
-

Visiting {{ name }} in {{ month_name }}? See how its comfort stacks up against - where you live.

- Compare {{ name }} with your city → -
- -

- ‹ {{ prev.name }} · - {{ name }} climate overview · - {{ next.name }} › -

-
-{% endblock %} diff --git a/templates/privacy.html.j2 b/templates/privacy.html.j2 deleted file mode 100644 index 82a6e91..0000000 --- a/templates/privacy.html.j2 +++ /dev/null @@ -1,39 +0,0 @@ -{% extends "base.html.j2" %} -{% block title %}{{ page_title }}{% endblock %} -{% block description %}{{ page_description }}{% endblock %} -{% block canonical_path %}{{ canonical_path }}{% endblock %} -{% block content %} - -
-

Privacy

-

Thermograph is free, has no ads, and needs no account. It is built so that - there is very little about you to collect in the first place.

- -

Your location

-

Thermograph never looks up your location from your IP address. The only way the site - learns where you are is if you press Use my location, which asks your browser for - permission. You can decline, and picking a place on the map works just as well.

-

Whichever way you choose a place, the coordinates are sent to the server only as part of - the ordinary request for that grid square's climate data, and are not logged beyond the - normal web-server request handling every site does. The place you picked is remembered in - your own browser's local storage, not on the server, so clearing your browser data - forgets it.

- -

Analytics

-

There is no analytics library, no cookies for tracking, and no per-visitor identifier. - The server keeps simple aggregate counts (how many people opened a page or tapped a - button, and which site referred them) with nothing tying any of it to an individual.

- -

Email

-

If you sign up for the monthly digest, your address is used for that digest and nothing - else. It is never sold or shared, and every message can unsubscribe you.

- -

Accounts

-

An account is optional and only exists to hold weather alerts you have asked for. It - stores your email address and the places you are watching.

- -

Questions: see About & methodology.

-
-{% endblock %} diff --git a/templates/records.html.j2 b/templates/records.html.j2 deleted file mode 100644 index eb53b61..0000000 --- a/templates/records.html.j2 +++ /dev/null @@ -1,96 +0,0 @@ -{% extends "base.html.j2" %} -{% block title %}{{ page_title }}{% endblock %} -{% block description %}{{ page_description }}{% endblock %} -{% block canonical_path %}{{ canonical_path }}{% endblock %} -{% block jsonld %}{% endblock %} -{% block content %} -{# A metric's two extremes — ▲ warmest and ▼ coldest it has ever been — as tinted - chips with the date each occurred. Used for the daytime-high and overnight-low - columns of the month and season tables. #} -{% macro extremes(metric) %} -{% if metric and metric.warm and metric.cold %} -
{{ metric.warm.txt }}{{ metric.warm.date }}
-
{{ metric.cold.txt }}{{ metric.cold.date }}
-{% else %}—{% endif %} -{% endmacro %} - - -
-

{{ display }} weather records

-

Record high and low temperatures for {{ display }}, by month, by season, and - all-time, with the dates they occurred, across {{ year_range[0] }}–{{ year_range[1] }} of daily - climate history.{% if all_time.tmax and all_time.tmin %} Its hottest day on record reached - {{ temp(all_time.tmax.max) }} on {{ all_time.tmax.max_date }}; its coldest fell to - {{ temp(all_time.tmin.min) }} on {{ all_time.tmin.min_date }}.{% endif %}

- -
-

Records by month

-

The warmest (▲) and coldest (▼) both the daytime high and the overnight low have ever been in - each calendar month, so each column shows its record high and its record low. Tap a - month for its full averages and typical range.

-
- - - - {% for m in monthly %} - - - - - - {% endfor %} - -
MonthDaytime highOvernight low
{{ m.name }}{{ extremes(m.high) }}{{ extremes(m.low) }}
-
-
- -
-

Records by season

-

The warmest (▲) and coldest (▼) both the daytime high and the overnight low have reached in each - meteorological season ({{ hemisphere }} Hemisphere, each a three-month span).

-
- - - - {% for s in seasonal %} - - - - - - {% endfor %} - -
SeasonDaytime highOvernight low
{{ s.name }} ({{ s.span }}){{ extremes(s.high) }}{{ extremes(s.low) }}
-
-
- -
-

All-time records

-

The most extreme value {{ name }} has recorded for each metric across its entire history.

-
- {% for r in rows %} -
-

{{ r.label }}

-
- {% if r.label == 'Precip' %}Wettest{% else %}Highest{% endif %} - {{ r.high }} - {{ r.high_date }} -
-
- {% if r.label == 'Precip' %}Driest{% else %}Lowest{% endif %} - {{ r.low }} - {{ r.low_date }} -
-
- {% endfor %} -
-

For rainfall, the “driest” figure is the longest run of consecutive - days without measurable rain, dated to when that dry spell began.

-
- -

Grade {{ name }}'s weather now →

-

‹ Back to {{ name }} climate

-
-{% endblock %} diff --git a/tests/web/test_api.py b/tests/web/test_api.py index 031f169..dbea466 100644 --- a/tests/web/test_api.py +++ b/tests/web/test_api.py @@ -185,18 +185,6 @@ def test_suggest_falls_back_to_upstream_geocoder(client, monkeypatch): assert body["corrected"] is None -def test_pages_serve_with_origin_filled_in(client): - """/ moved to frontend_ssr (repo-split Stage 4) -- it renders via real Jinja - template vars now, not a __ORIGIN__ string substitution, so there's nothing - left to check about it here. /calendar is still a _page()-served static - shell with the substitution, unaffected by the split.""" - r = client.get("/thermograph/calendar") - assert r.status_code == 200 - assert "text/html" in r.headers["content-type"] - assert "__ORIGIN__" not in r.text - assert client.head("/thermograph/calendar").status_code == 200 - - def test_cell_neighbors_flag_enqueues_warming(client, monkeypatch): from web import app as appmod monkeypatch.setattr(appmod, "_WARM_SEEN", {}) diff --git a/tests/web/test_homepage.py b/tests/web/test_homepage.py index 621e5c4..ac52db0 100644 --- a/tests/web/test_homepage.py +++ b/tests/web/test_homepage.py @@ -11,14 +11,10 @@ import datetime import json import time -import pytest - from api import homepage from data import climate from data import store -B = "/thermograph" - # --- the precompute ---------------------------------------------------------- def test_build_never_fetches_upstream(monkeypatch): @@ -179,17 +175,3 @@ def test_staleness(monkeypatch): assert not homepage.is_stale({"date": today, "generated_at": time.time()}) assert homepage.is_stale({"date": today, "generated_at": time.time() - 7 * 3600}) assert homepage.is_stale({"date": "1999-01-01", "generated_at": time.time()}) - - -# --- static frontend, unaffected by the SSR content split -------------------- -@pytest.fixture -def client(): - from fastapi.testclient import TestClient - from web import app as appmod - return TestClient(appmod.app) - - -def test_old_static_index_is_gone(client): - """index.html must not linger behind the static mount as indexable duplicate - content now that / is server-rendered (by frontend_ssr).""" - assert client.get(f"{B}/index.html").status_code == 404 diff --git a/web/app.py b/web/app.py index 70d4027..b20d449 100644 --- a/web/app.py +++ b/web/app.py @@ -5,20 +5,16 @@ import functools import hashlib import hmac import json -import mimetypes import os import queue import threading import time -import html as html_escape - import httpx from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import RedirectResponse -from fastapi.staticfiles import StaticFiles from accounts import api_accounts from api import content_routes @@ -40,8 +36,6 @@ from accounts import users from api import payloads from accounts.schemas import UserCreate, UserRead, UserUpdate -FRONTEND_DIR = paths.FRONTEND_DIR - # Everything (pages, assets, API) is served under this base path so the app can # live at https:///thermograph/ behind a shared domain. Override with the # THERMOGRAPH_BASE env var. The frontend uses base-relative URLs, so it follows @@ -70,10 +64,6 @@ _frontend_client = httpx.AsyncClient(base_url=FRONTEND_BASE, timeout=10.0) # every other env-driven constant here (BASE, WORKERS, ...). ROLE = os.environ.get("THERMOGRAPH_ROLE", "all").strip().lower() -# The PWA manifest is served as a static file; register its media type so -# StaticFiles labels it correctly (not in Python's default mimetypes map). -mimetypes.add_type("application/manifest+json", ".webmanifest") - def _weather_fetch_error(e) -> HTTPException: """A clean, retryable 503 when weather data is temporarily unavailable; the @@ -792,33 +782,11 @@ async def _proxy_to_frontend(request: Request) -> Response: return Response(content=upstream.content, status_code=upstream.status_code, headers=resp_headers) -# The exact path list content.register() used to own -- see backend/web/app.py's -# git history (repo-split Stage 3) for the Jinja implementation, now in -# frontend_ssr/content.py. /climate/{slug}/records must be registered before -# the {month} param route so the literal path wins, same ordering content.py -# itself used. -_FRONTEND_ROUTES = [ - f"{BASE}/", - f"{BASE}/about", - f"{BASE}/privacy", - f"{BASE}/glossary", - f"{BASE}/glossary/{{term}}", - f"{BASE}/climate", - f"{BASE}/climate/{{slug}}/records", - f"{BASE}/climate/{{slug}}/{{month}}", - f"{BASE}/climate/{{slug}}", - f"{BASE}/robots.txt", - f"{BASE}/sitemap.xml", -] -for _frontend_path in _FRONTEND_ROUTES: - app.add_api_route(_frontend_path, _proxy_to_frontend, methods=["GET", "HEAD"], include_in_schema=False) - -# The IndexNow ownership-verification key file, at a dynamic path fixed at -# startup -- mirrors how content.register() used to register it. -import indexnow # noqa: E402 - lazy, same as content.register() did - -app.add_api_route(f"{BASE}/{indexnow.key()}.txt", _proxy_to_frontend, - methods=["GET", "HEAD"], include_in_schema=False) +# The catch-all fallback registered at the very end of this file (after every +# backend route) proxies everything else -- content pages, the IndexNow key +# file, static assets, the SPA shells -- to frontend. See it there for why a +# single fallback replaced what used to be an enumerated path list (repo-split +# Stage 7a). # --- Discord slash commands (HTTP interactions) ---------------------------- @@ -863,73 +831,31 @@ app.include_router(api_accounts.router, prefix=f"{BASE}/api/v2") app.include_router(discord_link.router, prefix=f"{BASE}/api/v2") -# --- static frontend (served under BASE) ----------------------------------- -def head_verify_html() -> str: - """Search-engine ownership-verification tags, from env (empty when - unset). Injected into every _page()-served page's — Google verifies - the homepage, which frontend_ssr's own copy of this same logic handles for - the SSR content pages.""" - metas = [] - google = os.environ.get("THERMOGRAPH_GOOGLE_VERIFY", "").strip() - bing = os.environ.get("THERMOGRAPH_BING_VERIFY", "").strip() - if google: - metas.append(f'') - if bing: - metas.append(f'') - return "\n ".join(metas) - - -def _page(name): - """Route handler for one HTML page. Serves the file with its __ORIGIN__ - placeholders (the link-preview/Open Graph tags) filled in as the request's - scheme://host + BASE — preview crawlers (Discord, Slack, …) need absolute - URLs, and the host differs between LAN and prod. The scheme comes from - X-Forwarded-Proto when a reverse proxy (Caddy) fronts the plain-HTTP - uvicorn; the proxy passes the original Host header through untouched.""" - path = os.path.join(FRONTEND_DIR, name) - - def route(request: Request): - with open(path, encoding="utf-8") as f: - html = f.read() - proto = request.headers.get("x-forwarded-proto") or request.url.scheme - host = request.headers.get("host") or request.url.netloc - html = html.replace("__ORIGIN__", f"{proto}://{host}{BASE}") - # Search-engine verification tags (same source as the SEO pages), so - # the homepage Google/Bing verify against carries them too. - verify = head_verify_html() - if verify: - html = html.replace("", f"\n {verify}", 1) - etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"' - if _not_modified(request, etag): - return Response(status_code=304, headers={"ETag": etag}) - return Response(html, media_type="text/html", headers={"ETag": etag}) - return route - - # The un-slashed base redirects to BASE/ so the frontend's relative asset URLs # resolve correctly. Under a sub-path this keeps the app scoped to BASE (never # claiming "/", so the domain root stays free for another app via the proxy). When # BASE is "" the app owns the root: "/" is already the index route below, so there # is no bare base to redirect (and "" is not a valid route path). -# Pages also answer HEAD — link-preview crawlers probe with it before fetching. if BASE: app.add_api_route(BASE, lambda: RedirectResponse(url=f"{BASE}/"), methods=["GET", "HEAD"], include_in_schema=False) -# NOTE: "/" is not here — the homepage (and the other crawlable SSR pages) are -# proxied to the frontend_ssr service, registered above (_FRONTEND_ROUTES). -app.add_api_route(f"{BASE}/calendar", _page("calendar.html"), methods=["GET", "HEAD"], include_in_schema=False) -app.add_api_route(f"{BASE}/day", _page("day.html"), methods=["GET", "HEAD"], include_in_schema=False) -app.add_api_route(f"{BASE}/score", _page("score.html"), methods=["GET", "HEAD"], include_in_schema=False) -app.add_api_route(f"{BASE}/compare", _page("compare.html"), methods=["GET", "HEAD"], include_in_schema=False) -app.add_api_route(f"{BASE}/legend", _page("legend.html"), methods=["GET", "HEAD"], include_in_schema=False) -app.add_api_route(f"{BASE}/alerts", _page("subscriptions.html"), methods=["GET", "HEAD"], include_in_schema=False) # Monthly-digest signup (the footer form on every page). Answers the same generic # message whatever the outcome, so it can't be probed for whether an address is # already subscribed. app.add_api_route(f"{BASE}/digest", digest.signup, methods=["POST"], include_in_schema=False) -# Everything else under BASE (app.js, style.css, nav.js, …) is a static asset. -# Registered last so the explicit page routes above win. Mounts must start with -# "/", so at the root (BASE == "") mount at "/" — the earlier routes still win -# because Starlette matches in registration order. -app.mount(BASE or "/", StaticFiles(directory=FRONTEND_DIR), name="static") +# --- everything else: proxy to frontend (repo-split Stage 7a) --------------- +# Frontend now owns every page (the SSR content pages, and the interactive +# tool's SPA shells), every static asset (app.js, style.css, manifest, +# favicons, …), and the dynamic IndexNow key file -- backend serves none of +# that itself anymore (it used to read them from a sibling frontend/ +# directory; see _proxy_to_frontend's own docstring for why that stops being +# true once backend and frontend are separate repos). A single catch-all, +# registered dead last so every real backend route above already won, proxies +# anything unmatched -- simpler than (and a superset of) the enumerated path +# list this replaced, since static asset filenames aren't enumerable the way +# content pages are. Caddy path-splits directly to whichever service owns a +# path in prod/beta (deploy/Caddyfile), so this is only ever exercised as the +# fallback for environments with no Caddy in front (LAN dev, bare-metal +# run.sh) -- same role _proxy_to_frontend has always had. +app.add_api_route(f"{BASE}/{{path:path}}", _proxy_to_frontend, methods=["GET", "HEAD"], include_in_schema=False)