Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b57b7060ec | ||
|
|
3f27a0a292 | ||
|
|
b30d909a23 | ||
|
|
13fecd2118 | ||
|
|
11a138e19f | ||
|
|
ebd5911a38 |
+4
-3
@@ -73,10 +73,11 @@ 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. Only the downloaded
|
||||||
`dbip-*.mmdb` file is kept in the repository root and ignored by git. The
|
`.mmdb.gz` is kept on disk (in the working directory, ignored by git); it is
|
||||||
|
decompressed into RAM when opened. 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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -144,7 +146,11 @@ served Markdown at render time.
|
|||||||
|
|
||||||
`chunk_markdown(markdown)` splits the source into block-level chunks —
|
`chunk_markdown(markdown)` splits the source into block-level chunks —
|
||||||
blank-line-separated blocks: headings, paragraphs, code fences (kept whole),
|
blank-line-separated blocks: headings, paragraphs, code fences (kept whole),
|
||||||
list blocks, tables, HTML blocks. A chunk's identity is its **source text**,
|
list blocks, tables, HTML blocks. Container fence lines (`::: name` openers
|
||||||
|
and `:::` closers) are always their own chunk, blank lines or not — folded
|
||||||
|
into a prose chunk the closer would cross to the translator as part of the
|
||||||
|
text, where the model can drop it (the rest of the page then renders inside
|
||||||
|
the container). A chunk's identity is its **source text**,
|
||||||
gettext-msgid style:
|
gettext-msgid style:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
@@ -9,23 +9,28 @@
|
|||||||
// store and re-renders it).
|
// store and re-renders it).
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import LangSelect from './LangSelect.vue'
|
import LangSelect from './LangSelect.vue'
|
||||||
import { flagFor, langName } from './langs'
|
import { flagFor, langName, langSort } from './langs'
|
||||||
import { useStore } from './store'
|
import { useStore } from './store'
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
|
||||||
// The "(primary)" marker is admin-panel information; the public selector
|
// The "(primary)" marker is admin-panel information; the public selector
|
||||||
// lists plain languages.
|
// lists plain languages. Order: the primary language first, then the rest
|
||||||
const options = computed(() =>
|
// in the lang tab's geographic grouping (./langs langSort) — the head's
|
||||||
store.langAlternates.map((a) => ({
|
// hreflang order is just alphabetical.
|
||||||
tag: a.tag,
|
|
||||||
code: a.tag,
|
|
||||||
name: langName(a.tag),
|
|
||||||
flag: flagFor(a.tag),
|
|
||||||
primary: false,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
const primaryTag = computed(() => store.langAlternates.find((a) => a.primary)?.tag ?? '')
|
const primaryTag = computed(() => store.langAlternates.find((a) => a.primary)?.tag ?? '')
|
||||||
|
const options = computed(() => {
|
||||||
|
const rest = langSort(
|
||||||
|
store.langAlternates.map((a) => a.tag).filter((t) => t !== primaryTag.value),
|
||||||
|
)
|
||||||
|
return [primaryTag.value, ...rest].filter(Boolean).map((tag) => ({
|
||||||
|
tag,
|
||||||
|
code: tag,
|
||||||
|
name: langName(tag),
|
||||||
|
flag: flagFor(tag),
|
||||||
|
primary: false,
|
||||||
|
}))
|
||||||
|
})
|
||||||
// The explicit pick, else the served language (header-autodetected pages
|
// The explicit pick, else the served language (header-autodetected pages
|
||||||
// may have neither), else the primary.
|
// may have neither), else the primary.
|
||||||
const model = computed(() => store.lang || store.servedLang || primaryTag.value)
|
const model = computed(() => store.lang || store.servedLang || primaryTag.value)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import { keymap } from '@codemirror/view'
|
|||||||
import { indentWithTab } from '@codemirror/commands'
|
import { indentWithTab } from '@codemirror/commands'
|
||||||
import { markdown } from '@codemirror/lang-markdown'
|
import { markdown } from '@codemirror/lang-markdown'
|
||||||
import { cmHighlight, cmTheme } from './cmtheme'
|
import { cmHighlight, cmTheme } from './cmtheme'
|
||||||
import { flagFor, langName } from './langs'
|
import { flagFor, langName, langSort } from './langs'
|
||||||
import { editorLang, pagePrimary } from './editorLang'
|
import { editorLang, pagePrimary } from './editorLang'
|
||||||
import LangSelect from './LangSelect.vue'
|
import LangSelect from './LangSelect.vue'
|
||||||
import ConnNote from './ConnNote.vue'
|
import ConnNote from './ConnNote.vue'
|
||||||
@@ -121,11 +121,13 @@ function normPath(p) {
|
|||||||
// localization settings tab).
|
// localization settings tab).
|
||||||
|
|
||||||
// The picker's options: the primary language first, then the union of the
|
// The picker's options: the primary language first, then the union of the
|
||||||
// page's translations and the site-wide configured targets, sorted.
|
// page's translations and the site-wide configured targets in the lang
|
||||||
|
// tab's geographic grouping (./langs langSort).
|
||||||
const langOptions = computed(() => {
|
const langOptions = computed(() => {
|
||||||
const others = [...new Set([...siteLangs.value, ...pageLangs.value])]
|
const others = langSort(
|
||||||
.filter((l) => l && l !== primaryLang.value)
|
[...new Set([...siteLangs.value, ...pageLangs.value])]
|
||||||
.sort()
|
.filter((l) => l && l !== primaryLang.value),
|
||||||
|
)
|
||||||
return [primaryLang.value, ...others].map((code) => ({
|
return [primaryLang.value, ...others].map((code) => ({
|
||||||
tag: code === primaryLang.value ? '' : code,
|
tag: code === primaryLang.value ? '' : code,
|
||||||
code,
|
code,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { computed, inject, onActivated, onMounted, onUnmounted, provide, ref, wa
|
|||||||
import StructureTree from './StructureTree.vue'
|
import StructureTree from './StructureTree.vue'
|
||||||
import LangSelect from './LangSelect.vue'
|
import LangSelect from './LangSelect.vue'
|
||||||
import { slugify } from './slugify'
|
import { slugify } from './slugify'
|
||||||
import { flagFor, langName } from './langs'
|
import { flagFor, langName, langSort } from './langs'
|
||||||
import { editorLang, pagePrimary } from './editorLang'
|
import { editorLang, pagePrimary } from './editorLang'
|
||||||
import { dropPageCache, loadPlain } from './swapdoc'
|
import { dropPageCache, loadPlain } from './swapdoc'
|
||||||
|
|
||||||
@@ -40,9 +40,10 @@ const primaryLang = ref('en')
|
|||||||
const siteLangs = ref([])
|
const siteLangs = ref([])
|
||||||
|
|
||||||
// The strip's options: the primary language first, then the configured
|
// The strip's options: the primary language first, then the configured
|
||||||
// translation targets (the lang tab manages that set).
|
// translation targets (the lang tab manages that set) in the lang tab's
|
||||||
|
// geographic grouping (./langs langSort).
|
||||||
const langOptions = computed(() =>
|
const langOptions = computed(() =>
|
||||||
[primaryLang.value, ...siteLangs.value.filter((l) => l !== primaryLang.value)]
|
[primaryLang.value, ...langSort(siteLangs.value.filter((l) => l !== primaryLang.value))]
|
||||||
.map((code) => ({
|
.map((code) => ({
|
||||||
tag: code === primaryLang.value ? '' : code,
|
tag: code === primaryLang.value ? '' : code,
|
||||||
code,
|
code,
|
||||||
@@ -63,7 +64,7 @@ watch(lang, () => refreshPages())
|
|||||||
// dropdown lists "inherit" first (naming what it resolves to), then every
|
// dropdown lists "inherit" first (naming what it resolves to), then every
|
||||||
// site language. Setting it on a section covers its whole subtree.
|
// site language. Setting it on a section covers its whole subtree.
|
||||||
const rowLangChoices = computed(() =>
|
const rowLangChoices = computed(() =>
|
||||||
[primaryLang.value, ...siteLangs.value.filter((l) => l !== primaryLang.value)]
|
[primaryLang.value, ...langSort(siteLangs.value.filter((l) => l !== primaryLang.value))]
|
||||||
.map((code) => ({ tag: code, code, name: langName(code), flag: flagFor(code), primary: false })),
|
.map((code) => ({ tag: code, code, name: langName(code), flag: flagFor(code), primary: false })),
|
||||||
)
|
)
|
||||||
function rowLangOptions(el) {
|
function rowLangOptions(el) {
|
||||||
|
|||||||
@@ -30,6 +30,20 @@ export const LANG_GROUPS = [
|
|||||||
|
|
||||||
const displayNames = new Intl.DisplayNames(['en'], { type: 'language' })
|
const displayNames = new Intl.DisplayNames(['en'], { type: 'language' })
|
||||||
|
|
||||||
|
// Consistent menu ordering for language selectors: the geographic/cultural
|
||||||
|
// grouping above (similar languages sit together, and it does not vary with
|
||||||
|
// the display language the way alphabetical-by-name would). Tags outside
|
||||||
|
// the groups trail, ordered by tag. The primary language is not special
|
||||||
|
// here — callers put it first themselves.
|
||||||
|
const groupOrder = new Map(LANG_GROUPS.flat().map((c, i) => [c, i]))
|
||||||
|
export function langSort(codes) {
|
||||||
|
return [...codes].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(groupOrder.get(a) ?? groupOrder.size) - (groupOrder.get(b) ?? groupOrder.size)
|
||||||
|
|| a.localeCompare(b),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// English display name for a language tag ("fi" -> "Finnish").
|
// English display name for a language tag ("fi" -> "Finnish").
|
||||||
export function langName(tag) {
|
export function langName(tag) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+19
-3
@@ -17,6 +17,13 @@ from pagerite.segments import has_prose
|
|||||||
#: backticks or tildes (CommonMark).
|
#: backticks or tildes (CommonMark).
|
||||||
_FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}|~{3,})")
|
_FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}|~{3,})")
|
||||||
|
|
||||||
|
#: A container fence line (mdit-py-plugins container): the "::: aside"
|
||||||
|
#: opener and the ":::" closer alike. Always its own block, even with no
|
||||||
|
#: blank line around it: folded into a prose paragraph it would cross to
|
||||||
|
#: the translator as part of the text run, where the model can drop it —
|
||||||
|
#: the rest of the page then renders inside the container.
|
||||||
|
_CONTAINER = re.compile(r"^ {0,3}:{3,}(?:[ \t]|$)")
|
||||||
|
|
||||||
#: HTML block openers that may span blank lines (CommonMark types 1-5:
|
#: HTML block openers that may span blank lines (CommonMark types 1-5:
|
||||||
#: script/pre/style/textarea, comments, processing instructions,
|
#: script/pre/style/textarea, comments, processing instructions,
|
||||||
#: declarations, CDATA) with their closing condition. Other HTML blocks
|
#: declarations, CDATA) with their closing condition. Other HTML blocks
|
||||||
@@ -54,9 +61,11 @@ def chunk_markdown(markdown: str) -> list[str]:
|
|||||||
Blocks are separated by blank lines; fenced code blocks and the
|
Blocks are separated by blank lines; fenced code blocks and the
|
||||||
multi-line HTML blocks (comments, script/pre/style, CDATA...) are
|
multi-line HTML blocks (comments, script/pre/style, CDATA...) are
|
||||||
kept atomic, even across blank lines, and end at their closing
|
kept atomic, even across blank lines, and end at their closing
|
||||||
condition. Chunks carry no surrounding blank lines and no trailing
|
condition. Container fence lines (:::, open and close alike) are
|
||||||
newline; rejoining with ``join_chunks`` reproduces the source modulo
|
always their own block, blank lines or not (see _CONTAINER). Chunks
|
||||||
blank-line normalization.
|
carry no surrounding blank lines and no trailing newline; rejoining
|
||||||
|
with ``join_chunks`` reproduces the source modulo blank-line
|
||||||
|
normalization.
|
||||||
"""
|
"""
|
||||||
chunks: list[str] = []
|
chunks: list[str] = []
|
||||||
buf: list[str] = []
|
buf: list[str] = []
|
||||||
@@ -91,6 +100,13 @@ def chunk_markdown(markdown: str) -> list[str]:
|
|||||||
fence = m.group(1)
|
fence = m.group(1)
|
||||||
buf.append(line)
|
buf.append(line)
|
||||||
continue
|
continue
|
||||||
|
if _CONTAINER.match(line):
|
||||||
|
# Container fence lines (open and close alike) are their own
|
||||||
|
# block — never part of a prose chunk (see _CONTAINER).
|
||||||
|
flush()
|
||||||
|
buf.append(line)
|
||||||
|
flush()
|
||||||
|
continue
|
||||||
if not buf:
|
if not buf:
|
||||||
for open_re, close_re in _HTML_ATOMIC:
|
for open_re, close_re in _HTML_ATOMIC:
|
||||||
if open_re.match(line):
|
if open_re.match(line):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+27
-26
@@ -3,18 +3,19 @@
|
|||||||
The visitor-activity WebSocket (``/_ws``, public) and the admin analytics
|
The visitor-activity WebSocket (``/_ws``, public) and the admin analytics
|
||||||
stream (``/_api/ws/analytics``) plus the ``/_a`` viewer page. Client IPs are
|
stream (``/_api/ws/analytics``) plus the ``/_a`` viewer page. Client IPs are
|
||||||
enriched in background tasks with reverse DNS (cached PTR lookups) and the
|
enriched in background tasks with reverse DNS (cached PTR lookups) and the
|
||||||
DB-IP city MMDB (``GeoIP``, decompressed and opened once at startup);
|
DB-IP city MMDB (``GeoIP``, decompressed into RAM and opened once at
|
||||||
|
startup);
|
||||||
external referrers get their favicon fetched and stored content-hashed.
|
external referrers get their favicon fetched and stored content-hashed.
|
||||||
Snapshot broadcasts to connected admin sockets are debounced.
|
Snapshot broadcasts to connected admin sockets are debounced.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import gzip
|
import gzip
|
||||||
|
import io
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
|
||||||
import socket
|
import socket
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
@@ -44,8 +45,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 +62,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 +70,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 +95,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,15 +104,19 @@ 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: the ``.mmdb.gz`` download
|
||||||
``.mmdb`` over the matching ``.mmdb.gz``. Returns None if none is present.
|
is canonical (decompressed into RAM at open); a plain ``.mmdb`` left over
|
||||||
|
from older versions is still usable, and removed once the matching ``.gz``
|
||||||
|
is present so it does not linger on disk. Returns None if none is present.
|
||||||
"""
|
"""
|
||||||
mmdb = sorted(_REPO_ROOT.glob("dbip-*.mmdb"))
|
gz = sorted(_DBIP_DIR.glob("dbip-*.mmdb.gz"))
|
||||||
|
if gz:
|
||||||
|
for stale in _DBIP_DIR.glob("dbip-*.mmdb"):
|
||||||
|
stale.unlink()
|
||||||
|
return gz[0]
|
||||||
|
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"))
|
|
||||||
if gz:
|
|
||||||
return gz[0]
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -123,28 +129,23 @@ class GeoIP:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._reader: object | None = None
|
self._reader: object | None = None
|
||||||
|
|
||||||
def _decompress(self, source: Path, target: Path) -> None:
|
|
||||||
if target.exists():
|
|
||||||
return
|
|
||||||
tmp = target.with_suffix(target.suffix + ".tmp")
|
|
||||||
with gzip.open(source, "rb") as src, open(tmp, "wb") as dst:
|
|
||||||
shutil.copyfileobj(src, dst)
|
|
||||||
os.replace(tmp, target)
|
|
||||||
|
|
||||||
def _load(self) -> None:
|
def _load(self) -> None:
|
||||||
if self._reader is not None:
|
if self._reader is not None:
|
||||||
return
|
return
|
||||||
source = _geoip_db_path()
|
source = _geoip_db_path()
|
||||||
if source is None:
|
if source is None:
|
||||||
return
|
return
|
||||||
if source.suffix == ".gz":
|
|
||||||
target = source.with_suffix("")
|
|
||||||
self._decompress(source, target)
|
|
||||||
source = target
|
|
||||||
try:
|
try:
|
||||||
import maxminddb
|
import maxminddb
|
||||||
|
|
||||||
self._reader = maxminddb.open_database(str(source))
|
if source.suffix == ".gz":
|
||||||
|
# Only the .gz is kept on disk; the database is decompressed
|
||||||
|
# into RAM (MODE_FD makes the pure-Python Reader .read() the
|
||||||
|
# buffer — never mmap — and bypasses the C extension).
|
||||||
|
buf = io.BytesIO(gzip.decompress(source.read_bytes()))
|
||||||
|
self._reader = maxminddb.open_database(buf, maxminddb.MODE_FD)
|
||||||
|
else:
|
||||||
|
self._reader = maxminddb.open_database(str(source))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+63
-27
@@ -263,20 +263,31 @@ def _transition_css_url(transition: str) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _editor_css_url(vite_url: str | None) -> str | None:
|
def _editor_css_url(vite_url: str | None) -> str | None:
|
||||||
"""URL for the editor-specific stylesheet (Vue component styles).
|
"""URLs (comma-joined) for the editor-specific stylesheets (Vue
|
||||||
|
component styles).
|
||||||
|
|
||||||
This is linked by the public-page edit pen so the editor styles are
|
This is linked by the public-page edit pen so the editor styles are
|
||||||
loaded before the editor JS dynamic-import resolves.
|
loaded before the editor JS dynamic-import resolves. Component styles
|
||||||
|
can land on shared chunks rather than the entry's own stylesheet —
|
||||||
|
LangSelect's ride on the shared store chunk, as it is also used by the
|
||||||
|
on-demand public language selector — so collect the stylesheets of the
|
||||||
|
entry and its imported chunks (the same traversal _langselect_assets
|
||||||
|
does).
|
||||||
"""
|
"""
|
||||||
if vite_url:
|
if vite_url:
|
||||||
return None
|
return None
|
||||||
manifest = _manifest()
|
manifest = _manifest()
|
||||||
entry = manifest["src/main.js"]
|
|
||||||
base = manifest.get(_BASE_CSS_KEY, {}).get("file")
|
base = manifest.get(_BASE_CSS_KEY, {}).get("file")
|
||||||
for css in entry.get("css", []):
|
stylesheets, seen = [], set()
|
||||||
if css != base:
|
queue = ["src/main.js"]
|
||||||
return f"/{css}"
|
for key in queue: # grows with imported chunks
|
||||||
return None
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
entry = manifest[key]
|
||||||
|
stylesheets += [f"/{css}" for css in entry.get("css", []) if css != base]
|
||||||
|
queue += entry.get("imports", [])
|
||||||
|
return ",".join(stylesheets) or None
|
||||||
|
|
||||||
|
|
||||||
def _inline_asset(url: str) -> str:
|
def _inline_asset(url: str) -> str:
|
||||||
@@ -1021,6 +1032,42 @@ 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:
|
||||||
|
# Only languages the page actually has AND that are still enabled
|
||||||
|
# site-wide (a disabled target stops being advertised).
|
||||||
|
enabled = {original, *data.translate_langs}
|
||||||
|
alternates = [("x-default", url)] + [
|
||||||
|
(tag, url if tag == original else f"{url}?lang={tag}")
|
||||||
|
for tag in sorted({original, *node.langs} & enabled)
|
||||||
|
]
|
||||||
|
return canonical, alternates
|
||||||
|
|
||||||
|
|
||||||
def render_page(
|
def render_page(
|
||||||
menu: dict[str, Node],
|
menu: dict[str, Node],
|
||||||
data: Data,
|
data: Data,
|
||||||
@@ -1050,24 +1097,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 +1130,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 +1146,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 +1172,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),
|
||||||
|
|||||||
Reference in New Issue
Block a user