Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a4745389e | ||
|
|
4eea3ae3af | ||
|
|
69583fb9fe | ||
|
|
b57b7060ec | ||
|
|
3f27a0a292 | ||
|
|
b30d909a23 | ||
|
|
13fecd2118 | ||
|
|
11a138e19f | ||
|
|
ebd5911a38 | ||
|
|
db57125953 | ||
|
|
986e28c220 | ||
|
|
33a4a76364 | ||
|
|
9fb4b5a681 | ||
|
|
0c1349b037 | ||
|
|
54f8c8e09b | ||
|
|
78f4ddb2f0 |
@@ -25,10 +25,12 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke
|
|||||||
- `markdown.py` — markdown-it-py renderer.
|
- `markdown.py` — markdown-it-py renderer.
|
||||||
- `views.py` — shared page layout and rendering; theme/user-font resolution across `THEME_DIRS` / `FONT_DIRS` (cwd, site, platform data roots, then built-in `pagerite/themes/`, see `docs/themes-and-assets.md`).
|
- `views.py` — shared page layout and rendering; theme/user-font resolution across `THEME_DIRS` / `FONT_DIRS` (cwd, site, platform data roots, then built-in `pagerite/themes/`, see `docs/themes-and-assets.md`).
|
||||||
- `seed.py` — demo content, written only on first database creation.
|
- `seed.py` — demo content, written only on first database creation.
|
||||||
- `analytics.py` — visit analytics collection (see `docs/analytics.md`).
|
- `analytics.py` — visit analytics collection (see `docs/analytics.md`). UA formatting/bot detection comes from the **uarite** package.
|
||||||
- `frontend/src/` — Vue editor and public-page JS entries.
|
- `frontend/src/` — Vue editor and public-page JS entries.
|
||||||
- `main.js` — Vue editor app entry.
|
- `main.js` — Vue editor app entry.
|
||||||
- `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`).
|
- `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`).
|
||||||
|
- `langselect-main.js` + `LangSelector.vue` — public language selector, imported on demand by pagerite.js on pages with more than one hreflang alternate (the editors' `LangSelect` flag dropdown).
|
||||||
|
- `store.js` — the shared Pinia store (`useStore`, id `pagerite`) for cross-bundle UI state.
|
||||||
- `pagerite.js` — public page entry.
|
- `pagerite.js` — public page entry.
|
||||||
- `editorLang.js` + `LangSelect.vue` — the editor shell's shared language selection and its selector component (page + structure tabs; drives the page preview while the panel is open, via `swapdoc.setLangOverride`).
|
- `editorLang.js` + `LangSelect.vue` — the editor shell's shared language selection and its selector component (page + structure tabs; drives the page preview while the panel is open, via `swapdoc.setLangOverride`).
|
||||||
- `reconnect.js` — shared WebSocket pacing for all sockets (staggered connect slots, stuck-CONNECTING watchdog, exponential backoff): bursts and rapid retries trip the browser's WebSocket throttling.
|
- `reconnect.js` — shared WebSocket pacing for all sockets (staggered connect slots, stuck-CONNECTING watchdog, exponential backoff): bursts and rapid retries trip the browser's WebSocket throttling.
|
||||||
@@ -64,6 +66,6 @@ Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed).
|
|||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Keep dependencies minimal; add via `uv add` and mention it.
|
- Keep dependencies minimal; add via `uv add` and mention it.
|
||||||
- The public URL space belongs to content (pretty slugs at root). Reserve only `/_` for the machinery (`/_api/`, `/_f/`, `/_assets/`), plus `/favicon.ico` from the build. Slugs are lowercase ASCII letters, digits, hyphens and underscores `[a-z0-9_-]` (the site editor filters input live via `slugify.js`, built on the `transliteration` npm package — unicode folds to ASCII, spaces become hyphens; an empty slug on a new page is derived from its title), may not begin with `_` or `.`, and such URLs are never looked up as content.
|
- The public URL space belongs to content (pretty slugs at root). Reserve only `/_` for the machinery (`/_api/`, `/_f/`, `/_assets/`), plus `/favicon.ico` (backend redirect to the configured site icon). Slugs are lowercase ASCII letters, digits, hyphens and underscores `[a-z0-9_-]` (the site editor filters input live via `slugify.js`, built on the `transliteration` npm package — unicode folds to ASCII, spaces become hyphens; an empty slug on a new page is derived from its title), may not begin with `_` or `.`, and such URLs are never looked up as content.
|
||||||
- No auth in core code; the SSO/reverse proxy gates all of `/_api` (forward-auth) and owns `/auth/` (login/logout, session validation). Pages render identically for everyone; pagerite.js adds the editing UI only after the auth server validates the session. The one keyed exception is `/_translate/{key}` (translator service; `Data.translate_keys`, see docs/localization.md).
|
- No auth in core code; the SSO/reverse proxy gates all of `/_api` (forward-auth) and owns `/auth/` (login/logout, session validation). Pages render identically for everyone; pagerite.js adds the editing UI only after the auth server validates the session. The one keyed exception is `/_translate/{key}` (translator service; `Data.translate_keys`, see docs/localization.md).
|
||||||
- Update the relevant MarkDown files when architecture, tooling, or conventions change.
|
- Update the relevant MarkDown files when architecture, tooling, or conventions change.
|
||||||
|
|||||||
+23
-6
@@ -63,20 +63,35 @@ Each `Client` record (shared by every event, keyed by hash):
|
|||||||
when a database is available,
|
when a database is available,
|
||||||
- `city` — city name from the DB-IP MMDB lookup, when available,
|
- `city` — city name from the DB-IP MMDB lookup, when available,
|
||||||
- `ua` — raw `User-Agent` string,
|
- `ua` — raw `User-Agent` string,
|
||||||
- `ua_pretty` — compact display form of the UA (browser/OS/device) when
|
|
||||||
parsable, otherwise the raw string,
|
|
||||||
- `hide` — true for admin clients (`hide` message field): everything this
|
- `hide` — true for admin clients (`hide` message field): everything this
|
||||||
client ever did is recorded but excluded from every statistic and from the
|
client ever did is recorded but excluded from every statistic and from the
|
||||||
viewer payload. This is the one flag set at record time — it is a client
|
viewer payload. This is the one flag set at record time — it is a client
|
||||||
property, not a classification.
|
property, not a classification.
|
||||||
|
|
||||||
|
The viewer payload adds one display-time field to each client, never
|
||||||
|
persisted (stored records keep the default and old data always follows the
|
||||||
|
current uarite version):
|
||||||
|
|
||||||
|
- `uarite` — the `uarite.UA` dataclass from parsing the raw UA
|
||||||
|
(`pretty`/`engine`/`os`/`provider`/`kind`/`url`): the crawler name for
|
||||||
|
bots,
|
||||||
|
with a category suffix only where a provider runs crawlers of more than
|
||||||
|
one kind (`GPTBot (AI)` vs `OAI-SearchBot (search)`, `Googlebot (search)`
|
||||||
|
vs `Google-Extended (AI)`; single-kind providers stay plain: `Facebook`,
|
||||||
|
`WhatsApp`), `Browser/major OS` on the desktop, the device where that is
|
||||||
|
the relevant information (iPhone reports its iOS version, Android phones
|
||||||
|
their model instead of the OS), otherwise the raw string; `url` is the
|
||||||
|
crawler's info page when uarite knows one (rendered as a 🔗 link after the
|
||||||
|
pretty UA in the viewer), `kind` drives the bot classification.
|
||||||
|
|
||||||
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
|
||||||
@@ -168,7 +183,9 @@ for misses.
|
|||||||
`_CRAWLER_TIMEOUT` (10 s) is a crawler hit — plain bots that only fetch
|
`_CRAWLER_TIMEOUT` (10 s) is a crawler hit — plain bots that only fetch
|
||||||
documents never register as visits. JS-running crawlers (Googlebot,
|
documents never register as visits. JS-running crawlers (Googlebot,
|
||||||
GoogleOther, Applebot, ...) do connect and send messages, but their UA
|
GoogleOther, Applebot, ...) do connect and send messages, but their UA
|
||||||
gives them away (`_is_bot_ua`): their messages are ignored at display
|
gives them away (`_is_bot_ua`, backed by `uarite.uaparse` — which
|
||||||
|
also knows the disguised ones: facebookexternalhit, Google-Extended,
|
||||||
|
WhatsApp, ...): their messages are ignored at display
|
||||||
time, so their GETs never match and land in the crawler list too. Real-
|
time, so their GETs never match and land in the crawler list too. Real-
|
||||||
browser bots whose UA does not match are caught by engagement: a visit
|
browser bots whose UA does not match are caught by engagement: a visit
|
||||||
whose total reported reading time is under 5 seconds (`_MIN_VISIT_READ`;
|
whose total reported reading time is under 5 seconds (`_MIN_VISIT_READ`;
|
||||||
|
|||||||
+45
-12
@@ -53,11 +53,13 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`).
|
|||||||
article's own language), `?lang=xx` when serving a translation — however
|
article's own language), `?lang=xx` when serving a translation — however
|
||||||
the language was arrived at (query or header).
|
the language was arrived at (query or header).
|
||||||
- `<link rel="alternate" hreflang="…">` entries follow the canonical
|
- `<link rel="alternate" hreflang="…">` entries follow the canonical
|
||||||
directly (before the social meta tags) and are the same set on every
|
directly (before the social meta tags) and list the languages the page
|
||||||
page — the site-wide configured languages (`translate_langs`, which the
|
is **actually available in**: `x-default` first, pointing at the plain
|
||||||
translator works to fill in): `x-default` first, pointing at the plain
|
autodetecting URL, then every available language — the original again by
|
||||||
autodetecting URL, then every language explicitly with `?lang=`, the
|
its plain URL, translations by `?lang=`. The public language selector
|
||||||
page's own primary language included.
|
keys off these: pagerite.js mounts the editors' flag dropdown in the
|
||||||
|
top-right corner when the head advertises x-default plus more than one
|
||||||
|
language, loading its bundle (Vue + the flag SVG set) on demand.
|
||||||
- The override sticks for the session of clicks: a page requested with
|
- The override sticks for the session of clicks: a page requested with
|
||||||
`?lang=` replicates the query onto the navigation links it renders (nav,
|
`?lang=` replicates the query onto the navigation links it renders (nav,
|
||||||
sidebar, cards, brand — in-article links are content and stay as
|
sidebar, cards, brand — in-article links are content and stay as
|
||||||
@@ -66,6 +68,11 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`).
|
|||||||
`history.replaceState` (pretty, shareable URLs), remembers the language,
|
`history.replaceState` (pretty, shareable URLs), remembers the language,
|
||||||
and adds it to every internal fetch that lacks one (preloads,
|
and adds it to every internal fetch that lacks one (preloads,
|
||||||
fetch-navigations, history traversals); history entries stay query-less.
|
fetch-navigations, history traversals); history entries stay query-less.
|
||||||
|
- The public selector's pick is the same override, pure JS state
|
||||||
|
(`pagerite:set-session-lang`): the session language changes and the page
|
||||||
|
swaps in place — no `?lang=` in the address bar, no reload. The choice is
|
||||||
|
linked with the editor panel's language dropdown both ways; closing the
|
||||||
|
panel keeps the chosen language instead of reverting.
|
||||||
- A full page refresh or a shared link resets to automatic selection (header
|
- A full page refresh or a shared link resets to automatic selection (header
|
||||||
only). This gives a clean one-time override without cookies.
|
only). This gives a clean one-time override without cookies.
|
||||||
|
|
||||||
@@ -88,6 +95,10 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`).
|
|||||||
### Rendering
|
### Rendering
|
||||||
|
|
||||||
- The translated Markdown goes through the same `markdown.render` pipeline.
|
- The translated Markdown goes through the same `markdown.render` pipeline.
|
||||||
|
- Section anchors (`#hash` ids on h1/h2 headings) stay in the original
|
||||||
|
language: render(anchors_from=...) pins the translated render's heading
|
||||||
|
ids to the original text's slugs, matched by heading position, so links
|
||||||
|
to sections don't break across languages.
|
||||||
- Navigation/sidebar titles come from the translation's title map, with
|
- Navigation/sidebar titles come from the translation's title map, with
|
||||||
per-node fallback to the original title (a partially translated tree must
|
per-node fallback to the original title (a partially translated tree must
|
||||||
still render).
|
still render).
|
||||||
@@ -95,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.
|
||||||
@@ -133,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
|
||||||
@@ -398,9 +415,14 @@ verbatim source substring — entity-decoded text, backslash escapes — is
|
|||||||
skipped and stays in the original language), and the returned translations
|
skipped and stays in the original language), and the returned translations
|
||||||
are swapped in by offset. Markup corruption is therefore impossible by
|
are swapped in by offset. Markup corruption is therefore impossible by
|
||||||
construction; the failure modes that remain are a wrong segment count, an
|
construction; the failure modes that remain are a wrong segment count, an
|
||||||
empty segment, or markup injected INTO a segment (a `<br>` in a title
|
empty segment, markup injected INTO a segment (a `<br>` in a title
|
||||||
translation would splice live HTML) — each returned segment must parse as
|
translation would splice live HTML), or a line that would start a new
|
||||||
pure prose, or the whole result is dropped and logged, and the (lang, key)
|
block where the segment lands (a ``` or ::: fence line would eat the rest
|
||||||
|
of the block it splices into, closing fence included — segments are
|
||||||
|
inline prose, so `pure_prose` alone cannot see this) — each returned
|
||||||
|
segment must parse as
|
||||||
|
pure prose with no block-starting line or blank line, or the whole result
|
||||||
|
is dropped and logged, and the (lang, key)
|
||||||
pair is skipped for the rest of the server run (generation is
|
pair is skipped for the rest of the server run (generation is
|
||||||
near-deterministic, so an immediate retry would re-fail; the fragment stays
|
near-deterministic, so an immediate retry would re-fail; the fragment stays
|
||||||
pending and gets another chance on restart or `DELETE /_api/translations`).
|
pending and gets another chance on restart or `DELETE /_api/translations`).
|
||||||
@@ -451,14 +473,25 @@ stripped before the result goes back.
|
|||||||
|
|
||||||
The same client-side enforcement covers markup bleed as a CLASS, not per
|
The same client-side enforcement covers markup bleed as a CLASS, not per
|
||||||
artifact: `<` is the prose/markup boundary on the wire and never appears in
|
artifact: `<` is the prose/markup boundary on the wire and never appears in
|
||||||
a segment in either direction. Source pieces containing `<` are never
|
a segment in either direction. A literal `<` in the source text (`<1MB` is
|
||||||
dispatched (they stay in the original language — segments.py), and the
|
text, not markup — a tag needs a letter or `/!?`) crosses encoded as the
|
||||||
|
fullwidth `<` and is decoded on return, before the result is validated and
|
||||||
|
spliced (segments.py) — the wire itself still never carries `<`, and the
|
||||||
reference client cuts the model's output at the first `<`
|
reference client cuts the model's output at the first `<`
|
||||||
(scripts/translator.py) — echoed language tags, stray `<br>`s and any
|
(scripts/translator.py) — echoed language tags, stray `<br>`s and any
|
||||||
future variant are one handled case. (The cut is post-decode, not a
|
future variant are one handled case. (The cut is post-decode, not a
|
||||||
generation stop string: Seed-X opens every generation with its `<s>`
|
generation stop string: Seed-X opens every generation with its `<s>`
|
||||||
framing token, which would trip a `<` stop immediately.)
|
framing token, which would trip a `<` stop immediately.)
|
||||||
|
|
||||||
|
Server-side, a second layer covers what the inline parser cannot: ASCII
|
||||||
|
punctuation that is plain prose on the wire but Markdown syntax in the
|
||||||
|
splice context — quotes (a translated `"` would close the quoted image
|
||||||
|
title it lands in), brackets (alt texts, re-inserted link texts), `|` in
|
||||||
|
table rows, `\` escapes. Rather than rejecting such results, `join` swaps
|
||||||
|
them for Unicode look-alikes before splicing (`_NEUTRAL` in
|
||||||
|
segments.py — curly quotes, fullwidth brackets; the renderer's
|
||||||
|
typographer curls straight quotes anyway).
|
||||||
|
|
||||||
Short fragments get more than a bare prompt: each segment may carry its
|
Short fragments get more than a bare prompt: each segment may carry its
|
||||||
surround in `Job.contexts` — a title carries the article's opening prose
|
surround in `Job.contexts` — a title carries the article's opening prose
|
||||||
(its own block is just the title word), a segment carved out of a larger
|
(its own block is just the title word), a segment carved out of a larger
|
||||||
|
|||||||
@@ -37,3 +37,6 @@ __screenshots__/
|
|||||||
|
|
||||||
# Playwright browser downloads (if ever installed locally)
|
# Playwright browser downloads (if ever installed locally)
|
||||||
.pw-browsers/
|
.pw-browsers/
|
||||||
|
|
||||||
|
# npm project config (audit/fund off: the audit endpoint stalls installs)
|
||||||
|
!.npmrc
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
audit=false
|
||||||
|
fund=false
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"country-flag-icons": "^1.6.20",
|
"country-flag-icons": "^1.6.20",
|
||||||
"overlayscrollbars": "^2.16.0",
|
"overlayscrollbars": "^2.16.0",
|
||||||
|
"pinia": "^4.0.3",
|
||||||
"transliteration": "^2.6.1",
|
"transliteration": "^2.6.1",
|
||||||
"vue": "^3.5.26",
|
"vue": "^3.5.26",
|
||||||
"vuedraggable": "^4.1.0"
|
"vuedraggable": "^4.1.0"
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
|||||||
:ip-display="v.ipDisplay"
|
:ip-display="v.ipDisplay"
|
||||||
:ua="v.ua"
|
:ua="v.ua"
|
||||||
:ua-raw="v.uaRaw"
|
:ua-raw="v.uaRaw"
|
||||||
|
:ua-url="v.uaUrl"
|
||||||
:country="v.country"
|
:country="v.country"
|
||||||
:city="v.city"
|
:city="v.city"
|
||||||
:lang="v.lang"
|
:lang="v.lang"
|
||||||
@@ -253,6 +254,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
|||||||
:ip-display="c.ipDisplay"
|
:ip-display="c.ipDisplay"
|
||||||
:ua="c.ua"
|
:ua="c.ua"
|
||||||
:ua-raw="c.uaRaw"
|
:ua-raw="c.uaRaw"
|
||||||
|
:ua-url="c.uaUrl"
|
||||||
:country="c.country"
|
:country="c.country"
|
||||||
:city="c.city"
|
:city="c.city"
|
||||||
:lang="c.lang"
|
:lang="c.lang"
|
||||||
@@ -299,6 +301,8 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
|||||||
:ip-display="a.ipDisplay"
|
:ip-display="a.ipDisplay"
|
||||||
:ua="a.ua"
|
:ua="a.ua"
|
||||||
:ua-raw="a.uaRaw"
|
:ua-raw="a.uaRaw"
|
||||||
|
:ua-url="a.uaUrl"
|
||||||
|
:ua-raws="a.uaRaws"
|
||||||
:country="a.country"
|
:country="a.country"
|
||||||
:city="a.city"
|
:city="a.city"
|
||||||
:lang="a.lang"
|
:lang="a.lang"
|
||||||
@@ -506,22 +510,6 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
|||||||
.visit-table .clickable-list,
|
.visit-table .clickable-list,
|
||||||
.visit-table .last-seen {
|
.visit-table .last-seen {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visit-table :deep(.copy-popup) {
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 0.25rem);
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
padding: 0.15rem 0.4rem;
|
|
||||||
background: var(--text, CanvasText);
|
|
||||||
color: var(--bg, Canvas);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.crawler-top-uas {
|
.crawler-top-uas {
|
||||||
|
|||||||
@@ -403,14 +403,6 @@ onUnmounted(() => {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding: 0 0.2rem;
|
padding: 0 0.2rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-head .icon-btn:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The banner design selector stays compact; the upload button is pushed
|
/* The banner design selector stays compact; the upload button is pushed
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ const currentPath = ref(props.pagePath)
|
|||||||
const activeMode = ref(props.initialMode)
|
const activeMode = ref(props.initialMode)
|
||||||
|
|
||||||
// The shared language selection (./editorLang, v-modeled by the tabs'
|
// The shared language selection (./editorLang, v-modeled by the tabs'
|
||||||
// LangSelects) also drives the page preview: while the shell is open it
|
// LangSelects) is linked to the whole-page language: while the shell is
|
||||||
// overrides the normal language preferences (?lang= / Accept-Language),
|
// open it drives the page preview (overrides ?lang= / Accept-Language),
|
||||||
// so the page renders in the language being edited; closing restores.
|
// and closing keeps the pick as the session language. The primary
|
||||||
// The primary selection pins by the CURRENT PAGE's own primary language
|
// selection pins by the CURRENT PAGE's own primary language (pages may
|
||||||
// (pages may differ — Node.language is inherited down the tree).
|
// differ — Node.language is inherited down the tree).
|
||||||
let pinned = false
|
let pinned = false
|
||||||
function pinPreviewLang() {
|
function pinPreviewLang() {
|
||||||
pinned = true
|
pinned = true
|
||||||
@@ -34,6 +34,15 @@ function pinPreviewLang() {
|
|||||||
setLangOverride(editorLang.value || pagePrimary.value || 'en')
|
setLangOverride(editorLang.value || pagePrimary.value || 'en')
|
||||||
loadPlain(currentPath.value)
|
loadPlain(currentPath.value)
|
||||||
}
|
}
|
||||||
|
// Opening the panel must not switch the page's language: adopt the
|
||||||
|
// session's chosen language (public selector / earlier pick) once, then
|
||||||
|
// pin. Runs only on (re)open — after that the selection is the user's.
|
||||||
|
function openShell() {
|
||||||
|
const session = window.__pageriteLang
|
||||||
|
if (!editorLang.value && session && session !== (pagePrimary.value || 'en'))
|
||||||
|
editorLang.value = session
|
||||||
|
pinPreviewLang()
|
||||||
|
}
|
||||||
function unpinPreviewLang() {
|
function unpinPreviewLang() {
|
||||||
if (!pinned) return
|
if (!pinned) return
|
||||||
pinned = false
|
pinned = false
|
||||||
@@ -89,13 +98,13 @@ function onSwitchEvent(ev) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.body.dataset.editorMode = activeMode.value
|
document.body.dataset.editorMode = activeMode.value
|
||||||
addEventListener('pagerite:switch-editor', onSwitchEvent)
|
addEventListener('pagerite:switch-editor', onSwitchEvent)
|
||||||
addEventListener('pagerite:editor-shown', pinPreviewLang)
|
addEventListener('pagerite:editor-shown', openShell)
|
||||||
addEventListener('pagerite:editor-hidden', unpinPreviewLang)
|
addEventListener('pagerite:editor-hidden', unpinPreviewLang)
|
||||||
// The shell mounts visible (openEditor), so pin immediately. The site
|
// The shell mounts visible (openEditor), so open immediately. The site
|
||||||
// default primary language comes from the settings — it only fills the
|
// default primary language comes from the settings — it only fills the
|
||||||
// unknown; the page/structure tabs refine pagePrimary per page as they
|
// unknown; the page/structure tabs refine pagePrimary per page as they
|
||||||
// learn it (their knowledge is strictly better).
|
// learn it (their knowledge is strictly better).
|
||||||
pinPreviewLang()
|
openShell()
|
||||||
fetch('/_api/settings').then((r) => r.json()).then((s) => {
|
fetch('/_api/settings').then((r) => r.json()).then((s) => {
|
||||||
if (!pagePrimary.value) pagePrimary.value = s.primary_lang || 'en'
|
if (!pagePrimary.value) pagePrimary.value = s.primary_lang || 'en'
|
||||||
}).catch(() => { /* keep the fallback */ })
|
}).catch(() => { /* keep the fallback */ })
|
||||||
@@ -103,7 +112,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
removeEventListener('pagerite:switch-editor', onSwitchEvent)
|
removeEventListener('pagerite:switch-editor', onSwitchEvent)
|
||||||
removeEventListener('pagerite:editor-shown', pinPreviewLang)
|
removeEventListener('pagerite:editor-shown', openShell)
|
||||||
removeEventListener('pagerite:editor-hidden', unpinPreviewLang)
|
removeEventListener('pagerite:editor-hidden', unpinPreviewLang)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
// flag button opening a clean dropdown, v-modeled on the shared editorLang
|
// flag button opening a clean dropdown, v-modeled on the shared editorLang
|
||||||
// ('' = the primary language). The lang tab's flag grid is a different
|
// ('' = the primary language). The lang tab's flag grid is a different
|
||||||
// control (toggles, not a select) and stays as it is.
|
// control (toggles, not a select) and stays as it is.
|
||||||
import { computed, ref } from 'vue'
|
import { computed, nextTick, ref } from 'vue'
|
||||||
|
import { usePopup } from './dropdown'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: String, default: '' },
|
modelValue: { type: String, default: '' },
|
||||||
@@ -13,8 +14,12 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
|
const root = ref(null)
|
||||||
const toggleBtn = ref(null)
|
const toggleBtn = ref(null)
|
||||||
|
const pop = ref(null)
|
||||||
const popStyle = ref({})
|
const popStyle = ref({})
|
||||||
|
// Closes on outside click / Escape (./dropdown), not on mouseleave.
|
||||||
|
usePopup(open, root)
|
||||||
const current = computed(
|
const current = computed(
|
||||||
() => props.options.find((o) => o.tag === props.modelValue) ?? props.options[0],
|
() => props.options.find((o) => o.tag === props.modelValue) ?? props.options[0],
|
||||||
)
|
)
|
||||||
@@ -26,6 +31,17 @@ function toggle() {
|
|||||||
// onto the page area instead of being clipped by it.
|
// onto the page area instead of being clipped by it.
|
||||||
const r = toggleBtn.value.getBoundingClientRect()
|
const r = toggleBtn.value.getBoundingClientRect()
|
||||||
popStyle.value = { top: `${r.bottom + 2}px`, left: `${r.left}px` }
|
popStyle.value = { top: `${r.bottom + 2}px`, left: `${r.left}px` }
|
||||||
|
// A toggle mounted near the right window edge (the public page
|
||||||
|
// selector sits top-right) opens the popup flush against that edge.
|
||||||
|
nextTick(() => {
|
||||||
|
const p = pop.value?.getBoundingClientRect()
|
||||||
|
if (p && p.right > innerWidth - 4) {
|
||||||
|
popStyle.value = {
|
||||||
|
...popStyle.value,
|
||||||
|
left: `${Math.max(4, innerWidth - 4 - p.width)}px`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +52,7 @@ function select(tag) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span v-if="options.length > 1" class="lang-select">
|
<span v-if="options.length > 1" ref="root" class="lang-select">
|
||||||
<button
|
<button
|
||||||
ref="toggleBtn"
|
ref="toggleBtn"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -47,7 +63,7 @@ function select(tag) {
|
|||||||
: '')"
|
: '')"
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
><span v-if="current?.flag" class="flag" v-html="current.flag" /></button>
|
><span v-if="current?.flag" class="flag" v-html="current.flag" /></button>
|
||||||
<span v-if="open" class="lang-pop" :style="popStyle" @mouseleave="open = false">
|
<span v-if="open" ref="pop" class="lang-pop" :style="popStyle">
|
||||||
<button
|
<button
|
||||||
v-for="o in options"
|
v-for="o in options"
|
||||||
:key="o.code"
|
:key="o.code"
|
||||||
@@ -66,20 +82,23 @@ function select(tag) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The closed state is just the small flag — no button chrome until hovered. */
|
/* The closed state is just the small flag — no button chrome at all, on
|
||||||
|
hover either (it sits among borderless emoji-icon buttons); like them it
|
||||||
|
rests dimmed and brightens on hover. */
|
||||||
.lang-current {
|
.lang-current {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid transparent;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-current:hover,
|
.lang-current:hover,
|
||||||
.lang-current.open {
|
.lang-current.open {
|
||||||
border-color: var(--line);
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The dropdown matches the page's existing popups (.picker-pop look).
|
/* The dropdown matches the page's existing popups (.picker-pop look).
|
||||||
@@ -127,11 +146,13 @@ function select(tag) {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Flags render like in the analytics visitor cells. */
|
/* em-sized so the chip matches the surrounding text/icon size in each
|
||||||
|
context; the hairline border delineates white-flagged countries (not
|
||||||
|
button chrome). */
|
||||||
.flag {
|
.flag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: 18px;
|
width: 1.5em;
|
||||||
height: 12px;
|
height: 1em;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup>
|
||||||
|
// The public page's language selector: the editors' flag dropdown
|
||||||
|
// (LangSelect) as the first item of the banner's corner container, fed
|
||||||
|
// from the shared store (pagerite.js sets the page's hreflang alternates
|
||||||
|
// and served language per navigation). It binds the same store.lang the
|
||||||
|
// editor's dropdown binds, so both always show the same selection. A pick
|
||||||
|
// also dispatches pagerite:set-session-lang — pagerite.js swaps the page
|
||||||
|
// in place when the editor is closed (open, the editor reacts to the
|
||||||
|
// store and re-renders it).
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import LangSelect from './LangSelect.vue'
|
||||||
|
import { flagFor, langName, langSort } from './langs'
|
||||||
|
import { useStore } from './store'
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
// The "(primary)" marker is admin-panel information; the public selector
|
||||||
|
// lists plain languages. Order: the primary language first, then the rest
|
||||||
|
// in the lang tab's geographic grouping (./langs langSort) — the head's
|
||||||
|
// hreflang order is just alphabetical.
|
||||||
|
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
|
||||||
|
// may have neither), else the primary.
|
||||||
|
const model = computed(() => store.lang || store.servedLang || primaryTag.value)
|
||||||
|
|
||||||
|
function go(tag) {
|
||||||
|
store.lang = tag === primaryTag.value ? '' : tag
|
||||||
|
dispatchEvent(new CustomEvent('pagerite:set-session-lang', { detail: { lang: tag } }))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LangSelect :model-value="model" :options="options" @update:model-value="go" />
|
||||||
|
</template>
|
||||||
@@ -26,13 +26,14 @@
|
|||||||
// renders the version being edited, whichever language the page itself
|
// renders the version being edited, whichever language the page itself
|
||||||
// was loaded in.
|
// was loaded in.
|
||||||
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { usePopup } from './dropdown'
|
||||||
import { EditorView, basicSetup } from 'codemirror'
|
import { EditorView, basicSetup } from 'codemirror'
|
||||||
import { Compartment, EditorState } from '@codemirror/state'
|
import { Compartment, EditorState } from '@codemirror/state'
|
||||||
import { keymap } from '@codemirror/view'
|
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'
|
||||||
@@ -120,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,
|
||||||
@@ -621,8 +624,15 @@ const TABLE_MAX_ROWS = 6
|
|||||||
// Class pickers: popup listing the block class toggles (placement ↔︎,
|
// Class pickers: popup listing the block class toggles (placement ↔︎,
|
||||||
// text size AA), closed after applying. The block's current class of the
|
// text size AA), closed after applying. The block's current class of the
|
||||||
// group is marked; choosing "normal" (or the current class) removes it.
|
// group is marked; choosing "normal" (or the current class) removes it.
|
||||||
|
// All popups share the close behavior of ./dropdown (outside click /
|
||||||
|
// Escape; never mouseleave).
|
||||||
const classPicker = ref(null) // 'place' | 'size' | null
|
const classPicker = ref(null) // 'place' | 'size' | null
|
||||||
const activeClasses = ref(new Set())
|
const activeClasses = ref(new Set())
|
||||||
|
const placeRoot = ref(null)
|
||||||
|
const sizeRoot = ref(null)
|
||||||
|
const tableRoot = ref(null)
|
||||||
|
usePopup(classPicker, computed(() => (classPicker.value === 'place' ? placeRoot : sizeRoot).value))
|
||||||
|
usePopup(tablePicker, tableRoot)
|
||||||
|
|
||||||
function openClassPicker(which) {
|
function openClassPicker(which) {
|
||||||
classPicker.value = classPicker.value === which ? null : which
|
classPicker.value = classPicker.value === which ? null : which
|
||||||
@@ -1136,15 +1146,31 @@ onUnmounted(() => {
|
|||||||
<div class="format-bar">
|
<div class="format-bar">
|
||||||
<button type="button" class="code-btn" title="code — inline wrap, or a fenced block for line-spanning selections; click again to unwrap" @click="insertCode"><code></></code></button>
|
<button type="button" class="code-btn" title="code — inline wrap, or a fenced block for line-spanning selections; click again to unwrap" @click="insertCode"><code></></code></button>
|
||||||
<button type="button" title="link (toggle: click inside a link to unwrap it)" @click="insertLink">🔗︎</button>
|
<button type="button" title="link (toggle: click inside a link to unwrap it)" @click="insertLink">🔗︎</button>
|
||||||
|
<span class="picker" ref="tableRoot">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="table"
|
title="table"
|
||||||
:class="{ active: tablePicker }"
|
:class="{ active: tablePicker }"
|
||||||
@click="tablePicker = !tablePicker"
|
@click="tablePicker = !tablePicker"
|
||||||
>⊞</button>
|
>⊞</button>
|
||||||
|
<div v-if="tablePicker" class="table-picker" @mouseleave="tableSize = { cols: 0, rows: 0 }">
|
||||||
|
<div class="tp-grid" :style="{ gridTemplateColumns: `repeat(${TABLE_MAX_COLS}, 1fr)` }">
|
||||||
|
<button
|
||||||
|
v-for="n in TABLE_MAX_COLS * TABLE_MAX_ROWS"
|
||||||
|
:key="n"
|
||||||
|
type="button"
|
||||||
|
class="tp-cell"
|
||||||
|
:class="{ on: tableSize.cols >= (n - 1) % TABLE_MAX_COLS + 1 && tableSize.rows >= Math.floor((n - 1) / TABLE_MAX_COLS) + 1 }"
|
||||||
|
@mouseenter="tableSize = { cols: (n - 1) % TABLE_MAX_COLS + 1, rows: Math.floor((n - 1) / TABLE_MAX_COLS) + 1 }"
|
||||||
|
@click="insertTable(tableSize.cols, tableSize.rows)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="tp-size">{{ tableSize.cols || '–' }} × {{ tableSize.rows || '–' }}</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
<button type="button" title="insert image (upload) — pasting works too" @click="fileInput.click()">🖼︎</button>
|
<button type="button" title="insert image (upload) — pasting works too" @click="fileInput.click()">🖼︎</button>
|
||||||
<button type="button" title="aside box (::: aside) — wraps the selection or the cursor's line; clicked inside one, removes it" @click="insertAside">◧</button>
|
<button type="button" title="aside box (::: aside) — wraps the selection or the cursor's line; clicked inside one, removes it" @click="insertAside">◧</button>
|
||||||
<span class="picker">
|
<span class="picker" ref="placeRoot">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="block placement class"
|
title="block placement class"
|
||||||
@@ -1166,7 +1192,7 @@ onUnmounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
<button type="button" title="bold" @click="wrapInline('**')"><b>B</b></button>
|
<button type="button" title="bold" @click="wrapInline('**')"><b>B</b></button>
|
||||||
<button type="button" title="italic" @click="wrapInline('*')"><i>i</i></button>
|
<button type="button" title="italic" @click="wrapInline('*')"><i>i</i></button>
|
||||||
<span class="picker">
|
<span class="picker" ref="sizeRoot">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="text size class"
|
title="text size class"
|
||||||
@@ -1368,7 +1394,7 @@ onUnmounted(() => {
|
|||||||
.table-picker {
|
.table-picker {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: 6.5rem;
|
left: 0;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
|||||||
@@ -782,14 +782,6 @@ onUnmounted(() => {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding: 0 0.2rem;
|
padding: 0 0.2rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-head .icon-btn:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-input {
|
.text-input {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -4,15 +4,20 @@
|
|||||||
// Clicking the IP copies the full address to the clipboard.
|
// Clicking the IP copies the full address to the clipboard.
|
||||||
// ``variantCount`` overrides the UA line to warn when multiple client
|
// ``variantCount`` overrides the UA line to warn when multiple client
|
||||||
// fingerprints share the same IP (e.g. a scanner rotating UAs).
|
// fingerprints share the same IP (e.g. a scanner rotating UAs).
|
||||||
|
// Clicking the UA line copies the raw UA(s) to the clipboard, one per line
|
||||||
|
// (``uaRaws`` carries every variation for multi-client IPs).
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import * as flagSvgs from 'country-flag-icons/string/3x2'
|
import * as flagSvgs from 'country-flag-icons/string/3x2'
|
||||||
import { copyIp, formatLang } from './analytics/format.js'
|
import { copyIp, copyList, formatLang } from './analytics/format.js'
|
||||||
|
import { langName } from './langs.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
ip: { type: String, default: '' },
|
ip: { type: String, default: '' },
|
||||||
ipDisplay: { type: String, default: '—' },
|
ipDisplay: { type: String, default: '—' },
|
||||||
ua: { type: String, default: '' },
|
ua: { type: String, default: '' },
|
||||||
uaRaw: { type: String, default: '' },
|
uaRaw: { type: String, default: '' },
|
||||||
|
uaRaws: { type: String, default: '' },
|
||||||
|
uaUrl: { type: String, default: '' },
|
||||||
country: { type: String, default: '' },
|
country: { type: String, default: '' },
|
||||||
city: { type: String, default: '' },
|
city: { type: String, default: '' },
|
||||||
lang: { type: String, default: '' },
|
lang: { type: String, default: '' },
|
||||||
@@ -26,6 +31,7 @@ const hasCity = computed(() => !!(props.city && props.city !== '—'))
|
|||||||
const hasLocale = computed(() => hasCountry.value || hasCity.value)
|
const hasLocale = computed(() => hasCountry.value || hasCity.value)
|
||||||
const langValue = computed(() => props.langDisplay || formatLang(props.lang))
|
const langValue = computed(() => props.langDisplay || formatLang(props.lang))
|
||||||
const showLang = computed(() => langValue.value && langValue.value !== '—')
|
const showLang = computed(() => langValue.value && langValue.value !== '—')
|
||||||
|
const uaCopy = computed(() => props.uaRaws || props.uaRaw)
|
||||||
|
|
||||||
function flagSvg(code) {
|
function flagSvg(code) {
|
||||||
return flagSvgs[code?.toUpperCase()] || ''
|
return flagSvgs[code?.toUpperCase()] || ''
|
||||||
@@ -58,10 +64,16 @@ function countryName(code) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="visitor-row">
|
<div class="visitor-row">
|
||||||
<div class="ua-line">
|
<div class="ua-line">
|
||||||
<small v-if="variantCount > 1" class="muted variant-hint">{{ variantCount }} client variations</small>
|
<small v-if="variantCount > 1" class="muted variant-hint clickable-ip"
|
||||||
<small v-else class="muted" :title="uaRaw">{{ ua || '—' }}</small>
|
:title="uaCopy"
|
||||||
|
@click="copyList(uaCopy, $event)">{{ variantCount }} client variations</small>
|
||||||
|
<small v-else class="muted clickable-ip" :title="uaRaw"
|
||||||
|
@click="copyList(uaCopy, $event)">{{ ua || '—' }}</small><a v-if="uaUrl && variantCount <= 1"
|
||||||
|
class="ua-link icon-btn" :href="uaUrl"
|
||||||
|
target="_blank" rel="noopener noreferrer"
|
||||||
|
@click.stop>🔗</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showLang && variantCount <= 1" class="locale-lang"><small class="muted">{{ langValue }}</small></div>
|
<div v-if="showLang && variantCount <= 1" class="locale-lang"><small class="muted" :title="langName(lang)">{{ langValue }}</small></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -121,6 +133,13 @@ function countryName(code) {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ua-link {
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.75em;
|
||||||
|
margin-left: 0.2em;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
.locale-lang {
|
.locale-lang {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -25,32 +25,31 @@ export const hostIP = (ip) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showCopiedFeedback(el) {
|
function showCopiedFeedback(el, event) {
|
||||||
if (!el || typeof document === 'undefined') return
|
if (typeof document === 'undefined') return
|
||||||
const popup = document.createElement('span')
|
const popup = document.createElement('span')
|
||||||
popup.textContent = 'Copied!'
|
popup.textContent = 'Copied!'
|
||||||
popup.className = 'copy-popup'
|
popup.className = 'copy-popup'
|
||||||
|
// Fixed to the viewport at the click point: table cells clip absolute
|
||||||
|
// popups with their overflow: hidden ellipsis styling.
|
||||||
|
const x = event?.clientX ?? 0
|
||||||
|
const y = event?.clientY ?? 0
|
||||||
popup.style.cssText =
|
popup.style.cssText =
|
||||||
'position:absolute;bottom:calc(100% + 0.25rem);left:50%;' +
|
`position:fixed;left:${x}px;top:${y}px;` +
|
||||||
'transform:translateX(-50%);padding:0.15rem 0.4rem;' +
|
'transform:translate(-50%, calc(-100% - 0.5rem));padding:0.15rem 0.4rem;' +
|
||||||
'background:var(--text, CanvasText);color:var(--bg, Canvas);' +
|
'background:var(--text, CanvasText);color:var(--bg, Canvas);' +
|
||||||
'border-radius:0.25rem;font-size:0.75rem;white-space:nowrap;' +
|
'border-radius:0.25rem;font-size:0.75rem;white-space:nowrap;' +
|
||||||
'pointer-events:none;z-index:10;'
|
'pointer-events:none;z-index:100;'
|
||||||
el.classList.add('has-copy-popup')
|
document.body.appendChild(popup)
|
||||||
el.appendChild(popup)
|
setTimeout(() => popup.remove(), 1200)
|
||||||
setTimeout(() => {
|
|
||||||
popup.remove()
|
|
||||||
el.classList.remove('has-copy-popup')
|
|
||||||
}, 1200)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Copy the full IP to the clipboard and show a brief "Copied!" popup. */
|
/** Copy the full IP to the clipboard and show a brief "Copied!" popup. */
|
||||||
export async function copyIp(ip, event) {
|
export async function copyIp(ip, event) {
|
||||||
if (!ip) return
|
if (!ip) return
|
||||||
const el = event?.currentTarget
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(ip)
|
await navigator.clipboard.writeText(ip)
|
||||||
showCopiedFeedback(el)
|
showCopiedFeedback(event?.currentTarget, event)
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
@@ -59,10 +58,9 @@ export async function copyIp(ip, event) {
|
|||||||
/** Copy arbitrary text to the clipboard and show a brief "Copied!" popup. */
|
/** Copy arbitrary text to the clipboard and show a brief "Copied!" popup. */
|
||||||
export async function copyList(text, event) {
|
export async function copyList(text, event) {
|
||||||
if (!text) return
|
if (!text) return
|
||||||
const el = event?.currentTarget
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
showCopiedFeedback(el)
|
showCopiedFeedback(event?.currentTarget, event)
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
@@ -342,7 +340,7 @@ export function countCrawlerUas(crawlers, clients) {
|
|||||||
const counts = {}
|
const counts = {}
|
||||||
for (const c of crawlers || []) {
|
for (const c of crawlers || []) {
|
||||||
const client = (clients || {})[c.client] || {}
|
const client = (clients || {})[c.client] || {}
|
||||||
const value = client.ua_pretty || client.ua || '(no UA)'
|
const value = client.uarite?.pretty || client.ua || '(no UA)'
|
||||||
counts[value] = (counts[value] || 0) + 1
|
counts[value] = (counts[value] || 0) + 1
|
||||||
}
|
}
|
||||||
return Object.entries(counts).sort((a, b) => b[1] - a[1])
|
return Object.entries(counts).sort((a, b) => b[1] - a[1])
|
||||||
@@ -421,8 +419,9 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now())
|
|||||||
ip: client.ip || '',
|
ip: client.ip || '',
|
||||||
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip) || client.ip || '—',
|
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip) || client.ip || '—',
|
||||||
isHost,
|
isHost,
|
||||||
ua: client.ua_pretty || client.ua || '—',
|
ua: client.uarite?.pretty || client.ua || '—',
|
||||||
uaRaw: client.ua || '',
|
uaRaw: client.ua || '',
|
||||||
|
uaUrl: client.uarite?.url || '',
|
||||||
lang: client.lang || '—',
|
lang: client.lang || '—',
|
||||||
langDisplay: formatLang(client.lang),
|
langDisplay: formatLang(client.lang),
|
||||||
country: client.country || '—',
|
country: client.country || '—',
|
||||||
@@ -505,6 +504,13 @@ export function formatAbuseRows(abuse, clients, pageTree, now = Date.now()) {
|
|||||||
const client = (clients || {})[g.lastClient] || {}
|
const client = (clients || {})[g.lastClient] || {}
|
||||||
const host = client.host || ''
|
const host = client.host || ''
|
||||||
const isHost = !!host
|
const isHost = !!host
|
||||||
|
const uaRaws = [
|
||||||
|
...new Set(
|
||||||
|
[...g.clientHashes]
|
||||||
|
.map((h) => (clients || {})[h]?.ua)
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
].join('\n')
|
||||||
return {
|
return {
|
||||||
lastSeen: formatWhen(g.lastStart, now),
|
lastSeen: formatWhen(g.lastStart, now),
|
||||||
lastSeenIso: formatWhenIso(g.lastStart),
|
lastSeenIso: formatWhenIso(g.lastStart),
|
||||||
@@ -527,8 +533,10 @@ export function formatAbuseRows(abuse, clients, pageTree, now = Date.now()) {
|
|||||||
ip: client.ip || g.ip,
|
ip: client.ip || g.ip,
|
||||||
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip || g.ip) || client.ip || g.ip || '—',
|
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip || g.ip) || client.ip || g.ip || '—',
|
||||||
isHost,
|
isHost,
|
||||||
ua: client.ua_pretty || client.ua || '—',
|
ua: client.uarite?.pretty || client.ua || '—',
|
||||||
uaRaw: client.ua || '',
|
uaRaw: client.ua || '',
|
||||||
|
uaUrl: client.uarite?.url || '',
|
||||||
|
uaRaws,
|
||||||
lang: client.lang || '—',
|
lang: client.lang || '—',
|
||||||
langDisplay: formatLang(client.lang),
|
langDisplay: formatLang(client.lang),
|
||||||
country: client.country || '—',
|
country: client.country || '—',
|
||||||
@@ -582,8 +590,9 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now()) {
|
|||||||
lang: dash(client.lang),
|
lang: dash(client.lang),
|
||||||
country: dash(client.country),
|
country: dash(client.country),
|
||||||
city: dash(client.city),
|
city: dash(client.city),
|
||||||
ua: client.ua_pretty || client.ua || '—',
|
ua: client.uarite?.pretty || client.ua || '—',
|
||||||
uaRaw: client.ua || '',
|
uaRaw: client.ua || '',
|
||||||
|
uaUrl: client.uarite?.url || '',
|
||||||
utm: utm || '—',
|
utm: utm || '—',
|
||||||
utmTitle,
|
utmTitle,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -756,6 +756,20 @@ article {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Emoji/symbol icon buttons and links: dim until hovered. */
|
||||||
|
.icon-btn {
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.edit-link {
|
.edit-link {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.2rem;
|
top: 0.2rem;
|
||||||
@@ -763,12 +777,6 @@ article {
|
|||||||
left: -2.2rem;
|
left: -2.2rem;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
/* stay above full-bleed .wide images */
|
/* stay above full-bleed .wide images */
|
||||||
font: inherit;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.7;
|
|
||||||
text-shadow: 0 0 0.1em black;
|
text-shadow: 0 0 0.1em black;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,24 +798,14 @@ article h2 .edit-section {
|
|||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-link:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Login/profile links injected by pagerite.js when Paskia SSO is in use.
|
/* Login/profile links injected by pagerite.js when Paskia SSO is in use.
|
||||||
They live inside the .editor-pens flex container in the banner's top-right
|
They live inside the .editor-pens flex container in the banner's top-right
|
||||||
corner and inherit its reset; keep only their opacity/text-shadow tweaks. */
|
corner and inherit its reset; keep only their text-shadow tweak. */
|
||||||
.editor-pens a.login-link,
|
.editor-pens a.login-link,
|
||||||
.editor-pens a.profile-link {
|
.editor-pens a.profile-link {
|
||||||
opacity: 0.7;
|
|
||||||
text-shadow: 0 0 0.1em black;
|
text-shadow: 0 0 0.1em black;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-pens a.login-link:hover,
|
|
||||||
.editor-pens a.profile-link:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
article p,
|
article p,
|
||||||
article li,
|
article li,
|
||||||
article dd {
|
article dd {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Shared popup open-state behavior: while `open` (a ref, truthy = open)
|
||||||
|
// is set, a pointerdown outside `root` (a template ref covering both the
|
||||||
|
// toggle button and the popup) or Escape resets it to null. One logic for
|
||||||
|
// every dropdown (LangSelect, the page editor's class/table pickers), so
|
||||||
|
// they can't drift apart.
|
||||||
|
import { onBeforeUnmount, watch } from 'vue'
|
||||||
|
|
||||||
|
export function usePopup(open, root) {
|
||||||
|
let off = null
|
||||||
|
const stop = watch(open, (v) => {
|
||||||
|
off?.()
|
||||||
|
off = null
|
||||||
|
if (!v) return
|
||||||
|
const down = (ev) => { if (!root.value?.contains(ev.target)) open.value = null }
|
||||||
|
const key = (ev) => { if (ev.key === 'Escape') open.value = null }
|
||||||
|
addEventListener('pointerdown', down, true)
|
||||||
|
addEventListener('keydown', key)
|
||||||
|
off = () => {
|
||||||
|
removeEventListener('pointerdown', down, true)
|
||||||
|
removeEventListener('keydown', key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => { off?.(); stop() })
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
// The editor shell's shared language selection ('' = the primary language):
|
// The editor shell's shared language selection ('' = the primary language):
|
||||||
// one state, v-modeled by the LangSelect of every tab that has one (page,
|
// backed by the app-wide store (./store), so the editor tabs' LangSelects
|
||||||
// structure). While the panel is open it also drives the page preview —
|
// and the public corner selector bind the same value. Linked to the
|
||||||
// EditorShell applies it as the fetch-time language override (swapdoc).
|
// whole-page language: while the panel is open it drives the page preview
|
||||||
import { ref } from 'vue'
|
// (EditorShell applies it as the fetch-time language override, swapdoc).
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { pinia, useStore } from './store'
|
||||||
|
|
||||||
export const editorLang = ref('')
|
export const editorLang = computed({
|
||||||
|
get: () => useStore(pinia).lang,
|
||||||
|
set: (v) => { useStore(pinia).lang = v },
|
||||||
|
})
|
||||||
|
|
||||||
// The CURRENT PAGE's primary language ('' = not yet learned): the shell's
|
// The CURRENT PAGE's primary language ('' = not yet learned): the shell's
|
||||||
// settings fetch fills it with the site default; the page/structure tabs
|
// settings fetch fills it with the site default; the page/structure tabs
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Public language-selector entry: imported on demand by pagerite.js on
|
||||||
|
// pages advertising more than one language in their hreflang alternates.
|
||||||
|
// Vue, Pinia and the flag SVG set live in this chunk only — untranslated
|
||||||
|
// pages never pay for them. The selector's state lives in the shared
|
||||||
|
// store (./store), not the DOM: the corner container is rebuilt freely
|
||||||
|
// and ensureMounted re-mounts from the store.
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import LangSelector from './LangSelector.vue'
|
||||||
|
import { pinia, useStore } from './store'
|
||||||
|
|
||||||
|
let app = null
|
||||||
|
|
||||||
|
function store() {
|
||||||
|
return useStore(pinia)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The current page's languages (called on every navigation).
|
||||||
|
export function setLanguages(alternates, current) {
|
||||||
|
Object.assign(store(), {
|
||||||
|
langAlternates: alternates,
|
||||||
|
servedLang: current,
|
||||||
|
langSelectorActive: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The current page is single-language: the selector goes away.
|
||||||
|
export function hide() {
|
||||||
|
store().langSelectorActive = false
|
||||||
|
app?.unmount()
|
||||||
|
app = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount the selector as the container's first item; re-mount when its
|
||||||
|
// element went away with a container rebuild (a live app updates from the
|
||||||
|
// store reactively).
|
||||||
|
export function ensureMounted(host) {
|
||||||
|
if (!store().langSelectorActive || !host) {
|
||||||
|
app?.unmount()
|
||||||
|
app = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (app && host.contains(app._container)) return
|
||||||
|
app?.unmount()
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.id = 'lang-selector'
|
||||||
|
host.prepend(el)
|
||||||
|
app = createApp(LangSelector)
|
||||||
|
app.use(pinia)
|
||||||
|
app.mount(el)
|
||||||
|
}
|
||||||
+94
-17
@@ -51,13 +51,18 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
url.searchParams.delete("lang");
|
url.searchParams.delete("lang");
|
||||||
history.replaceState(history.state, "", url);
|
history.replaceState(history.state, "", url);
|
||||||
}
|
}
|
||||||
// The session language. While the editor panel is open, its language
|
// The session language: the user's explicit pick (initial ?lang=, public
|
||||||
// selection overrides the normal preference (swapdoc.setLangOverride):
|
// selector, editor dropdown) is kept in chosenLang; while the editor is
|
||||||
// internal fetches and prefetches follow it until the panel closes and
|
// open its selection overrides it (swapdoc.setLangOverride), and closing
|
||||||
// the override clears (null restores the initial ?lang=, if any).
|
// falls back to chosenLang. JS state only — pretty URLs, no reloads.
|
||||||
|
// window.__pageriteLang is the pin for swapdoc.loadPlain's fetches.
|
||||||
|
let chosenLang = langParam;
|
||||||
let sessionLang = langParam;
|
let sessionLang = langParam;
|
||||||
|
window.__pageriteLang = sessionLang;
|
||||||
addEventListener("pagerite:session-lang", (ev) => {
|
addEventListener("pagerite:session-lang", (ev) => {
|
||||||
sessionLang = ev.detail?.lang || langParam;
|
if (ev.detail?.lang) chosenLang = ev.detail.lang;
|
||||||
|
sessionLang = ev.detail?.lang || chosenLang;
|
||||||
|
window.__pageriteLang = sessionLang;
|
||||||
});
|
});
|
||||||
// An internal URL as fetched: carries the session's ?lang= unless the
|
// An internal URL as fetched: carries the session's ?lang= unless the
|
||||||
// link already pins a language of its own. With no ?lang= on the initial
|
// link already pins a language of its own. With no ?lang= on the initial
|
||||||
@@ -127,16 +132,16 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
if (line != null) {
|
if (line != null) {
|
||||||
// Section pen on an anchored h2: opens the page editor at the
|
// Section pen on an anchored h2: opens the page editor at the
|
||||||
// section's markdown source line (data-line, from the backend).
|
// section's markdown source line (data-line, from the backend).
|
||||||
btn.className = "edit-link edit-section";
|
btn.className = "edit-link edit-section icon-btn";
|
||||||
btn.title = "edit section";
|
btn.title = "edit section";
|
||||||
btn.textContent = "🖊️";
|
btn.textContent = "🖊️";
|
||||||
btn.dataset.editorLine = line;
|
btn.dataset.editorLine = line;
|
||||||
} else if (mode === "page") {
|
} else if (mode === "page") {
|
||||||
btn.className = "edit-link edit-page";
|
btn.className = "edit-link edit-page icon-btn";
|
||||||
btn.title = "edit page";
|
btn.title = "edit page";
|
||||||
btn.textContent = "🖊️";
|
btn.textContent = "🖊️";
|
||||||
} else {
|
} else {
|
||||||
btn.className = "edit-link site-edit-link";
|
btn.className = "edit-link site-edit-link icon-btn";
|
||||||
btn.title = "site settings";
|
btn.title = "site settings";
|
||||||
btn.textContent = "⚙️";
|
btn.textContent = "⚙️";
|
||||||
}
|
}
|
||||||
@@ -160,13 +165,29 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
|
|
||||||
function makeAuthLink(admin) {
|
function makeAuthLink(admin) {
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.className = admin ? "profile-link" : "login-link";
|
a.className = (admin ? "profile-link" : "login-link") + " icon-btn";
|
||||||
a.href = "/auth/";
|
a.href = "/auth/";
|
||||||
a.title = admin ? "profile" : "log in";
|
a.title = admin ? "profile" : "log in";
|
||||||
a.textContent = admin ? "\u{1F510}" : "\u{1F511}";
|
a.textContent = admin ? "\u{1F510}" : "\u{1F511}";
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The banner top-right corner container: the language selector (first
|
||||||
|
// item) plus the admin pens and auth links. renderAuthUi rebuilds it from
|
||||||
|
// scratch; the selector's state lives in the shared store, not the DOM,
|
||||||
|
// so the langselect bundle re-mounts it into the fresh container.
|
||||||
|
function pensContainer() {
|
||||||
|
let pens = document.querySelector(".editor-pens");
|
||||||
|
if (!pens) {
|
||||||
|
const banner = document.getElementById("page-banner");
|
||||||
|
if (!banner) return null;
|
||||||
|
pens = document.createElement("div");
|
||||||
|
pens.className = "editor-pens";
|
||||||
|
banner.after(pens);
|
||||||
|
}
|
||||||
|
return pens;
|
||||||
|
}
|
||||||
|
|
||||||
function removePens() {
|
function removePens() {
|
||||||
document.querySelectorAll(".editor-pens, #main article button.edit-link")
|
document.querySelectorAll(".editor-pens, #main article button.edit-link")
|
||||||
.forEach((el) => el.remove());
|
.forEach((el) => el.remove());
|
||||||
@@ -178,22 +199,19 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
// pens that may have been injected while the browser cache made us look
|
// pens that may have been injected while the browser cache made us look
|
||||||
// authenticated.
|
// authenticated.
|
||||||
removePens();
|
removePens();
|
||||||
if (!authReady) return;
|
if (authReady) {
|
||||||
|
|
||||||
// Editing is open for admins and, as a dev/no-proxy fallback, when no
|
// Editing is open for admins and, as a dev/no-proxy fallback, when no
|
||||||
// Paskia SSO is detected at all.
|
// Paskia SSO is detected at all.
|
||||||
const canEdit = isAdmin || !ssoAvailable;
|
const canEdit = isAdmin || !ssoAvailable;
|
||||||
// The analytics page is a read-only dashboard: editing pens and the side
|
// The analytics page is a read-only dashboard: editing pens and the side
|
||||||
// panel do not apply there. Login/logout links are still useful.
|
// panel do not apply there. Login/logout links are still useful.
|
||||||
const onAnalytics = currentPath === "/_a";
|
const onAnalytics = currentPath === "/_a";
|
||||||
const banner = document.getElementById("page-banner");
|
if (document.getElementById("page-banner")) {
|
||||||
if (banner) {
|
const pens = pensContainer();
|
||||||
const pens = document.createElement("div");
|
|
||||||
pens.className = "editor-pens";
|
|
||||||
if (canEdit && !onAnalytics) {
|
if (canEdit && !onAnalytics) {
|
||||||
// Analytics viewer is now a normal page at /_a.
|
// Analytics viewer is now a normal page at /_a.
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.className = "edit-link analytics-link";
|
a.className = "edit-link analytics-link icon-btn";
|
||||||
a.href = "/_a";
|
a.href = "/_a";
|
||||||
a.title = "analytics";
|
a.title = "analytics";
|
||||||
a.textContent = "📊";
|
a.textContent = "📊";
|
||||||
@@ -201,10 +219,14 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
pens.append(makePen("site"));
|
pens.append(makePen("site"));
|
||||||
}
|
}
|
||||||
if (ssoAvailable) pens.append(makeAuthLink(isAdmin));
|
if (ssoAvailable) pens.append(makeAuthLink(isAdmin));
|
||||||
banner.after(pens);
|
if (!pens.firstElementChild) pens.remove();
|
||||||
}
|
}
|
||||||
if (canEdit && !onAnalytics) injectPagePen();
|
if (canEdit && !onAnalytics) injectPagePen();
|
||||||
}
|
}
|
||||||
|
// Re-mount the selector into the fresh container (no-op until the
|
||||||
|
// bundle has been loaded once).
|
||||||
|
langselectMod?.ensureMounted(document.querySelector(".editor-pens"));
|
||||||
|
}
|
||||||
|
|
||||||
async function setupAuth() {
|
async function setupAuth() {
|
||||||
authReady = false;
|
authReady = false;
|
||||||
@@ -407,6 +429,9 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
// copy pinned to a language (?lang=) caches under its own key, where
|
// copy pinned to a language (?lang=) caches under its own key, where
|
||||||
// navigation with the same session language finds it.
|
// navigation with the same session language finds it.
|
||||||
pageCache.set(rawKey(ev.detail.url), ev.detail.html);
|
pageCache.set(rawKey(ev.detail.url), ev.detail.html);
|
||||||
|
// Editor-driven swaps don't go through load(): re-evaluate the
|
||||||
|
// language selector from the fresh copy too.
|
||||||
|
mountLangselect(new DOMParser().parseFromString(ev.detail.html, "text/html"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Editors mutate site-wide state (theme, structure, headings, banners),
|
// Editors mutate site-wide state (theme, structure, headings, banners),
|
||||||
@@ -742,6 +767,56 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Public language selector ------------------------------------------
|
||||||
|
// Pages translated into more than one language advertise it via hreflang
|
||||||
|
// alternates (x-default + one link per language). Those pages get the
|
||||||
|
// editors' flag dropdown as the first item of the corner container; its
|
||||||
|
// bundle (Vue + the flag SVG set) loads on demand. Re-evaluated from the
|
||||||
|
// fresh document on every swap (the head's own alternates stay stale).
|
||||||
|
let langselectMod = null;
|
||||||
|
async function mountLangselect(doc) {
|
||||||
|
const links = [...doc.head.querySelectorAll('link[rel="alternate"][hreflang]')];
|
||||||
|
const dflt = links.find((l) => l.hreflang === "x-default");
|
||||||
|
const langs = links.filter((l) => l.hreflang && l.hreflang !== "x-default");
|
||||||
|
if (!dflt || langs.length <= 1) return langselectMod?.hide();
|
||||||
|
try {
|
||||||
|
langselectMod ??= await import(/* @vite-ignore */ assets["pagerite:langselect-src"]);
|
||||||
|
for (const css of (assets["pagerite:langselect-css"] || "").split(",")) {
|
||||||
|
if (css && !document.querySelector(`link[href="${css}"]`)) {
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.href = css;
|
||||||
|
link.dataset.pagerite = "langselect-css";
|
||||||
|
document.head.append(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
langselectMod.setLanguages(
|
||||||
|
// The original's alternate is the plain URL — x-default's href —
|
||||||
|
// which also marks it as the primary option.
|
||||||
|
langs.map((l) => ({ tag: l.hreflang, href: l.href, primary: l.href === dflt.href })),
|
||||||
|
doc.documentElement.lang,
|
||||||
|
);
|
||||||
|
langselectMod.ensureMounted(pensContainer());
|
||||||
|
} catch (e) {
|
||||||
|
console.error("language selector mount failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The selector's pick (LangSelector dispatches this): make it the
|
||||||
|
// session language and swap the page in place. With the editor open the
|
||||||
|
// pick already landed in the shared store — the editor's watch re-renders
|
||||||
|
// the page itself, so there is nothing to do here.
|
||||||
|
addEventListener("pagerite:set-session-lang", async (ev) => {
|
||||||
|
const tag = ev.detail?.lang;
|
||||||
|
if (!tag || tag === sessionLang) return;
|
||||||
|
if (document.body.classList.contains("editing")) return;
|
||||||
|
chosenLang = sessionLang = tag;
|
||||||
|
window.__pageriteLang = tag;
|
||||||
|
const y = scrollY; // a language switch is not a navigation: keep scroll
|
||||||
|
await load(currentPath, false);
|
||||||
|
scrollTo(0, y);
|
||||||
|
});
|
||||||
|
|
||||||
// --- Fetch navigation ------------------------------------------------
|
// --- Fetch navigation ------------------------------------------------
|
||||||
async function load(url, push = true, back = false) {
|
async function load(url, push = true, back = false) {
|
||||||
// Navigating with the editor open closes it; unsaved edits are lost
|
// Navigating with the editor open closes it; unsaved edits are lost
|
||||||
@@ -843,6 +918,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
runScripts(document.getElementById("main"));
|
runScripts(document.getElementById("main"));
|
||||||
applyEffects();
|
applyEffects();
|
||||||
mountAnalytics(doc);
|
mountAnalytics(doc);
|
||||||
|
mountLangselect(doc);
|
||||||
};
|
};
|
||||||
// Rotating cube page transition (styles injected as #pagerite-transition
|
// Rotating cube page transition (styles injected as #pagerite-transition
|
||||||
// from the selected design's transition.css, e.g. themes/cube/);
|
// from the selected design's transition.css, e.g. themes/cube/);
|
||||||
@@ -1076,4 +1152,5 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
setupAuth();
|
setupAuth();
|
||||||
applyEffects();
|
applyEffects();
|
||||||
mountAnalytics(document);
|
mountAnalytics(document);
|
||||||
|
mountLangselect(document);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// The app's shared Pinia store — cross-bundle UI state lives here. Every
|
||||||
|
// entry chunk imports its own copy of this module, so the Pinia instance
|
||||||
|
// is parked on window (Vue itself is a shared chunk, so reactivity works
|
||||||
|
// across the copies). Pass `pinia` explicitly when calling useStore
|
||||||
|
// outside a component (module code, no active instance).
|
||||||
|
import { createPinia, defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export const pinia = (window.__pageritePinia ??= createPinia())
|
||||||
|
|
||||||
|
export const useStore = defineStore('pagerite', {
|
||||||
|
state: () => ({
|
||||||
|
// The ONE language selection, v-modeled by both dropdowns (editor
|
||||||
|
// tabs, public corner selector): '' = no explicit pick (the page's
|
||||||
|
// primary / autodetect), else a concrete tag. A pick from either
|
||||||
|
// dropdown is visible to everyone immediately.
|
||||||
|
lang: '',
|
||||||
|
// The language the current page was actually served in (set by
|
||||||
|
// pagerite.js per navigation) — the selector's highlight fallback
|
||||||
|
// when there is no explicit pick.
|
||||||
|
servedLang: '',
|
||||||
|
// The public selector's page data: hreflang alternates
|
||||||
|
// ([{tag, href, primary}]) and whether to show at all.
|
||||||
|
langAlternates: [],
|
||||||
|
langSelectorActive: false,
|
||||||
|
}),
|
||||||
|
})
|
||||||
+12
-9
@@ -12,12 +12,13 @@ export function dropPageCache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The editor's language override (set by EditorShell): while the panel is
|
// The editor's language override (set by EditorShell): while the panel is
|
||||||
// open, its language selection wins over the normal preferences (?lang= /
|
// open, its language selection wins over the normal preferences — every
|
||||||
// Accept-Language) — every in-place re-render asks for that language
|
// in-place re-render asks for that language explicitly, and pagerite.js
|
||||||
// explicitly, and pagerite.js applies it to its own fetches and prefetches
|
// applies it to its own fetches and prefetches (pagerite:session-lang).
|
||||||
// (pagerite:session-lang). The primary selection pins by its code:
|
// The primary selection pins by its code: ?lang=<primary> selects the
|
||||||
// ?lang=<primary> selects the original explicitly (i18n.select_language).
|
// original explicitly (i18n.select_language). Panel closed, the session's
|
||||||
let overrideLang = null // the ?lang= value in force, null = normal prefs
|
// chosen language (window.__pageriteLang) takes over — the pick stays.
|
||||||
|
let overrideLang = null // the ?lang= value in force, null = the session's
|
||||||
|
|
||||||
export function setLangOverride(queryLang) {
|
export function setLangOverride(queryLang) {
|
||||||
overrideLang = queryLang || null
|
overrideLang = queryLang || null
|
||||||
@@ -129,14 +130,16 @@ function swapRegions(doc) {
|
|||||||
// Fetch /p, swap its regions into the live page and replaceState to it.
|
// Fetch /p, swap its regions into the live page and replaceState to it.
|
||||||
// Returns the final URL (after redirects), or null when the fetch did not
|
// Returns the final URL (after redirects), or null when the fetch did not
|
||||||
// yield a page. Category and missing URLs render a placeholder 404 page —
|
// yield a page. Category and missing URLs render a placeholder 404 page —
|
||||||
// fine to swap in (new pages are created by editing them). While the
|
// fine to swap in (new pages are created by editing them). The fetch pins
|
||||||
// editor's language override is set the fetch pins that language.
|
// the editor's language override, or — panel closed — the session's chosen
|
||||||
|
// language (window.__pageriteLang).
|
||||||
export async function loadPlain(p) {
|
export async function loadPlain(p) {
|
||||||
let doc
|
let doc
|
||||||
let finalUrl = `/${p}`
|
let finalUrl = `/${p}`
|
||||||
let html
|
let html
|
||||||
try {
|
try {
|
||||||
const res = await fetch(overrideLang ? `${finalUrl}?lang=${overrideLang}` : finalUrl)
|
const pin = overrideLang || window.__pageriteLang
|
||||||
|
const res = await fetch(pin ? `${finalUrl}?lang=${pin}` : finalUrl)
|
||||||
const type = res.headers.get('content-type') || ''
|
const type = res.headers.get('content-type') || ''
|
||||||
if (!type.includes('text/html')) return null
|
if (!type.includes('text/html')) return null
|
||||||
if (res.redirected) finalUrl = res.url
|
if (res.redirected) finalUrl = res.url
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default defineConfig({
|
|||||||
chunkSizeWarningLimit: 1200,
|
chunkSizeWarningLimit: 1200,
|
||||||
// Mirror the URL space in the build output: hashed files land under
|
// Mirror the URL space in the build output: hashed files land under
|
||||||
// frontend-build/_assets/ and the Frontend serves the build directory
|
// frontend-build/_assets/ and the Frontend serves the build directory
|
||||||
// at the site root (frontend/public/favicon.ico -> /favicon.ico).
|
// at the site root.
|
||||||
manifest: true,
|
manifest: true,
|
||||||
assetsDir: '_assets',
|
assetsDir: '_assets',
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
@@ -50,6 +50,7 @@ export default defineConfig({
|
|||||||
main: fileURLToPath(new URL('./src/main.js', import.meta.url)),
|
main: fileURLToPath(new URL('./src/main.js', import.meta.url)),
|
||||||
pagerite: fileURLToPath(new URL('./src/pagerite.js', import.meta.url)),
|
pagerite: fileURLToPath(new URL('./src/pagerite.js', import.meta.url)),
|
||||||
analytics: fileURLToPath(new URL('./src/analytics-main.js', import.meta.url)),
|
analytics: fileURLToPath(new URL('./src/analytics-main.js', import.meta.url)),
|
||||||
|
langselect: fileURLToPath(new URL('./src/langselect-main.js', import.meta.url)),
|
||||||
// Only the base CSS is built; theme/banner-design stylesheets live
|
// Only the base CSS is built; theme/banner-design stylesheets live
|
||||||
// in pagerite/themes/{name}/ and are served by the backend as-is.
|
// in pagerite/themes/{name}/ and are served by the backend as-is.
|
||||||
pagerite_base: fileURLToPath(new URL('./src/assets/pagerite.css', import.meta.url)),
|
pagerite_base: fileURLToPath(new URL('./src/assets/pagerite.css', import.meta.url)),
|
||||||
|
|||||||
+26
-34
@@ -60,32 +60,17 @@ from urllib.parse import parse_qs, urlencode, urlparse
|
|||||||
|
|
||||||
import blake3
|
import blake3
|
||||||
import msgspec
|
import msgspec
|
||||||
from ua_parser import parse
|
from uarite import UA, uaparse
|
||||||
|
|
||||||
|
|
||||||
def _compact_user_agent(ua: str) -> str:
|
def _display_client(client: Client) -> Client:
|
||||||
"""Format a User-Agent string into a compact display form.
|
"""Client copy with ``uarite`` filled in by the current uarite.
|
||||||
|
|
||||||
Returns the original UA when the parser cannot identify the browser/OS.
|
The parsed UA is a display-time field: stored records always carry the
|
||||||
|
default (None), so it never lands on disk, and old records always
|
||||||
|
follow current uarite rules.
|
||||||
"""
|
"""
|
||||||
if not ua or not ua.strip() or ua == "-":
|
return msgspec.structs.replace(client, uarite=uaparse(client.ua))
|
||||||
return ""
|
|
||||||
r = parse(ua)
|
|
||||||
browser = r.user_agent.family if r.user_agent else None
|
|
||||||
ver = r.user_agent.major if r.user_agent else ""
|
|
||||||
os_name = r.os.family if r.os else None
|
|
||||||
dev = r.device.family if r.device else None
|
|
||||||
if browser in (None, "Other") and os_name in (None, "Other"):
|
|
||||||
return ua
|
|
||||||
if browser and browser != "Other":
|
|
||||||
browser = browser.split()[0]
|
|
||||||
else:
|
|
||||||
browser = ""
|
|
||||||
os_name = os_name if os_name and os_name != "Other" else ""
|
|
||||||
if dev in (None, "Other") or dev == browser:
|
|
||||||
dev = ""
|
|
||||||
parts = [f"{browser}/{ver}" if browser else "", os_name, dev]
|
|
||||||
return " ".join(p for p in parts if p).strip()
|
|
||||||
|
|
||||||
|
|
||||||
class Ping(msgspec.Struct, omit_defaults=True):
|
class Ping(msgspec.Struct, omit_defaults=True):
|
||||||
@@ -172,11 +157,14 @@ class Client(msgspec.Struct, omit_defaults=True):
|
|||||||
city: str = ""
|
city: str = ""
|
||||||
#: Raw User-Agent header.
|
#: Raw User-Agent header.
|
||||||
ua: str = ""
|
ua: str = ""
|
||||||
#: Compact display form of ``ua`` (browser/OS/device) when parsable.
|
|
||||||
ua_pretty: str = ""
|
|
||||||
#: True for admin clients (hide=1 ping): everything this client ever did
|
#: True for admin clients (hide=1 ping): everything this client ever did
|
||||||
#: is excluded from all statistics and from the viewer payload.
|
#: is excluded from all statistics and from the viewer payload.
|
||||||
hide: bool = False
|
hide: bool = False
|
||||||
|
#: Display-time parsed UA (uarite.UA dataclass: pretty/engine/os/
|
||||||
|
#: provider/kind/url). Set only on the display-payload copies by
|
||||||
|
#: ``_display_client`` — stored records keep the default, so it is never
|
||||||
|
#: persisted and old data always follows the current uarite version.
|
||||||
|
uarite: UA | None = None
|
||||||
|
|
||||||
|
|
||||||
# --- Display DTOs -------------------------------------------------------
|
# --- Display DTOs -------------------------------------------------------
|
||||||
@@ -418,17 +406,22 @@ _MIN_VISIT_READ = 5
|
|||||||
_FAVICON_RETRY = timedelta(days=7)
|
_FAVICON_RETRY = timedelta(days=7)
|
||||||
|
|
||||||
#: UAs of JS-running crawlers, which would register as visitors on their
|
#: UAs of JS-running crawlers, which would register as visitors on their
|
||||||
#: activity messages. Anything calling itself a "bot" or "spider" matches;
|
#: activity messages. ``uarite`` knows the common crawlers
|
||||||
#: known crawlers without those tokens (GoogleOther) are listed as extra
|
#: and link-preview fetchers (including disguised ones such as
|
||||||
#: alternates. No source verification: a spoofed bot UA just lands in the
|
#: facebookexternalhit and Google-Extended) plus any UA with a
|
||||||
#: crawler list, and scanners that probe telltale paths are caught by the
|
#: bot/spider/crawler/scanner token. No source verification: a spoofed
|
||||||
#: abuse rules anyway.
|
#: bot UA just lands in the crawler list, and scanners that probe
|
||||||
_BOT_UA = re.compile(r"bot|spider|googleother", re.IGNORECASE)
|
#: telltale paths are caught by the abuse rules anyway.
|
||||||
|
|
||||||
|
|
||||||
def _is_bot_ua(ua: str) -> bool:
|
def _is_bot_ua(ua: str) -> bool:
|
||||||
"""True when the UA claims a crawler identity (bot or spider)."""
|
"""True when the UA is not a regular browser.
|
||||||
return bool(_BOT_UA.search(ua))
|
|
||||||
|
Every real browser registers as ``kind == "browser"``; anything else
|
||||||
|
(recognized crawler/previewer, generic bot token, or an unclassified
|
||||||
|
HTTP client such as httpx) is not a visitor.
|
||||||
|
"""
|
||||||
|
return uaparse(ua).kind != "browser"
|
||||||
|
|
||||||
|
|
||||||
#: Plain-404 count per IP within ``_ABUSE_404_WINDOW`` that classifies it as
|
#: Plain-404 count per IP within ``_ABUSE_404_WINDOW`` that classifies it as
|
||||||
@@ -560,7 +553,6 @@ class Store:
|
|||||||
self.data.clients[h] = Client(
|
self.data.clients[h] = Client(
|
||||||
ip=ip,
|
ip=ip,
|
||||||
ua=ua,
|
ua=ua,
|
||||||
ua_pretty=_compact_user_agent(ua),
|
|
||||||
lang=lang,
|
lang=lang,
|
||||||
country=country,
|
country=country,
|
||||||
)
|
)
|
||||||
@@ -940,7 +932,7 @@ class Store:
|
|||||||
and not self._hidden(g.client)
|
and not self._hidden(g.client)
|
||||||
and ip_of.get(g.client, "") in abuse_ips
|
and ip_of.get(g.client, "") in abuse_ips
|
||||||
],
|
],
|
||||||
clients={h: c for h, c in data.clients.items() if not c.hide},
|
clients={h: _display_client(c) for h, c in data.clients.items() if not c.hide},
|
||||||
favicons={
|
favicons={
|
||||||
origin: f"/_f/{f.file}"
|
origin: f"/_f/{f.file}"
|
||||||
for origin, f in data.favicons.items()
|
for origin, f in data.favicons.items()
|
||||||
|
|||||||
@@ -503,6 +503,14 @@ async def editor_ws(ws: WebSocket) -> None:
|
|||||||
# The title is injected as h1 when the markdown has
|
# The title is injected as h1 when the markdown has
|
||||||
# none; the editor's title field edits live-preview.
|
# none; the editor's title field edits live-preview.
|
||||||
title=msg.get("title") or (node.title if node else ""),
|
title=msg.get("title") or (node.title if node else ""),
|
||||||
|
# Pin section anchors to the original language so the
|
||||||
|
# preview of a translation matches the served page
|
||||||
|
# (no-op when the previewed markdown is the original).
|
||||||
|
anchors_from=(
|
||||||
|
(node_markdown(data, node) or "", node.title)
|
||||||
|
if node
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await ws.send_json(
|
await ws.send_json(
|
||||||
{
|
{
|
||||||
|
|||||||
+2
-2
@@ -48,7 +48,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Vue build served at the site root, no SPA catch-all (assets only). The
|
# Vue build served at the site root, no SPA catch-all (assets only). The
|
||||||
# build mirrors the URL space: hashed, immutable files live under
|
# build mirrors the URL space: hashed, immutable files live under
|
||||||
# /_assets/ (assetsDir: '_/assets'), the favicon at /favicon.ico.
|
# /_assets/ (assetsDir: '_/assets').
|
||||||
frontend = Frontend(
|
frontend = Frontend(
|
||||||
Path(__file__).with_name("frontend-build"), spa=False, cached="/_assets/"
|
Path(__file__).with_name("frontend-build"), spa=False, cached="/_assets/"
|
||||||
)
|
)
|
||||||
@@ -124,7 +124,7 @@ app.include_router(tracking.router)
|
|||||||
app.include_router(files.router)
|
app.include_router(files.router)
|
||||||
|
|
||||||
# Vue build asset routes are inserted at this position during load(): the
|
# Vue build asset routes are inserted at this position during load(): the
|
||||||
# build mirrors the URL space (/_assets/*, /favicon.ico at the root).
|
# build mirrors the URL space (/_assets/*).
|
||||||
frontend.route(app, "/")
|
frontend.route(app, "/")
|
||||||
|
|
||||||
# The content catch-all goes last: built assets win over content slugs,
|
# The content catch-all goes last: built assets win over content slugs,
|
||||||
|
|||||||
+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):
|
||||||
|
|||||||
+2
-2
@@ -98,8 +98,8 @@ class Data(msgspec.Struct):
|
|||||||
#: Trusted author content; not sanitized.
|
#: Trusted author content; not sanitized.
|
||||||
custom_css: str = ""
|
custom_css: str = ""
|
||||||
#: Favicon: content-addressed file name (served at "/_f/{name}"),
|
#: Favicon: content-addressed file name (served at "/_f/{name}"),
|
||||||
#: linked as <link rel="icon"> on every page. Empty = the build's
|
#: linked as <link rel="icon"> on every page; /favicon.ico redirects
|
||||||
#: /favicon.ico.
|
#: to it. Empty = no icon (and /favicon.ico 404s).
|
||||||
favicon: str = ""
|
favicon: str = ""
|
||||||
#: API keys gating the translator service WebSocket (/_translate/{key};
|
#: API keys gating the translator service WebSocket (/_translate/{key};
|
||||||
#: the external forward-auth does not cover that route): key -> display
|
#: the external forward-auth does not cover that route): key -> display
|
||||||
|
|||||||
+19
-3
@@ -6,7 +6,8 @@ when compression shrinks the body), served immutable at ``/_f/``. Raster
|
|||||||
images and SVGs are recompressed into AVIF/WebP/JPEG derivatives
|
images and SVGs are recompressed into AVIF/WebP/JPEG derivatives
|
||||||
(``store_image`` and helpers); the untouched original is kept alongside as
|
(``store_image`` and helpers); the untouched original is kept alongside as
|
||||||
``<hash>.orig<ext>`` (never served). Routes: upload/delete under
|
``<hash>.orig<ext>`` (never served). Routes: upload/delete under
|
||||||
``/_api/files``, the favicon settings endpoints, the ``/_f/`` server with
|
``/_api/files``, the favicon settings endpoints, the /favicon.ico
|
||||||
|
redirect to the configured icon, the ``/_f/`` server with
|
||||||
Accept-negotiated formats, and the user assets (``/_themes/``, ``/_fonts/``).
|
Accept-negotiated formats, and the user assets (``/_themes/``, ``/_fonts/``).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import blake3
|
import blake3
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import RedirectResponse, Response
|
||||||
from mediapreview import dispatch
|
from mediapreview import dispatch
|
||||||
|
|
||||||
from pagerite import views
|
from pagerite import views
|
||||||
@@ -252,6 +253,20 @@ async def delete_file(name: str) -> None:
|
|||||||
file_store.delete(name)
|
file_store.delete(name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/favicon.ico", include_in_schema=False)
|
||||||
|
async def favicon_ico() -> Response:
|
||||||
|
"""The conventional /favicon.ico: redirect to the configured site icon.
|
||||||
|
|
||||||
|
Browsers request this path on their own (tabs, bookmarks, feeds and
|
||||||
|
other non-HTML contexts) regardless of the <link rel="icon"> pages
|
||||||
|
carry. Redirect to the icon's store URL, which negotiates the format
|
||||||
|
and caches immutably; 404 when no custom icon is configured.
|
||||||
|
"""
|
||||||
|
if not data.favicon:
|
||||||
|
raise HTTPException(404)
|
||||||
|
return RedirectResponse(f"/_f/{data.favicon}")
|
||||||
|
|
||||||
|
|
||||||
@router.put("/_api/settings/favicon")
|
@router.put("/_api/settings/favicon")
|
||||||
async def put_favicon(request: Request) -> dict[str, str]:
|
async def put_favicon(request: Request) -> dict[str, str]:
|
||||||
"""Upload a favicon into the content-addressed store and activate it.
|
"""Upload a favicon into the content-addressed store and activate it.
|
||||||
@@ -276,7 +291,8 @@ async def put_favicon(request: Request) -> dict[str, str]:
|
|||||||
|
|
||||||
@router.delete("/_api/settings/favicon", status_code=204)
|
@router.delete("/_api/settings/favicon", status_code=204)
|
||||||
async def delete_favicon(request: Request) -> None:
|
async def delete_favicon(request: Request) -> None:
|
||||||
"""Clear the custom favicon (back to the build's /favicon.ico).
|
"""Clear the custom favicon (/favicon.ico goes back to 404, pages drop
|
||||||
|
the <link rel="icon">).
|
||||||
|
|
||||||
The blob stays in the content-addressed store; only the reference goes.
|
The blob stays in the content-addressed store; only the reference goes.
|
||||||
"""
|
"""
|
||||||
|
|||||||
+51
-3
@@ -402,7 +402,9 @@ def _heading_ids(state) -> None:
|
|||||||
its self-link is ``href=""`` (back to the top of the page). An
|
its self-link is ``href=""`` (back to the top of the page). An
|
||||||
author-set `{#id}` always wins; auto ids slugify the heading text
|
author-set `{#id}` always wins; auto ids slugify the heading text
|
||||||
(python-slugify, mirroring the editor's slugify.js) and dedupe with
|
(python-slugify, mirroring the editor's slugify.js) and dedupe with
|
||||||
-2/-3 suffixes per render. Headings that already contain a link are
|
-2/-3 suffixes per render — unless env["anchor_ids"] presets them, as
|
||||||
|
render(anchors_from=...) does for translated pages so section URLs
|
||||||
|
stay in the original language. Headings that already contain a link are
|
||||||
``data-line`` records the heading's markdown source line (0-based, after
|
``data-line`` records the heading's markdown source line (0-based, after
|
||||||
undoing the render(title=...) injection offset via ``env``) — the page
|
undoing the render(title=...) injection offset via ``env``) — the page
|
||||||
editor uses it for section pens and piecewise-linear scroll sync.
|
editor uses it for section pens and piecewise-linear scroll sync.
|
||||||
@@ -443,13 +445,23 @@ def _heading_ids(state) -> None:
|
|||||||
if len(heads) < ANCHOR_MIN_HEADINGS:
|
if len(heads) < ANCHOR_MIN_HEADINGS:
|
||||||
return
|
return
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for i, token in heads:
|
preset = state.env.get("anchor_ids")
|
||||||
|
for k, (i, token) in enumerate(heads):
|
||||||
inline = tokens[i + 1]
|
inline = tokens[i + 1]
|
||||||
hid = token.attrGet("id")
|
hid = token.attrGet("id")
|
||||||
if not isinstance(hid, str) or not hid:
|
if not isinstance(hid, str) or not hid:
|
||||||
|
if preset is not None and k < len(preset):
|
||||||
|
# Translated render: the original language's slug, matched
|
||||||
|
# by heading position (a translation never adds, removes or
|
||||||
|
# reorders headings; a patched one that does falls back to
|
||||||
|
# slugging its own text past the end of the list).
|
||||||
|
base = preset[k]
|
||||||
|
else:
|
||||||
# Slug the visible text, not the raw markdown (`## [a](url)`).
|
# Slug the visible text, not the raw markdown (`## [a](url)`).
|
||||||
text = "".join(
|
text = "".join(
|
||||||
c.content for c in inline.children if c.type in ("text", "code_inline")
|
c.content
|
||||||
|
for c in inline.children
|
||||||
|
if c.type in ("text", "code_inline")
|
||||||
)
|
)
|
||||||
base = slugify(text) or "section"
|
base = slugify(text) or "section"
|
||||||
hid, n = base, 2
|
hid, n = base, 2
|
||||||
@@ -463,6 +475,36 @@ def _heading_ids(state) -> None:
|
|||||||
wrap(i, token, f"#{hid}")
|
wrap(i, token, f"#{hid}")
|
||||||
|
|
||||||
|
|
||||||
|
def anchor_ids(text: str, title: str | None = None) -> list[str]:
|
||||||
|
"""The section anchor ids of text, in heading order.
|
||||||
|
|
||||||
|
render(anchors_from=...) feeds these to _heading_ids via
|
||||||
|
env["anchor_ids"], pinning a translated render's anchors to the
|
||||||
|
original language's slugs. The selection mirrors _heading_ids exactly
|
||||||
|
(the same md instance assigns the ids during this parse, author-set
|
||||||
|
{#id} included as-is); the in-body title h1 is excluded.
|
||||||
|
"""
|
||||||
|
if title and not has_h1(text):
|
||||||
|
text = f"# {title}\n\n{text}"
|
||||||
|
tokens = md.parse(text, {"page_path": ""})
|
||||||
|
first_h1 = next(
|
||||||
|
(
|
||||||
|
i
|
||||||
|
for i, t in enumerate(tokens)
|
||||||
|
if t.type == "heading_open" and t.tag == "h1" and t.level == 0
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
t.attrGet("id")
|
||||||
|
for i, t in enumerate(tokens)
|
||||||
|
if t.type == "heading_open"
|
||||||
|
and t.tag in ("h1", "h2")
|
||||||
|
and t.level == 0
|
||||||
|
and i != first_h1
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def make_md(*, verbatim: bool = False) -> MarkdownIt:
|
def make_md(*, verbatim: bool = False) -> MarkdownIt:
|
||||||
"""A fully configured parser. The module-level ``md`` (below) is the
|
"""A fully configured parser. The module-level ``md`` (below) is the
|
||||||
render instance; ``verbatim=True`` builds the segmentation instance for
|
render instance; ``verbatim=True`` builds the segmentation instance for
|
||||||
@@ -618,12 +660,16 @@ def render(
|
|||||||
created: datetime | None = None,
|
created: datetime | None = None,
|
||||||
modified: datetime | None = None,
|
modified: datetime | None = None,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
|
anchors_from: tuple[str, str] | None = None,
|
||||||
) -> Rendered:
|
) -> Rendered:
|
||||||
"""Render Markdown text to the article body's HTML and layout flags.
|
"""Render Markdown text to the article body's HTML and layout flags.
|
||||||
|
|
||||||
``title`` injects a ``# {title}`` line at the top when the markdown has
|
``title`` injects a ``# {title}`` line at the top when the markdown has
|
||||||
no h1 of its own, so the implicit page title goes through the exact
|
no h1 of its own, so the implicit page title goes through the exact
|
||||||
same pipeline as an explicit one (first-h1 anchor treatment included).
|
same pipeline as an explicit one (first-h1 anchor treatment included).
|
||||||
|
``anchors_from`` is the (markdown, title) of the ORIGINAL language when
|
||||||
|
rendering a translation: section anchors are pinned to its slugs so
|
||||||
|
localized pages keep the original #hash URLs.
|
||||||
|
|
||||||
The top-level blocks are grouped into column segments: boundary blocks
|
The top-level blocks are grouped into column segments: boundary blocks
|
||||||
(h1/h2 headings, .wide — see _is_boundary) are rendered bare, the runs
|
(h1/h2 headings, .wide — see _is_boundary) are rendered bare, the runs
|
||||||
@@ -641,6 +687,8 @@ def render(
|
|||||||
right after the article's h1.
|
right after the article's h1.
|
||||||
"""
|
"""
|
||||||
env = {"page_path": page_path, "line_offset": 0}
|
env = {"page_path": page_path, "line_offset": 0}
|
||||||
|
if anchors_from is not None:
|
||||||
|
env["anchor_ids"] = anchor_ids(*anchors_from)
|
||||||
if title and not has_h1(text):
|
if title and not has_h1(text):
|
||||||
text = f"# {title}\n\n{text}"
|
text = f"# {title}\n\n{text}"
|
||||||
# The injected title shifts source lines by two; _heading_ids
|
# The injected title shifts source lines by two; _heading_ids
|
||||||
|
|||||||
+93
-20
@@ -20,8 +20,13 @@ each segment's source span was located at dispatch (``split``), and
|
|||||||
``join`` swaps in the translations. Markup therefore cannot break — it
|
``join`` swaps in the translations. Markup therefore cannot break — it
|
||||||
never left the server. A returned segment must still be pure prose itself
|
never left the server. A returned segment must still be pure prose itself
|
||||||
(the model could inject markup INTO a segment); anything else — count
|
(the model could inject markup INTO a segment); anything else — count
|
||||||
mismatch, empty segment, markup tokens — rejects the whole result and the
|
mismatch, empty segment, markup tokens, a line that would start a new
|
||||||
fragment stays pending.
|
block (a ``` or ::: fence would eat the rest of the block it lands in) —
|
||||||
|
rejects the whole result and the
|
||||||
|
fragment stays pending. Punctuation that is prose on the wire but syntax
|
||||||
|
in the splice context (quotes in a title attribute, brackets in an alt
|
||||||
|
text, "|" in a table row) is not worth a rejection either: it is swapped
|
||||||
|
for Unicode look-alikes (``_NEUTRAL``) before splicing.
|
||||||
|
|
||||||
A block of plain text, prose links and paired text formatting
|
A block of plain text, prose links and paired text formatting
|
||||||
(strong/em/s) crosses as ONE segment — link texts and formatted text
|
(strong/em/s) crosses as ONE segment — link texts and formatted text
|
||||||
@@ -42,9 +47,11 @@ snippets that don't fit together. Blocks with any other inline markup
|
|||||||
|
|
||||||
Locating is best effort: a run that is not a verbatim source substring
|
Locating is best effort: a run that is not a verbatim source substring
|
||||||
(entity-decoded text, backslash escapes) is skipped — it simply stays in
|
(entity-decoded text, backslash escapes) is skipped — it simply stays in
|
||||||
the original language. So is any piece containing "<": "<" is the
|
the original language. A literal "<" in prose ("<1MB") is text, not
|
||||||
prose/markup boundary on the wire — translators cut their output there,
|
markup, but cannot cross as-is — "<" is the prose/markup boundary on the
|
||||||
so such pieces could not survive the round trip.
|
wire, translators cut their output there — so it crosses encoded as the
|
||||||
|
fullwidth "<" (``_encode``) and ``join`` decodes it back before
|
||||||
|
validating and splicing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import bisect
|
import bisect
|
||||||
@@ -69,6 +76,38 @@ _ALERT = re.compile(r"^\[![A-Za-z]+\][ \t]*")
|
|||||||
#: (inline attrs are consumed by the parser; a lone {dates} is not).
|
#: (inline attrs are consumed by the parser; a lone {dates} is not).
|
||||||
_BRACES = re.compile(r"\{[^{}\n]*\}")
|
_BRACES = re.compile(r"\{[^{}\n]*\}")
|
||||||
|
|
||||||
|
|
||||||
|
def _encode(text: str) -> str:
|
||||||
|
"""Wire form of a segment or context: a literal "<" as fullwidth "<".
|
||||||
|
|
||||||
|
A "<" in prose is text, not markup ("<1MB" — a tag needs a letter or
|
||||||
|
/!?), but "<" is the prose/markup boundary on the wire (translators
|
||||||
|
cut output at the first "<", scripts/translator.py), so it cannot
|
||||||
|
cross as-is. join decodes it back before the pure_prose check and
|
||||||
|
splicing — anything tag-like the model may have formed around it is
|
||||||
|
still rejected there.
|
||||||
|
"""
|
||||||
|
return text.replace("<", "<")
|
||||||
|
|
||||||
|
#: ASCII punctuation that is plain prose to the inline parser (so
|
||||||
|
#: pure_prose cannot catch it) but Markdown SYNTAX in a splice context:
|
||||||
|
#: quotes close a quoted image/link title, brackets the [...] of alt and
|
||||||
|
#: re-inserted link texts, "|" splits a table row, and "\" escapes the
|
||||||
|
#: character after it (a trailing one eats a title's closing quote).
|
||||||
|
#: Neutralized to Unicode look-alikes (join), which Markdown treats as
|
||||||
|
#: plain text everywhere — the quotes are curled the way typographer=True
|
||||||
|
#: renders them anyway.
|
||||||
|
_NEUTRAL = str.maketrans(
|
||||||
|
{
|
||||||
|
'"': "”",
|
||||||
|
"'": "’",
|
||||||
|
"[": "[",
|
||||||
|
"]": "]",
|
||||||
|
"\\": "\",
|
||||||
|
"|": "│",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
#: A link's tail after its text: "](dest)", "](dest \"title\")", "][ref]",
|
#: A link's tail after its text: "](dest)", "](dest \"title\")", "][ref]",
|
||||||
#: "[]" or a bare "]" (shortcut reference); the destination may nest one
|
#: "[]" or a bare "]" (shortcut reference); the destination may nest one
|
||||||
#: level of parens. Best effort — a mis-scan fails the span-reconstruction
|
#: level of parens. Best effort — a mis-scan fails the span-reconstruction
|
||||||
@@ -273,7 +312,7 @@ def _linked_block(
|
|||||||
raw = "".join(text for text, _ in pieces)
|
raw = "".join(text for text, _ in pieces)
|
||||||
lead = len(raw) - len(raw.lstrip())
|
lead = len(raw) - len(raw.lstrip())
|
||||||
wire = raw.strip()
|
wire = raw.strip()
|
||||||
if not _LETTER.search(wire) or "<" in wire or _BRACES.search(wire):
|
if not _LETTER.search(wire) or _BRACES.search(wire):
|
||||||
return None
|
return None
|
||||||
# Locate each piece verbatim, in order; the source slices between the
|
# Locate each piece verbatim, in order; the source slices between the
|
||||||
# located pieces are then the link syntax, exact by construction.
|
# located pieces are then the link syntax, exact by construction.
|
||||||
@@ -338,7 +377,7 @@ def _linked_block(
|
|||||||
rec.append(text_)
|
rec.append(text_)
|
||||||
if source[span_start:span_end] != "".join(rec):
|
if source[span_start:span_end] != "".join(rec):
|
||||||
return None
|
return None
|
||||||
return Span(span_start, span_end, _weight(wire), marks), wire
|
return Span(span_start, span_end, _weight(wire), marks), _encode(wire)
|
||||||
|
|
||||||
|
|
||||||
def split(text: str) -> tuple[list[Span], list[str], list[str]]:
|
def split(text: str) -> tuple[list[Span], list[str], list[str]]:
|
||||||
@@ -367,10 +406,8 @@ def split(text: str) -> tuple[list[Span], list[str], list[str]]:
|
|||||||
def emit(run: str, at: int, ctx: str) -> None:
|
def emit(run: str, at: int, ctx: str) -> None:
|
||||||
"""Carve {...} spans out of the located run; emit the prose pieces,
|
"""Carve {...} spans out of the located run; emit the prose pieces,
|
||||||
stripped — padding whitespace stays in the template, off the wire.
|
stripped — padding whitespace stays in the template, off the wire.
|
||||||
Pieces containing "<" are never emitted: translators cut output at
|
A literal "<" crosses encoded (``_encode``): it is text, not
|
||||||
the first "<" (the prose/markup boundary, scripts/translator.py),
|
markup, but the wire keeps "<" as the prose/markup boundary."""
|
||||||
so such a piece could not survive the round trip — it stays in the
|
|
||||||
original language instead."""
|
|
||||||
pieces = []
|
pieces = []
|
||||||
pos = 0
|
pos = 0
|
||||||
for m in _BRACES.finditer(run):
|
for m in _BRACES.finditer(run):
|
||||||
@@ -380,10 +417,10 @@ def split(text: str) -> tuple[list[Span], list[str], list[str]]:
|
|||||||
for p0, p1 in pieces:
|
for p0, p1 in pieces:
|
||||||
raw = run[p0:p1]
|
raw = run[p0:p1]
|
||||||
piece = raw.strip()
|
piece = raw.strip()
|
||||||
if _LETTER.search(piece) and "<" not in piece:
|
if _LETTER.search(piece):
|
||||||
start = at + p0 + (len(raw) - len(raw.lstrip()))
|
start = at + p0 + (len(raw) - len(raw.lstrip()))
|
||||||
spans.append(Span(start, start + len(piece), 0, []))
|
spans.append(Span(start, start + len(piece), 0, []))
|
||||||
segments.append(piece)
|
segments.append(_encode(piece))
|
||||||
contexts.append(ctx)
|
contexts.append(ctx)
|
||||||
|
|
||||||
tokens = _MD.parse(text)
|
tokens = _MD.parse(text)
|
||||||
@@ -409,7 +446,7 @@ def split(text: str) -> tuple[list[Span], list[str], list[str]]:
|
|||||||
cursor = span.end
|
cursor = span.end
|
||||||
continue
|
continue
|
||||||
runs = _runs(kids)
|
runs = _runs(kids)
|
||||||
block = _block_text(kids).strip()
|
block = _encode(_block_text(kids).strip())
|
||||||
if alert and runs:
|
if alert and runs:
|
||||||
run = _ALERT.sub("", runs[0], count=1)
|
run = _ALERT.sub("", runs[0], count=1)
|
||||||
if _LETTER.search(run):
|
if _LETTER.search(run):
|
||||||
@@ -417,7 +454,7 @@ def split(text: str) -> tuple[list[Span], list[str], list[str]]:
|
|||||||
else:
|
else:
|
||||||
runs.pop(0)
|
runs.pop(0)
|
||||||
for run in runs:
|
for run in runs:
|
||||||
ctx = block if block and run.strip() != block else ""
|
ctx = block if block and _encode(run.strip()) != block else ""
|
||||||
pos = _locate(text, run, cursor)
|
pos = _locate(text, run, cursor)
|
||||||
if pos != -1:
|
if pos != -1:
|
||||||
emit(run, pos, ctx)
|
emit(run, pos, ctx)
|
||||||
@@ -435,6 +472,22 @@ def split(text: str) -> tuple[list[Span], list[str], list[str]]:
|
|||||||
return spans, segments, contexts
|
return spans, segments, contexts
|
||||||
|
|
||||||
|
|
||||||
|
#: Block-level Markdown a translation must not introduce: a segment is
|
||||||
|
#: spliced INSIDE a block of the fragment, so a line starting a heading,
|
||||||
|
#: quote, list, code/container fence or a setext/thematic-break underline
|
||||||
|
#: would break the fragment's block structure — a ``` or ::: line eats the
|
||||||
|
#: rest of the fence it lands in, closing fence included. pure_prose only
|
||||||
|
#: parses inline and lets such lines through as softbreak prose, so join
|
||||||
|
#: rejects them here. Blank lines split the host block and are rejected
|
||||||
|
#: too (a faithful translation of a single block has none).
|
||||||
|
_BLOCK = re.compile(
|
||||||
|
r"^[ \t]*(?:#{1,6}(?:[ \t]|$)|>[ \t]?|(?:[-+*]|\d{1,9}[.)])[ \t]|`{3,}|~{3,}|:{3,}(?:[ \t]|$)"
|
||||||
|
r"|-(?:[ \t]*-){2,}[ \t]*$|=[ =]*$|_(?:[ \t]*_){2,}[ \t]*$)",
|
||||||
|
re.M,
|
||||||
|
)
|
||||||
|
_BLANK = re.compile(r"\n[ \t]*\n")
|
||||||
|
|
||||||
|
|
||||||
def pure_prose(text: str) -> bool:
|
def pure_prose(text: str) -> bool:
|
||||||
"""True when the text parses as nothing but prose (text and softbreak
|
"""True when the text parses as nothing but prose (text and softbreak
|
||||||
tokens) — the acceptance test for a translated segment: the model may
|
tokens) — the acceptance test for a translated segment: the model may
|
||||||
@@ -597,17 +650,37 @@ def _place_marks(translation: str, weight: int, marks: list[Mark]) -> str | None
|
|||||||
|
|
||||||
def join(original: str, spans: list[Span], texts: list[str]) -> str | None:
|
def join(original: str, spans: list[Span], texts: list[str]) -> str | None:
|
||||||
"""Splice translated segments back into the original fragment; None on
|
"""Splice translated segments back into the original fragment; None on
|
||||||
any validation failure (count mismatch, empty or non-prose segment) —
|
any validation failure (count mismatch, empty, non-prose or
|
||||||
the caller drops the result and the fragment stays pending. Segments
|
block-structure segment) — the caller drops the result and the fragment
|
||||||
with marks (a block that crossed as one piece) get their links
|
stays pending. Segments with marks (a block that crossed as one piece)
|
||||||
re-inserted at weight-mapped positions after the prose check."""
|
get their links re-inserted at weight-mapped positions after the prose
|
||||||
|
check.
|
||||||
|
|
||||||
|
Markdown-significant ASCII punctuation that pure_prose cannot see
|
||||||
|
(plain text inline, syntax in the splice context — quoted titles, alt
|
||||||
|
and link texts, table rows) is neutralized to Unicode look-alikes
|
||||||
|
(``_NEUTRAL``) before splicing and mark placement (the swap is
|
||||||
|
char-for-char, so unit alignment is unaffected); lines that would
|
||||||
|
start a new block (a heading, a ``` or ::: fence — they would eat the
|
||||||
|
rest of the block/fence they land in) reject the result outright
|
||||||
|
(``_BLOCK``, ``_BLANK``)."""
|
||||||
if len(texts) != len(spans):
|
if len(texts) != len(spans):
|
||||||
return None
|
return None
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
cursor = 0
|
cursor = 0
|
||||||
for span, translation in zip(spans, texts):
|
for span, translation in zip(spans, texts):
|
||||||
if not translation.strip() or not pure_prose(translation):
|
# Decode the wire form ("<" back to "<") first: pure_prose then
|
||||||
|
# validates exactly what gets spliced — a "<" the model formed
|
||||||
|
# into anything tag-like is markup and rejects the result.
|
||||||
|
translation = translation.replace("<", "<")
|
||||||
|
if (
|
||||||
|
not translation.strip()
|
||||||
|
or not pure_prose(translation)
|
||||||
|
or _BLOCK.search(translation)
|
||||||
|
or _BLANK.search(translation.strip())
|
||||||
|
):
|
||||||
return None
|
return None
|
||||||
|
translation = translation.translate(_NEUTRAL)
|
||||||
if span.marks:
|
if span.marks:
|
||||||
translation = _place_marks(translation, span.weight, span.marks)
|
translation = _place_marks(translation, span.weight, span.marks)
|
||||||
if translation is None:
|
if translation is None:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+28
-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
|
||||||
@@ -25,6 +26,7 @@ import httpx
|
|||||||
import msgspec
|
import msgspec
|
||||||
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
from uarite import uaparse
|
||||||
|
|
||||||
from pagerite import analytics
|
from pagerite import analytics
|
||||||
from pagerite.data import resolve
|
from pagerite.data import resolve
|
||||||
@@ -44,8 +46,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 +63,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 +71,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 +96,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 +105,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,27 +130,22 @@ 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
|
||||||
|
|
||||||
|
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))
|
self._reader = maxminddb.open_database(str(source))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -442,7 +444,7 @@ async def activity_ws(ws: WebSocket) -> None:
|
|||||||
# already printed there): compact UA plus the browser's language tag.
|
# already printed there): compact UA plus the browser's language tag.
|
||||||
lang, _country = analytics._parse_accept_language(accept_language)
|
lang, _country = analytics._parse_accept_language(accept_language)
|
||||||
ws.scope.setdefault("state", {})["log_extra"] = " ".join(
|
ws.scope.setdefault("state", {})["log_extra"] = " ".join(
|
||||||
part for part in (analytics._compact_user_agent(ua), lang) if part
|
part for part in (uaparse(ua).pretty, lang) if part
|
||||||
)
|
)
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
try:
|
try:
|
||||||
|
|||||||
+22
-13
@@ -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:
|
||||||
@@ -277,16 +280,20 @@ class Dispatcher:
|
|||||||
job = None
|
job = None
|
||||||
spans: list[Span] = []
|
spans: list[Span] = []
|
||||||
original = ""
|
original = ""
|
||||||
|
# Titles before articles — across languages too, so every menu
|
||||||
|
# is named before any article body is worked on (a page's name
|
||||||
|
# is its most visible string). pending_items emits in menu
|
||||||
|
# order, a page's title before its chunks; filtering by kind
|
||||||
|
# keeps that stable order within each kind.
|
||||||
|
pending = {lang: pending_items(self.data, lang) for lang in sorted(langs)}
|
||||||
|
for kind in ("title", "chunk"):
|
||||||
for lang in sorted(langs):
|
for lang in sorted(langs):
|
||||||
# Titles first: a page's name in the menu is its most
|
for item in pending[lang]:
|
||||||
# visible string (stable: menu order kept within each kind).
|
if (
|
||||||
for item in sorted(
|
item.kind != kind
|
||||||
pending_items(self.data, lang), key=lambda it: it.kind != "title"
|
or (lang, item.key) in inflight
|
||||||
|
or (lang, item.key) in self.validation_failures
|
||||||
):
|
):
|
||||||
if (lang, item.key) in inflight or (
|
|
||||||
lang,
|
|
||||||
item.key,
|
|
||||||
) in self.validation_failures:
|
|
||||||
continue
|
continue
|
||||||
spans, texts, contexts = split(item.text)
|
spans, texts, contexts = split(item.text)
|
||||||
if not texts:
|
if not texts:
|
||||||
@@ -307,6 +314,8 @@ class Dispatcher:
|
|||||||
break
|
break
|
||||||
if job is not None:
|
if job is not None:
|
||||||
break
|
break
|
||||||
|
if job is not None:
|
||||||
|
break
|
||||||
if job is None:
|
if job is None:
|
||||||
continue
|
continue
|
||||||
state.inflight = (job.lang, job.key) # before the await: no double-assign
|
state.inflight = (job.lang, job.key) # before the await: no double-assign
|
||||||
|
|||||||
+101
-30
@@ -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:
|
||||||
@@ -372,8 +383,8 @@ def _layout(
|
|||||||
doc.meta(property=key, content=value)
|
doc.meta(property=key, content=value)
|
||||||
else:
|
else:
|
||||||
doc.meta(name=key, content=value)
|
doc.meta(name=key, content=value)
|
||||||
# A custom favicon (from the site editor) is linked explicitly; without
|
# A custom favicon (from the site editor) is linked explicitly;
|
||||||
# one, browsers fall back to the build's /favicon.ico by convention.
|
# /favicon.ico redirects to the same store file for non-HTML contexts.
|
||||||
if favicon:
|
if favicon:
|
||||||
doc.link(rel="icon", href=f"/_f/{favicon}", id="pagerite-favicon")
|
doc.link(rel="icon", href=f"/_f/{favicon}", id="pagerite-favicon")
|
||||||
# Asset URLs for the on-demand bundles (editor, analytics) for
|
# Asset URLs for the on-demand bundles (editor, analytics) for
|
||||||
@@ -385,10 +396,14 @@ def _layout(
|
|||||||
# carries the on-demand URLs in one JSON script instead.
|
# carries the on-demand URLs in one JSON script instead.
|
||||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
||||||
editor_scripts, editor_css = _editor_assets()
|
editor_scripts, editor_css = _editor_assets()
|
||||||
|
langselect_scripts, langselect_css = _langselect_assets()
|
||||||
config = {
|
config = {
|
||||||
"pagerite:editor-src": editor_scripts[-1],
|
"pagerite:editor-src": editor_scripts[-1],
|
||||||
"pagerite:analytics-src": _analytics_assets()[0][0],
|
"pagerite:analytics-src": _analytics_assets()[0][0],
|
||||||
|
"pagerite:langselect-src": langselect_scripts[-1],
|
||||||
}
|
}
|
||||||
|
if langselect_css:
|
||||||
|
config["pagerite:langselect-css"] = ",".join(langselect_css)
|
||||||
if editor_css:
|
if editor_css:
|
||||||
config["pagerite:editor-css"] = editor_css
|
config["pagerite:editor-css"] = editor_css
|
||||||
if vite_url:
|
if vite_url:
|
||||||
@@ -783,7 +798,11 @@ def page_content(
|
|||||||
node = resolve(menu, path)[-1]
|
node = resolve(menu, path)[-1]
|
||||||
content = node_markdown(data, node) or ""
|
content = node_markdown(data, node) or ""
|
||||||
title = node.title
|
title = node.title
|
||||||
|
# The original text pins the section anchors: on a translated page the
|
||||||
|
# heading slugs (and thus #hash URLs) stay in the original language.
|
||||||
|
anchors_from = None
|
||||||
if translation:
|
if translation:
|
||||||
|
anchors_from = (content, title)
|
||||||
if translation.markdown is not None:
|
if translation.markdown is not None:
|
||||||
content = translation.markdown
|
content = translation.markdown
|
||||||
title = (
|
title = (
|
||||||
@@ -793,7 +812,9 @@ def page_content(
|
|||||||
)
|
)
|
||||||
# The title is injected into the markdown (as # title when it has no
|
# The title is injected into the markdown (as # title when it has no
|
||||||
# h1 of its own), so title and content render as one article.
|
# h1 of its own), so title and content render as one article.
|
||||||
rendered = render(content, path, node.created, node.modified, title=title)
|
rendered = render(
|
||||||
|
content, path, node.created, node.modified, title=title, anchors_from=anchors_from
|
||||||
|
)
|
||||||
# Long articles get .multicol: the article column cap lifts (see the
|
# Long articles get .multicol: the article column cap lifts (see the
|
||||||
# #content grid in pagerite.css) and the .cols segments lay out in at
|
# #content grid in pagerite.css) and the .cols segments lay out in at
|
||||||
# most two columns. The html is already segmented by render() — the
|
# most two columns. The html is already segmented by render() — the
|
||||||
@@ -1011,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,
|
||||||
@@ -1040,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 are site-wide, the same set on every
|
|
||||||
# page: the configured translate_langs (the translator works to fill
|
|
||||||
# them all in), x-default first (the plain, autodetecting URL), then
|
|
||||||
# every language explicitly, the page's own primary included.
|
|
||||||
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, f"{url}?lang={tag}")
|
|
||||||
for tag in sorted({original, *data.translate_langs})
|
|
||||||
]
|
|
||||||
return str(
|
return str(
|
||||||
_layout(
|
_layout(
|
||||||
*_page_assets(),
|
*_page_assets(),
|
||||||
@@ -1090,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,
|
||||||
@@ -1105,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)
|
||||||
@@ -1127,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),
|
||||||
@@ -1222,6 +1268,31 @@ def _analytics_assets() -> tuple[list[str], list[str]]:
|
|||||||
return _asset_cache["analytics"]
|
return _asset_cache["analytics"]
|
||||||
|
|
||||||
|
|
||||||
|
def _langselect_assets() -> tuple[list[str], list[str]]:
|
||||||
|
"""Script and stylesheet URLs for the on-demand public language selector."""
|
||||||
|
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
||||||
|
if vite_url:
|
||||||
|
return [f"{vite_url}/src/langselect-main.js"], []
|
||||||
|
if "langselect" not in _asset_cache:
|
||||||
|
manifest = _manifest()
|
||||||
|
# import() loads no CSS automatically: collect the stylesheets of
|
||||||
|
# the entry and its imported chunks (LangSelect's ride on the
|
||||||
|
# shared langs chunk).
|
||||||
|
scripts, stylesheets, seen = [], [], set()
|
||||||
|
queue = ["src/langselect-main.js"]
|
||||||
|
for key in queue: # grows with imported chunks
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
entry = manifest[key]
|
||||||
|
if entry.get("isEntry"):
|
||||||
|
scripts.append(f"/{entry['file']}")
|
||||||
|
stylesheets += [f"/{css}" for css in entry.get("css", [])]
|
||||||
|
queue += entry.get("imports", [])
|
||||||
|
_asset_cache["langselect"] = scripts, stylesheets
|
||||||
|
return _asset_cache["langselect"]
|
||||||
|
|
||||||
|
|
||||||
def render_analytics(
|
def render_analytics(
|
||||||
menu: dict[str, Node],
|
menu: dict[str, Node],
|
||||||
brand: str = SITE_NAME,
|
brand: str = SITE_NAME,
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ dependencies = [
|
|||||||
"platformdirs>=4.11.5",
|
"platformdirs>=4.11.5",
|
||||||
"pygments>=2.20.0",
|
"pygments>=2.20.0",
|
||||||
"python-slugify>=8.0.4",
|
"python-slugify>=8.0.4",
|
||||||
"ua-parser>=1.0.2",
|
"uarite>=0.1.2",
|
||||||
"zstandard>=0.25.0",
|
"zstandard>=0.25.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user