Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0c1b37c0b | ||
|
|
b004fcd644 | ||
|
|
0c7fe15635 | ||
|
|
2be3b32586 | ||
|
|
867132f26b | ||
|
|
4522b8fa40 | ||
|
|
2d221f6204 | ||
|
|
626dc1df8f | ||
|
|
02396f4482 | ||
|
|
7724290921 | ||
|
|
3b1804b093 | ||
|
|
4cd8dbfc73 | ||
|
|
dc4bdf6efc | ||
|
|
a458bded09 | ||
|
|
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.
|
||||
- `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.
|
||||
- `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.
|
||||
- `main.js` — Vue editor app entry.
|
||||
- `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.
|
||||
- `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.
|
||||
@@ -64,6 +66,6 @@ Server run by CLI entry point `uv run pagerite` (no auto reloads, build needed).
|
||||
## Conventions
|
||||
|
||||
- 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).
|
||||
- Update the relevant MarkDown files when architecture, tooling, or conventions change.
|
||||
|
||||
+70
-20
@@ -38,6 +38,9 @@ Each `Get` record (one per served document):
|
||||
(`x-pagerite-preload` header): never counted as a view, crawler hit or
|
||||
abuse — recorded only so a navigation later served from the in-memory page
|
||||
cache (which issues no GET at all) can be attributed this GET's status,
|
||||
- `lang` — rendered content language of the served document (the resolved
|
||||
language of a localized page), `""` for non-localized responses (404
|
||||
probes, reserved paths),
|
||||
- `client` — 6-byte blake3 hash referencing `Analytics.clients`.
|
||||
|
||||
304 revalidation responses return before recording and are not logged.
|
||||
@@ -51,7 +54,9 @@ Each `Msg` record (one per pagerite.js activity message over `/_ws`):
|
||||
- `to` — navigation target (validated at record time: internal slug path or
|
||||
external https URL; anything else is dropped — sanitation, not
|
||||
classification),
|
||||
- `read` — active seconds spent on `fr` since the previous report.
|
||||
- `read` — active seconds spent on `fr` since the previous report,
|
||||
- `lang` — rendered language reported by the client for the page the
|
||||
activity happened on (the page's `<html lang>`; `""` from old clients).
|
||||
|
||||
Each `Client` record (shared by every event, keyed by hash):
|
||||
|
||||
@@ -63,20 +68,35 @@ Each `Client` record (shared by every event, keyed by hash):
|
||||
when a database is available,
|
||||
- `city` — city name from the DB-IP MMDB lookup, when available,
|
||||
- `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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
stored, so WebSocket message handling is never delayed. The decompressed
|
||||
`dbip-*.mmdb` file is kept in the repository root and ignored by git. The
|
||||
stored, so WebSocket message handling is never delayed. Only the downloaded
|
||||
`.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
|
||||
`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
|
||||
@@ -89,7 +109,11 @@ The client (`pagerite.js`) keeps a WebSocket connection to `/_ws` for the
|
||||
whole browsing session and sends activity messages over it — JSON text
|
||||
frames matching the server's `Ping` msgspec struct with the fields `fr`
|
||||
(source path), `to` (navigation target), `read` (active seconds on `fr`
|
||||
since the last report) and `hide`; falsy fields are omitted. One channel
|
||||
since the last report), `lang` (the rendered language of the page the
|
||||
activity happened on — its `<html lang>`, except the language-switch
|
||||
navigation ping, which passes the picked tag explicitly because the view
|
||||
transition applies the new `<html lang>` only after the ping goes out) and
|
||||
`hide`; falsy fields are omitted. One channel
|
||||
follows the session, so the activity of a visit stays tied together, and
|
||||
while the user is active the accumulated reading time is flushed every few
|
||||
seconds: the times are incremental, so a disconnection simply leaves the
|
||||
@@ -158,17 +182,25 @@ for misses.
|
||||
(`_SESSION_GAP`). A fresh page load with an already-open visit (second
|
||||
tab) extends it, logging a `(direct)` transition. The visit's trail holds
|
||||
first-seen targets in order; `read` updates accumulate active seconds on
|
||||
the trail item matching `fr`. Each trail item's HTTP status comes from
|
||||
the trail item matching `fr` (preferring the item whose language matches
|
||||
the report, so seconds after a language switch land on the new-language
|
||||
step). Each trail item's HTTP status comes from
|
||||
the client's latest GET for that path — preloads included, which is what
|
||||
allows 404 pages to render red in the viewer even when the navigation
|
||||
itself was served from the page cache. The entry page's referer and
|
||||
itself was served from the page cache. Each trail item also carries the
|
||||
rendered language: the client's report, for the entry page falling back
|
||||
to its GET's rendered language (old clients don't send one); a page
|
||||
re-visited in a different language becomes a distinct trail step instead
|
||||
of merging into the existing item. The entry page's referer and
|
||||
`utm_*` tags come from the GET that loaded it (within 10 s before the
|
||||
first message).
|
||||
- **Crawler hits**: a document GET no activity message matched within
|
||||
`_CRAWLER_TIMEOUT` (10 s) is a crawler hit — plain bots that only fetch
|
||||
documents never register as visits. JS-running crawlers (Googlebot,
|
||||
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-
|
||||
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`;
|
||||
@@ -215,6 +247,10 @@ to tell misses from real pages at a glance.
|
||||
The `Display` payload contains the derived `visits`, `crawlers` and `abuse`
|
||||
rows (structs `Visit`/`Nav`/`TrailItem`, `CrawlerHit`, `AbuseHit` — display
|
||||
DTOs only, never persisted), the visible `clients`, the fetched `favicons`,
|
||||
the site language context (`multilingual` — translation languages are
|
||||
configured, so the viewer can suppress language UI on single-language
|
||||
sites — and `primary_lang` — the front page's primary language, so the
|
||||
viewer can skip the primary-language default case),
|
||||
and the aggregates below.
|
||||
|
||||
Each derived `Visit`:
|
||||
@@ -226,8 +262,9 @@ Each derived `Visit`:
|
||||
- `trail` — the entry page and everything seen afterwards, keyed by the
|
||||
timestamp of first sight (insertion order = first-seen order). Each item
|
||||
holds `to` (page path or external exit URL), the accumulated active
|
||||
reading time in seconds (`read`) and the most recent HTTP status seen
|
||||
for the target (`status`),
|
||||
reading time in seconds (`read`), the most recent HTTP status seen
|
||||
for the target (`status`) and the rendered language (`lang`; a page
|
||||
seen in two languages within one visit gets one item per language),
|
||||
- `navs` — every navigation (`fr`, `to`), keyed by its timestamp, repeats
|
||||
included. The aggregates are computed from this log,
|
||||
- `utm` — `utm_*` query parameters from the landing URL, as a dict.
|
||||
@@ -240,7 +277,8 @@ Each derived `CrawlerHit`:
|
||||
- `referer` — external https origin of the request, `""` for direct/none,
|
||||
- `query` — raw query string of the request,
|
||||
- `status` — HTTP status of the served response (200 for a real page, 404
|
||||
for a category placeholder or missing page).
|
||||
for a category placeholder or missing page),
|
||||
- `lang` — rendered content language of the served document (from the GET).
|
||||
|
||||
Each derived `AbuseHit`:
|
||||
|
||||
@@ -326,13 +364,25 @@ Axes always start at 0 and end at a multiple of a 1-2-5 major step (max 5
|
||||
labeled intervals, minor lines at fifths when integral; the minimum y-axis
|
||||
range is 10 so tiny values such as a single visit are not stretched to a
|
||||
fractional scale).
|
||||
The week range is aligned to Monday 00:00 UTC and overlays up to 8 previous
|
||||
weeks in the muted color at decreasing opacity (the current week keeps the
|
||||
accent color and is
|
||||
truncated at the current bucket, never drawing fake zeroes for the future);
|
||||
a compact legend inside the top right of the visits chart marks the current
|
||||
ISO week in accent and the overlaid past weeks as "Week M" or "Week M–N" on
|
||||
a muted specimen. Its x labels are weekday names centered at midday UTC, without
|
||||
The week range is aligned to Monday 00:00 UTC (the current week keeps the
|
||||
accent color and is truncated at the current bucket, never drawing fake
|
||||
zeroes for the future). Both the week and day views overlay a **"typical"
|
||||
history curve** in the muted color (`analytics/seasonal.js`, a port of
|
||||
`seasonal.py`): the whole recorded history is densified to 5-minute bins,
|
||||
smoothed with the same Gaussian as the week view, then folded onto a weekly
|
||||
grid with exponential decay over age — a 7-day half-life for the average
|
||||
time-of-day pattern and a 42-day half-life for per-weekday deviations from
|
||||
it, the deviation shrunk by the effective number of weeks behind each bin
|
||||
(`n_eff / (n_eff + 3)`) so the estimate falls back to the common daily
|
||||
pattern when history is short. History is capped at the most recent 180
|
||||
days, beyond which even the slow kernel's weight is negligible (~5%). The
|
||||
week view draws the full Monday-first
|
||||
estimate as "Typical week" (future included); the day view cuts the rolling
|
||||
24-hour window's bins from the same estimate and labels them by the weekday
|
||||
("Typical Saturday"). A compact legend inside the top right of the visits
|
||||
chart marks the current data in accent (ISO week label, or a bar specimen
|
||||
for "Last 24 hours") and the typical curve on a muted specimen. The week
|
||||
view's x labels are weekday names centered at midday UTC, without
|
||||
vertical grid
|
||||
lines (day boundaries would be misleading in the viewer's timezone). The
|
||||
month view labels days the same lineless way — day numbers at noon UTC,
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ msgspec Structs for the kanta database. See `docs/content-model.md` for the full
|
||||
|
||||
## `markdown.py`
|
||||
|
||||
markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). In bodies with at least three top-level h1/h2 headings (nested ones, e.g. inside `::: aside`, never participate), each gets a slug id (`python-slugify`, mirroring the editor's `slugify.js` — unicode folds to ASCII, separators become single hyphens) unless the author set `{#id}`, and their text is wrapped in a self-link (`a.anchor`) so section links are copyable; anchored headings also carry `data-line` with their markdown source line (the page editor's section pens and piecewise scroll sync key off it); the first in-body h1 is the article title — when the markdown has no h1, `render(title=...)` injects it as `# {title}` so implicit and explicit titles take the same path — it gets no id and doesn't count toward the three, its self-link is `href=""` (scroll to top); shorter articles stay anchor-free, h3+ is never navigable, and duplicates get `-2`/`-3` suffixes. Custom image rule: relative srcs resolve against the page path; an image standing alone in its paragraph becomes a figure (captioned when titled), while inline-with-text images and raw `<img>` HTML stay plain. A `{dates}` line expands to the article's published/updated dateline (`p.dateline`, from `Node.created`/`modified`; left literal in previews of unsaved pages). Code fences take pandoc-style brace attributes on the info line (` ```{.python .wide #id key=val} ` — the first class is the language when no bare language word precedes the braces) as well as a trailing `{...}` line; both land on the `<pre>`, the `<code>` keeps only the language class.
|
||||
markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). In bodies with at least three top-level h1/h2 headings (nested ones, e.g. inside `::: aside`, never participate), each gets a slug id (`python-slugify`, mirroring the editor's `slugify.js` — unicode folds to ASCII, separators become single hyphens) unless the author set `{#id}`, and their text is wrapped in a self-link (`a.anchor`) so section links are copyable; anchored headings also carry `data-line` with their markdown source line (the page editor's section pens and piecewise scroll sync key off it); the first in-body h1 is the article title — when the markdown has no h1, `render(title=...)` injects it as `# {title}` so implicit and explicit titles take the same path — it gets no id and doesn't count toward the three, its self-link is `href=""` (scroll to top); shorter articles stay anchor-free, h3+ is never navigable, and duplicates get `-2`/`-3` suffixes. Custom image rule: relative srcs resolve against the page path; an image standing alone in its paragraph becomes a figure (captioned when titled), while inline-with-text images and raw `<img>` HTML stay plain. A lone `{name}` / `{name: args}` line is a block directive: a core rule turns it into a `directive` token (render instance only — the verbatim parser keeps the plain paragraph so segments/chunks see the placeholder source), and the render rule delegates to the resolvers passed as `render(directives=...)`, leaving the source literal where no resolver applies (e.g. the editor preview of a page that does not exist yet). Built in: `{dates}` expands to the article's published/updated dateline (`p.dateline`, from `Node.created`/`modified`, registered by `render()` when `created` is given); views.py resolves `{cards}` — the page's published children, one card each (on the front page: the other top-level pages) — and `{cards: path path/* path/** ...}` (space-separated: a plain path renders that page alone, `path/*` its published children, `path/**` all published descendant pages; a page-less item is represented by its first leaf page, the nav-link logic) into the same card-row markup as category pages (`.cards.wide`, a boundary block outside the column segments). A page with any `{cards}` tag drops the automatic end-of-page child cards; multiple tags each render their own row. Code fences take pandoc-style brace attributes on the info line (` ```{.python .wide #id key=val} ` — the first class is the language when no bare language word precedes the braces) as well as a trailing `{...}` line; both land on the `<pre>`, the `<code>` keeps only the language class.
|
||||
|
||||
`render()` returns a `Rendered(html, multicol)`: the article content segmented for the column layout (there is no wrapper div — segments and bare blocks are direct `<article>` children) — h1/h2 headings and `.wide` blocks stand bare, the runs between them become `<div class="colseg">` (margin-breakout boxes — `.margin`, `::: aside` — stay inside the segment at their anchor point; the CSS positions them out of flow into the side zone) (plus `.cols` on segments with enough text in at least two paragraphs or one long enough to split across columns, `::: nocols` opting out; in column segments, paragraphs past `BREAKABLE_TEXT` visible characters are marked `.breakable` so they may split across columns), and `multicol` flags bodies long enough to columnize (visible-text thresholds, code excluded). `views.py` puts the class on the article; pagerite.css takes it from there (at most two columns, the left-margin breakout, all viewport adaptation).
|
||||
|
||||
|
||||
+48
-20
@@ -33,14 +33,9 @@ Deliberately simple — **q-values are ignored**:
|
||||
- Selection rule (`select_language` in `pagerite/i18n.py`):
|
||||
1. If `?lang=<tag>` is present, use it (if a translation exists; otherwise
|
||||
fall through to header logic).
|
||||
2. If the article's original language appears anywhere in the header list,
|
||||
use the **original**. Rationale: an AI translation is strictly worse
|
||||
than the original for anyone who has that language configured at all
|
||||
(e.g. `fi-FI, fi, en-US, en` gets English, not machine-translated
|
||||
Finnish).
|
||||
3. Otherwise walk the header list in order and use the first language for
|
||||
which a translation exists.
|
||||
4. Fall back to the original.
|
||||
2. Otherwise walk the header list in order and use the first language that
|
||||
can be served — the original, or one with an available translation.
|
||||
3. Fall back to the original.
|
||||
|
||||
Region tags normalize to their base subtag (`fi-FI` → `fi`).
|
||||
|
||||
@@ -53,11 +48,13 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`).
|
||||
article's own language), `?lang=xx` when serving a translation — however
|
||||
the language was arrived at (query or header).
|
||||
- `<link rel="alternate" hreflang="…">` entries follow the canonical
|
||||
directly (before the social meta tags) and are the same set on every
|
||||
page — the site-wide configured languages (`translate_langs`, which the
|
||||
translator works to fill in): `x-default` first, pointing at the plain
|
||||
autodetecting URL, then every language explicitly with `?lang=`, the
|
||||
page's own primary language included.
|
||||
directly (before the social meta tags) and list the languages the page
|
||||
is **actually available in**: `x-default` first, pointing at 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: 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
|
||||
`?lang=` replicates the query onto the navigation links it renders (nav,
|
||||
sidebar, cards, brand — in-article links are content and stay as
|
||||
@@ -66,6 +63,11 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`).
|
||||
`history.replaceState` (pretty, shareable URLs), remembers the language,
|
||||
and adds it to every internal fetch that lacks one (preloads,
|
||||
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
|
||||
only). This gives a clean one-time override without cookies.
|
||||
|
||||
@@ -88,6 +90,10 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`).
|
||||
### Rendering
|
||||
|
||||
- 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
|
||||
per-node fallback to the original title (a partially translated tree must
|
||||
still render).
|
||||
@@ -95,7 +101,9 @@ Region tags normalize to their base subtag (`fi-FI` → `fi`).
|
||||
language like content pages, but over the **subtree's** combined
|
||||
availability (`subtree_languages`) — they have no chunks of their own;
|
||||
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
|
||||
Markdown where that page is available in the served language, with
|
||||
per-card fallback to the original.
|
||||
@@ -133,7 +141,11 @@ served Markdown at render time.
|
||||
|
||||
`chunk_markdown(markdown)` splits the source into block-level chunks —
|
||||
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:
|
||||
|
||||
```python
|
||||
@@ -398,9 +410,14 @@ verbatim source substring — entity-decoded text, backslash escapes — is
|
||||
skipped and stays in the original language), and the returned translations
|
||||
are swapped in by offset. Markup corruption is therefore impossible by
|
||||
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
|
||||
translation would splice live HTML) — each returned segment must parse as
|
||||
pure prose, or the whole result is dropped and logged, and the (lang, key)
|
||||
empty segment, markup injected INTO a segment (a `<br>` in a title
|
||||
translation would splice live HTML), or a line that would start a new
|
||||
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
|
||||
near-deterministic, so an immediate retry would re-fail; the fragment stays
|
||||
pending and gets another chance on restart or `DELETE /_api/translations`).
|
||||
@@ -451,14 +468,25 @@ stripped before the result goes back.
|
||||
|
||||
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
|
||||
a segment in either direction. Source pieces containing `<` are never
|
||||
dispatched (they stay in the original language — segments.py), and the
|
||||
a segment in either direction. A literal `<` in the source text (`<1MB` is
|
||||
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 `<`
|
||||
(scripts/translator.py) — echoed language tags, stray `<br>`s and any
|
||||
future variant are one handled case. (The cut is post-decode, not a
|
||||
generation stop string: Seed-X opens every generation with its `<s>`
|
||||
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
|
||||
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
|
||||
|
||||
@@ -37,3 +37,6 @@ __screenshots__/
|
||||
|
||||
# Playwright browser downloads (if ever installed locally)
|
||||
.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",
|
||||
"country-flag-icons": "^1.6.20",
|
||||
"overlayscrollbars": "^2.16.0",
|
||||
"pinia": "^4.0.3",
|
||||
"transliteration": "^2.6.1",
|
||||
"vue": "^3.5.26",
|
||||
"vuedraggable": "^4.1.0"
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
formatVisitRows,
|
||||
} from './analytics/format.js'
|
||||
import TrailLink from './TrailLink.vue'
|
||||
import RefererBadge from './RefererBadge.vue'
|
||||
import VisitorCell from './VisitorCell.vue'
|
||||
import TransitionGraph from './TransitionGraph.vue'
|
||||
import VisitorCharts from './VisitorCharts.vue'
|
||||
@@ -133,7 +134,8 @@ onUnmounted(() => {
|
||||
const window = computed(() => rangeWindow(range.value))
|
||||
|
||||
// All non-chart stats follow the selected range; the charts keep their own
|
||||
// range-specific x windows (week overlays previous weeks aligned to Monday).
|
||||
// range-specific x windows (week aligned to Monday, overlaid with the
|
||||
// seasonal "typical week" curve).
|
||||
const rangeData = computed(() => {
|
||||
if (!data.value) return null
|
||||
const { t0, t1 } = window.value
|
||||
@@ -160,9 +162,15 @@ watch(range, (r) => {
|
||||
|
||||
const clients = computed(() => data.value?.clients || {})
|
||||
const favicons = computed(() => data.value?.favicons || {})
|
||||
const visitRows = computed(() => formatVisitRows(visits.value, clients.value, pageTree.value, now.value))
|
||||
// Site language context from the payload: drives the discreet rendered-
|
||||
// language markers in the visit/crawler rows (multilingual sites only).
|
||||
const site = computed(() => ({
|
||||
multilingual: !!data.value?.multilingual,
|
||||
primaryLang: data.value?.primary_lang || '',
|
||||
}))
|
||||
const visitRows = computed(() => formatVisitRows(visits.value, clients.value, pageTree.value, now.value, site.value))
|
||||
const crawlers = computed(() => rangeData.value?.crawlers || [])
|
||||
const crawlerRows = computed(() => formatCrawlerRows(crawlers.value, clients.value, pageTree.value, now.value))
|
||||
const crawlerRows = computed(() => formatCrawlerRows(crawlers.value, clients.value, pageTree.value, now.value, site.value))
|
||||
const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], clients.value, pageTree.value, now.value))
|
||||
|
||||
</script>
|
||||
@@ -209,15 +217,16 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
||||
<tbody>
|
||||
<tr v-for="(v, i) in visitRows" :key="i">
|
||||
<td class="trail">
|
||||
<TrailLink v-if="v.refererStep" :step="v.refererStep" :favicons="favicons" @close="$emit('close')" />
|
||||
<span v-if="v.utm && v.utm !== '—'" class="utm-tag small muted" :title="v.utmTitle">{{ v.utm }}</span>
|
||||
<TrailLink v-for="(s, si) in v.trail" :key="si" :step="s" :favicons="favicons" @close="$emit('close')" />
|
||||
<RefererBadge v-if="v.refererBadge" :badge="v.refererBadge" :favicons="favicons" />
|
||||
<span v-if="v.rowFlag" class="flag" v-html="v.rowFlag" :title="v.rowFlagTitle"></span>
|
||||
<TrailLink v-for="(s, si) in v.trail" :key="si" :step="s" :favicons="favicons" :flags="s.langFlags" @close="$emit('close')" />
|
||||
</td>
|
||||
<VisitorCell
|
||||
:ip="v.ip"
|
||||
:ip-display="v.ipDisplay"
|
||||
:ua="v.ua"
|
||||
:ua-raw="v.uaRaw"
|
||||
:ua-url="v.uaUrl"
|
||||
:country="v.country"
|
||||
:city="v.city"
|
||||
:lang="v.lang"
|
||||
@@ -245,14 +254,16 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
||||
<tbody>
|
||||
<tr v-for="(c, i) in crawlerRows" :key="i">
|
||||
<td class="trail">
|
||||
<TrailLink v-if="c.refererStep" :step="c.refererStep" :favicons="favicons" @close="$emit('close')" />
|
||||
<RefererBadge v-if="c.refererBadge" :badge="c.refererBadge" :favicons="favicons" />
|
||||
<TrailLink v-for="(s, si) in c.pages" :key="si" :step="s" :count="s.count" @close="$emit('close')" />
|
||||
<span v-for="(f, fi) in c.readFlags" :key="fi" class="flag" v-html="f.flag" :title="f.name"></span>
|
||||
</td>
|
||||
<VisitorCell
|
||||
:ip="c.ip"
|
||||
:ip-display="c.ipDisplay"
|
||||
:ua="c.ua"
|
||||
:ua-raw="c.uaRaw"
|
||||
:ua-url="c.uaUrl"
|
||||
:country="c.country"
|
||||
:city="c.city"
|
||||
:lang="c.lang"
|
||||
@@ -299,6 +310,8 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
||||
:ip-display="a.ipDisplay"
|
||||
:ua="a.ua"
|
||||
:ua-raw="a.uaRaw"
|
||||
:ua-url="a.uaUrl"
|
||||
:ua-raws="a.uaRaws"
|
||||
:country="a.country"
|
||||
:city="a.city"
|
||||
:lang="a.lang"
|
||||
@@ -468,16 +481,39 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
||||
color: var(--error, #c00);
|
||||
}
|
||||
|
||||
.visit-table .utm-tag {
|
||||
display: inline-block;
|
||||
/* The referer badge outgrows the 8rem trail-link cap (it carries the UTM
|
||||
summary too); keep the inline-flex layout from the component. The
|
||||
generic trail-link rule above would otherwise clip the badge (overflow:
|
||||
hidden, hiding the absolutely positioned favicon) and cap its inner
|
||||
link — the link is display: contents, so its parts lay out as badge
|
||||
flex items. */
|
||||
.visit-table .trail .referer-badge {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.25rem;
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.visit-table .trail .referer-badge a.badge-link {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Same flag chip as the visitor cells (VisitorCell.vue); the flags here
|
||||
mark the language the page was read in. */
|
||||
.visit-table .flag {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2) inset;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.visit-table .flag :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.visit-table .clickable-list {
|
||||
@@ -506,22 +542,6 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
||||
.visit-table .clickable-list,
|
||||
.visit-table .last-seen {
|
||||
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 {
|
||||
|
||||
@@ -403,14 +403,6 @@ onUnmounted(() => {
|
||||
margin-left: auto;
|
||||
padding: 0 0.2rem;
|
||||
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
|
||||
|
||||
@@ -21,11 +21,11 @@ const currentPath = ref(props.pagePath)
|
||||
const activeMode = ref(props.initialMode)
|
||||
|
||||
// The shared language selection (./editorLang, v-modeled by the tabs'
|
||||
// LangSelects) also drives the page preview: while the shell is open it
|
||||
// overrides the normal language preferences (?lang= / Accept-Language),
|
||||
// so the page renders in the language being edited; closing restores.
|
||||
// The primary selection pins by the CURRENT PAGE's own primary language
|
||||
// (pages may differ — Node.language is inherited down the tree).
|
||||
// LangSelects) is linked to the whole-page language: while the shell is
|
||||
// open it drives the page preview (overrides ?lang= / Accept-Language),
|
||||
// and closing keeps the pick as the session language. The primary
|
||||
// selection pins by the CURRENT PAGE's own primary language (pages may
|
||||
// differ — Node.language is inherited down the tree).
|
||||
let pinned = false
|
||||
function pinPreviewLang() {
|
||||
pinned = true
|
||||
@@ -34,6 +34,15 @@ function pinPreviewLang() {
|
||||
setLangOverride(editorLang.value || pagePrimary.value || 'en')
|
||||
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() {
|
||||
if (!pinned) return
|
||||
pinned = false
|
||||
@@ -89,13 +98,13 @@ function onSwitchEvent(ev) {
|
||||
onMounted(() => {
|
||||
document.body.dataset.editorMode = activeMode.value
|
||||
addEventListener('pagerite:switch-editor', onSwitchEvent)
|
||||
addEventListener('pagerite:editor-shown', pinPreviewLang)
|
||||
addEventListener('pagerite:editor-shown', openShell)
|
||||
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
|
||||
// unknown; the page/structure tabs refine pagePrimary per page as they
|
||||
// learn it (their knowledge is strictly better).
|
||||
pinPreviewLang()
|
||||
openShell()
|
||||
fetch('/_api/settings').then((r) => r.json()).then((s) => {
|
||||
if (!pagePrimary.value) pagePrimary.value = s.primary_lang || 'en'
|
||||
}).catch(() => { /* keep the fallback */ })
|
||||
@@ -103,7 +112,7 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
removeEventListener('pagerite:switch-editor', onSwitchEvent)
|
||||
removeEventListener('pagerite:editor-shown', pinPreviewLang)
|
||||
removeEventListener('pagerite:editor-shown', openShell)
|
||||
removeEventListener('pagerite:editor-hidden', unpinPreviewLang)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// flag button opening a clean dropdown, v-modeled on the shared editorLang
|
||||
// ('' = the primary language). The lang tab's flag grid is a different
|
||||
// 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({
|
||||
modelValue: { type: String, default: '' },
|
||||
@@ -13,8 +14,12 @@ const props = defineProps({
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const open = ref(false)
|
||||
const root = ref(null)
|
||||
const toggleBtn = ref(null)
|
||||
const pop = ref(null)
|
||||
const popStyle = ref({})
|
||||
// Closes on outside click / Escape (./dropdown), not on mouseleave.
|
||||
usePopup(open, root)
|
||||
const current = computed(
|
||||
() => 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.
|
||||
const r = toggleBtn.value.getBoundingClientRect()
|
||||
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>
|
||||
|
||||
<template>
|
||||
<span v-if="options.length > 1" class="lang-select">
|
||||
<span v-if="options.length > 1" ref="root" class="lang-select">
|
||||
<button
|
||||
ref="toggleBtn"
|
||||
type="button"
|
||||
@@ -47,7 +63,7 @@ function select(tag) {
|
||||
: '')"
|
||||
@click="toggle"
|
||||
><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
|
||||
v-for="o in options"
|
||||
:key="o.code"
|
||||
@@ -66,20 +82,23 @@ function select(tag) {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.lang-current:hover,
|
||||
.lang-current.open {
|
||||
border-color: var(--line);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* The dropdown matches the page's existing popups (.picker-pop look).
|
||||
@@ -127,11 +146,13 @@ function select(tag) {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 12px;
|
||||
width: 1.5em;
|
||||
height: 1em;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 2px;
|
||||
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>
|
||||
+39
-15
@@ -26,13 +26,14 @@
|
||||
// renders the version being edited, whichever language the page itself
|
||||
// was loaded in.
|
||||
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { usePopup } from './dropdown'
|
||||
import { EditorView, basicSetup } from 'codemirror'
|
||||
import { Compartment, EditorState } from '@codemirror/state'
|
||||
import { keymap } from '@codemirror/view'
|
||||
import { indentWithTab } from '@codemirror/commands'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { cmHighlight, cmTheme } from './cmtheme'
|
||||
import { flagFor, langName } from './langs'
|
||||
import { flagFor, langName, langSort } from './langs'
|
||||
import { editorLang, pagePrimary } from './editorLang'
|
||||
import LangSelect from './LangSelect.vue'
|
||||
import ConnNote from './ConnNote.vue'
|
||||
@@ -120,11 +121,13 @@ function normPath(p) {
|
||||
// localization settings tab).
|
||||
|
||||
// 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 others = [...new Set([...siteLangs.value, ...pageLangs.value])]
|
||||
.filter((l) => l && l !== primaryLang.value)
|
||||
.sort()
|
||||
const others = langSort(
|
||||
[...new Set([...siteLangs.value, ...pageLangs.value])]
|
||||
.filter((l) => l && l !== primaryLang.value),
|
||||
)
|
||||
return [primaryLang.value, ...others].map((code) => ({
|
||||
tag: code === primaryLang.value ? '' : code,
|
||||
code,
|
||||
@@ -621,8 +624,15 @@ const TABLE_MAX_ROWS = 6
|
||||
// Class pickers: popup listing the block class toggles (placement ↔︎,
|
||||
// text size AA), closed after applying. The block's current class of the
|
||||
// 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 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) {
|
||||
classPicker.value = classPicker.value === which ? null : which
|
||||
@@ -726,17 +736,15 @@ function previewIntoArticle(html, multicol) {
|
||||
if (!article) return
|
||||
// The server render owns the article completely — the injected title h1,
|
||||
// the column layout (.multicol on the article, the .colseg/.cols
|
||||
// segments) — so the whole article content swaps as one. Only the edit
|
||||
// pen and the category cards survive: detach them before innerHTML wipes
|
||||
// them. pagerite.js re-places the pen into the first visible h1 on
|
||||
// pagerite:preview.
|
||||
// segments), the card stacks ({cards} tags expanded, or the children's
|
||||
// cards appended when the page has no tag) — so the whole article
|
||||
// content swaps as one. Only the edit pen survives: detach it before
|
||||
// innerHTML wipes it. pagerite.js re-places the pen into the first
|
||||
// visible h1 on pagerite:preview.
|
||||
article.classList.toggle('multicol', multicol)
|
||||
const pen = article.querySelector('button.edit-link')
|
||||
if (pen) pen.remove()
|
||||
const cards = article.querySelector(':scope > .cards')
|
||||
if (cards) cards.remove()
|
||||
article.innerHTML = html
|
||||
if (cards) article.append(cards)
|
||||
runScripts(article)
|
||||
dispatchEvent(new CustomEvent('pagerite:preview'))
|
||||
}
|
||||
@@ -1136,15 +1144,31 @@ onUnmounted(() => {
|
||||
<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" title="link (toggle: click inside a link to unwrap it)" @click="insertLink">🔗︎</button>
|
||||
<span class="picker" ref="tableRoot">
|
||||
<button
|
||||
type="button"
|
||||
title="table"
|
||||
:class="{ active: tablePicker }"
|
||||
@click="tablePicker = !tablePicker"
|
||||
>⊞</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="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
|
||||
type="button"
|
||||
title="block placement class"
|
||||
@@ -1166,7 +1190,7 @@ onUnmounted(() => {
|
||||
</span>
|
||||
<button type="button" title="bold" @click="wrapInline('**')"><b>B</b></button>
|
||||
<button type="button" title="italic" @click="wrapInline('*')"><i>i</i></button>
|
||||
<span class="picker">
|
||||
<span class="picker" ref="sizeRoot">
|
||||
<button
|
||||
type="button"
|
||||
title="text size class"
|
||||
@@ -1368,7 +1392,7 @@ onUnmounted(() => {
|
||||
.table-picker {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 6.5rem;
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
// Referer + UTM as one badge in the analytics visit/crawler tables: the
|
||||
// referer's favicon flush on the left, its host as the link text, then the
|
||||
// UTM summary smaller/muted inside the same badge. Only the favicon and
|
||||
// host are the link (external, new tab) — everything else, padding and
|
||||
// UTM text included, copies the full utm tag list to the clipboard. The
|
||||
// link is display: contents so its parts lay out as badge flex items.
|
||||
// The badge carries a single one-fact-per-line tooltip (badge.title:
|
||||
// origin, then each utm pair) — no titles on the inner elements.
|
||||
// Referers are external, so there is no close event.
|
||||
import { computed } from 'vue'
|
||||
import { copyList } from './analytics/format.js'
|
||||
|
||||
const props = defineProps({
|
||||
badge: { type: Object, required: true },
|
||||
favicons: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const favicon = computed(() => (props.badge.origin ? props.favicons?.[props.badge.origin] : null))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="referer-badge"
|
||||
:class="{ 'with-icon': favicon, copyable: badge.utm }"
|
||||
:title="badge.title"
|
||||
@click="badge.utm && copyList(badge.utmCopy, $event)">
|
||||
<a v-if="badge.href" class="badge-link" :href="badge.href"
|
||||
target="_blank" rel="noopener" @click.stop>
|
||||
<img v-if="favicon" class="badge-favicon" :src="favicon" alt="" />
|
||||
<span v-if="badge.label">{{ badge.label }}</span>
|
||||
</a>
|
||||
<template v-else>
|
||||
<img v-if="favicon" class="badge-favicon" :src="favicon" alt="" />
|
||||
<span v-if="badge.label">{{ badge.label }}</span>
|
||||
</template>
|
||||
<small v-if="badge.utm" class="small">{{ badge.utm }}</small>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Browser-chrome chip on a translucent neutral wash (--badge-* in
|
||||
pagerite.css, deliberately unthemed): black-on-transparent and
|
||||
white-on-transparent favicons both stay legible on it. Colors go on the
|
||||
inner elements, so the theme's link color rules cannot cascade in. The
|
||||
padding is matched by negative margins so the chip's content stays
|
||||
exactly where the bare text would sit without the badge — except on the
|
||||
right, which keeps a small positive margin so the next trail item does
|
||||
not abut the chip. */
|
||||
.referer-badge {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
/* Fixed line-height: the bar height is then exactly 1.2em + padding =
|
||||
1.6em, so the icon below can be sized to match precisely (an
|
||||
absolutely positioned replaced element cannot derive its height from
|
||||
top/bottom offsets — its aspect ratio wins and bottom is dropped). */
|
||||
line-height: 1.2;
|
||||
padding: 0.2em 0.5em;
|
||||
margin: -0.2em 0.25em -0.2em -0.2em;
|
||||
/* Fully rounded: the bar is exactly 1.6em tall, so a 0.8em radius makes
|
||||
both ends semicircles — a pill, with a full circle around the favicon
|
||||
on the left. */
|
||||
border-radius: 0.8em;
|
||||
background: var(--badge-bg);
|
||||
color: var(--badge-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Room for the absolutely positioned icon (its 1.6em width plus the gap). */
|
||||
.referer-badge.with-icon {
|
||||
padding-left: 1.95em;
|
||||
}
|
||||
|
||||
/* Exactly the bar's height (1.6em, see line-height above), flush to the
|
||||
top/left/bottom borders. The badge does not clip it (no overflow:
|
||||
hidden): the icon may stick out past the bar's rounded corners.
|
||||
Absolute on purpose: an in-flow image is the flex
|
||||
container's first item and would supply the badge's baseline (an image's
|
||||
baseline is its bottom edge), pushing the badge text above the baseline
|
||||
of the trail items that follow. Out of flow, the badge's baseline comes
|
||||
from its text, so baselines match. */
|
||||
.badge-favicon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1.6em;
|
||||
height: 1.6em;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* The link is display: contents: the favicon and label lay out as flex
|
||||
items of the badge itself, and only their actual boxes are clickable. */
|
||||
.badge-link {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Click-to-copy affordance on everything outside the link (the UTM text
|
||||
and the surrounding padding). */
|
||||
.referer-badge.copyable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.referer-badge span,
|
||||
.referer-badge small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.referer-badge span { color: var(--badge-text); }
|
||||
.referer-badge small { color: var(--badge-muted); }
|
||||
</style>
|
||||
@@ -782,14 +782,6 @@ onUnmounted(() => {
|
||||
margin-left: auto;
|
||||
padding: 0 0.2rem;
|
||||
font-size: 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.block-head .icon-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { computed, inject, onActivated, onMounted, onUnmounted, provide, ref, wa
|
||||
import StructureTree from './StructureTree.vue'
|
||||
import LangSelect from './LangSelect.vue'
|
||||
import { slugify } from './slugify'
|
||||
import { flagFor, langName } from './langs'
|
||||
import { flagFor, langName, langSort } from './langs'
|
||||
import { editorLang, pagePrimary } from './editorLang'
|
||||
import { dropPageCache, loadPlain } from './swapdoc'
|
||||
|
||||
@@ -40,9 +40,10 @@ const primaryLang = ref('en')
|
||||
const siteLangs = ref([])
|
||||
|
||||
// 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(() =>
|
||||
[primaryLang.value, ...siteLangs.value.filter((l) => l !== primaryLang.value)]
|
||||
[primaryLang.value, ...langSort(siteLangs.value.filter((l) => l !== primaryLang.value))]
|
||||
.map((code) => ({
|
||||
tag: code === primaryLang.value ? '' : code,
|
||||
code,
|
||||
@@ -63,7 +64,7 @@ watch(lang, () => refreshPages())
|
||||
// dropdown lists "inherit" first (naming what it resolves to), then every
|
||||
// site language. Setting it on a section covers its whole subtree.
|
||||
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 })),
|
||||
)
|
||||
function rowLangOptions(el) {
|
||||
|
||||
@@ -6,6 +6,7 @@ const props = defineProps({
|
||||
step: { type: Object, required: true },
|
||||
count: { type: Number, default: 0 },
|
||||
favicons: { type: Object, default: null },
|
||||
flags: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
defineEmits(['close'])
|
||||
@@ -38,6 +39,7 @@ const title = computed(() => {
|
||||
<small v-if="count > 1" class="muted">{{ formatCount(count) }}×</small>
|
||||
<img v-if="favicon" class="favicon" :src="favicon" alt="" />
|
||||
<span>{{ step.slug }}</span>
|
||||
<span v-for="(f, fi) in flags" :key="fi" class="flag" v-html="f"></span>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
@@ -48,4 +50,23 @@ const title = computed(() => {
|
||||
margin-right: 0.25em;
|
||||
vertical-align: -0.1em;
|
||||
}
|
||||
|
||||
/* Same flag chip as the visitor cells (VisitorCell.vue). */
|
||||
.flag {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 12px;
|
||||
margin-left: 0.25em;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2) inset;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.flag :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -257,11 +257,14 @@ const countLabel = (n) =>
|
||||
filter: drop-shadow(0 0 2.5px var(--accent));
|
||||
}
|
||||
.tmap .txnode {
|
||||
fill: var(--text);
|
||||
stroke: none;
|
||||
/* External source/exit pills: plain white on every theme, with a hairline
|
||||
so the pill stays visible on a white page. */
|
||||
fill: #fff;
|
||||
stroke: var(--line, rgba(128, 128, 128, 0.4));
|
||||
stroke-width: 1;
|
||||
}
|
||||
.tmap .txnode-source { fill: var(--text); }
|
||||
.tmap .txnode-exit { fill: var(--text); }
|
||||
.tmap .txnode-source { fill: #fff; }
|
||||
.tmap .txnode-exit { fill: #fff; }
|
||||
/* Branch lanes: one wide concentric arc per path prefix, running behind
|
||||
the node pills around the fan's circle center; parent levels sit one
|
||||
indent (radius step) outward. Each lane's label follows a short guide
|
||||
@@ -289,15 +292,18 @@ const countLabel = (n) =>
|
||||
stroke: none;
|
||||
}
|
||||
/* Text sizes are viewBox units: they shrink along with the graph on
|
||||
narrow panels. Overlong labels are clipped at the pill border. */
|
||||
narrow panels. Overlong labels are clipped at the pill border. The text
|
||||
is always black, on accent (internal pills) and white (external pills)
|
||||
alike — black stands out from any accent color, so the coloring stays
|
||||
stable across themes and light/dark modes. */
|
||||
.tmap .tnodeslug {
|
||||
fill: var(--bg, Canvas);
|
||||
fill: #000;
|
||||
font-size: 19px;
|
||||
text-anchor: start;
|
||||
}
|
||||
.tmap a { cursor: pointer; }
|
||||
.tmap .tnodecount {
|
||||
fill: var(--bg, Canvas);
|
||||
fill: #000;
|
||||
opacity: 0.75;
|
||||
font-size: 15px;
|
||||
text-anchor: middle;
|
||||
|
||||
@@ -4,15 +4,20 @@
|
||||
// Clicking the IP copies the full address to the clipboard.
|
||||
// ``variantCount`` overrides the UA line to warn when multiple client
|
||||
// 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 * 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({
|
||||
ip: { type: String, default: '' },
|
||||
ipDisplay: { type: String, default: '—' },
|
||||
ua: { type: String, default: '' },
|
||||
uaRaw: { type: String, default: '' },
|
||||
uaRaws: { type: String, default: '' },
|
||||
uaUrl: { type: String, default: '' },
|
||||
country: { type: String, default: '' },
|
||||
city: { 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 langValue = computed(() => props.langDisplay || formatLang(props.lang))
|
||||
const showLang = computed(() => langValue.value && langValue.value !== '—')
|
||||
const uaCopy = computed(() => props.uaRaws || props.uaRaw)
|
||||
|
||||
function flagSvg(code) {
|
||||
return flagSvgs[code?.toUpperCase()] || ''
|
||||
@@ -58,10 +64,16 @@ function countryName(code) {
|
||||
</div>
|
||||
<div class="visitor-row">
|
||||
<div class="ua-line">
|
||||
<small v-if="variantCount > 1" class="muted variant-hint">{{ variantCount }} client variations</small>
|
||||
<small v-else class="muted" :title="uaRaw">{{ ua || '—' }}</small>
|
||||
<small v-if="variantCount > 1" class="muted variant-hint clickable-ip"
|
||||
: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 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>
|
||||
</td>
|
||||
@@ -121,6 +133,13 @@ function countryName(code) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ua-link {
|
||||
text-decoration: none;
|
||||
font-size: 0.75em;
|
||||
margin-left: 0.2em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.locale-lang {
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { makeSeries } from './analytics/time.js'
|
||||
import { typicalWeek, weekBinIndex } from './analytics/seasonal.js'
|
||||
import {
|
||||
CHART_H,
|
||||
CHART_W,
|
||||
@@ -35,9 +36,6 @@ const allViews = computed(() => {
|
||||
return all
|
||||
})
|
||||
|
||||
const visitSeries = computed(() => makeSeries(props.data?.site_visits, props.range))
|
||||
const viewSeries = computed(() => makeSeries(allViews.value, props.range))
|
||||
|
||||
function freqLabel(unit) {
|
||||
return unit === '5min' ? '5 min' : unit === 'hour' ? 'hourly' : 'daily'
|
||||
}
|
||||
@@ -47,12 +45,6 @@ function axisLabel(unit, ylabel) {
|
||||
return unit === '5min' ? `${ylabel} / 5 min` : `${freqLabel(unit)} ${ylabel}`
|
||||
}
|
||||
|
||||
/** Legend label for the overlaid past weeks: "Week M" or "Week M–N". */
|
||||
function pastLabel(series) {
|
||||
const oldest = series.at(-1).label.slice(5) // strip "Week "
|
||||
return series.length > 2 ? `Week ${oldest}–${series[1].label.slice(5)}` : `Week ${oldest}`
|
||||
}
|
||||
|
||||
const now = ref(Date.now())
|
||||
let refreshInterval = null
|
||||
onMounted(() => {
|
||||
@@ -62,8 +54,29 @@ onUnmounted(() => {
|
||||
if (refreshInterval) clearInterval(refreshInterval)
|
||||
})
|
||||
|
||||
const visitChart = computed(() => buildChart(visitSeries.value, now.value))
|
||||
const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
||||
/**
|
||||
* Series for the current range plus, on the day and week views, the
|
||||
* seasonal "typical week" history curve (all history up to now, already
|
||||
* smoothed). Week view: the full Monday-first week. Day view: the rolling
|
||||
* window's bins looked up from the same estimate, labeled by the weekday.
|
||||
*/
|
||||
function withTypical(buckets) {
|
||||
const input = makeSeries(buckets, props.range)
|
||||
if (props.range !== 'day' && props.range !== 'week') return input
|
||||
const estimate = typicalWeek(buckets, now.value)
|
||||
if (!estimate) return input
|
||||
if (props.range === 'week') {
|
||||
return { ...input, typical: { values: [...estimate], label: 'Typical week' } }
|
||||
}
|
||||
const values = input.series[0].points.map((p) => estimate[weekBinIndex(p.t)])
|
||||
const weekday = new Date(now.value).toLocaleDateString(undefined, {
|
||||
weekday: 'long', timeZone: 'UTC',
|
||||
})
|
||||
return { ...input, typical: { values, label: `Typical ${weekday}` } }
|
||||
}
|
||||
|
||||
const visitChart = computed(() => buildChart(withTypical(props.data?.site_visits), now.value))
|
||||
const viewChart = computed(() => buildChart(withTypical(allViews.value), now.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -75,8 +88,7 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
||||
<svg class="chart" :viewBox="`${-MARGIN_L} 0 ${VIEW_W} ${VIEW_H}`"
|
||||
:style="{ maxWidth: `${VIEW_W}px`, marginLeft: CHART_MARGIN }"
|
||||
role="img" :aria-label="axisLabel(c.chart.unit, c.ylabel)">
|
||||
<!-- Clip the plot curves to the chart area: past-week overlays can
|
||||
run far above the autoscaled y range, and the svg itself is
|
||||
<!-- Clip the plot curves to the chart area; the svg itself is
|
||||
overflow: visible for the axis labels. -->
|
||||
<clipPath :id="`plot-${c.ylabel}`">
|
||||
<rect x="0" y="0" :width="CHART_W" :height="CHART_H" />
|
||||
@@ -88,17 +100,17 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
||||
class="minor vertical" />
|
||||
</template>
|
||||
<g :clip-path="`url(#plot-${c.ylabel})`">
|
||||
<!-- The muted "typical" history curve under the current data. -->
|
||||
<path v-if="c.chart.typical" :d="c.chart.typical.line" class="line past" />
|
||||
<template v-if="c.chart.bars">
|
||||
<rect v-for="(b, i) in c.chart.bars" :key="'b' + i"
|
||||
:x="b.x" :y="b.y" :width="b.width" :height="b.height" class="bar" />
|
||||
<path :d="c.chart.skyline" class="line" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Oldest overlay weeks first so the current week paints on top. -->
|
||||
<template v-for="(s, i) in [...c.chart.series].reverse()" :key="i">
|
||||
<template v-for="(s, i) in c.chart.series" :key="i">
|
||||
<path v-if="s.area" :d="s.area" class="area" />
|
||||
<path :d="s.line" class="line" :class="{ past: s.past }"
|
||||
:style="{ opacity: s.opacity }" />
|
||||
<path :d="s.line" class="line" />
|
||||
</template>
|
||||
</template>
|
||||
</g>
|
||||
@@ -111,16 +123,17 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
||||
class="yaxis-label">{{ axisLabel(c.chart.unit, c.ylabel) }}</text>
|
||||
<text v-for="t in c.chart.xticks" :key="'x' + t.x" :x="t.x" :y="CHART_H + MARGIN_B - 8"
|
||||
text-anchor="middle" class="xlab">{{ t.label }}</text>
|
||||
<!-- Week overlay legend, top right inside the plot: current week in
|
||||
accent, one muted specimen for the whole past range. -->
|
||||
<g v-if="c.legend && c.chart.series.length > 1">
|
||||
<line :x1="CHART_W - 98" :x2="CHART_W - 78" y1="10" y2="10" class="line" />
|
||||
<text :x="CHART_W - 72" y="10" dominant-baseline="middle"
|
||||
class="leglab">{{ c.chart.series[0].label }}</text>
|
||||
<line :x1="CHART_W - 98" :x2="CHART_W - 78" y1="25" y2="25"
|
||||
<!-- Legend, top right inside the plot: current data in accent
|
||||
(week label, or "Last 24 hours" on the day view) and the
|
||||
typical history curve in muted. -->
|
||||
<g v-if="c.legend && c.chart.typical">
|
||||
<line :x1="CHART_W - 118" :x2="CHART_W - 98" y1="10" y2="10" class="line" />
|
||||
<text :x="CHART_W - 92" y="10" dominant-baseline="middle"
|
||||
class="leglab">{{ c.chart.bars ? 'Last 24 hours' : c.chart.series[0].label }}</text>
|
||||
<line :x1="CHART_W - 118" :x2="CHART_W - 98" y1="25" y2="25"
|
||||
class="line past" style="opacity: 0.6" />
|
||||
<text :x="CHART_W - 72" y="25" dominant-baseline="middle"
|
||||
class="leglab">{{ pastLabel(c.chart.series) }}</text>
|
||||
<text :x="CHART_W - 92" y="25" dominant-baseline="middle"
|
||||
class="leglab">{{ c.chart.typical.label }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -183,12 +196,12 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
|
||||
|
||||
.chart .area {
|
||||
fill: var(--accent);
|
||||
opacity: 0.15;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.chart .bar {
|
||||
fill: var(--accent);
|
||||
opacity: 0.15;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.chart .line {
|
||||
|
||||
@@ -181,16 +181,21 @@ export function spline(pts) {
|
||||
export function buildChart(input, now = Date.now()) {
|
||||
if (!input || !input.series.length) return null
|
||||
if (input.unit === '5min') return buildDayChart(input, now)
|
||||
const { series, t0, t1, rate, binMinutes, unitMinutes, unit } = input
|
||||
const { series, t0, t1, rate, binMinutes, unitMinutes, unit, typical } = input
|
||||
// Values are per-unit rates (hour on the week view, day on month+); the
|
||||
// y max is derived from the *smoothed* curves so random single-bucket
|
||||
// spikes don't blow up the scale. Smoothing works on raw counts (its edge
|
||||
// detector thresholds are count-based), the result is scaled back to rates.
|
||||
const smoothed = series.map((s) =>
|
||||
smooth(s.points.map((p) => p.count), binMinutes, unitMinutes).map((v) => v * rate))
|
||||
// Scale from the current/primary series only; older overlay weeks are drawn
|
||||
// with the same scale and allowed to overflow if they are busier.
|
||||
const highest = Math.max(0, ...smoothed[0])
|
||||
// The "typical week" seasonal estimate is already smooth: one value per
|
||||
// bin spanning the full week (future included), drawn in the muted color.
|
||||
const typicalRates = typical
|
||||
? [...typical.values].map((v) => v * rate)
|
||||
: null
|
||||
// Scale from the current series plus the typical curve; both are smooth,
|
||||
// and neither should be clipped in normal traffic.
|
||||
const highest = Math.max(0, ...smoothed[0], ...(typicalRates || []))
|
||||
const { max, step, minor } = yScale(highest)
|
||||
const x = (t) => ((t - t0) / (t1 - t0)) * CHART_W
|
||||
const y = (v) => PAD_TOP + (1 - Math.max(0, v) / max) * (CHART_H - PAD_TOP)
|
||||
@@ -205,6 +210,12 @@ export function buildChart(input, now = Date.now()) {
|
||||
area: s.area ? `${line}L${last.x},${CHART_H}L${first.x},${CHART_H}Z` : null,
|
||||
}
|
||||
})
|
||||
let typicalLine = null
|
||||
if (typicalRates) {
|
||||
const binMs = (t1 - t0) / typicalRates.length
|
||||
const pts = typicalRates.map((v, i) => ({ x: x(t0 + i * binMs), y: y(v) }))
|
||||
typicalLine = { line: spline(pts), label: typical.label }
|
||||
}
|
||||
// Major (labeled) and minor (hairline) y grid ticks.
|
||||
const majors = []
|
||||
const minors = []
|
||||
@@ -256,16 +267,19 @@ export function buildChart(input, now = Date.now()) {
|
||||
x: x(t), label: fmtTick(t, t1 - t0), line: true,
|
||||
}))
|
||||
}
|
||||
return { max, majors, minors, series: drawn, xticks, unit }
|
||||
return { max, majors, minors, series: drawn, typical: typicalLine, xticks, unit }
|
||||
}
|
||||
|
||||
/**
|
||||
* Day view: 5-minute bars for the last 24 hours. Bars are drawn at raw
|
||||
* counts; the skyline uses a projected full-bucket value for the still-open
|
||||
* final bucket. The y scale is derived from the projected skyline maximum.
|
||||
* The optional "typical day" curve (per-bin counts aligned to the window's
|
||||
* bins, cut from the typical-week estimate) overlays the bars as a smooth
|
||||
* muted line and also feeds the y scale.
|
||||
*/
|
||||
export function buildDayChart(input, now = Date.now()) {
|
||||
const { series, t0, t1 } = input
|
||||
const { series, t0, t1, typical } = input
|
||||
const points = series[0]?.points || []
|
||||
const n = points.length
|
||||
if (!n) return null
|
||||
@@ -285,7 +299,7 @@ export function buildDayChart(input, now = Date.now()) {
|
||||
const share = elapsed / bucketMs
|
||||
return p.count + prevRaw * (1 - share)
|
||||
})
|
||||
const highest = Math.max(0, ...projected)
|
||||
const highest = Math.max(0, ...projected, ...(typical ? typical.values : []))
|
||||
const { max, step, minor } = yScale(highest)
|
||||
const y = (v) => PAD_TOP + (1 - Math.max(0, v) / max) * (CHART_H - PAD_TOP)
|
||||
|
||||
@@ -313,6 +327,15 @@ export function buildDayChart(input, now = Date.now()) {
|
||||
}
|
||||
}
|
||||
|
||||
let typicalLine = null
|
||||
if (typical) {
|
||||
const pts = points.map((p, i) => ({
|
||||
x: (i + 0.5) * bucketWidth,
|
||||
y: y(typical.values[i] || 0),
|
||||
}))
|
||||
typicalLine = { line: spline(pts), label: typical.label }
|
||||
}
|
||||
|
||||
const majors = []
|
||||
const minors = []
|
||||
const nMajor = Math.round(max / step)
|
||||
@@ -338,7 +361,7 @@ export function buildDayChart(input, now = Date.now()) {
|
||||
line: false,
|
||||
})
|
||||
}
|
||||
return { bars, skyline: skyline.trim(), max, majors, minors, xticks, unit: '5min', series: [] }
|
||||
return { bars, skyline: skyline.trim(), typical: typicalLine, max, majors, minors, xticks, unit: '5min', series: [] }
|
||||
}
|
||||
|
||||
/** X ticks for year/all: Monday boundaries up to a quarter, UTC month
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Formatters and aggregators for summary sections: totals and the recent
|
||||
* visit trail.
|
||||
*/
|
||||
import { flagFor, langName } from '../langs.js'
|
||||
|
||||
/**
|
||||
* IPv4 unchanged, IPv6 returns the /64 network prefix in compact form.
|
||||
@@ -25,32 +26,31 @@ export const hostIP = (ip) => {
|
||||
}
|
||||
}
|
||||
|
||||
function showCopiedFeedback(el) {
|
||||
if (!el || typeof document === 'undefined') return
|
||||
function showCopiedFeedback(el, event) {
|
||||
if (typeof document === 'undefined') return
|
||||
const popup = document.createElement('span')
|
||||
popup.textContent = 'Copied!'
|
||||
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 =
|
||||
'position:absolute;bottom:calc(100% + 0.25rem);left:50%;' +
|
||||
'transform:translateX(-50%);padding:0.15rem 0.4rem;' +
|
||||
`position:fixed;left:${x}px;top:${y}px;` +
|
||||
'transform:translate(-50%, calc(-100% - 0.5rem));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;'
|
||||
el.classList.add('has-copy-popup')
|
||||
el.appendChild(popup)
|
||||
setTimeout(() => {
|
||||
popup.remove()
|
||||
el.classList.remove('has-copy-popup')
|
||||
}, 1200)
|
||||
'pointer-events:none;z-index:100;'
|
||||
document.body.appendChild(popup)
|
||||
setTimeout(() => popup.remove(), 1200)
|
||||
}
|
||||
|
||||
/** Copy the full IP to the clipboard and show a brief "Copied!" popup. */
|
||||
export async function copyIp(ip, event) {
|
||||
if (!ip) return
|
||||
const el = event?.currentTarget
|
||||
try {
|
||||
await navigator.clipboard.writeText(ip)
|
||||
showCopiedFeedback(el)
|
||||
showCopiedFeedback(event?.currentTarget, event)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -59,10 +59,9 @@ export async function copyIp(ip, event) {
|
||||
/** Copy arbitrary text to the clipboard and show a brief "Copied!" popup. */
|
||||
export async function copyList(text, event) {
|
||||
if (!text) return
|
||||
const el = event?.currentTarget
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showCopiedFeedback(el)
|
||||
showCopiedFeedback(event?.currentTarget, event)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -178,6 +177,55 @@ function stepOf(path, titles) {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge data combining a visit's/crawler's external referer origin with the
|
||||
* visit's UTM tags: the origin as the badge link/label (the favicon is
|
||||
* looked up by origin in the component), a compact UTM summary (the few
|
||||
* most informative values) as ``utm`` with the full ``utm_*=value`` list
|
||||
* as ``utmCopy`` for click-to-copy, and a one-fact-per-line tooltip — the
|
||||
* full origin URL on the first line, then every ``utm_*=value`` pair.
|
||||
* Null when there is no external referer and no UTM tag (a plain direct
|
||||
* visit).
|
||||
*/
|
||||
function refererBadgeOf(referer, titles, utmTags = {}) {
|
||||
const step = stepOf(referer, titles)
|
||||
const external = step?.external ? step : null
|
||||
// Compact UTM summary, in display order source, campaign, content, term:
|
||||
// source only when no referer is known (it just repeats where the visitor
|
||||
// came from), content only as a stand-in when there is no term. The
|
||||
// remaining tags (medium and any nonstandard utm_*) fill in only when
|
||||
// fewer than three of these more useful items exist — and never when a
|
||||
// term is present (the term alone says enough). The tooltip keeps
|
||||
// every tag, one pair per line.
|
||||
const useful = []
|
||||
if (utmTags.utm_source && !referer) useful.push(utmTags.utm_source)
|
||||
if (utmTags.utm_campaign) useful.push(utmTags.utm_campaign)
|
||||
if (utmTags.utm_content && !utmTags.utm_term) useful.push(utmTags.utm_content)
|
||||
if (utmTags.utm_term) useful.push(utmTags.utm_term)
|
||||
const rest = useful.length < 3 && !utmTags.utm_term
|
||||
? Object.keys(utmTags)
|
||||
.filter((k) => !['utm_source', 'utm_campaign', 'utm_content', 'utm_term'].includes(k))
|
||||
.map((k) => utmTags[k])
|
||||
.filter(Boolean)
|
||||
: []
|
||||
const utm = [...useful, ...rest].join(' · ')
|
||||
if (!external && !utm) return null
|
||||
const utmCopy = Object.entries(utmTags)
|
||||
.map(([k, value]) => `${k}=${value}`)
|
||||
.join('\n')
|
||||
return {
|
||||
href: external?.origin || '',
|
||||
label: external?.slug || '',
|
||||
origin: external?.origin || '',
|
||||
utm,
|
||||
utmCopy,
|
||||
title: [
|
||||
...(external ? [external.origin] : []),
|
||||
...Object.entries(utmTags).map(([k, value]) => `${k}=${value}`),
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable relative timestamp. Adapted from cista-storage: uses
|
||||
* ``Intl.RelativeTimeFormat`` for short intervals and a compact date for
|
||||
@@ -342,7 +390,7 @@ export function countCrawlerUas(crawlers, clients) {
|
||||
const counts = {}
|
||||
for (const c of crawlers || []) {
|
||||
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
|
||||
}
|
||||
return Object.entries(counts).sort((a, b) => b[1] - a[1])
|
||||
@@ -370,12 +418,13 @@ export function mainDomain(host, limit = 24) {
|
||||
/**
|
||||
* Group raw crawler hits by client hash and format each group as a row showing
|
||||
* every internal page that crawler visited. Rows are sorted by most recent hit
|
||||
* first, with total hits as a tie-breaker. The group's ``refererStep`` is the
|
||||
* latest external referer seen for the crawler — spiders often advertise
|
||||
* their own site there — rendered with its favicon like visit referers.
|
||||
* first, with total hits as a tie-breaker. The group's ``refererBadge`` is
|
||||
* the latest external referer seen for the crawler — spiders often advertise
|
||||
* their own site there — rendered as a badge with its favicon like visit
|
||||
* referers.
|
||||
* ``clients`` maps client hashes to client records.
|
||||
*/
|
||||
export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now()) {
|
||||
export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now(), site = { multilingual: false, primaryLang: '' }) {
|
||||
const titles = buildTitleMap(pageTree)
|
||||
const groups = new Map()
|
||||
for (const c of crawlers || []) {
|
||||
@@ -386,10 +435,12 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now())
|
||||
lastStart: 0,
|
||||
referer: '',
|
||||
pages: new Map(),
|
||||
langs: new Set(),
|
||||
}
|
||||
const start = new Date(c.start).getTime()
|
||||
if (start > g.lastStart) g.lastStart = start
|
||||
if (c.referer) g.referer = c.referer
|
||||
if (c.lang) g.langs.add(c.lang)
|
||||
if (c.entry?.startsWith('/')) {
|
||||
const existing = g.pages.get(c.entry) || { count: 0, status: c.status || 200 }
|
||||
existing.count += 1
|
||||
@@ -410,19 +461,28 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now())
|
||||
const client = g.client || {}
|
||||
const host = client.host || ''
|
||||
const isHost = !!host
|
||||
// Rendered languages read, shown only when they say something the
|
||||
// primary language alone would not (multilingual sites only).
|
||||
const langs = [...g.langs].sort()
|
||||
const showLangs =
|
||||
site.multilingual && (langs.length > 1 || (langs[0] && langs[0] !== site.primaryLang))
|
||||
return {
|
||||
lastSeen: formatWhen(g.lastStart, now),
|
||||
lastSeenIso: formatWhenIso(g.lastStart),
|
||||
lastSeenLocal: formatWhenLocal(g.lastStart),
|
||||
refererStep: stepOf(g.referer, titles),
|
||||
refererBadge: refererBadgeOf(g.referer, titles),
|
||||
pages: [...g.pages.entries()]
|
||||
.sort((a, b) => b[1].count - a[1].count)
|
||||
.map(([path, info]) => ({ ...stepOf(path, titles), count: info.count, status: info.status })),
|
||||
readFlags: showLangs
|
||||
? langs.map((l) => ({ flag: flagFor(l), name: langName(l) })).filter((f) => f.flag)
|
||||
: [],
|
||||
ip: client.ip || '',
|
||||
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip) || client.ip || '—',
|
||||
isHost,
|
||||
ua: client.ua_pretty || client.ua || '—',
|
||||
ua: client.uarite?.pretty || client.ua || '—',
|
||||
uaRaw: client.ua || '',
|
||||
uaUrl: client.uarite?.url || '',
|
||||
lang: client.lang || '—',
|
||||
langDisplay: formatLang(client.lang),
|
||||
country: client.country || '—',
|
||||
@@ -505,6 +565,13 @@ export function formatAbuseRows(abuse, clients, pageTree, now = Date.now()) {
|
||||
const client = (clients || {})[g.lastClient] || {}
|
||||
const host = client.host || ''
|
||||
const isHost = !!host
|
||||
const uaRaws = [
|
||||
...new Set(
|
||||
[...g.clientHashes]
|
||||
.map((h) => (clients || {})[h]?.ua)
|
||||
.filter(Boolean),
|
||||
),
|
||||
].join('\n')
|
||||
return {
|
||||
lastSeen: formatWhen(g.lastStart, now),
|
||||
lastSeenIso: formatWhenIso(g.lastStart),
|
||||
@@ -527,8 +594,10 @@ export function formatAbuseRows(abuse, clients, pageTree, now = Date.now()) {
|
||||
ip: client.ip || g.ip,
|
||||
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip || g.ip) || client.ip || g.ip || '—',
|
||||
isHost,
|
||||
ua: client.ua_pretty || client.ua || '—',
|
||||
ua: client.uarite?.pretty || client.ua || '—',
|
||||
uaRaw: client.ua || '',
|
||||
uaUrl: client.uarite?.url || '',
|
||||
uaRaws,
|
||||
lang: client.lang || '—',
|
||||
langDisplay: formatLang(client.lang),
|
||||
country: client.country || '—',
|
||||
@@ -540,52 +609,88 @@ export function formatAbuseRows(abuse, clients, pageTree, now = Date.now()) {
|
||||
|
||||
/**
|
||||
* Format raw visit records as rows for a technical table. Returns objects
|
||||
* with display strings; missing values become "—". ``trail`` starts with the
|
||||
* external referer (when present), then the entry page and any further internal
|
||||
* pages or external exit origins. Only the 20 most recent visits are shown.
|
||||
* ``clients`` maps client hashes to client records.
|
||||
* with display strings; missing values become "—". The external referer
|
||||
* (when present) and the UTM tags ride along as ``refererBadge``; ``trail``
|
||||
* holds the entry page and any further internal pages or external exit
|
||||
* origins; consecutive views of the same page (e.g. a
|
||||
* language switch re-view) merge into one step that keeps the
|
||||
* consecutive-distinct rendered languages, summed read time, and the latest
|
||||
* status. On multilingual sites the rendered languages surface as flag
|
||||
* icons: a visit read entirely in one non-primary language gets ``rowFlag``,
|
||||
* and a visit spanning languages gets per-step ``langFlags`` markers where
|
||||
* the language begins or changes. Only the 20 most recent visits are shown.
|
||||
* ``clients`` maps client hashes to client records; ``site`` carries the
|
||||
* payload's multilingual/primary-language context.
|
||||
*/
|
||||
export function formatVisitRows(visits, clients, pageTree, now = Date.now()) {
|
||||
export function formatVisitRows(visits, clients, pageTree, now = Date.now(), site = { multilingual: false, primaryLang: '' }) {
|
||||
const titles = buildTitleMap(pageTree)
|
||||
return [...(visits || [])].reverse().slice(0, 20).map((v) => {
|
||||
const client = (clients || {})[v.client] || {}
|
||||
const trail = Object.values(v.trail || {})
|
||||
const steps = Object.values(v.trail || {})
|
||||
.map((item) => {
|
||||
const step = stepOf(item.to, titles)
|
||||
if (step) {
|
||||
if (item.read) step.readSeconds = item.read
|
||||
if (item.status) step.status = item.status
|
||||
if (item.lang) step.lang = item.lang
|
||||
}
|
||||
return step
|
||||
})
|
||||
.filter(Boolean)
|
||||
const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']
|
||||
const utmValues = utmKeys.map((k) => (v.utm || {})[k]).filter(Boolean)
|
||||
const utm = utmValues.length ? utmValues.join(' · ') : ''
|
||||
const utmTitle = Object.entries(v.utm || {})
|
||||
.map(([k, value]) => `${k}=${value}`)
|
||||
.join(', ')
|
||||
const trail = []
|
||||
for (const step of steps) {
|
||||
const prev = trail[trail.length - 1]
|
||||
if (prev && prev.path === step.path) {
|
||||
if (step.lang && step.lang !== prev.langs[prev.langs.length - 1]) prev.langs.push(step.lang)
|
||||
if (step.readSeconds) prev.readSeconds = (prev.readSeconds || 0) + step.readSeconds
|
||||
if (step.status) prev.status = step.status
|
||||
} else {
|
||||
step.langs = step.lang ? [step.lang] : []
|
||||
trail.push(step)
|
||||
}
|
||||
}
|
||||
const distinctLangs = new Set(trail.flatMap((s) => s.langs))
|
||||
const dash = (s) => (s || '—')
|
||||
const host = client.host || ''
|
||||
const isHost = !!host
|
||||
return {
|
||||
const row = {
|
||||
lastSeen: formatWhen(v.start, now),
|
||||
lastSeenIso: formatWhenIso(v.start),
|
||||
lastSeenLocal: formatWhenLocal(v.start),
|
||||
langDisplay: formatLang(client.lang),
|
||||
trail,
|
||||
refererStep: stepOf(v.referer, titles),
|
||||
referer: dash(v.referer),
|
||||
refererBadge: refererBadgeOf(v.referer, titles, v.utm),
|
||||
ip: client.ip || '',
|
||||
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip) || client.ip || '—',
|
||||
isHost,
|
||||
lang: dash(client.lang),
|
||||
country: dash(client.country),
|
||||
city: dash(client.city),
|
||||
ua: client.ua_pretty || client.ua || '—',
|
||||
ua: client.uarite?.pretty || client.ua || '—',
|
||||
uaRaw: client.ua || '',
|
||||
utm: utm || '—',
|
||||
utmTitle,
|
||||
uaUrl: client.uarite?.url || '',
|
||||
}
|
||||
if (site.multilingual && distinctLangs.size) {
|
||||
if (distinctLangs.size === 1) {
|
||||
const [tag] = distinctLangs
|
||||
const flag = flagFor(tag)
|
||||
if (flag && tag !== site.primaryLang) {
|
||||
row.rowFlag = flag
|
||||
row.rowFlagTitle = langName(tag)
|
||||
}
|
||||
} else {
|
||||
// Flag the steps where the rendered language begins or changes;
|
||||
// lang-less steps keep the comparison chain going, they never flag.
|
||||
let lastLang = null
|
||||
for (const step of trail) {
|
||||
if (!step.langs.length) continue
|
||||
if (!lastLang || step.langs[step.langs.length - 1] !== lastLang) {
|
||||
step.langFlags = step.langs.map(flagFor).filter(Boolean)
|
||||
}
|
||||
lastLang = step.langs[step.langs.length - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Seasonal "typical week" estimate from the full visit history.
|
||||
*
|
||||
* Port of the seasonal.py demo algorithm: the whole smoothed 5-minute
|
||||
* history is collapsed onto a weekly grid with exponential decay over age —
|
||||
* a fast kernel (half-life 7 days) for the average time-of-day pattern and
|
||||
* a slow one (half-life 42 days) for per-weekday deviations from it. The
|
||||
* deviation is shrunk by the effective number of weeks behind each bin
|
||||
* (n_eff / (n_eff + 3)), so with little history the estimate falls back to
|
||||
* the common daily pattern and weekday character emerges as data accrues.
|
||||
* Bins before the first recorded bucket are treated as missing.
|
||||
*/
|
||||
|
||||
import { DAY, MIN5, mondayUTC, rawTimes } from './time.js'
|
||||
import { smooth } from './chart.js'
|
||||
|
||||
export const BINS_PER_DAY = 288
|
||||
export const BINS_PER_WEEK = 7 * BINS_PER_DAY
|
||||
|
||||
// History cap: at 180 days the slow kernel's weight is 2^(-180/42) ≈ 5%
|
||||
// (and the fast kernel's utterly negligible), so older data cannot move
|
||||
// this noisy estimate — skipping it keeps the smoothing pass O(1).
|
||||
const MAX_HISTORY_DAYS = 180
|
||||
|
||||
/**
|
||||
* Estimate the typical week from a dense 5-minute count series (oldest
|
||||
* first; non-finite values count as missing). endWeekBin is the week bin
|
||||
* (Monday-first) just past the last sample. Returns BINS_PER_WEEK counts
|
||||
* per 5-minute bin, starting Monday 00:00.
|
||||
*/
|
||||
export function seasonalCurve(counts, {
|
||||
endWeekBin,
|
||||
binsPerDay = BINS_PER_DAY,
|
||||
recentHalfLife = 7,
|
||||
weekdayHalfLife = 42,
|
||||
shrinkWeeks = 3,
|
||||
} = {}) {
|
||||
const n = counts.length
|
||||
const binsPerWeek = 7 * binsPerDay
|
||||
|
||||
const recentW = new Float64Array(binsPerDay)
|
||||
const recentX = new Float64Array(binsPerDay)
|
||||
const dayW = new Float64Array(binsPerDay)
|
||||
const dayX = new Float64Array(binsPerDay)
|
||||
const weekW = new Float64Array(binsPerWeek)
|
||||
const weekX = new Float64Array(binsPerWeek)
|
||||
const weekW2 = new Float64Array(binsPerWeek)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = counts[i]
|
||||
if (!Number.isFinite(x)) continue
|
||||
let weekBin = (endWeekBin - n + i) % binsPerWeek
|
||||
if (weekBin < 0) weekBin += binsPerWeek
|
||||
const dayBin = weekBin % binsPerDay
|
||||
const ageDays = (n - 1 - i) / binsPerDay
|
||||
const recent = 2 ** (-ageDays / recentHalfLife)
|
||||
const slow = 2 ** (-ageDays / weekdayHalfLife)
|
||||
recentW[dayBin] += recent
|
||||
recentX[dayBin] += recent * x
|
||||
dayW[dayBin] += slow
|
||||
dayX[dayBin] += slow * x
|
||||
weekW[weekBin] += slow
|
||||
weekX[weekBin] += slow * x
|
||||
weekW2[weekBin] += slow * slow
|
||||
}
|
||||
|
||||
const estimate = new Float64Array(binsPerWeek)
|
||||
for (let wb = 0; wb < binsPerWeek; wb++) {
|
||||
const db = wb % binsPerDay
|
||||
const recentMean = recentW[db] > 0 ? recentX[db] / recentW[db] : NaN
|
||||
const dayMean = dayW[db] > 0 ? dayX[db] / dayW[db] : NaN
|
||||
const weekMean = weekW[wb] > 0 ? weekX[wb] / weekW[wb] : 0
|
||||
const nEff = weekW2[wb] > 0 ? (weekW[wb] * weekW[wb]) / weekW2[wb] : 0
|
||||
const shrink = nEff / (nEff + shrinkWeeks)
|
||||
const base = Number.isFinite(recentMean)
|
||||
? recentMean
|
||||
: Number.isFinite(dayMean) ? dayMean : 0
|
||||
const deviation = Number.isFinite(dayMean) ? weekMean - dayMean : 0
|
||||
estimate[wb] = base + shrink * deviation
|
||||
}
|
||||
return estimate
|
||||
}
|
||||
|
||||
/** Week bin (0 = Monday 00:00–00:05 UTC) containing timestamp t. */
|
||||
export function weekBinIndex(t) {
|
||||
return Math.floor((t - mondayUTC(t)) / MIN5)
|
||||
}
|
||||
|
||||
/**
|
||||
* Typical-week estimate from sparse 5-minute buckets, using history up to
|
||||
* tEnd (default now): bins are densified from the first recorded bucket
|
||||
* (capped at MAX_HISTORY_DAYS back), smoothed with the same Gaussian the
|
||||
* week view uses, then folded by seasonalCurve. Returns BINS_PER_WEEK
|
||||
* counts per 5-minute bin starting Monday, or null when there is less than
|
||||
* a day of history.
|
||||
*/
|
||||
export function typicalWeek(buckets, tEnd = Date.now()) {
|
||||
const raw = rawTimes(buckets)
|
||||
const times = Object.keys(raw).map(Number)
|
||||
if (!times.length) return null
|
||||
const end = Math.floor(tEnd / MIN5) * MIN5
|
||||
const start = Math.max(Math.min(...times), end - MAX_HISTORY_DAYS * DAY)
|
||||
const n = Math.floor((end - start) / MIN5)
|
||||
if (n < BINS_PER_DAY) return null
|
||||
const counts = new Array(n)
|
||||
for (let i = 0; i < n; i++) counts[i] = raw[start + i * MIN5] || 0
|
||||
const smoothed = smooth(counts, 5, 60)
|
||||
return seasonalCurve(smoothed, { endWeekBin: weekBinIndex(end) })
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* Raw data comes as sparse 5-minute buckets; the range picks the x window
|
||||
* and a coarser bucket size to keep point counts sane. The week range is
|
||||
* aligned to Monday 00:00 UTC and overlays previous weeks' curves (fading
|
||||
* with age), so weekly patterns compare directly.
|
||||
* aligned to Monday 00:00 UTC; a "typical week" seasonal estimate
|
||||
* (seasonal.js) is overlaid on the week and day views by the chart builder.
|
||||
*/
|
||||
|
||||
export const MIN5 = 5 * 60e3
|
||||
@@ -51,25 +51,21 @@ export function sumRange(raw, t0, t1) {
|
||||
}
|
||||
|
||||
/**
|
||||
* One series per overlaid week: [this week, 1 week ago, ...], at native
|
||||
* 5-minute resolution, up to 8 weeks back (and only weeks that overlap the
|
||||
* recorded data at all). Each older week's timestamps are shifted forward
|
||||
* onto the current week's axis so all curves overlay inside the plot.
|
||||
* The current week is truncated at the current bucket
|
||||
* — no fake zeroes drawn for the future. Counts are rates per hour
|
||||
* The current week at native 5-minute resolution, truncated at the current
|
||||
* bucket — no fake zeroes drawn for the future. Counts are rates per hour
|
||||
* (bucket count * 12): a lone visit in a 5-minute bucket reads as "12/h".
|
||||
* The coarser ranges use per-day rates instead (unitMinutes = 24*60).
|
||||
* Previous weeks are no longer overlaid; the "typical week" seasonal
|
||||
* estimate (seasonal.js) takes their place as the history reference.
|
||||
*/
|
||||
export function weeklySeries(buckets) {
|
||||
const raw = rawTimes(buckets)
|
||||
const times = Object.keys(raw).map(Number)
|
||||
const now = Date.now()
|
||||
const thisMonday = mondayUTC(now)
|
||||
if (!times.length) {
|
||||
const points = []
|
||||
const end = Math.min(thisMonday + WEEK, Math.floor(now / MIN5) * MIN5 + MIN5)
|
||||
for (let t = thisMonday; t < end; t += MIN5) {
|
||||
points.push({ t, count: 0 })
|
||||
points.push({ t, count: raw[t] || 0 })
|
||||
}
|
||||
return {
|
||||
series: [{ points, label: `Week ${isoWeek(thisMonday)}`, opacity: 1, area: true }],
|
||||
@@ -81,38 +77,6 @@ export function weeklySeries(buckets) {
|
||||
unit: 'hour',
|
||||
}
|
||||
}
|
||||
const oldest = Math.min(...times)
|
||||
// Weeks back as far as the data reaches: difference in Monday indices.
|
||||
const available = (thisMonday - mondayUTC(oldest)) / WEEK + 1
|
||||
const count = Math.min(available, 8)
|
||||
const out = []
|
||||
for (let back = 0; back < count; back++) {
|
||||
const start = thisMonday - back * WEEK
|
||||
const end = back === 0
|
||||
? Math.min(start + WEEK, Math.floor(now / MIN5) * MIN5 + MIN5)
|
||||
: start + WEEK
|
||||
const points = []
|
||||
for (let t = start; t < end; t += MIN5) {
|
||||
points.push({ t: t + back * WEEK, count: raw[t] || 0 })
|
||||
}
|
||||
out.push({
|
||||
points,
|
||||
label: `Week ${isoWeek(start)}`,
|
||||
opacity: Math.max(0.15, 1 - back * 0.25),
|
||||
past: back > 0,
|
||||
area: back === 0,
|
||||
})
|
||||
}
|
||||
return {
|
||||
series: out,
|
||||
t0: thisMonday,
|
||||
t1: thisMonday + WEEK,
|
||||
rate: HOUR / MIN5,
|
||||
binMinutes: 5,
|
||||
unitMinutes: 60,
|
||||
unit: 'hour',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolling window for the non-week ranges (x max = now), counts converted
|
||||
|
||||
@@ -26,6 +26,15 @@
|
||||
/* Selection fill for page text and the CodeMirror editors; themes
|
||||
override when the accent tint clashes with accent-colored text. */
|
||||
--selection-bg: color-mix(var(--accent) 30%, transparent);
|
||||
/* Referer-badge chip in the analytics viewer: a translucent neutral wash,
|
||||
deliberately NOT themed — the chip sits behind transparent favicons, so
|
||||
black-on-transparent and white-on-transparent glyphs must both stay
|
||||
legible on every theme (a slight whitening keeps black glyphs readable
|
||||
on dark bars without a glaring solid-white chip). Being translucent, it
|
||||
takes the page's tone, so its text follows the theme's colors. */
|
||||
--badge-bg: #aaaaaa44;
|
||||
--badge-text: var(--text);
|
||||
--badge-muted: var(--muted);
|
||||
/* Code highlighting palette, consumed by pygments.css: complete light and
|
||||
dark sets (background included), resolved by light-dark() from the
|
||||
used color-scheme. A theme picks a set simply by declaring
|
||||
@@ -472,19 +481,20 @@ main {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* Child-entry card stacks: a category page (and the content-less category
|
||||
404) lists its published children after the markdown content — one column
|
||||
per child, the child's whole subtree flattened into the column in menu
|
||||
order (see _cards in views.py). The row bleeds to full page width
|
||||
(div.wide): the columns first grow to fill it, then shrink rather than
|
||||
wrap. Every card has the same fixed 16/10 shape, covered entirely by the
|
||||
/* Child-entry cards: a category page (and the content-less category
|
||||
404) lists its published children after the markdown content — one
|
||||
card per child (a child without a page of its own is represented by
|
||||
its first leaf page; see _cards in views.py). The row bleeds to full
|
||||
page width (div.wide): the cards first grow to fill it, then shrink
|
||||
rather than wrap. Every card has the same fixed 16/10 shape, covered
|
||||
entirely by the
|
||||
page's share image (og:image heuristics, as a background — a gradient
|
||||
placeholder when it has none) with the title overlaid on a translucent
|
||||
band at the bottom. The card is one <a> holding only phrasing-level
|
||||
spans; the spans lay out as blocks. */
|
||||
.cards {
|
||||
display: flex;
|
||||
/* Stacks stop growing at their cap; center the row in the bleed then. */
|
||||
/* Cards stop growing at their cap; center the row in the bleed then. */
|
||||
justify-content: center;
|
||||
gap: 1.25rem;
|
||||
margin-top: 2.5rem;
|
||||
@@ -493,18 +503,7 @@ main {
|
||||
padding-inline: 1.25rem;
|
||||
}
|
||||
|
||||
.cards .stack {
|
||||
/* Grow to fill the row (up to the cap — full-width rows would make huge
|
||||
cards), shrink (not wrap) when there are too many. */
|
||||
flex: 1 1 0;
|
||||
max-width: 24rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
/* Phones: the columns stack vertically instead of shrinking to slivers.
|
||||
/* Phones: the cards stack vertically instead of shrinking to slivers.
|
||||
Text-only cards (gradient cover + description) then fit their content —
|
||||
the fixed 16/10 shape only makes sense for image covers. */
|
||||
@media (max-width: 48rem) {
|
||||
@@ -518,12 +517,16 @@ main {
|
||||
}
|
||||
|
||||
.card {
|
||||
/* Grow to fill the row (up to the cap — full-width rows would make huge
|
||||
cards), shrink (not wrap) when there are too many. */
|
||||
flex: 1 1 0;
|
||||
max-width: 24rem;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
aspect-ratio: 16 / 10;
|
||||
/* Allow shrinking below the text's min-content width: without this the
|
||||
text cards hold their stack wider than the image-only stacks. */
|
||||
text cards hold their row wider than the image-only cards. */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
@@ -756,6 +759,20 @@ article {
|
||||
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 {
|
||||
position: absolute;
|
||||
top: 0.2rem;
|
||||
@@ -763,12 +780,6 @@ article {
|
||||
left: -2.2rem;
|
||||
z-index: 2;
|
||||
/* 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;
|
||||
}
|
||||
|
||||
@@ -790,24 +801,14 @@ article h2 .edit-section {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.edit-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 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
|
||||
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.profile-link {
|
||||
opacity: 0.7;
|
||||
text-shadow: 0 0 0.1em black;
|
||||
}
|
||||
|
||||
.editor-pens a.login-link:hover,
|
||||
.editor-pens a.profile-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
article p,
|
||||
article li,
|
||||
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):
|
||||
// one state, v-modeled by the LangSelect of every tab that has one (page,
|
||||
// structure). While the panel is open it also drives the page preview —
|
||||
// EditorShell applies it as the fetch-time language override (swapdoc).
|
||||
import { ref } from 'vue'
|
||||
// backed by the app-wide store (./store), so the editor tabs' LangSelects
|
||||
// and the public corner selector bind the same value. Linked to the
|
||||
// whole-page language: while the panel is open it drives the page preview
|
||||
// (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
|
||||
// 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' })
|
||||
|
||||
// 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").
|
||||
export function langName(tag) {
|
||||
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)
|
||||
}
|
||||
+105
-18
@@ -51,13 +51,18 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
url.searchParams.delete("lang");
|
||||
history.replaceState(history.state, "", url);
|
||||
}
|
||||
// The session language. While the editor panel is open, its language
|
||||
// selection overrides the normal preference (swapdoc.setLangOverride):
|
||||
// internal fetches and prefetches follow it until the panel closes and
|
||||
// the override clears (null restores the initial ?lang=, if any).
|
||||
// The session language: the user's explicit pick (initial ?lang=, public
|
||||
// selector, editor dropdown) is kept in chosenLang; while the editor is
|
||||
// open its selection overrides it (swapdoc.setLangOverride), and closing
|
||||
// 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;
|
||||
window.__pageriteLang = sessionLang;
|
||||
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
|
||||
// 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) {
|
||||
// Section pen on an anchored h2: opens the page editor at the
|
||||
// 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.textContent = "🖊️";
|
||||
btn.dataset.editorLine = line;
|
||||
} else if (mode === "page") {
|
||||
btn.className = "edit-link edit-page";
|
||||
btn.className = "edit-link edit-page icon-btn";
|
||||
btn.title = "edit page";
|
||||
btn.textContent = "🖊️";
|
||||
} else {
|
||||
btn.className = "edit-link site-edit-link";
|
||||
btn.className = "edit-link site-edit-link icon-btn";
|
||||
btn.title = "site settings";
|
||||
btn.textContent = "⚙️";
|
||||
}
|
||||
@@ -160,13 +165,29 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
|
||||
function makeAuthLink(admin) {
|
||||
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.title = admin ? "profile" : "log in";
|
||||
a.textContent = admin ? "\u{1F510}" : "\u{1F511}";
|
||||
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() {
|
||||
document.querySelectorAll(".editor-pens, #main article button.edit-link")
|
||||
.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
|
||||
// authenticated.
|
||||
removePens();
|
||||
if (!authReady) return;
|
||||
|
||||
if (authReady) {
|
||||
// Editing is open for admins and, as a dev/no-proxy fallback, when no
|
||||
// Paskia SSO is detected at all.
|
||||
const canEdit = isAdmin || !ssoAvailable;
|
||||
// The analytics page is a read-only dashboard: editing pens and the side
|
||||
// panel do not apply there. Login/logout links are still useful.
|
||||
const onAnalytics = currentPath === "/_a";
|
||||
const banner = document.getElementById("page-banner");
|
||||
if (banner) {
|
||||
const pens = document.createElement("div");
|
||||
pens.className = "editor-pens";
|
||||
if (document.getElementById("page-banner")) {
|
||||
const pens = pensContainer();
|
||||
if (canEdit && !onAnalytics) {
|
||||
// Analytics viewer is now a normal page at /_a.
|
||||
const a = document.createElement("a");
|
||||
a.className = "edit-link analytics-link";
|
||||
a.className = "edit-link analytics-link icon-btn";
|
||||
a.href = "/_a";
|
||||
a.title = "analytics";
|
||||
a.textContent = "📊";
|
||||
@@ -201,10 +219,14 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
pens.append(makePen("site"));
|
||||
}
|
||||
if (ssoAvailable) pens.append(makeAuthLink(isAdmin));
|
||||
banner.after(pens);
|
||||
if (!pens.firstElementChild) pens.remove();
|
||||
}
|
||||
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() {
|
||||
authReady = false;
|
||||
@@ -407,6 +429,9 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
// copy pinned to a language (?lang=) caches under its own key, where
|
||||
// navigation with the same session language finds it.
|
||||
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),
|
||||
@@ -603,7 +628,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
wsQueue.push(msg);
|
||||
}
|
||||
|
||||
function ping({ to, fr = currentPath, read = 0 } = {}) {
|
||||
function ping({ to, fr = currentPath, read = 0, lang } = {}) {
|
||||
// Reading-time updates from the analytics page itself are not tracked
|
||||
// (/_a is admin machinery; the server would reject the path anyway).
|
||||
if (!to && currentPath === "/_a") return;
|
||||
@@ -612,6 +637,11 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
if (to) msg.to = to;
|
||||
const secs = Math.round(read / 1000);
|
||||
if (secs > 0) msg.read = secs;
|
||||
// The rendered language: normally the live <html lang>, but a language
|
||||
// switch passes it explicitly — the swap that updates <html> runs inside
|
||||
// the view-transition callback, after the switch ping goes out.
|
||||
lang = lang || document.documentElement.lang;
|
||||
if (lang) msg.lang = lang;
|
||||
if (!msg.to && !msg.read) return;
|
||||
report(msg);
|
||||
}
|
||||
@@ -742,6 +772,61 @@ 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);
|
||||
// Log the switch as a trail event in the new language (load() updated
|
||||
// <html lang>, but possibly inside a still-pending view transition, so
|
||||
// pass the tag explicitly): the ping matches the switch's GET
|
||||
// server-side, so it is not misclassified as a crawler hit.
|
||||
ping({ to: currentPath, lang: tag });
|
||||
});
|
||||
|
||||
// --- Fetch navigation ------------------------------------------------
|
||||
async function load(url, push = true, back = false) {
|
||||
// Navigating with the editor open closes it; unsaved edits are lost
|
||||
@@ -843,6 +928,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
runScripts(document.getElementById("main"));
|
||||
applyEffects();
|
||||
mountAnalytics(doc);
|
||||
mountLangselect(doc);
|
||||
};
|
||||
// Rotating cube page transition (styles injected as #pagerite-transition
|
||||
// from the selected design's transition.css, e.g. themes/cube/);
|
||||
@@ -1076,4 +1162,5 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
||||
setupAuth();
|
||||
applyEffects();
|
||||
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
|
||||
// open, its language selection wins over the normal preferences (?lang= /
|
||||
// Accept-Language) — every in-place re-render asks for that language
|
||||
// explicitly, and pagerite.js applies it to its own fetches and prefetches
|
||||
// (pagerite:session-lang). The primary selection pins by its code:
|
||||
// ?lang=<primary> selects the original explicitly (i18n.select_language).
|
||||
let overrideLang = null // the ?lang= value in force, null = normal prefs
|
||||
// open, its language selection wins over the normal preferences — every
|
||||
// in-place re-render asks for that language explicitly, and pagerite.js
|
||||
// applies it to its own fetches and prefetches (pagerite:session-lang).
|
||||
// The primary selection pins by its code: ?lang=<primary> selects the
|
||||
// original explicitly (i18n.select_language). Panel closed, the session's
|
||||
// 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) {
|
||||
overrideLang = queryLang || null
|
||||
@@ -129,14 +130,16 @@ function swapRegions(doc) {
|
||||
// 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
|
||||
// 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
|
||||
// editor's language override is set the fetch pins that language.
|
||||
// fine to swap in (new pages are created by editing them). The fetch pins
|
||||
// the editor's language override, or — panel closed — the session's chosen
|
||||
// language (window.__pageriteLang).
|
||||
export async function loadPlain(p) {
|
||||
let doc
|
||||
let finalUrl = `/${p}`
|
||||
let html
|
||||
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') || ''
|
||||
if (!type.includes('text/html')) return null
|
||||
if (res.redirected) finalUrl = res.url
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
* - Disables Vite's screen clearing on startup
|
||||
*
|
||||
* Options:
|
||||
* paths - Array of paths to proxy (default: ["/api"])
|
||||
* paths - Array of paths to proxy (default: ['/api'])
|
||||
*/
|
||||
|
||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
const backendUrl = process.env.PAGERITE_BACKEND_URL || "http://localhost:8210"
|
||||
export default function fastapiVue({ paths = ['/api'] } = {}) {
|
||||
const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:8210'
|
||||
|
||||
// Build proxy configuration for each path
|
||||
const proxy = {}
|
||||
@@ -25,12 +25,12 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
}
|
||||
|
||||
return {
|
||||
name: "vite-plugin-fastapi-pagerite",
|
||||
name: 'vite-plugin-fastapi-pagerite',
|
||||
config: () => ({
|
||||
clearScreen: false,
|
||||
server: { proxy },
|
||||
build: {
|
||||
outDir: "../pagerite/frontend-build",
|
||||
outDir: '../pagerite/frontend-build',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:3200'
|
||||
const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:8210'
|
||||
|
||||
// Proxy everything except Vite's own dev-time paths and the backend machinery
|
||||
// to the FastAPI backend in dev. /_api, /_f, /_themes, /_fonts and /_a are
|
||||
@@ -38,7 +38,7 @@ export default defineConfig({
|
||||
chunkSizeWarningLimit: 1200,
|
||||
// Mirror the URL space in the build output: hashed files land under
|
||||
// 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,
|
||||
assetsDir: '_assets',
|
||||
rollupOptions: {
|
||||
@@ -50,6 +50,7 @@ export default defineConfig({
|
||||
main: fileURLToPath(new URL('./src/main.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)),
|
||||
langselect: fileURLToPath(new URL('./src/langselect-main.js', import.meta.url)),
|
||||
// Only the base CSS is built; theme/banner-design stylesheets live
|
||||
// in pagerite/themes/{name}/ and are served by the backend as-is.
|
||||
pagerite_base: fileURLToPath(new URL('./src/assets/pagerite.css', import.meta.url)),
|
||||
|
||||
+13
-3
@@ -5,12 +5,12 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
import msgspec
|
||||
from fastapi_vue import server
|
||||
from fastapi_vue import env, server
|
||||
|
||||
from pagerite.config import Config
|
||||
|
||||
DEFAULT_PORT = 8100
|
||||
DEVMODE = os.getenv("PAGERITE_DEV") == "1"
|
||||
os.environ["FASTAPI_VUE"] = "PAGERITE"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -54,7 +54,17 @@ def main() -> None:
|
||||
listen=args.listen,
|
||||
default_port=DEFAULT_PORT,
|
||||
server_header=False,
|
||||
reload=Path(__file__).parent if DEVMODE else False,
|
||||
reload=Path(__file__).parent if env.dev else False,
|
||||
# Partial log config, merged over uvicorn's default by fastapi-vue:
|
||||
# root prints at WARNING in production / INFO in dev. Keep our own
|
||||
# loggers audible in production, and silence httpx's per-request INFO
|
||||
# (tracking._schedule_favicon_fetch logs its own one-line summary).
|
||||
log_config={
|
||||
"loggers": {
|
||||
"pagerite": {"level": "INFO"},
|
||||
"httpx": {"level": "WARNING"},
|
||||
}
|
||||
},
|
||||
**run_args,
|
||||
)
|
||||
|
||||
|
||||
+139
-57
@@ -2,8 +2,9 @@
|
||||
|
||||
Raw recording, display-time classification. Every document GET is appended
|
||||
to ``Analytics.gets`` as a raw access-log line (path with query string, true
|
||||
HTTP status, external referer origin, preload flag) and every pagerite.js
|
||||
activity message from the /_ws WebSocket is appended to ``Analytics.msgs``
|
||||
HTTP status, external referer origin, preload flag, rendered content
|
||||
language) and every pagerite.js activity message from the /_ws WebSocket is
|
||||
appended to ``Analytics.msgs``
|
||||
(navigations ``fr`` -> ``to`` and active reading-time updates). Nothing is
|
||||
classified when it is recorded: whether a client turns out to be a reader,
|
||||
a crawler or a scanner is decided by ``Store.display()`` from the raw
|
||||
@@ -60,32 +61,17 @@ from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
import blake3
|
||||
import msgspec
|
||||
from ua_parser import parse
|
||||
from uarite import UA, uaparse
|
||||
|
||||
|
||||
def _compact_user_agent(ua: str) -> str:
|
||||
"""Format a User-Agent string into a compact display form.
|
||||
def _display_client(client: Client) -> Client:
|
||||
"""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 ""
|
||||
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()
|
||||
return msgspec.structs.replace(client, uarite=uaparse(client.ua))
|
||||
|
||||
|
||||
class Ping(msgspec.Struct, omit_defaults=True):
|
||||
@@ -107,6 +93,9 @@ class Ping(msgspec.Struct, omit_defaults=True):
|
||||
read: int = 0
|
||||
#: Admin client: record but hide everything from the statistics.
|
||||
hide: bool = False
|
||||
#: Rendered language of the page the activity happened on (the page's
|
||||
#: ``<html lang>``, sent by pagerite.js).
|
||||
lang: str = ""
|
||||
|
||||
|
||||
class Get(msgspec.Struct, omit_defaults=True):
|
||||
@@ -130,6 +119,9 @@ class Get(msgspec.Struct, omit_defaults=True):
|
||||
#: never counted as a view/crawler/abuse hit; recorded only so a later
|
||||
#: cache-served navigation can be attributed this GET's status.
|
||||
pre: bool = False
|
||||
#: Rendered content language of the served document; "" for
|
||||
#: non-localized responses (404 probes, reserved paths).
|
||||
lang: str = ""
|
||||
|
||||
|
||||
class Msg(msgspec.Struct, omit_defaults=True):
|
||||
@@ -150,6 +142,9 @@ class Msg(msgspec.Struct, omit_defaults=True):
|
||||
to: str = ""
|
||||
#: Active reading time (seconds) spent on ``fr`` since the last report.
|
||||
read: int = 0
|
||||
#: Rendered language reported by the client for the page the activity
|
||||
#: happened on.
|
||||
lang: str = ""
|
||||
|
||||
|
||||
class Client(msgspec.Struct, omit_defaults=True):
|
||||
@@ -172,11 +167,14 @@ class Client(msgspec.Struct, omit_defaults=True):
|
||||
city: str = ""
|
||||
#: Raw User-Agent header.
|
||||
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
|
||||
#: is excluded from all statistics and from the viewer payload.
|
||||
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 -------------------------------------------------------
|
||||
@@ -203,6 +201,8 @@ class TrailItem(msgspec.Struct, omit_defaults=True):
|
||||
|
||||
``read`` accumulates active reading time (seconds) across the whole
|
||||
visit; ``status`` is the most recent HTTP status seen for the target.
|
||||
A page seen in two rendered languages within one visit (a mid-article
|
||||
language switch) gets one item per language.
|
||||
"""
|
||||
|
||||
to: str
|
||||
@@ -210,6 +210,10 @@ class TrailItem(msgspec.Struct, omit_defaults=True):
|
||||
read: int = 0
|
||||
#: Most recent HTTP status of the response (200 or 404).
|
||||
status: int = 200
|
||||
#: Rendered language of the target: the client's report, for the entry
|
||||
#: page falling back to its GET's rendered language; "" when unknown
|
||||
#: (old clients or data from before language recording).
|
||||
lang: str = ""
|
||||
|
||||
|
||||
class Visit(msgspec.Struct, omit_defaults=True):
|
||||
@@ -218,8 +222,9 @@ class Visit(msgspec.Struct, omit_defaults=True):
|
||||
``trail`` holds the entry page and everything seen afterwards, keyed by
|
||||
the timestamp of first sight (insertion order = first-seen order);
|
||||
re-visiting an already seen target updates its item instead of
|
||||
appending. Client metadata is held in ``Analytics.clients`` keyed by
|
||||
``client``.
|
||||
appending — unless the client reports a different rendered language for
|
||||
it, which appends a distinct item (a mid-article language switch).
|
||||
Client metadata is held in ``Analytics.clients`` keyed by ``client``.
|
||||
"""
|
||||
|
||||
start: datetime
|
||||
@@ -253,6 +258,8 @@ class CrawlerHit(msgspec.Struct, omit_defaults=True):
|
||||
query: str = ""
|
||||
#: HTTP status of the served response (200 or 404 for content pages).
|
||||
status: int = 200
|
||||
#: Rendered content language of the served document (from the GET).
|
||||
lang: str = ""
|
||||
|
||||
|
||||
class AbuseHit(msgspec.Struct, omit_defaults=True):
|
||||
@@ -331,6 +338,12 @@ class Display(msgspec.Struct, omit_defaults=True):
|
||||
views: dict[str, dict[str, int]] = {}
|
||||
#: New visits per 5-minute bucket: bucket ISO -> count (sparse).
|
||||
site_visits: dict[str, int] = {}
|
||||
#: Site context: true when translation languages are configured, so the
|
||||
#: viewer can suppress language UI on single-language sites.
|
||||
multilingual: bool = False
|
||||
#: The site's primary language (the front page's), so the viewer can
|
||||
#: skip the primary-language default case.
|
||||
primary_lang: str = ""
|
||||
|
||||
|
||||
def _bucket(now: datetime) -> str:
|
||||
@@ -418,17 +431,22 @@ _MIN_VISIT_READ = 5
|
||||
_FAVICON_RETRY = timedelta(days=7)
|
||||
|
||||
#: UAs of JS-running crawlers, which would register as visitors on their
|
||||
#: activity messages. Anything calling itself a "bot" or "spider" matches;
|
||||
#: known crawlers without those tokens (GoogleOther) are listed as extra
|
||||
#: alternates. No source verification: a spoofed bot UA just lands in the
|
||||
#: crawler list, and scanners that probe telltale paths are caught by the
|
||||
#: abuse rules anyway.
|
||||
_BOT_UA = re.compile(r"bot|spider|googleother", re.IGNORECASE)
|
||||
#: activity messages. ``uarite`` knows the common crawlers
|
||||
#: and link-preview fetchers (including disguised ones such as
|
||||
#: facebookexternalhit and Google-Extended) plus any UA with a
|
||||
#: bot/spider/crawler/scanner token. No source verification: a spoofed
|
||||
#: bot UA just lands in the crawler list, and scanners that probe
|
||||
#: telltale paths are caught by the abuse rules anyway.
|
||||
|
||||
|
||||
def _is_bot_ua(ua: str) -> bool:
|
||||
"""True when the UA claims a crawler identity (bot or spider)."""
|
||||
return bool(_BOT_UA.search(ua))
|
||||
"""True when the UA is not a regular browser.
|
||||
|
||||
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
|
||||
@@ -560,7 +578,6 @@ class Store:
|
||||
self.data.clients[h] = Client(
|
||||
ip=ip,
|
||||
ua=ua,
|
||||
ua_pretty=_compact_user_agent(ua),
|
||||
lang=lang,
|
||||
country=country,
|
||||
)
|
||||
@@ -602,22 +619,25 @@ class Store:
|
||||
referer: str = "",
|
||||
accept_language: str = "",
|
||||
pre: bool = False,
|
||||
lang: str = "",
|
||||
) -> bytes | None:
|
||||
"""Append one document GET to the raw log.
|
||||
|
||||
``path`` is the full request path, query string included; ``status``
|
||||
the true HTTP status of the response; ``referer`` the raw Referer
|
||||
header (reduced here to an external https origin, "" when internal
|
||||
or absent); ``pre`` marks idle-time preloads from pagerite.js.
|
||||
or absent); ``pre`` marks idle-time preloads from pagerite.js;
|
||||
``lang`` the rendered content language of the served document (""
|
||||
for non-localized responses such as 404 probes and reserved paths).
|
||||
|
||||
Returns the client hash when the client record was just created (so
|
||||
the caller can schedule async enrichment), else None.
|
||||
"""
|
||||
lang, country = _parse_accept_language(accept_language)
|
||||
client_hash = _client_hash(ip, ua, lang)
|
||||
client_lang, country = _parse_accept_language(accept_language)
|
||||
client_hash = _client_hash(ip, ua, client_lang)
|
||||
new = client_hash not in self.data.clients
|
||||
if new:
|
||||
self._ensure_client(ip, ua, lang, country=country)
|
||||
self._ensure_client(ip, ua, client_lang, country=country)
|
||||
self.data.gets.append(
|
||||
Get(
|
||||
t=datetime.now(UTC),
|
||||
@@ -626,6 +646,7 @@ class Store:
|
||||
status=status,
|
||||
ref=_origin(referer) or "",
|
||||
pre=pre,
|
||||
lang=lang,
|
||||
)
|
||||
)
|
||||
self._save()
|
||||
@@ -640,6 +661,7 @@ class Store:
|
||||
accept_language: str = "",
|
||||
hide: bool = False,
|
||||
read: int = 0,
|
||||
lang: str = "",
|
||||
) -> bytes | None:
|
||||
"""Append one client activity message (``Ping`` from pagerite.js) to
|
||||
the raw log.
|
||||
@@ -650,16 +672,18 @@ class Store:
|
||||
stored raw and filtered at display time, so future rule changes lose
|
||||
nothing. ``hide`` flags the client record as an admin; the message
|
||||
itself is recorded normally and hidden at display time like
|
||||
everything else the client ever did.
|
||||
everything else the client ever did. ``lang`` is the rendered
|
||||
language reported by the client for the page the activity happened
|
||||
on.
|
||||
|
||||
Returns the client hash when the client record was just created (so
|
||||
the caller can schedule async enrichment), else None.
|
||||
"""
|
||||
lang, country = _parse_accept_language(accept_language)
|
||||
client_hash = _client_hash(ip, ua, lang)
|
||||
client_lang, country = _parse_accept_language(accept_language)
|
||||
client_hash = _client_hash(ip, ua, client_lang)
|
||||
new = client_hash not in self.data.clients
|
||||
if new:
|
||||
self._ensure_client(ip, ua, lang, country=country)
|
||||
self._ensure_client(ip, ua, client_lang, country=country)
|
||||
if hide:
|
||||
self.data.clients[client_hash].hide = True
|
||||
fr = (_internal_path(fr) or "") if fr else ""
|
||||
@@ -671,7 +695,14 @@ class Store:
|
||||
target = _external_target(to) or ""
|
||||
if target or read > 0:
|
||||
self.data.msgs.append(
|
||||
Msg(t=datetime.now(UTC), client=client_hash, fr=fr, to=target, read=read)
|
||||
Msg(
|
||||
t=datetime.now(UTC),
|
||||
client=client_hash,
|
||||
fr=fr,
|
||||
to=target,
|
||||
read=read,
|
||||
lang=lang,
|
||||
)
|
||||
)
|
||||
if target or read > 0 or hide:
|
||||
self._save()
|
||||
@@ -708,7 +739,13 @@ class Store:
|
||||
self.data.favicons[origin] = Favicon(file=file, fetched=datetime.now(UTC))
|
||||
self._save()
|
||||
|
||||
def display(self, in_menu: Callable[[str], bool] | None = None) -> Display:
|
||||
def display(
|
||||
self,
|
||||
in_menu: Callable[[str], bool] | None = None,
|
||||
*,
|
||||
multilingual: bool = False,
|
||||
primary_lang: str = "",
|
||||
) -> Display:
|
||||
"""Build the viewer payload from the raw events.
|
||||
|
||||
All classification happens here, so the stored data is independent
|
||||
@@ -730,10 +767,17 @@ class Store:
|
||||
- visits: the remaining messages, grouped per client with a new
|
||||
visit after ``_SESSION_GAP`` of inactivity. Trail statuses come
|
||||
from the client's GETs (preloads included — a cache-served
|
||||
navigation's only GET is its preload); the entry referer and UTM
|
||||
tags from the GET that loaded the entry page.
|
||||
navigation's only GET is its preload); trail languages come from
|
||||
the client's messages (the entry item falling back to its GET's
|
||||
rendered language), and a page re-visited in a different rendered
|
||||
language becomes a distinct trail step. The entry referer and
|
||||
UTM tags come from the GET that loaded the entry page.
|
||||
|
||||
Hidden (admin) clients are excluded from every list and aggregate.
|
||||
``multilingual`` and ``primary_lang`` are site context (translation
|
||||
languages configured, the front page's primary language) copied
|
||||
onto the payload so the viewer can suppress language UI on
|
||||
single-language sites and skip the primary-language default case.
|
||||
"""
|
||||
in_menu = in_menu or (lambda path: False)
|
||||
data = self.data
|
||||
@@ -844,7 +888,11 @@ class Store:
|
||||
g.path.split("?", 1)[1] if "?" in g.path else ""
|
||||
)
|
||||
visit.trail[m.t] = TrailItem(
|
||||
to=m.to, status=status_at(h, m.to, m.t)
|
||||
to=m.to,
|
||||
status=status_at(h, m.to, m.t),
|
||||
# The client's report wins; the entry GET fills
|
||||
# in for old clients that don't send lang.
|
||||
lang=m.lang or (g.lang if g is not None else ""),
|
||||
)
|
||||
visits.append(visit)
|
||||
else:
|
||||
@@ -852,18 +900,40 @@ class Store:
|
||||
visit.navs[m.t] = Nav(fr=fr, to=m.to)
|
||||
status = status_at(h, m.to, m.t)
|
||||
# First-seen only: repeat pages and repeated exits
|
||||
# update the existing trail item instead of appending.
|
||||
# update the existing trail item instead of
|
||||
# appending — but a repeat in a different rendered
|
||||
# language (a mid-article language switch) becomes
|
||||
# a distinct step.
|
||||
for item in visit.trail.values():
|
||||
if item.to == m.to:
|
||||
if m.lang and item.lang and m.lang != item.lang:
|
||||
visit.trail[m.t] = TrailItem(
|
||||
to=m.to, status=status, lang=m.lang
|
||||
)
|
||||
else:
|
||||
item.status = status
|
||||
if not item.lang:
|
||||
item.lang = m.lang
|
||||
break
|
||||
else:
|
||||
visit.trail[m.t] = TrailItem(to=m.to, status=status)
|
||||
visit.trail[m.t] = TrailItem(
|
||||
to=m.to, status=status, lang=m.lang
|
||||
)
|
||||
if m.read > 0 and m.fr and visit is not None:
|
||||
# A page appears in the trail once per language seen:
|
||||
# land the seconds on the matching-language step when
|
||||
# the client reports one, else on the first-seen item.
|
||||
read_item: TrailItem | None = None
|
||||
for item in visit.trail.values():
|
||||
if item.to == m.fr:
|
||||
item.read += m.read
|
||||
if item.to != m.fr:
|
||||
continue
|
||||
if read_item is None:
|
||||
read_item = item
|
||||
if m.lang and item.lang == m.lang:
|
||||
read_item = item
|
||||
break
|
||||
if read_item is not None:
|
||||
read_item.read += m.read
|
||||
last_t = m.t
|
||||
|
||||
# --- crawler hits: document GETs no message matched
|
||||
@@ -897,6 +967,7 @@ class Store:
|
||||
referer=g.ref,
|
||||
query=query,
|
||||
status=g.status,
|
||||
lang=g.lang,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -920,6 +991,7 @@ class Store:
|
||||
referer=visit.referer if first else "",
|
||||
query=query if first else "",
|
||||
status=item.status,
|
||||
lang=item.lang,
|
||||
)
|
||||
)
|
||||
first = False
|
||||
@@ -940,12 +1012,14 @@ class Store:
|
||||
and not self._hidden(g.client)
|
||||
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={
|
||||
origin: f"/_f/{f.file}"
|
||||
for origin, f in data.favicons.items()
|
||||
if f.file
|
||||
},
|
||||
multilingual=multilingual,
|
||||
primary_lang=primary_lang,
|
||||
)
|
||||
for visit in kept:
|
||||
bucket = _bucket(visit.start)
|
||||
@@ -967,6 +1041,14 @@ class Store:
|
||||
nbuckets[nb] = nbuckets.get(nb, 0) + 1
|
||||
return display
|
||||
|
||||
def display_json(self, in_menu: Callable[[str], bool] | None = None) -> str:
|
||||
def display_json(
|
||||
self,
|
||||
in_menu: Callable[[str], bool] | None = None,
|
||||
*,
|
||||
multilingual: bool = False,
|
||||
primary_lang: str = "",
|
||||
) -> str:
|
||||
"""The ``display()`` payload as a JSON string for the WebSocket."""
|
||||
return msgspec.json.encode(self.display(in_menu)).decode()
|
||||
return msgspec.json.encode(
|
||||
self.display(in_menu, multilingual=multilingual, primary_lang=primary_lang)
|
||||
).decode()
|
||||
|
||||
+33
-1
@@ -19,6 +19,7 @@ from fastapi import (
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from html5tagger import E
|
||||
from pydantic import BaseModel
|
||||
|
||||
from pagerite import i18n, views
|
||||
@@ -495,6 +496,11 @@ async def editor_ws(ws: WebSocket) -> None:
|
||||
markdown = msg.get("markdown", "")
|
||||
chain = resolve(data.menu, path)
|
||||
node = chain[-1] if chain else None
|
||||
# Expand {cards} like page_content does, so the preview
|
||||
# shows real cards, not the literal tag. No translation
|
||||
# context: the preview has no lang of its own, so cards
|
||||
# render in their originals.
|
||||
has_cards_tag = views._CARDS_TAG_RE.search(markdown) is not None
|
||||
rendered = render(
|
||||
markdown,
|
||||
path,
|
||||
@@ -503,12 +509,38 @@ async def editor_ws(ws: WebSocket) -> None:
|
||||
# The title is injected as h1 when the markdown has
|
||||
# none; the editor's title field edits live-preview.
|
||||
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
|
||||
),
|
||||
directives=(
|
||||
{
|
||||
"cards": lambda args, _env: views._cards_tag(
|
||||
data.menu, data, node, path, args
|
||||
)
|
||||
}
|
||||
if node is not None and has_cards_tag
|
||||
else None
|
||||
),
|
||||
)
|
||||
html = rendered.html
|
||||
if node is not None and not has_cards_tag:
|
||||
# Without a {cards} tag page_content appends the
|
||||
# children's cards after the content — the preview
|
||||
# replaces the whole article, so include them here.
|
||||
doc = E.div
|
||||
with doc:
|
||||
views._cards(doc, data.menu, data, node, path)
|
||||
html += str(doc)
|
||||
await ws.send_json(
|
||||
{
|
||||
"type": "html",
|
||||
"path": path,
|
||||
"html": rendered.html,
|
||||
"html": html,
|
||||
# Column-layout flag: the preview toggles the
|
||||
# article's .multicol class and swaps in the
|
||||
# segmented (.colseg/.cols) article html.
|
||||
|
||||
+4
-5
@@ -36,11 +36,10 @@ from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import Response
|
||||
from fastapi_vue import Frontend
|
||||
from fastapi_vue import Frontend, env
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from pagerite import api, files, pages, tracking
|
||||
from pagerite.__main__ import DEVMODE
|
||||
from pagerite.files import file_store
|
||||
from pagerite.state import analytics_store, config, kanta
|
||||
|
||||
@@ -48,7 +47,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Vue build served at the site root, no SPA catch-all (assets only). The
|
||||
# build mirrors the URL space: hashed, immutable files live under
|
||||
# /_assets/ (assetsDir: '_/assets'), the favicon at /favicon.ico.
|
||||
# /_assets/ (assetsDir: '_/assets').
|
||||
frontend = Frontend(
|
||||
Path(__file__).with_name("frontend-build"), spa=False, cached="/_assets/"
|
||||
)
|
||||
@@ -99,7 +98,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator:
|
||||
# is not meant to be browsable by the public anyway.
|
||||
app = FastAPI(
|
||||
title="Pagerite",
|
||||
debug=DEVMODE,
|
||||
debug=env.dev,
|
||||
lifespan=lifespan,
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
@@ -124,7 +123,7 @@ app.include_router(tracking.router)
|
||||
app.include_router(files.router)
|
||||
|
||||
# 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, "/")
|
||||
|
||||
# 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).
|
||||
_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:
|
||||
#: script/pre/style/textarea, comments, processing instructions,
|
||||
#: 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
|
||||
multi-line HTML blocks (comments, script/pre/style, CDATA...) are
|
||||
kept atomic, even across blank lines, and end at their closing
|
||||
condition. Chunks carry no surrounding blank lines and no trailing
|
||||
newline; rejoining with ``join_chunks`` reproduces the source modulo
|
||||
blank-line normalization.
|
||||
condition. Container fence lines (:::, open and close alike) are
|
||||
always their own block, blank lines or not (see _CONTAINER). Chunks
|
||||
carry no surrounding blank lines and no trailing newline; rejoining
|
||||
with ``join_chunks`` reproduces the source modulo blank-line
|
||||
normalization.
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
buf: list[str] = []
|
||||
@@ -91,6 +100,13 @@ def chunk_markdown(markdown: str) -> list[str]:
|
||||
fence = m.group(1)
|
||||
buf.append(line)
|
||||
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:
|
||||
for open_re, close_re in _HTML_ATOMIC:
|
||||
if open_re.match(line):
|
||||
|
||||
+2
-2
@@ -98,8 +98,8 @@ class Data(msgspec.Struct):
|
||||
#: Trusted author content; not sanitized.
|
||||
custom_css: str = ""
|
||||
#: Favicon: content-addressed file name (served at "/_f/{name}"),
|
||||
#: linked as <link rel="icon"> on every page. Empty = the build's
|
||||
#: /favicon.ico.
|
||||
#: linked as <link rel="icon"> on every page; /favicon.ico redirects
|
||||
#: to it. Empty = no icon (and /favicon.ico 404s).
|
||||
favicon: str = ""
|
||||
#: API keys gating the translator service WebSocket (/_translate/{key};
|
||||
#: the external forward-auth does not cover that route): key -> display
|
||||
|
||||
+21
-10
@@ -6,7 +6,8 @@ when compression shrinks the body), served immutable at ``/_f/``. Raster
|
||||
images and SVGs are recompressed into AVIF/WebP/JPEG derivatives
|
||||
(``store_image`` and helpers); the untouched original is kept alongside as
|
||||
``<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/``).
|
||||
"""
|
||||
|
||||
@@ -19,7 +20,7 @@ from pathlib import Path
|
||||
|
||||
import blake3
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
from fastapi.responses import RedirectResponse, Response
|
||||
from mediapreview import dispatch
|
||||
|
||||
from pagerite import views
|
||||
@@ -38,10 +39,6 @@ from pagerite.state import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# mediapreview logs pyvips noise ("VipsForeignSaveJpegTarget argument strip is
|
||||
# deprecated", "threadpool completed with N workers") at INFO; keep warnings.
|
||||
logging.getLogger("mediapreview").setLevel(logging.WARNING)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -158,14 +155,13 @@ def _svg_to_png(body: bytes, maxsize: int) -> bytes | None:
|
||||
|
||||
def _avif_to_format(avif: bytes, suffix: str, quality: int) -> bytes:
|
||||
"""Re-encode the AVIF derivative into a fallback format (WebP/JPEG)
|
||||
via pyvips. JPEG has no alpha, so it is flattened onto white;
|
||||
``strip`` keeps metadata (EXIF) out of the fallbacks."""
|
||||
via pyvips. JPEG has no alpha, so it is flattened onto white."""
|
||||
import pyvips
|
||||
|
||||
img = pyvips.Image.new_from_buffer(avif, "")
|
||||
if suffix == ".jpg" and img.hasalpha():
|
||||
img = img.flatten(background=[255, 255, 255])
|
||||
return img.write_to_buffer(suffix, Q=quality, strip=True)
|
||||
return img.write_to_buffer(suffix, Q=quality, keep="none")
|
||||
|
||||
|
||||
def _image_derivatives(
|
||||
@@ -252,6 +248,20 @@ async def delete_file(name: str) -> None:
|
||||
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")
|
||||
async def put_favicon(request: Request) -> dict[str, str]:
|
||||
"""Upload a favicon into the content-addressed store and activate it.
|
||||
@@ -276,7 +286,8 @@ async def put_favicon(request: Request) -> dict[str, str]:
|
||||
|
||||
@router.delete("/_api/settings/favicon", status_code=204)
|
||||
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.
|
||||
"""
|
||||
|
||||
+5
-10
@@ -87,21 +87,16 @@ def select_language(
|
||||
|
||||
1. ``?lang=`` wins when a translation exists for it (otherwise falls
|
||||
through to the header logic).
|
||||
2. The original language anywhere in the header list wins — an AI
|
||||
translation is strictly worse than the original for anyone who has
|
||||
English configured at all.
|
||||
3. Otherwise the first header language with an available translation.
|
||||
4. Fall back to the original.
|
||||
2. Otherwise the first header language that can be served — the
|
||||
original, or one with an available translation.
|
||||
3. Fall back to the original.
|
||||
"""
|
||||
if query_lang:
|
||||
tag = base_tag(query_lang)
|
||||
if tag == original or (tag and is_available(tag)):
|
||||
return tag
|
||||
langs = parse_accept_language(accept_language or "")
|
||||
if original in langs:
|
||||
return original
|
||||
for lang in langs:
|
||||
if lang != original and is_available(lang):
|
||||
for lang in parse_accept_language(accept_language or ""):
|
||||
if lang == original or is_available(lang):
|
||||
return lang
|
||||
return original
|
||||
|
||||
|
||||
+130
-8
@@ -56,9 +56,15 @@ becomes a block `<figure>` — with `<figcaption>` when it has a title.
|
||||
Images inline with other content stay plain inline `<img>`, as does raw
|
||||
`<img>` HTML written by the author. Positioning is done with attribute
|
||||
classes, e.g. `{.right}`.
|
||||
|
||||
A lone `{name}` or `{name: args}` line is a block directive, expanded by
|
||||
the caller through render(directives=...) — `{dates}` (built in) expands
|
||||
to the article's dateline, `{cards}` / `{cards: path ...}` to card rows
|
||||
of other pages (views.py). Unresolved tags render as the literal source.
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
from typing import NamedTuple
|
||||
|
||||
@@ -402,7 +408,9 @@ def _heading_ids(state) -> None:
|
||||
its self-link is ``href=""`` (back to the top of the page). An
|
||||
author-set `{#id}` always wins; auto ids slugify the heading text
|
||||
(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
|
||||
undoing the render(title=...) injection offset via ``env``) — the page
|
||||
editor uses it for section pens and piecewise-linear scroll sync.
|
||||
@@ -443,13 +451,23 @@ def _heading_ids(state) -> None:
|
||||
if len(heads) < ANCHOR_MIN_HEADINGS:
|
||||
return
|
||||
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]
|
||||
hid = token.attrGet("id")
|
||||
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)`).
|
||||
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"
|
||||
hid, n = base, 2
|
||||
@@ -463,6 +481,94 @@ def _heading_ids(state) -> None:
|
||||
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
|
||||
]
|
||||
|
||||
|
||||
#: A lone {...} paragraph: a block directive like {dates} or
|
||||
#: {cards: docs/* news} — name, then optional ":"-separated argument text.
|
||||
_DIRECTIVE_RE = re.compile(r"\{([a-z][a-z0-9_-]*)(?::([^{}\n]*))?\}")
|
||||
|
||||
|
||||
def _directives(state) -> None:
|
||||
"""Turn lone ``{name}`` / ``{name: args}`` paragraphs into directive tokens.
|
||||
|
||||
The expansion is not markdown.py's business: _directive_rule delegates
|
||||
to the resolvers render() put in env["directives"], falling back to the
|
||||
literal source when the tag is unknown in the context (e.g. the editor
|
||||
preview of a page that does not exist yet). The ``cards`` directive gets .wide so it
|
||||
stands alone as a full-width block outside the column segments (the
|
||||
card markup never flows in columns). Runs on the render instance only —
|
||||
the verbatim parser keeps the plain paragraph so segments/chunks see
|
||||
the placeholder source.
|
||||
"""
|
||||
tokens = state.tokens
|
||||
out = []
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
if (
|
||||
i + 2 < len(tokens)
|
||||
and tokens[i].type == "paragraph_open"
|
||||
and tokens[i + 1].type == "inline"
|
||||
and tokens[i + 2].type == "paragraph_close"
|
||||
):
|
||||
inline = tokens[i + 1]
|
||||
children = inline.children or []
|
||||
if len(children) == 1 and children[0].type == "text":
|
||||
m = _DIRECTIVE_RE.fullmatch(children[0].content.strip())
|
||||
if m:
|
||||
token = Token("directive", "", 0)
|
||||
token.level = tokens[i].level
|
||||
token.map = tokens[i].map
|
||||
token.content = m.group(0)
|
||||
token.meta = {"name": m.group(1), "args": (m.group(2) or "").strip()}
|
||||
if m.group(1) == "cards":
|
||||
token.attrSet("class", "wide")
|
||||
out.append(token)
|
||||
i += 3
|
||||
continue
|
||||
out.append(tokens[i])
|
||||
i += 1
|
||||
state.tokens = out
|
||||
|
||||
|
||||
def _directive_rule(self: RendererHTML, tokens, idx: int, options, env: dict) -> str:
|
||||
"""Render a directive token via env["directives"][name](args, env);
|
||||
unresolved tags render as the literal source paragraph."""
|
||||
token = tokens[idx]
|
||||
resolver = (env.get("directives") or {}).get(token.meta["name"])
|
||||
html = resolver(token.meta["args"], env) if resolver else None
|
||||
if html is None:
|
||||
return f"<p>{escapeHtml(token.content)}</p>\n"
|
||||
return html + "\n"
|
||||
|
||||
|
||||
def make_md(*, verbatim: bool = False) -> MarkdownIt:
|
||||
"""A fully configured parser. The module-level ``md`` (below) is the
|
||||
render instance; ``verbatim=True`` builds the segmentation instance for
|
||||
@@ -497,6 +603,7 @@ def make_md(*, verbatim: bool = False) -> MarkdownIt:
|
||||
)
|
||||
parser.add_render_rule("image", _image_rule)
|
||||
parser.add_render_rule("fence", _fence_rule)
|
||||
parser.add_render_rule("directive", _directive_rule)
|
||||
# GFM alerts (`> [!NOTE]` etc.), built into markdown-it-py's blockquote rule.
|
||||
parser.options["alerts"] = True
|
||||
# Block attrs must be stripped before the typographer curlifies their quotes.
|
||||
@@ -506,6 +613,8 @@ def make_md(*, verbatim: bool = False) -> MarkdownIt:
|
||||
parser.core.ruler.push("tag_task_checkboxes", _tag_task_checkboxes)
|
||||
parser.core.ruler.push("shorten_autolinks", _shorten_autolinks)
|
||||
parser.core.ruler.push("heading_ids", _heading_ids)
|
||||
if not verbatim:
|
||||
parser.core.ruler.push("directives", _directives)
|
||||
return parser
|
||||
|
||||
|
||||
@@ -618,12 +727,17 @@ def render(
|
||||
created: datetime | None = None,
|
||||
modified: datetime | None = None,
|
||||
title: str | None = None,
|
||||
anchors_from: tuple[str, str] | None = None,
|
||||
directives: dict[str, Callable[[str, dict], str | None]] | None = None,
|
||||
) -> Rendered:
|
||||
"""Render Markdown text to the article body's HTML and layout flags.
|
||||
|
||||
``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
|
||||
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
|
||||
(h1/h2 headings, .wide — see _is_boundary) are rendered bare, the runs
|
||||
@@ -636,11 +750,21 @@ def render(
|
||||
classes.
|
||||
|
||||
A ``{dates}`` line expands to the article's published/updated dateline
|
||||
(needs ``created``/``modified``; left as-is in contexts without them,
|
||||
e.g. the editor preview). Position is the author's choice — typically
|
||||
(needs ``created``/``modified``). Block directives in general — a lone
|
||||
``{name}`` or ``{name: args}`` line — are expanded by the resolvers
|
||||
passed as ``directives`` (name → (args, env) → HTML or None), with
|
||||
``dates`` built in when ``created`` is given; unresolved tags render as
|
||||
the literal source (e.g. in the editor preview of a not-yet-created
|
||||
page).
|
||||
Position is the author's choice — the dateline typically goes
|
||||
right after the article's h1.
|
||||
"""
|
||||
env = {"page_path": page_path, "line_offset": 0}
|
||||
directives = dict(directives or {})
|
||||
if created is not None:
|
||||
directives.setdefault("dates", lambda _args, _env: _dateline(created, modified))
|
||||
env = {"page_path": page_path, "line_offset": 0, "directives": directives}
|
||||
if anchors_from is not None:
|
||||
env["anchor_ids"] = anchor_ids(*anchors_from)
|
||||
if title and not has_h1(text):
|
||||
text = f"# {title}\n\n{text}"
|
||||
# The injected title shifts source lines by two; _heading_ids
|
||||
@@ -684,8 +808,6 @@ def render(
|
||||
html = marked
|
||||
parts.append(f'<div class="colseg{cols}">{html}</div>')
|
||||
html = "".join(parts)
|
||||
if created is not None and "<p>{dates}</p>" in html:
|
||||
html = html.replace("<p>{dates}</p>", _dateline(created, modified))
|
||||
return Rendered(html, total > MULTICOL_TEXT)
|
||||
|
||||
|
||||
|
||||
+4
-3
@@ -152,7 +152,8 @@ async def show_page(request: Request, path: str) -> Response:
|
||||
if node is not None and node.published and node.chunks is not None:
|
||||
# Language selection (docs/localization.md): ?lang= wins when a
|
||||
# translation exists, else header logic. Analytics keep the raw
|
||||
# Accept-Language header regardless of the selection.
|
||||
# Accept-Language header regardless of the selection, and record
|
||||
# the resolved language as the GET's rendered language.
|
||||
query_lang = request.query_params.get("lang")
|
||||
lang = i18n.select_language(
|
||||
query_lang,
|
||||
@@ -175,7 +176,7 @@ async def show_page(request: Request, path: str) -> Response:
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304)
|
||||
if _is_trackable_path(path):
|
||||
_record_get(request)
|
||||
_record_get(request, lang=lang)
|
||||
return _html_response(
|
||||
request,
|
||||
"page",
|
||||
@@ -205,7 +206,7 @@ async def show_page(request: Request, path: str) -> Response:
|
||||
)
|
||||
link_lang = i18n.base_tag(query_lang or "")
|
||||
if _is_trackable_path(path):
|
||||
_record_get(request, status=404)
|
||||
_record_get(request, status=404, lang=lang)
|
||||
return _html_response(
|
||||
request,
|
||||
"category",
|
||||
|
||||
+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
|
||||
never left the server. A returned segment must still be pure prose itself
|
||||
(the model could inject markup INTO a segment); anything else — count
|
||||
mismatch, empty segment, markup tokens — rejects the whole result and the
|
||||
fragment stays pending.
|
||||
mismatch, empty segment, markup tokens, a line that would start a new
|
||||
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
|
||||
(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
|
||||
(entity-decoded text, backslash escapes) is skipped — it simply stays in
|
||||
the original language. So is any piece containing "<": "<" is the
|
||||
prose/markup boundary on the wire — translators cut their output there,
|
||||
so such pieces could not survive the round trip.
|
||||
the original language. A literal "<" in prose ("<1MB") is text, not
|
||||
markup, but cannot cross as-is — "<" is the prose/markup boundary on the
|
||||
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
|
||||
@@ -69,6 +76,38 @@ _ALERT = re.compile(r"^\[![A-Za-z]+\][ \t]*")
|
||||
#: (inline attrs are consumed by the parser; a lone {dates} is not).
|
||||
_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]",
|
||||
#: "[]" or a bare "]" (shortcut reference); the destination may nest one
|
||||
#: 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)
|
||||
lead = len(raw) - len(raw.lstrip())
|
||||
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
|
||||
# Locate each piece verbatim, in order; the source slices between the
|
||||
# located pieces are then the link syntax, exact by construction.
|
||||
@@ -338,7 +377,7 @@ def _linked_block(
|
||||
rec.append(text_)
|
||||
if source[span_start:span_end] != "".join(rec):
|
||||
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]]:
|
||||
@@ -367,10 +406,8 @@ def split(text: str) -> tuple[list[Span], list[str], list[str]]:
|
||||
def emit(run: str, at: int, ctx: str) -> None:
|
||||
"""Carve {...} spans out of the located run; emit the prose pieces,
|
||||
stripped — padding whitespace stays in the template, off the wire.
|
||||
Pieces containing "<" are never emitted: translators cut output at
|
||||
the first "<" (the prose/markup boundary, scripts/translator.py),
|
||||
so such a piece could not survive the round trip — it stays in the
|
||||
original language instead."""
|
||||
A literal "<" crosses encoded (``_encode``): it is text, not
|
||||
markup, but the wire keeps "<" as the prose/markup boundary."""
|
||||
pieces = []
|
||||
pos = 0
|
||||
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:
|
||||
raw = run[p0:p1]
|
||||
piece = raw.strip()
|
||||
if _LETTER.search(piece) and "<" not in piece:
|
||||
if _LETTER.search(piece):
|
||||
start = at + p0 + (len(raw) - len(raw.lstrip()))
|
||||
spans.append(Span(start, start + len(piece), 0, []))
|
||||
segments.append(piece)
|
||||
segments.append(_encode(piece))
|
||||
contexts.append(ctx)
|
||||
|
||||
tokens = _MD.parse(text)
|
||||
@@ -409,7 +446,7 @@ def split(text: str) -> tuple[list[Span], list[str], list[str]]:
|
||||
cursor = span.end
|
||||
continue
|
||||
runs = _runs(kids)
|
||||
block = _block_text(kids).strip()
|
||||
block = _encode(_block_text(kids).strip())
|
||||
if alert and runs:
|
||||
run = _ALERT.sub("", runs[0], count=1)
|
||||
if _LETTER.search(run):
|
||||
@@ -417,7 +454,7 @@ def split(text: str) -> tuple[list[Span], list[str], list[str]]:
|
||||
else:
|
||||
runs.pop(0)
|
||||
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)
|
||||
if pos != -1:
|
||||
emit(run, pos, ctx)
|
||||
@@ -435,6 +472,22 @@ def split(text: str) -> tuple[list[Span], list[str], list[str]]:
|
||||
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:
|
||||
"""True when the text parses as nothing but prose (text and softbreak
|
||||
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:
|
||||
"""Splice translated segments back into the original fragment; None on
|
||||
any validation failure (count mismatch, empty or non-prose segment) —
|
||||
the caller drops the result and the fragment stays pending. Segments
|
||||
with marks (a block that crossed as one piece) get their links
|
||||
re-inserted at weight-mapped positions after the prose check."""
|
||||
any validation failure (count mismatch, empty, non-prose or
|
||||
block-structure segment) — the caller drops the result and the fragment
|
||||
stays pending. Segments with marks (a block that crossed as one piece)
|
||||
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):
|
||||
return None
|
||||
out: list[str] = []
|
||||
cursor = 0
|
||||
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
|
||||
translation = translation.translate(_NEUTRAL)
|
||||
if span.marks:
|
||||
translation = _place_marks(translation, span.weight, span.marks)
|
||||
if translation is None:
|
||||
|
||||
+7
-2
@@ -22,11 +22,11 @@ from pathlib import Path
|
||||
import blake3
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
from fastapi_vue import env
|
||||
from kanta import Kanta
|
||||
from zstandard import ZstdCompressor
|
||||
|
||||
from pagerite import analytics, i18n, seed, translate, views
|
||||
from pagerite.__main__ import DEVMODE
|
||||
from pagerite.chunks import store_chunks
|
||||
from pagerite.config import load
|
||||
from pagerite.data import (
|
||||
@@ -56,6 +56,10 @@ DB_PATH = os.getenv("PAGERITE_DB", str(SITE_DIR / "content.kantadb"))
|
||||
|
||||
# Visit analytics go to their own JSON file, not the kanta database.
|
||||
ANALYTICS_PATH = Path(os.getenv("PAGERITE_ANALYTICS", str(SITE_DIR / "analytics.json")))
|
||||
# The per-hostname data directory may not exist yet on first run; kanta
|
||||
# creates the database file but not its parent directory.
|
||||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
ANALYTICS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
analytics_store = analytics.Store(ANALYTICS_PATH)
|
||||
|
||||
# Content-addressed file store (uploads, seed assets, fetched favicons):
|
||||
@@ -132,6 +136,7 @@ def _render_html(
|
||||
data.theme,
|
||||
data.favicon,
|
||||
data.brand_html,
|
||||
base_url,
|
||||
transition=data.transition,
|
||||
lang=lang,
|
||||
translation=translation,
|
||||
@@ -225,7 +230,7 @@ def _html_response(
|
||||
# Absolute social/canonical URLs use the site's public origin; on
|
||||
# localhost (varying ports) fall back to the request's own base URL.
|
||||
base_url = SITE_URL or str(request.base_url).rstrip("/")
|
||||
if DEVMODE:
|
||||
if env.dev:
|
||||
identity = _render_html(kind, path, base_url, lang, link_lang).encode()
|
||||
body = _zstd.compress(identity) if zstd else identity
|
||||
else:
|
||||
|
||||
+53
-36
@@ -3,18 +3,19 @@
|
||||
The visitor-activity WebSocket (``/_ws``, public) and the admin analytics
|
||||
stream (``/_api/ws/analytics``) plus the ``/_a`` viewer page. Client IPs are
|
||||
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.
|
||||
Snapshot broadcasts to connected admin sockets are debounced.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import gzip
|
||||
import io
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
from datetime import date
|
||||
from functools import lru_cache
|
||||
@@ -25,18 +26,15 @@ import httpx
|
||||
import msgspec
|
||||
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import Response
|
||||
from uarite import uaparse
|
||||
|
||||
from pagerite import analytics
|
||||
from pagerite import analytics, i18n
|
||||
from pagerite.data import resolve
|
||||
from pagerite.files import _hash_name, file_store
|
||||
from pagerite.state import SITE_URL, _html_response, analytics_store, data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# httpx logs every request at INFO (e.g. the favicon fetches below); our own
|
||||
# one-line summary in _schedule_favicon_fetch replaces that noise.
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Live WebSocket clients for the analytics stream.
|
||||
@@ -44,8 +42,9 @@ _analytics_ws_clients: set[WebSocket] = set()
|
||||
_analytics_broadcast_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
# Repository root from this file's location (pagerite/tracking.py -> ..).
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
# DB-IP databases persist in the working directory (one download serves all
|
||||
# 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"
|
||||
|
||||
@@ -60,7 +59,7 @@ def _download_dbip() -> None:
|
||||
|
||||
existing = sorted(
|
||||
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]:
|
||||
logger.info("DB-IP database is current (%s), skipping download", existing[-1])
|
||||
@@ -68,7 +67,7 @@ def _download_dbip() -> None:
|
||||
|
||||
for month in months:
|
||||
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")
|
||||
logger.info("Downloading %s", url)
|
||||
try:
|
||||
@@ -93,7 +92,7 @@ def _download_dbip() -> None:
|
||||
continue
|
||||
os.replace(tmp, target)
|
||||
# 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:
|
||||
old.unlink()
|
||||
logger.info("DB-IP database updated to %s", target.name)
|
||||
@@ -102,15 +101,19 @@ def _download_dbip() -> None:
|
||||
|
||||
|
||||
def _geoip_db_path() -> Path | None:
|
||||
"""Find a DB-IP MMDB in the repo root, preferring an already-decompressed
|
||||
``.mmdb`` over the matching ``.mmdb.gz``. Returns None if none is present.
|
||||
"""Find a DB-IP MMDB in the working directory: the ``.mmdb.gz`` download
|
||||
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:
|
||||
return mmdb[0]
|
||||
gz = sorted(_REPO_ROOT.glob("dbip-*.mmdb.gz"))
|
||||
if gz:
|
||||
return gz[0]
|
||||
return None
|
||||
|
||||
|
||||
@@ -123,27 +126,22 @@ class GeoIP:
|
||||
def __init__(self) -> 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:
|
||||
if self._reader is not None:
|
||||
return
|
||||
source = _geoip_db_path()
|
||||
if source is None:
|
||||
return
|
||||
if source.suffix == ".gz":
|
||||
target = source.with_suffix("")
|
||||
self._decompress(source, target)
|
||||
source = target
|
||||
try:
|
||||
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))
|
||||
except Exception:
|
||||
pass
|
||||
@@ -332,7 +330,7 @@ async def _broadcast_analytics() -> None:
|
||||
"""Send the current analytics snapshot to every connected WS client."""
|
||||
if not _analytics_ws_clients:
|
||||
return
|
||||
payload = analytics_store.display_json(_in_menu)
|
||||
payload = _display_json()
|
||||
closed = set()
|
||||
for ws in _analytics_ws_clients:
|
||||
try:
|
||||
@@ -368,12 +366,29 @@ def _in_menu(path: str) -> bool:
|
||||
return resolve(data.menu, path.strip("/")) is not None
|
||||
|
||||
|
||||
def _record_get(request: Request, *, status: int = 200) -> None:
|
||||
def _display_json() -> str:
|
||||
"""The current analytics snapshot as JSON for the admin stream.
|
||||
|
||||
Adds the site's language context: ``multilingual`` (translation
|
||||
languages configured) lets the viewer suppress language UI on
|
||||
single-language sites, ``primary_lang`` (the front page's) lets it skip
|
||||
the primary-language default case.
|
||||
"""
|
||||
return analytics_store.display_json(
|
||||
_in_menu,
|
||||
multilingual=bool(data.translate_langs),
|
||||
primary_lang=i18n.primary_lang(data.menu, ""),
|
||||
)
|
||||
|
||||
|
||||
def _record_get(request: Request, *, status: int = 200, lang: str = "") -> None:
|
||||
"""Record the document GET as one raw access-log line in analytics.
|
||||
|
||||
Nothing is classified here — the true HTTP status, the full request path
|
||||
(query included), an external referer origin and the preload flag are
|
||||
stored, and visitor/crawler/abuse classification happens at display time
|
||||
(query included), an external referer origin, the preload flag and the
|
||||
rendered content language (``lang``, "" for non-localized responses such
|
||||
as 404 probes and reserved paths) are stored, and
|
||||
visitor/crawler/abuse classification happens at display time
|
||||
(see analytics.Store.display). Idle-time preloads from pagerite.js
|
||||
(``x-pagerite-preload`` header) are recorded with ``pre=True``: never
|
||||
counted, but a navigation later served from the in-memory page cache is
|
||||
@@ -401,6 +416,7 @@ def _record_get(request: Request, *, status: int = 200) -> None:
|
||||
referer=referer,
|
||||
accept_language=request.headers.get("accept-language", ""),
|
||||
pre=bool(request.headers.get("x-pagerite-preload")),
|
||||
lang=lang,
|
||||
)
|
||||
if client_hash is not None:
|
||||
_schedule_client_enrichment([client_hash])
|
||||
@@ -442,7 +458,7 @@ async def activity_ws(ws: WebSocket) -> None:
|
||||
# already printed there): compact UA plus the browser's language tag.
|
||||
lang, _country = analytics._parse_accept_language(accept_language)
|
||||
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()
|
||||
try:
|
||||
@@ -460,6 +476,7 @@ async def activity_ws(ws: WebSocket) -> None:
|
||||
accept_language,
|
||||
hide=msg.hide,
|
||||
read=msg.read,
|
||||
lang=msg.lang,
|
||||
)
|
||||
if new_client is not None:
|
||||
_schedule_client_enrichment([new_client])
|
||||
@@ -476,7 +493,7 @@ async def analytics_websocket(ws: WebSocket) -> None:
|
||||
endpoint. Powers the analytics viewer rendered at /_a.
|
||||
"""
|
||||
await ws.accept()
|
||||
await ws.send_text(analytics_store.display_json(_in_menu))
|
||||
await ws.send_text(_display_json())
|
||||
_analytics_ws_clients.add(ws)
|
||||
try:
|
||||
while True:
|
||||
|
||||
+22
-13
@@ -101,7 +101,8 @@ ClientMsg = Hello | Result
|
||||
def pending_items(data: Data, lang: str) -> list[TransItem]:
|
||||
"""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
|
||||
no-translate (``node.no_trans``) and has no ``trans`` entry for ``lang``
|
||||
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
|
||||
# An article whose primary language IS the target needs no
|
||||
# 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
|
||||
if node.chunks is not None and node_lang != lang:
|
||||
if node_lang != lang:
|
||||
if node.title:
|
||||
emit(
|
||||
chunk_key(node.title),
|
||||
@@ -143,7 +146,7 @@ def pending_items(data: Data, lang: str) -> list[TransItem]:
|
||||
"title",
|
||||
context=opening(node),
|
||||
)
|
||||
for h in node.chunks:
|
||||
for h in node.chunks or ():
|
||||
text = data.chunks.get(h)
|
||||
if (
|
||||
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):
|
||||
path = f"{prefix}/{slug}" if prefix else slug
|
||||
node_lang = node.language or inherited
|
||||
if node.chunks is not None and node_lang != lang:
|
||||
keys = set(node.chunks)
|
||||
if node_lang != lang:
|
||||
keys = set(node.chunks or ())
|
||||
if node.title:
|
||||
keys.add(chunk_key(node.title))
|
||||
if keys & stored:
|
||||
@@ -277,16 +280,20 @@ class Dispatcher:
|
||||
job = None
|
||||
spans: list[Span] = []
|
||||
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):
|
||||
# Titles first: a page's name in the menu is its most
|
||||
# visible string (stable: menu order kept within each kind).
|
||||
for item in sorted(
|
||||
pending_items(self.data, lang), key=lambda it: it.kind != "title"
|
||||
for item in pending[lang]:
|
||||
if (
|
||||
item.kind != kind
|
||||
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
|
||||
spans, texts, contexts = split(item.text)
|
||||
if not texts:
|
||||
@@ -307,6 +314,8 @@ class Dispatcher:
|
||||
break
|
||||
if job is not None:
|
||||
break
|
||||
if job is not None:
|
||||
break
|
||||
if job is None:
|
||||
continue
|
||||
state.inflight = (job.lang, job.key) # before the await: no double-assign
|
||||
|
||||
+239
-56
@@ -21,6 +21,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from fastapi_vue import env
|
||||
from html5tagger import HTML, Document, E, Template
|
||||
from platformdirs import site_data_dir, user_data_path
|
||||
|
||||
@@ -263,20 +264,31 @@ def _transition_css_url(transition: str) -> 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
|
||||
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:
|
||||
return None
|
||||
manifest = _manifest()
|
||||
entry = manifest["src/main.js"]
|
||||
base = manifest.get(_BASE_CSS_KEY, {}).get("file")
|
||||
for css in entry.get("css", []):
|
||||
if css != base:
|
||||
return f"/{css}"
|
||||
return None
|
||||
stylesheets, seen = [], set()
|
||||
queue = ["src/main.js"]
|
||||
for key in queue: # grows with imported chunks
|
||||
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:
|
||||
@@ -326,7 +338,7 @@ def _layout(
|
||||
) -> Template:
|
||||
"""Page layout template with standard assets and ES-module scripts.
|
||||
|
||||
In dev (PAGERITE_VITE_URL set) assets are linked from the Vite dev
|
||||
In dev (Vite dev-server URL set) assets are linked from the Vite dev
|
||||
server and stylesheets use ``blocking="render"`` so the browser waits
|
||||
for them before showing the page, avoiding a flash of unstyled content.
|
||||
In production all page assets are inlined into the document: stylesheets
|
||||
@@ -372,8 +384,8 @@ def _layout(
|
||||
doc.meta(property=key, content=value)
|
||||
else:
|
||||
doc.meta(name=key, content=value)
|
||||
# A custom favicon (from the site editor) is linked explicitly; without
|
||||
# one, browsers fall back to the build's /favicon.ico by convention.
|
||||
# A custom favicon (from the site editor) is linked explicitly;
|
||||
# /favicon.ico redirects to the same store file for non-HTML contexts.
|
||||
if favicon:
|
||||
doc.link(rel="icon", href=f"/_f/{favicon}", id="pagerite-favicon")
|
||||
# Asset URLs for the on-demand bundles (editor, analytics) for
|
||||
@@ -383,12 +395,16 @@ def _layout(
|
||||
# dev-server URLs as meta tags (Vite serves the modules and injects
|
||||
# their CSS for hot reloads); production inlines all page assets and
|
||||
# carries the on-demand URLs in one JSON script instead.
|
||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
||||
vite_url = env.vite_url
|
||||
editor_scripts, editor_css = _editor_assets()
|
||||
langselect_scripts, langselect_css = _langselect_assets()
|
||||
config = {
|
||||
"pagerite:editor-src": editor_scripts[-1],
|
||||
"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:
|
||||
config["pagerite:editor-css"] = editor_css
|
||||
if vite_url:
|
||||
@@ -775,7 +791,10 @@ def page_content(
|
||||
"""Render the contents of the #main element for a page.
|
||||
|
||||
A page with published children (a category page) lists them as cards
|
||||
after the markdown content. With a translation, its Markdown goes
|
||||
after the markdown content — unless the content has a ``{cards}`` tag,
|
||||
which places card rows itself (bare: the children; with paths:
|
||||
those pages, ``path/*`` their children, ``path/**`` all descendants),
|
||||
one row per tag. With a translation, its Markdown goes
|
||||
through the same render pipeline; missing pieces (markdown=None, absent
|
||||
title entries) fall back to the original. ``lang`` feeds the cards'
|
||||
per-target localization.
|
||||
@@ -783,7 +802,11 @@ def page_content(
|
||||
node = resolve(menu, path)[-1]
|
||||
content = node_markdown(data, node) or ""
|
||||
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:
|
||||
anchors_from = (content, title)
|
||||
if translation.markdown is not None:
|
||||
content = translation.markdown
|
||||
title = (
|
||||
@@ -793,7 +816,25 @@ def page_content(
|
||||
)
|
||||
# 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.
|
||||
rendered = render(content, path, node.created, node.modified, title=title)
|
||||
# A {cards} tag places the card rows itself (possibly several);
|
||||
# without one the children are appended after the content as before.
|
||||
has_cards_tag = _CARDS_TAG_RE.search(content) is not None
|
||||
directives = None
|
||||
if has_cards_tag:
|
||||
directives = {
|
||||
"cards": lambda args, _env: _cards_tag(
|
||||
menu, data, node, path, args, translation, link_lang, lang
|
||||
)
|
||||
}
|
||||
rendered = render(
|
||||
content,
|
||||
path,
|
||||
node.created,
|
||||
node.modified,
|
||||
title=title,
|
||||
anchors_from=anchors_from,
|
||||
directives=directives,
|
||||
)
|
||||
# Long articles get .multicol: the article column cap lifts (see the
|
||||
# #content grid in pagerite.css) and the .cols segments lay out in at
|
||||
# most two columns. The html is already segmented by render() — the
|
||||
@@ -801,10 +842,25 @@ def page_content(
|
||||
doc = E.article(class_="multicol") if rendered.multicol else E.article
|
||||
with doc:
|
||||
doc(HTML(rendered.html))
|
||||
if not has_cards_tag:
|
||||
_cards(doc, menu, data, node, path, translation, link_lang, lang)
|
||||
return HTML(str(doc))
|
||||
|
||||
|
||||
def _represent(node: Node, path: str) -> tuple[str, Node] | None:
|
||||
"""The (path, node) a card for this menu item points at: the item
|
||||
itself when it has a page, else its first published leaf page,
|
||||
recursively — the same logic as nav links (first_leaf)."""
|
||||
if node.chunks:
|
||||
return path, node
|
||||
for slug, child in sorted_nodes(node.children):
|
||||
if child.published:
|
||||
cpath = f"{path}/{slug}" if path else slug
|
||||
if r := _represent(child, cpath):
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def _cards(
|
||||
doc,
|
||||
menu: dict[str, Node],
|
||||
@@ -815,38 +871,107 @@ def _cards(
|
||||
link_lang: str = "",
|
||||
lang: str = "",
|
||||
) -> None:
|
||||
"""Card stacks of the node's published children (nothing when childless).
|
||||
"""Cards of the node's published children (nothing when childless).
|
||||
|
||||
One column per direct child, all in a single full-width row (the .wide
|
||||
breakout): the columns grow to fill the page and shrink rather than
|
||||
wrap. A column holds the child's whole subtree flattened in menu order
|
||||
— nesting levels are not split out — starting with the first page that
|
||||
has actual content (the child itself when it does, its first leaf
|
||||
otherwise, recursively). Each card is one <a> showing the page's share
|
||||
One card per direct child, all in a single full-width row (the .wide
|
||||
breakout): the cards grow to fill the page and shrink rather than
|
||||
wrap. A child without a page of its own is represented by its first
|
||||
leaf page (_represent, the nav-link logic). Each card is one <a>
|
||||
showing the page's share
|
||||
image (the same heuristics as og:image) as the cover and its title;
|
||||
image-less cards get a gradient cover and also show the description.
|
||||
Only phrasing-level elements (spans) go inside the <a>: as a formatting
|
||||
element it would be cloned by the HTML parser around any block-level
|
||||
child, splitting one card into several links.
|
||||
"""
|
||||
items = [(s, c) for s, c in sorted_nodes(node.children) if c.published]
|
||||
items = [
|
||||
r
|
||||
for s, c in sorted_nodes(node.children)
|
||||
if c.published
|
||||
for r in [_represent(c, f"{path}/{s}" if path else s)]
|
||||
if r
|
||||
]
|
||||
if not items:
|
||||
return
|
||||
with doc.div(class_="cards wide"):
|
||||
for slug, child in items:
|
||||
cpath = f"{path}/{slug}" if path else slug
|
||||
entries = list(_walk(child, cpath))
|
||||
if not entries:
|
||||
continue
|
||||
with doc.div(class_="stack"):
|
||||
for epath, enode in entries:
|
||||
_card(doc, data, enode, epath, translation, link_lang, lang)
|
||||
for cpath, cnode in items:
|
||||
_card(doc, data, cnode, cpath, translation, link_lang, lang)
|
||||
|
||||
|
||||
#: A lone {cards} or {cards: ...} line in the markdown: card rows placed
|
||||
#: by the author. Any such tag suppresses the automatic end-of-page cards.
|
||||
_CARDS_TAG_RE = re.compile(r"^\{cards(?::[^{}\n]*)?\}[ \t]*$", re.M)
|
||||
|
||||
|
||||
def _cards_tag(
|
||||
menu: dict[str, Node],
|
||||
data: Data,
|
||||
node: Node,
|
||||
path: str,
|
||||
args: str,
|
||||
translation: Translation | None = None,
|
||||
link_lang: str = "",
|
||||
lang: str = "",
|
||||
) -> str:
|
||||
"""Expand a ``{cards}`` directive to a card row (the same markup as
|
||||
_cards, so author-placed cards look like category cards).
|
||||
|
||||
A bare ``{cards}`` lists the page's own published children — what
|
||||
page_content appends when the tag is absent — or, on the front page,
|
||||
the other top-level pages (the front page is a top-level item itself,
|
||||
not the parent of the others). Arguments are space-separated page
|
||||
paths: a plain path renders that page alone (never its children),
|
||||
``path/*`` its published children and ``path/**`` all published
|
||||
descendant pages. One card per item; a page-less item is represented
|
||||
by its first leaf page (_represent). Unresolvable paths are skipped;
|
||||
a tag that ends up with nothing renders as nothing.
|
||||
"""
|
||||
items: list[tuple[str, Node]] = []
|
||||
specs = args.split()
|
||||
|
||||
def children(base: str, parent: Node):
|
||||
for s, c in sorted_nodes(parent.children):
|
||||
if c.published:
|
||||
if r := _represent(c, f"{base}/{s}" if base else s):
|
||||
items.append(r)
|
||||
|
||||
if not specs:
|
||||
if path:
|
||||
children(path, node)
|
||||
else:
|
||||
for s, c in sorted_nodes(menu):
|
||||
if c.published and s:
|
||||
if r := _represent(c, s):
|
||||
items.append(r)
|
||||
else:
|
||||
for spec in specs:
|
||||
spec = spec.strip("/")
|
||||
if spec.endswith("/**"):
|
||||
base = spec[:-3].rstrip("/")
|
||||
if chain := resolve(menu, base):
|
||||
for s, c in sorted_nodes(chain[-1].children):
|
||||
if c.published:
|
||||
items.extend(_walk(c, f"{base}/{s}" if base else s))
|
||||
elif spec.endswith("/*"):
|
||||
base = spec[:-2].rstrip("/")
|
||||
if chain := resolve(menu, base):
|
||||
children(base, chain[-1])
|
||||
elif chain := resolve(menu, spec):
|
||||
if r := _represent(chain[-1], spec):
|
||||
items.append(r)
|
||||
if not items:
|
||||
return ""
|
||||
doc = E.div(class_="cards wide")
|
||||
with doc:
|
||||
for cpath, cnode in items:
|
||||
_card(doc, data, cnode, cpath, translation, link_lang, lang)
|
||||
return str(doc)
|
||||
|
||||
|
||||
def _walk(node: Node, path: str):
|
||||
"""Published content pages of a subtree, pre-order in menu order: the
|
||||
node itself first when it has content (the stack's landing card), then
|
||||
its descendants (content-less nodes contribute only their subtree)."""
|
||||
node itself first when it has content, then its descendants
|
||||
(content-less nodes contribute only their subtree)."""
|
||||
if node.chunks:
|
||||
yield path, node
|
||||
for slug, child in sorted_nodes(node.children):
|
||||
@@ -863,7 +988,7 @@ def _card(
|
||||
link_lang: str = "",
|
||||
lang: str = "",
|
||||
) -> None:
|
||||
"""One card in a stack: cover + title, plus the description when the
|
||||
"""One card: cover + title, plus the description when the
|
||||
page has no image (its card shows a gradient cover instead).
|
||||
|
||||
The card text localizes per target article where that page is
|
||||
@@ -876,7 +1001,15 @@ def _card(
|
||||
md = node_markdown(data, node) or ""
|
||||
if lang and lang in node.langs:
|
||||
md = i18n.hybrid_markdown(data, node, path, lang)
|
||||
html = render(md, path, node.created, node.modified).html
|
||||
html = render(
|
||||
md,
|
||||
path,
|
||||
node.created,
|
||||
node.modified,
|
||||
# Card heuristics only mine the prose: nested {cards} rows
|
||||
# would just be noise in the description extraction.
|
||||
directives={"cards": lambda _args, _env: ""},
|
||||
).html
|
||||
image, _ = _media(html)
|
||||
if not image:
|
||||
description = _description(html, 150)
|
||||
@@ -1011,6 +1144,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(
|
||||
menu: dict[str, Node],
|
||||
data: Data,
|
||||
@@ -1040,24 +1209,7 @@ def render_page(
|
||||
title = _title(path.rpartition("/")[2], node, translation, path)
|
||||
main = page_content(menu, data, path, translation, link_lang, lang)
|
||||
social = _social_meta(node, path, title, str(main), brand, base_url)
|
||||
# Canonical/hreflang URLs (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 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})
|
||||
]
|
||||
canonical, alternates = _language_urls(data, path, node, lang, original, base_url)
|
||||
return str(
|
||||
_layout(
|
||||
*_page_assets(),
|
||||
@@ -1090,6 +1242,7 @@ def render_category(
|
||||
theme: str = "",
|
||||
favicon: str = "",
|
||||
brand_html: str = "",
|
||||
base_url: str = "",
|
||||
transition: str = "cube",
|
||||
lang: str = i18n.ORIGINAL_LANGUAGE,
|
||||
translation: Translation | None = None,
|
||||
@@ -1105,12 +1258,16 @@ def render_category(
|
||||
With a translation (titles only — the category has no Markdown) the
|
||||
heading, navigation and card text localize per target article
|
||||
(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]
|
||||
original = i18n.primary_lang(menu, path)
|
||||
if translation is None:
|
||||
lang = i18n.primary_lang(menu, path)
|
||||
lang = original
|
||||
title = _title(path.rpartition("/")[2], node, translation, path)
|
||||
_, alternates = _language_urls(data, path, node, lang, original, base_url)
|
||||
doc = E.article
|
||||
with doc:
|
||||
doc.h1(title)
|
||||
@@ -1127,6 +1284,7 @@ def render_category(
|
||||
transition,
|
||||
favicon,
|
||||
lang=lang,
|
||||
alternates=alternates,
|
||||
)(
|
||||
Title=f"{title} – {brand}" if brand else title,
|
||||
Brand=_brand_link(brand, brand_html, link_lang),
|
||||
@@ -1180,7 +1338,7 @@ def _page_assets() -> tuple[list[str], list[str]]:
|
||||
by the entry (e.g. overlayscrollbars.css) is extracted by Vite and must
|
||||
be linked separately.
|
||||
"""
|
||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
||||
vite_url = env.vite_url
|
||||
if vite_url:
|
||||
return [f"{vite_url}/src/pagerite.js"], []
|
||||
if "page" not in _asset_cache:
|
||||
@@ -1198,7 +1356,7 @@ def _editor_assets() -> tuple[list[str], str | None]:
|
||||
The shared CSS is already linked on the page, so the pen only needs the
|
||||
editor-specific stylesheet.
|
||||
"""
|
||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
||||
vite_url = env.vite_url
|
||||
if vite_url:
|
||||
return [f"{vite_url}/@vite/client", f"{vite_url}/src/main.js"], None
|
||||
if "editor" not in _asset_cache:
|
||||
@@ -1210,7 +1368,7 @@ def _editor_assets() -> tuple[list[str], str | None]:
|
||||
|
||||
def _analytics_assets() -> tuple[list[str], list[str]]:
|
||||
"""Script and stylesheet URLs for the analytics page entry."""
|
||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
||||
vite_url = env.vite_url
|
||||
if vite_url:
|
||||
return [f"{vite_url}/src/analytics-main.js"], []
|
||||
if "analytics" not in _asset_cache:
|
||||
@@ -1222,6 +1380,31 @@ def _analytics_assets() -> tuple[list[str], list[str]]:
|
||||
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 = env.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(
|
||||
menu: dict[str, Node],
|
||||
brand: str = SITE_NAME,
|
||||
|
||||
+4
-4
@@ -17,19 +17,19 @@ readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"blake3>=1.0.9",
|
||||
"fastapi-vue~=1.4.2",
|
||||
"fastapi-vue~=1.7.2",
|
||||
"fastapi[standard]>=0.141.1",
|
||||
"html5tagger>=2.0.0",
|
||||
"httpx>=0.28.1",
|
||||
"kanta>=0.9.0",
|
||||
"kanta>=0.9.2",
|
||||
"markdown-it-py>=4.2.0",
|
||||
"maxminddb>=3.1.1",
|
||||
"mdit-py-plugins>=0.6.1",
|
||||
"mediapreview[standard]>=0.2.3",
|
||||
"mediapreview[standard]>=0.2.5",
|
||||
"platformdirs>=4.11.5",
|
||||
"pygments>=2.20.0",
|
||||
"python-slugify>=8.0.4",
|
||||
"ua-parser>=1.0.2",
|
||||
"uarite>=0.1.2",
|
||||
"zstandard>=0.25.0",
|
||||
]
|
||||
|
||||
|
||||
+10
-5
@@ -1,11 +1,12 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
||||
"""Run Vite development server for Vue app and FastAPI backend with auto-reload."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
import tracerite
|
||||
@@ -47,11 +48,11 @@ async def run_devserver(
|
||||
os.environ["PAGERITE_DEV"] = "1"
|
||||
|
||||
async with ProcessGroup() as pg:
|
||||
pg.create_task(check_ports_free(viteurl, backurl))
|
||||
npm_i = await pg.spawn(*npm_install, cwd=front)
|
||||
await check_ports_free(viteurl, backurl)
|
||||
await pg.spawn(*pagerite, *(extra_args or []))
|
||||
await pg.spawn(*pagerite, *(extra_args or []), vital=True)
|
||||
await pg.wait(npm_i, ready(backurl, path=HEALTH))
|
||||
await pg.spawn(*vite, cwd=front)
|
||||
await pg.spawn(*vite, cwd=front, vital=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -74,8 +75,12 @@ def main() -> None:
|
||||
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
||||
)
|
||||
args, extra_args = parser.parse_known_args()
|
||||
with suppress(KeyboardInterrupt):
|
||||
try:
|
||||
asyncio.run(run_devserver(args.listen, args.backend, extra_args))
|
||||
except* KeyboardInterrupt:
|
||||
pass # user stopped the devserver: normal exit
|
||||
except* subprocess.SubprocessError, RuntimeError:
|
||||
raise SystemExit(1) from None # logged in devutil already; exit 1
|
||||
|
||||
|
||||
HELP_EPILOG = """
|
||||
|
||||
@@ -11,20 +11,27 @@ from pathlib import Path
|
||||
MIN_NODE_VERSION = 20
|
||||
|
||||
|
||||
class _PrefixFormatter(logging.Formatter):
|
||||
"""Formatter that adds prefix based on log level."""
|
||||
class _Formatter(logging.Formatter):
|
||||
"""Prefix formatter, intentionally different from fastapi_vue.logging.
|
||||
|
||||
INFO and below pass through unprefixed so messages can use their own
|
||||
markings (>>>, ###); WARNING and above get an emoji prefix.
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
if record.levelno >= logging.ERROR:
|
||||
return f"🛑 {record.getMessage()}"
|
||||
if record.levelno >= logging.WARNING:
|
||||
return f"⚠️ {record.getMessage()}"
|
||||
return f"💣 {record.getMessage()}"
|
||||
return record.getMessage()
|
||||
|
||||
|
||||
_handler = logging.StreamHandler()
|
||||
_handler.setFormatter(_PrefixFormatter())
|
||||
_handler.setFormatter(_Formatter())
|
||||
logger = logging.getLogger("fastapi-vue")
|
||||
logger.addHandler(_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False # own handler; do not double-print via a configured root
|
||||
|
||||
|
||||
def _check_node_version(node_path: str) -> None:
|
||||
|
||||
@@ -1,108 +1,87 @@
|
||||
# ruff: noqa: INP001
|
||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
from asyncio.subprocess import Process
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Self
|
||||
from subprocess import CalledProcessError
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from buildutil import find_dev_tool, find_install_tool, logger
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Coroutine
|
||||
from collections.abc import Awaitable
|
||||
|
||||
|
||||
class ProcessGroup:
|
||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||
class ProcessGroup(asyncio.TaskGroup):
|
||||
"""TaskGroup with structured ownership of async subprocesses."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize empty process tracking."""
|
||||
self._procs: list[asyncio.subprocess.Process] = []
|
||||
self._cmds: dict[int, str] = {} # pid -> command name
|
||||
def __init__(self, *, terminate_timeout: float = 10) -> None:
|
||||
"""Set the grace period before terminate() escalates to kill()."""
|
||||
super().__init__()
|
||||
self._terminate_timeout = terminate_timeout
|
||||
self._cmds: dict[Process, tuple[str, ...]] = {}
|
||||
|
||||
async def spawn(
|
||||
self,
|
||||
*cmd: str,
|
||||
cwd: str | None = None,
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Spawn a subprocess and track it."""
|
||||
cmd_name = Path(cmd[0]).stem
|
||||
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
|
||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||
self._procs.append(proc)
|
||||
self._cmds[proc.pid] = cmd_name
|
||||
return proc
|
||||
self, *cmd: str, cwd: str | None = None, vital: bool = False
|
||||
) -> Process:
|
||||
"""Spawn and own a subprocess. If a vital process exits, the group cancels."""
|
||||
|
||||
async def wait(
|
||||
self,
|
||||
*waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]",
|
||||
) -> None:
|
||||
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
||||
|
||||
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
|
||||
returncode = await proc.wait()
|
||||
if returncode != 0:
|
||||
cmd_name = self._cmds.get(proc.pid, "unknown")
|
||||
raise subprocess.CalledProcessError(returncode, cmd_name)
|
||||
|
||||
tasks = [
|
||||
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
|
||||
for w in waitables
|
||||
]
|
||||
async def run() -> None:
|
||||
name = Path(cmd[0]).stem
|
||||
logger.info(">>> %s", " ".join([name, *cmd[1:]]))
|
||||
try:
|
||||
await asyncio.gather(*tasks)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
"""Enter the async context manager."""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
|
||||
"""Wait for one process to exit, terminate others, then wait for all."""
|
||||
await self._cleanup(immediate=exc_type is not None)
|
||||
|
||||
async def _cleanup(self, *, immediate: bool = False) -> None:
|
||||
running = [p for p in self._procs if p.returncode is None]
|
||||
if not running:
|
||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||
self._cmds[proc] = cmd
|
||||
started.set_result(proc)
|
||||
except Exception as e: # noqa: BLE001
|
||||
started.set_exception(e)
|
||||
return
|
||||
|
||||
if not immediate:
|
||||
# Wait for any one process to exit
|
||||
with suppress(asyncio.CancelledError):
|
||||
await asyncio.wait(
|
||||
[asyncio.create_task(p.wait()) for p in running],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
# Terminate remaining processes
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
with suppress(ProcessLookupError):
|
||||
p.terminate()
|
||||
|
||||
# Wait for all to finish (with overall timeout), shielded from cancellation
|
||||
still_running = [p for p in self._procs if p.returncode is None]
|
||||
if still_running:
|
||||
with suppress(asyncio.CancelledError):
|
||||
try:
|
||||
await asyncio.shield(
|
||||
asyncio.wait_for(
|
||||
asyncio.gather(*[p.wait() for p in still_running]),
|
||||
timeout=10,
|
||||
),
|
||||
)
|
||||
except TimeoutError:
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
returncode = await proc.wait()
|
||||
finally:
|
||||
with suppress(ProcessLookupError):
|
||||
p.kill()
|
||||
await p.wait()
|
||||
proc.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), self._terminate_timeout)
|
||||
except TimeoutError:
|
||||
with suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
|
||||
if vital:
|
||||
logger.warning("Vital process %s exited", name)
|
||||
raise CalledProcessError(returncode, cmd)
|
||||
|
||||
started = asyncio.get_running_loop().create_future()
|
||||
self.create_task(run())
|
||||
return await asyncio.shield(started)
|
||||
|
||||
async def wait(self, *waitables: Process | Awaitable) -> tuple[Any, ...]:
|
||||
"""Wait concurrently and return results in argument order."""
|
||||
|
||||
async def task(w: Process | Awaitable) -> Any: # noqa: ANN401
|
||||
if not isinstance(w, Process):
|
||||
return await w
|
||||
if retcode := await w.wait():
|
||||
cmd = self._cmds[w]
|
||||
logger.warning(
|
||||
"Process %s exited with status %d", Path(cmd[0]).stem, retcode
|
||||
)
|
||||
raise CalledProcessError(retcode, cmd)
|
||||
return retcode
|
||||
|
||||
async with asyncio.TaskGroup() as group:
|
||||
tasks = [group.create_task(task(w)) for w in waitables]
|
||||
|
||||
return tuple(task.result() for task in tasks)
|
||||
|
||||
|
||||
async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109
|
||||
@@ -128,42 +107,43 @@ async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYN
|
||||
writer.close()
|
||||
except OSError, EOFError, ValueError, TimeoutError:
|
||||
return None
|
||||
for line in data.decode("latin-1").split("\r\n"):
|
||||
for line in data.decode(errors="replace").split("\r\n"):
|
||||
if line.lower().startswith("server:"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
return line[7:].strip()
|
||||
return ""
|
||||
|
||||
|
||||
async def check_ports_free(*urls: str) -> None:
|
||||
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
|
||||
"""Verify URLs are not responding (ports are free).
|
||||
|
||||
async def check(url: str) -> None:
|
||||
server = await http_get_server(url, timeout=0.1)
|
||||
Meant to run as a task inside a TaskGroup. Logs the conflict and raises
|
||||
RuntimeError (handled like a failed process) if any URL responds.
|
||||
"""
|
||||
servers = await asyncio.gather(*(http_get_server(url, timeout=0.1) for url in urls))
|
||||
for url, server in zip(urls, servers, strict=True):
|
||||
if server is not None:
|
||||
logger.warning(
|
||||
logger.error(
|
||||
"Conflicting %s already running at %s", server or "server", url
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
await asyncio.gather(*[check(url) for url in urls])
|
||||
raise RuntimeError(url)
|
||||
|
||||
|
||||
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
||||
"""Wait for the server to be ready by polling an endpoint.
|
||||
|
||||
Use empty path to disable the check and make this return immediately.
|
||||
Raises SystemExit(1) if server doesn't start in time.
|
||||
Logs, then raises RuntimeError if the server doesn't start in time.
|
||||
"""
|
||||
if not path:
|
||||
return
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
if await http_get_server(f"{url}{path}", timeout=1.0) is not None:
|
||||
logger.info("✓ Backend ready!")
|
||||
logger.info("🟢 Backend ready!")
|
||||
return
|
||||
if attempt == max_attempts - 1:
|
||||
logger.warning("Backend didn't start in time")
|
||||
raise SystemExit(1)
|
||||
logger.error("Backend at %s didn't start in time", url)
|
||||
raise RuntimeError(url)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user