Compare commits

..
3 Commits
6 changed files with 65 additions and 36 deletions
+2 -2
View File
@@ -73,10 +73,10 @@ Each `Client` record (shared by every event, keyed by hash):
A reverse-DNS lookup is attempted for each new client and the result, when A reverse-DNS lookup is attempted for each new client and the result, when
available, is stored as `host`; local/reserved/multicast addresses are available, is stored as `host`; local/reserved/multicast addresses are
skipped. If a DB-IP MMDB file (`dbip-*.mmdb` or `dbip-*.mmdb.gz`) is present skipped. If a DB-IP MMDB file (`dbip-*.mmdb` or `dbip-*.mmdb.gz`) is present
in the repository root, it is loaded at startup and used to look up in the working directory, it is loaded at startup and used to look up
`country`/`city`. These lookups run in background tasks after the event is `country`/`city`. These lookups run in background tasks after the event is
stored, so WebSocket message handling is never delayed. The decompressed stored, so WebSocket message handling is never delayed. The decompressed
`dbip-*.mmdb` file is kept in the repository root and ignored by git. The `dbip-*.mmdb` file is kept in the working directory and ignored by git. The
CLI flag `--dbip` (`uv run pagerite --dbip`) downloads the latest CLI flag `--dbip` (`uv run pagerite --dbip`) downloads the latest
`dbip-city-lite-YYYY-MM.mmdb.gz` from DB-IP at startup (in the app lifespan, `dbip-city-lite-YYYY-MM.mmdb.gz` from DB-IP at startup (in the app lifespan,
before the MMDB is opened), skipping the download when the local database is before the MMDB is opened), skipping the download when the local database is
+3 -1
View File
@@ -106,7 +106,9 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`).
language like content pages, but over the **subtree's** combined language like content pages, but over the **subtree's** combined
availability (`subtree_languages`) — they have no chunks of their own; availability (`subtree_languages`) — they have no chunks of their own;
the heading, navigation and card text localize from the title map and the heading, navigation and card text localize from the title map and
the target articles' translations. the target articles' translations. Their hreflang alternates are
computed exactly like a content page's (a translated title counts as
availability, so the language selector is offered there too).
- Card descriptions and cover picks run on the target article's hybrid - Card descriptions and cover picks run on the target article's hybrid
Markdown where that page is available in the served language, with Markdown where that page is available in the served language, with
per-card fallback to the original. per-card fallback to the original.
+1
View File
@@ -132,6 +132,7 @@ def _render_html(
data.theme, data.theme,
data.favicon, data.favicon,
data.brand_html, data.brand_html,
base_url,
transition=data.transition, transition=data.transition,
lang=lang, lang=lang,
translation=translation, translation=translation,
+9 -8
View File
@@ -44,8 +44,9 @@ _analytics_ws_clients: set[WebSocket] = set()
_analytics_broadcast_task: asyncio.Task | None = None _analytics_broadcast_task: asyncio.Task | None = None
# Repository root from this file's location (pagerite/tracking.py -> ..). # DB-IP databases persist in the working directory (one download serves all
_REPO_ROOT = Path(__file__).resolve().parent.parent # sites run from it). Not the package directory: reinstalls/upgrades wipe it.
_DBIP_DIR = Path.cwd()
DBIP_URL = "https://download.db-ip.com/free/dbip-city-lite-{month}.mmdb.gz" DBIP_URL = "https://download.db-ip.com/free/dbip-city-lite-{month}.mmdb.gz"
@@ -60,7 +61,7 @@ def _download_dbip() -> None:
existing = sorted( existing = sorted(
p.stem.removeprefix("dbip-city-lite-").removesuffix(".mmdb") p.stem.removeprefix("dbip-city-lite-").removesuffix(".mmdb")
for p in _REPO_ROOT.glob("dbip-city-lite-*.mmdb*") for p in _DBIP_DIR.glob("dbip-city-lite-*.mmdb*")
) )
if existing and existing[-1] >= months[0]: if existing and existing[-1] >= months[0]:
logger.info("DB-IP database is current (%s), skipping download", existing[-1]) logger.info("DB-IP database is current (%s), skipping download", existing[-1])
@@ -68,7 +69,7 @@ def _download_dbip() -> None:
for month in months: for month in months:
url = DBIP_URL.format(month=month) url = DBIP_URL.format(month=month)
target = _REPO_ROOT / f"dbip-city-lite-{month}.mmdb.gz" target = _DBIP_DIR / f"dbip-city-lite-{month}.mmdb.gz"
tmp = target.with_suffix(".mmdb.gz.tmp") tmp = target.with_suffix(".mmdb.gz.tmp")
logger.info("Downloading %s", url) logger.info("Downloading %s", url)
try: try:
@@ -93,7 +94,7 @@ def _download_dbip() -> None:
continue continue
os.replace(tmp, target) os.replace(tmp, target)
# Drop older databases so the app never picks up a stale one. # Drop older databases so the app never picks up a stale one.
for old in _REPO_ROOT.glob("dbip-city-lite-*.mmdb*"): for old in _DBIP_DIR.glob("dbip-city-lite-*.mmdb*"):
if old.name != target.name: if old.name != target.name:
old.unlink() old.unlink()
logger.info("DB-IP database updated to %s", target.name) logger.info("DB-IP database updated to %s", target.name)
@@ -102,13 +103,13 @@ def _download_dbip() -> None:
def _geoip_db_path() -> Path | None: def _geoip_db_path() -> Path | None:
"""Find a DB-IP MMDB in the repo root, preferring an already-decompressed """Find a DB-IP MMDB in the working directory, preferring an already-decompressed
``.mmdb`` over the matching ``.mmdb.gz``. Returns None if none is present. ``.mmdb`` over the matching ``.mmdb.gz``. Returns None if none is present.
""" """
mmdb = sorted(_REPO_ROOT.glob("dbip-*.mmdb")) mmdb = sorted(_DBIP_DIR.glob("dbip-*.mmdb"))
if mmdb: if mmdb:
return mmdb[0] return mmdb[0]
gz = sorted(_REPO_ROOT.glob("dbip-*.mmdb.gz")) gz = sorted(_DBIP_DIR.glob("dbip-*.mmdb.gz"))
if gz: if gz:
return gz[0] return gz[0]
return None return None
+8 -5
View File
@@ -101,7 +101,8 @@ ClientMsg = Hello | Result
def pending_items(data: Data, lang: str) -> list[TransItem]: def pending_items(data: Data, lang: str) -> list[TransItem]:
"""Fragments of the site still untranslated for ``lang``, deduped by key. """Fragments of the site still untranslated for ``lang``, deduped by key.
Every page node (published or not) contributes its title and each chunk Every node (published or not, pages and pure category labels alike)
contributes its title; pages also contribute each chunk
that needs translation (``needs_translation``), is not editor-flagged that needs translation (``needs_translation``), is not editor-flagged
no-translate (``node.no_trans``) and has no ``trans`` entry for ``lang`` no-translate (``node.no_trans``) and has no ``trans`` entry for ``lang``
yet. Content-addressed text (shared paragraphs, repeated titles) appears yet. Content-addressed text (shared paragraphs, repeated titles) appears
@@ -133,8 +134,10 @@ def pending_items(data: Data, lang: str) -> list[TransItem]:
path = f"{prefix}/{slug}" if prefix else slug path = f"{prefix}/{slug}" if prefix else slug
# An article whose primary language IS the target needs no # An article whose primary language IS the target needs no
# translation into it — skip its title and chunks entirely. # translation into it — skip its title and chunks entirely.
# Category labels (chunks is None) contribute only their title:
# it is their nav-menu label.
node_lang = node.language or inherited node_lang = node.language or inherited
if node.chunks is not None and node_lang != lang: if node_lang != lang:
if node.title: if node.title:
emit( emit(
chunk_key(node.title), chunk_key(node.title),
@@ -143,7 +146,7 @@ def pending_items(data: Data, lang: str) -> list[TransItem]:
"title", "title",
context=opening(node), context=opening(node),
) )
for h in node.chunks: for h in node.chunks or ():
text = data.chunks.get(h) text = data.chunks.get(h)
if ( if (
text is not None text is not None
@@ -178,8 +181,8 @@ def store_results(data: Data, lang: str, items: list[TransResult]) -> list[str]:
for slug, node in sorted_nodes(nodes): for slug, node in sorted_nodes(nodes):
path = f"{prefix}/{slug}" if prefix else slug path = f"{prefix}/{slug}" if prefix else slug
node_lang = node.language or inherited node_lang = node.language or inherited
if node.chunks is not None and node_lang != lang: if node_lang != lang:
keys = set(node.chunks) keys = set(node.chunks or ())
if node.title: if node.title:
keys.add(chunk_key(node.title)) keys.add(chunk_key(node.title))
if keys & stored: if keys & stored:
+42 -20
View File
@@ -1021,6 +1021,39 @@ def _social_meta(
} }
def _language_urls(
data: Data,
path: str,
node: Node,
lang: str,
original: str,
base_url: str,
) -> tuple[str, list[tuple[str, str]]]:
"""(canonical, hreflang alternates) for a page (docs/localization.md).
The canonical names the actually served language — the plain URL for
the original (for SEO the non-query URL means the article's language),
?lang= for a translation — regardless of how the language was arrived
at (query or header). The alternates list the languages the page is
actually available in (``node.langs``; a category label's title counts
as its content): x-default first (the plain, autodetecting URL), then
every available language — the original again by its plain URL,
translations by ?lang=. The public language selector keys off these.
("", []) without a base_url.
"""
if not base_url:
return "", []
url = f"{base_url}/{path}"
canonical = url if lang == original else f"{url}?lang={lang}"
alternates = []
if data.translate_langs:
alternates = [("x-default", url)] + [
(tag, url if tag == original else f"{url}?lang={tag}")
for tag in sorted({original, *node.langs})
]
return canonical, alternates
def render_page( def render_page(
menu: dict[str, Node], menu: dict[str, Node],
data: Data, data: Data,
@@ -1050,24 +1083,7 @@ def render_page(
title = _title(path.rpartition("/")[2], node, translation, path) title = _title(path.rpartition("/")[2], node, translation, path)
main = page_content(menu, data, path, translation, link_lang, lang) main = page_content(menu, data, path, translation, link_lang, lang)
social = _social_meta(node, path, title, str(main), brand, base_url) social = _social_meta(node, path, title, str(main), brand, base_url)
# Canonical/hreflang URLs (docs/localization.md): the canonical names canonical, alternates = _language_urls(data, path, node, lang, original, base_url)
# the actually served language — the plain URL for the original (for
# SEO the non-query URL means the article's language), ?lang= for a
# translation — regardless of how the language was arrived at (query
# or header). The alternates list the languages the page is actually
# available in: x-default first (the plain, autodetecting URL), then
# every available language — the original again by its plain URL,
# translations by ?lang=. The public language selector keys off these.
canonical = ""
alternates = []
if base_url:
url = f"{base_url}/{path}"
canonical = url if lang == original else f"{url}?lang={lang}"
if data.translate_langs:
alternates = [("x-default", url)] + [
(tag, url if tag == original else f"{url}?lang={tag}")
for tag in sorted({original, *node.langs})
]
return str( return str(
_layout( _layout(
*_page_assets(), *_page_assets(),
@@ -1100,6 +1116,7 @@ def render_category(
theme: str = "", theme: str = "",
favicon: str = "", favicon: str = "",
brand_html: str = "", brand_html: str = "",
base_url: str = "",
transition: str = "cube", transition: str = "cube",
lang: str = i18n.ORIGINAL_LANGUAGE, lang: str = i18n.ORIGINAL_LANGUAGE,
translation: Translation | None = None, translation: Translation | None = None,
@@ -1115,12 +1132,16 @@ def render_category(
With a translation (titles only — the category has no Markdown) the With a translation (titles only — the category has no Markdown) the
heading, navigation and card text localize per target article heading, navigation and card text localize per target article
(docs/localization.md); ``link_lang`` replicates the ?lang= override (docs/localization.md); ``link_lang`` replicates the ?lang= override
onto the navigation links as on content pages. onto the navigation links as on content pages. The hreflang alternates
are computed as on content pages — a translated title makes the
language available here too.
""" """
node = resolve(menu, path)[-1] node = resolve(menu, path)[-1]
original = i18n.primary_lang(menu, path)
if translation is None: if translation is None:
lang = i18n.primary_lang(menu, path) lang = original
title = _title(path.rpartition("/")[2], node, translation, path) title = _title(path.rpartition("/")[2], node, translation, path)
_, alternates = _language_urls(data, path, node, lang, original, base_url)
doc = E.article doc = E.article
with doc: with doc:
doc.h1(title) doc.h1(title)
@@ -1137,6 +1158,7 @@ def render_category(
transition, transition,
favicon, favicon,
lang=lang, lang=lang,
alternates=alternates,
)( )(
Title=f"{title} {brand}" if brand else title, Title=f"{title} {brand}" if brand else title,
Brand=_brand_link(brand, brand_html, link_lang), Brand=_brand_link(brand, brand_html, link_lang),