Compare commits

...
3 Commits
Author SHA1 Message Date
LeoVasanko b57b7060ec Keep container fence lines out of prose chunks
A closing ::: glued to a paragraph (no blank line before it) rode inside
the prose chunk and crossed to the translator as part of the text run;
when the model dropped it, validation passed and the splice lost the
fence — the rest of the page rendered inside the container (seen in the
Spanish translation). Container fence lines (::: openers and closers
alike) are now always their own prose-free chunk, never reaching the
translator. Affected pages re-chunk on next save and re-translate under
the new hashes, repairing themselves.
2026-09-04 19:04:24 +00:00
LeoVasanko 3f27a0a292 Fix unstyled editor language selector, order all language menus logically
LangSelect's scoped CSS landed on the shared store chunk, whose stylesheet
the editor never loaded (only the public selector path injected it), so the
editor's language selector rendered unstyled on untranslated pages. Collect
editor stylesheets from the entry's imported chunks too (same traversal as
the langselect assets).

Also: hreflang alternates now skip languages disabled site-wide, and all
selectors (page editor, structure tab, public selector) order languages the
same way — primary first, then the lang tab's geographic grouping.
2026-09-04 18:52:22 +00:00
LeoVasanko b30d909a23 Don't extract GeoIP .mmdb.gz on filesystem, only in RAM. 2026-09-04 18:30:55 +00:00
9 changed files with 111 additions and 54 deletions
+3 -2
View File
@@ -75,8 +75,9 @@ 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 working directory, 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 working directory 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
+5 -1
View File
@@ -146,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
+16 -11
View File
@@ -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)
+7 -5
View File
@@ -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,
+5 -4
View File
@@ -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) {
+14
View File
@@ -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
View File
@@ -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):
+20 -20
View File
@@ -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
@@ -103,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 working directory, 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.
""" """
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")) mmdb = sorted(_DBIP_DIR.glob("dbip-*.mmdb"))
if mmdb: if mmdb:
return mmdb[0] return mmdb[0]
gz = sorted(_DBIP_DIR.glob("dbip-*.mmdb.gz"))
if gz:
return gz[0]
return None return None
@@ -124,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
+22 -8
View File
@@ -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:
@@ -1047,9 +1058,12 @@ def _language_urls(
canonical = url if lang == original else f"{url}?lang={lang}" canonical = url if lang == original else f"{url}?lang={lang}"
alternates = [] alternates = []
if data.translate_langs: 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)] + [ alternates = [("x-default", url)] + [
(tag, url if tag == original else f"{url}?lang={tag}") (tag, url if tag == original else f"{url}?lang={tag}")
for tag in sorted({original, *node.langs}) for tag in sorted({original, *node.langs} & enabled)
] ]
return canonical, alternates return canonical, alternates