Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddf323bf41 | ||
|
|
f708e1dbca | ||
|
|
1d0bc3f59d | ||
|
|
60d5155fd2 | ||
|
|
3518ac9ac7 | ||
|
|
5c2433b766 | ||
|
|
2959f971bc | ||
|
|
8ee22de060 | ||
|
|
9be1491f0e | ||
|
|
29c1dc64ed | ||
|
|
a27958117f | ||
|
|
56fd5f854d |
+40
-20
@@ -27,7 +27,8 @@ falsy values are omitted):
|
|||||||
(an `fr` equal to `to` would log a bogus self-transition when a session
|
(an `fr` equal to `to` would log a bogus self-transition when a session
|
||||||
already exists, e.g. a second tab). This ping is what starts
|
already exists, e.g. a second tab). This ping is what starts
|
||||||
the visit and counts the entry page view — the document GET alone records
|
the visit and counts the entry page view — the document GET alone records
|
||||||
nothing, so bots and admin browsing never register. JS-running crawlers
|
nothing, so bots never register (admin browsing does register, but
|
||||||
|
flagged `hide`; see **Admins** below). JS-running crawlers
|
||||||
(Googlebot, GoogleOther, Applebot, ...) do ping, but their User-Agent
|
(Googlebot, GoogleOther, Applebot, ...) do ping, but their User-Agent
|
||||||
gives them away: pings whose UA matches `_is_bot_ua` (anything calling
|
gives them away: pings whose UA matches `_is_bot_ua` (anything calling
|
||||||
itself a "bot", plus known exceptions such as GoogleOther) are ignored
|
itself a "bot", plus known exceptions such as GoogleOther) are ignored
|
||||||
@@ -57,12 +58,18 @@ falsy values are omitted):
|
|||||||
without the preload header, and without the ping that GET would flush to
|
without the preload header, and without the ping that GET would flush to
|
||||||
the crawler list.
|
the crawler list.
|
||||||
- **Admins**: when SSO is in use and the session is known to be an admin,
|
- **Admins**: when SSO is in use and the session is known to be an admin,
|
||||||
the client still pings but adds `hide=1`. The server then records
|
the client still pings but adds `hide=1`. The activity is recorded as
|
||||||
nothing — and if the same client session already had a visit from before
|
usual (navigations and all), but the `hide` flag is set on the **client
|
||||||
logging in, that visit is removed from the JSON along with every count
|
record** — so it covers everything that client ever did: visits and
|
||||||
it recorded — an in-memory per-visit log of count events makes full
|
crawler hits from before the login included. Hidden clients never appear
|
||||||
reversal possible. With no auth proxy (dev/test)
|
in the viewer payload: `Store.display()` drops their visits, crawler
|
||||||
"admin" is everyone's state, so `hide` stays 0 and everything is recorded.
|
hits, abuse hits and metadata, and computes every aggregate (site visits,
|
||||||
|
page views, transitions) from the visible visits only, so nothing needs
|
||||||
|
to be reversed or redacted. Pending crawler hits from a hidden client
|
||||||
|
are discarded when they expire, so admin browsing never lands in the
|
||||||
|
crawler list either. With no auth proxy
|
||||||
|
(dev/test) "admin" is everyone's state, so `hide` stays 0 and everything
|
||||||
|
is recorded.
|
||||||
- The server validates `to`: internal paths must be valid slug paths
|
- The server validates `to`: internal paths must be valid slug paths
|
||||||
("/" or `[a-z0-9_-]` segments), external ones are re-derived to the
|
("/" or `[a-z0-9_-]` segments), external ones are re-derived to the
|
||||||
https origin and accepted only when the client sent exactly that.
|
https origin and accepted only when the client sent exactly that.
|
||||||
@@ -94,13 +101,14 @@ falsy values are omitted):
|
|||||||
the header only hides a GET from the crawler stats, the path-based abuse
|
the header only hides a GET from the crawler stats, the path-based abuse
|
||||||
classification is unaffected). If a ping
|
classification is unaffected). If a ping
|
||||||
from the same client arrives within 10 seconds the hit is discarded;
|
from the same client arrives within 10 seconds the hit is discarded;
|
||||||
otherwise it is written to `crawlers`. Crawlers do not count as
|
otherwise it is written to `crawlers` — unless the client is hidden
|
||||||
|
(admin), in which case the hit is discarded on expiry too. Crawlers do not count as
|
||||||
visits or views. The `Accept-Language` header is stored on the shared
|
visits or views. The `Accept-Language` header is stored on the shared
|
||||||
`Client` immediately; reverse-DNS host names and DB-IP geoip
|
`Client` immediately; reverse-DNS host names and DB-IP geoip
|
||||||
country/city are filled in asynchronously, just like for real visits. In
|
country/city are filled in asynchronously, just like for real visits. In
|
||||||
the analytics viewer, crawler hits are grouped by client hash and shown as
|
the analytics viewer, crawler hits are grouped by client hash and shown as
|
||||||
a trail of internal pages that crawler visited; the crawler table lists
|
a trail of internal pages that crawler visited; the crawler table lists
|
||||||
the most active crawlers first rather than the most recent hits.
|
the most recent crawler first, with the most active as a tie-breaker.
|
||||||
- **Abuse (scanner) hits**: a 404 for a telltale path — any URL segment
|
- **Abuse (scanner) hits**: a 404 for a telltale path — any URL segment
|
||||||
starting with a dot (`/.env`, `/.git/config`) or ending in `.php` —
|
starting with a dot (`/.env`, `/.git/config`) or ending in `.php` —
|
||||||
classifies the source IP as abuse immediately, and ten plain 404s from one
|
classifies the source IP as abuse immediately, and ten plain 404s from one
|
||||||
@@ -141,7 +149,10 @@ Each `Client` record:
|
|||||||
- `city` — city name from the DB-IP MMDB lookup, when available,
|
- `city` — city name from the DB-IP MMDB lookup, when available,
|
||||||
- `ua` — raw `User-Agent` string,
|
- `ua` — raw `User-Agent` string,
|
||||||
- `ua_pretty` — compact display form of the UA (browser/OS/device) when
|
- `ua_pretty` — compact display form of the UA (browser/OS/device) when
|
||||||
parsable, otherwise the raw string.
|
parsable, otherwise the raw string,
|
||||||
|
- `hide` — true for admin clients (`hide=1` ping): all their visits,
|
||||||
|
crawler hits and abuse hits are recorded but excluded from every
|
||||||
|
statistic and from the viewer payload.
|
||||||
|
|
||||||
Each `Visit` record:
|
Each `Visit` record:
|
||||||
|
|
||||||
@@ -149,13 +160,16 @@ Each `Visit` record:
|
|||||||
- `entry` — first page (path) seen,
|
- `entry` — first page (path) seen,
|
||||||
- `referer` — external https origin of the initial load, `""` for direct,
|
- `referer` — external https origin of the initial load, `""` for direct,
|
||||||
- `client` — 6-byte blake3 hash referencing `Analytics.clients`,
|
- `client` — 6-byte blake3 hash referencing `Analytics.clients`,
|
||||||
- `trail` — everything seen afterwards in first-seen order: page paths and
|
- `trail` — the entry page and everything seen afterwards, keyed by the
|
||||||
external exit URLs. Re-visiting an already seen page (incl. the entry)
|
timestamp of first sight (insertion order = first-seen order). Each item
|
||||||
does not append.
|
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`). Re-visiting an already seen target updates
|
||||||
|
its item instead of appending.
|
||||||
|
- `navs` — every navigation ping (`fr`, `to`), keyed by its timestamp,
|
||||||
|
repeats included. The aggregates are computed from this log at display
|
||||||
|
time.
|
||||||
- `utm` — `utm_*` query parameters from the landing URL, as a dict.
|
- `utm` — `utm_*` query parameters from the landing URL, as a dict.
|
||||||
- `read` — active reading time per path (seconds), keyed by path.
|
|
||||||
- `statuses` — HTTP status of the response when each path was first seen
|
|
||||||
(200 or 404), keyed by path.
|
|
||||||
|
|
||||||
Each `CrawlerHit` record:
|
Each `CrawlerHit` record:
|
||||||
|
|
||||||
@@ -190,10 +204,16 @@ to tell misses from real pages at a glance.
|
|||||||
|
|
||||||
## Aggregates
|
## Aggregates
|
||||||
|
|
||||||
|
Aggregates are **not stored**; they are computed at display time by
|
||||||
|
`Store.display()` from the visit records (entry + `navs` log), skipping
|
||||||
|
hidden clients' visits. This is what allows a client to become hidden after
|
||||||
|
navigations were already logged: no counts need reversing. The computed
|
||||||
|
shapes, part of the WebSocket payload (`Display` struct alongside `visits`,
|
||||||
|
`crawlers`, `abuse` and `clients`):
|
||||||
|
|
||||||
- `transitions`: time series of page transitions, sparse nested dict
|
- `transitions`: time series of page transitions, sparse nested dict
|
||||||
`from -> to -> bucket -> count` with the same 5-minute bucketing as
|
`from -> to -> bucket -> count` with 5-minute bucketing. `from` is the
|
||||||
`views`. `from` is the referer origin or `"(direct)"` for initial loads,
|
referer origin or `"(direct)"` for initial loads, a page path for pings.
|
||||||
a page path for pings.
|
|
||||||
- `views`: time series of page loads, `path -> bucket -> count`, sparse: only
|
- `views`: time series of page loads, `path -> bucket -> count`, sparse: only
|
||||||
non-zero 5-minute buckets exist (bucket key is its floored ISO timestamp).
|
non-zero 5-minute buckets exist (bucket key is its floored ISO timestamp).
|
||||||
Every load counts, including repeats within a visit; external exit origins
|
Every load counts, including repeats within a visit; external exit origins
|
||||||
@@ -202,7 +222,7 @@ to tell misses from real pages at a glance.
|
|||||||
5-minute bucketing.
|
5-minute bucketing.
|
||||||
|
|
||||||
Sparseness keeps quiet sites small; dropping old data is a matter of deleting
|
Sparseness keeps quiet sites small; dropping old data is a matter of deleting
|
||||||
list/dict entries (`visits` is a plain append-only list, buckets plain keys).
|
list entries (`visits` is a plain append-only list).
|
||||||
|
|
||||||
## Persistence
|
## Persistence
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ msgspec Structs for the kanta database. See `docs/content-model.md` for the full
|
|||||||
|
|
||||||
markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). 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).
|
markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). 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).
|
||||||
|
|
||||||
|
`render()` returns a `Rendered(html, multicol)`: the body segmented for the column layout — h1/h2 headings, `.wide` blocks and margin-breakout blocks (`.margin`, `::: aside`) stand bare, the runs between them become `<div class="colseg">` (plus `.cols` on segments with enough text, `::: nocols` opting out), 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).
|
||||||
|
|
||||||
## `views.py`
|
## `views.py`
|
||||||
|
|
||||||
The shared page layout as an html5tagger `Template` with placeholders (`Title`, `Brand`, `Banner`, `Nav`, `Sidebar`, `Main`), nav rendering straight from the `Data.menu` tree (siblings sorted by `Node.order`; nav links to content-less labels point at their first child via `first_leaf`, the first published descendant with content), and page/404 rendering.
|
The shared page layout as an html5tagger `Template` with placeholders (`Title`, `Brand`, `Banner`, `Nav`, `Sidebar`, `Main`), nav rendering straight from the `Data.menu` tree (siblings sorted by `Node.order`; nav links to content-less labels point at their first child via `first_leaf`, the first published descendant with content), and page/404 rendering.
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
|
|||||||
|
|
||||||
- Content is written in **Markdown** with powerful extensions (tables, footnotes, code highlighting, etc.).
|
- Content is written in **Markdown** with powerful extensions (tables, footnotes, code highlighting, etc.).
|
||||||
- **Embedded HTML is passed through unfiltered**, including inline scripts and other dynamic content the author wants to post. This is safe by the single-trusted-author assumption above.
|
- **Embedded HTML is passed through unfiltered**, including inline scripts and other dynamic content the author wants to post. This is safe by the single-trusted-author assumption above.
|
||||||
- Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition lists, task lists, brace-attributes; tables and strikethrough from the default preset), with `html=True` for raw passthrough, `typographer=True` for SmartyPants-style replacements in body text (curly quotes, `--` / `---` → en / em dashes, `...` → ellipsis, `(c)` → ©, etc.), and `breaks=True` so single line breaks inside paragraphs become `<br>`. Code spans/blocks and raw HTML are left untouched. Fenced code blocks are highlighted server-side with **Pygments** (`nowrap` spans styled by `/_assets/pygments-*.css`, which maps every token class onto the `--code-*` variables; the base stylesheet defines light and dark palette sets resolved via `light-dark()`, so each theme gets the set matching its `color-scheme` and may only retint `--code-bg` to keep the well in the page's color family); a JS copy button appears on hover. Should this prove limiting, we implement our own renderer on top of html5tagger, which we already use for all HTML generation.
|
- Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition lists, task lists, brace-attributes, admonitions and `::: name` containers — generic `<div class="name">` wrappers (the name may be followed by brace attributes: `::: aside {.right}`), of which `::: aside` floats as a muted side box and `{.margin}` / `::: margin` marks any block a margin note — both drop into the left margin when the layout has room for it (the sticky sidebar shares that gutter and slides over them translucently, like full-bleed images), and stay in-column floats otherwise — and `::: nocols` opts its section out of column layout; tables and strikethrough from the default preset), GitHub-style alerts (`> [!NOTE]` / TIP / IMPORTANT / WARNING / CAUTION, rendered in the admonition callout styling), with `html=True` for raw passthrough, `typographer=True` for SmartyPants-style replacements in body text (curly quotes, `--` / `---` → en / em dashes, `...` → ellipsis, `(c)` → ©, etc.), and `breaks=True` so single line breaks inside paragraphs become `<br>` — including inside blockquotes, where every newline is kept and a blank `>` line starts a new paragraph. Code spans/blocks and raw HTML are left untouched. Fenced code blocks are highlighted server-side with **Pygments** (`nowrap` spans styled by `/_assets/pygments-*.css`, which maps every token class onto the `--code-*` variables; the base stylesheet defines light and dark palette sets resolved via `light-dark()`, so each theme gets the set matching its `color-scheme` and may only retint `--code-bg` to keep the well in the page's color family); a JS copy button appears on hover. Should this prove limiting, we implement our own renderer on top of html5tagger, which we already use for all HTML generation.
|
||||||
- **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`) are stored by content hash — blake3, first 6 bytes hex + original extension — and served immutable from `/_f/{hash}.ext`. Absolute URLs that survive page renames and dedupe identical content; pages no longer own files. An image standing alone in its paragraph becomes a block `<figure>` — with `<figcaption>` when it has a title; images inline with text and raw `<img>` HTML stay plain inline images. Positioning is by attribute classes: `{.right}` — `{.right}`, `{.left}` float at 30% of the text column (the caption wraps within it; an explicit `width=300` makes the figure shrink-wrap the image instead), `{.wide}` goes full bleed (viewport edge to edge, or up to the docked editor; the sidebar stacks on top of it); plain attributes like `width=300` work too. Headings (h1/h2) clear floats, so images never overflow into the next section.
|
- **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`) are stored by content hash — blake3, first 6 bytes hex + original extension — and served immutable from `/_f/{hash}.ext`. Absolute URLs that survive page renames and dedupe identical content; pages no longer own files. An image standing alone in its paragraph becomes a block `<figure>` — with `<figcaption>` when it has a title; images inline with text and raw `<img>` HTML stay plain inline images. Positioning is by attribute classes: `{.right}` — `{.right}`, `{.left}` float at 30% of the text column (the caption wraps within it; an explicit `width=300` makes the figure shrink-wrap the image instead), `{.margin}` drops it into the left margin like a margin note, `{.wide}` goes full bleed (viewport edge to edge, or up to the docked editor; the sidebar stacks on top of it); plain attributes like `width=300` work too. The same brace syntax on a block's last line (no blank line between) applies to the whole block: a paragraph ending with `{.wide}` becomes a full-width element that breaks out of the column layout; written on the line after a block it applies to that preceding block — this is how headings, `::: containers` and code fences take classes (a wide code fence goes full bleed like a wide figure). Headings (h1/h2) clear floats, so images never overflow into the next section.
|
||||||
|
|
||||||
## Page structure and navigation
|
## Page structure and navigation
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
|
|||||||
|
|
||||||
## Reading experience
|
## Reading experience
|
||||||
|
|
||||||
- The article column is sized by the **viewport, never by content**: a symmetric grid (`1fr minmax(0, 78rem) 1fr`) with flexible gutters keeps the layout stable across navigation. The sidebar occupies the left gutter, the right gutter balances it; wide screens get columns inside long articles without changing the article's width.
|
- The article column is sized by the **viewport, never by content**: a symmetric grid (`1fr minmax(0, 78rem) 1fr`) with flexible gutters keeps the layout stable across navigation. The sidebar occupies the left gutter, the right gutter balances it; long articles (flagged `.multicol` by the backend render) lift the 78rem cap and take everything right of the left gutter, out to the viewport edge. The backend also splits the body into `.colseg` segments at h1/h2 headings, `.wide` elements and margin-breakout blocks (full-width separators or margin boxes, never inside columns), tagging segments that hold enough text with `.cols` — code blocks are excluded from that measure, and a `::: nocols` container opts its whole section out. The CSS then fits **at most two columns** of at least 30rem per segment, so a wider window widens the pair instead of adding columns. Margin boxes (`.margin`, `::: aside`) leave the column flow for the left gutter on wide viewports and stay in-column floats below that.
|
||||||
- A gentle **scroll-reveal** of headings, figures and block-level elements (IntersectionObserver). It is layout-level: articles need no support for it, and `prefers-reduced-motion` disables all motion.
|
- A gentle **scroll-reveal** of headings, figures and block-level elements (IntersectionObserver). It is layout-level: articles need no support for it, and `prefers-reduced-motion` disables all motion.
|
||||||
|
|
||||||
## Styling
|
## Styling
|
||||||
|
|||||||
@@ -34,4 +34,4 @@ The banner artwork has scroll parallax: pagerite.js sets the `--pry` scroll para
|
|||||||
|
|
||||||
## Stylesheet order
|
## Stylesheet order
|
||||||
|
|
||||||
The backend emits the stylesheets in a fixed order — base (Vite build), theme, banner design, entry sheets, custom CSS last — each with a stable id so fetch-navigation and the site editor can sync them in place. In dev they are `<link>`s (the base is Vite-injected from JS instead); in production they are inlined as `<style>` elements. The base stylesheet's `--font-brand` defaults to `var(--font-heading)`.
|
The backend emits the stylesheets in a fixed order — base (Vite build), theme, banner design, entry sheets, custom CSS last — each with a stable id so fetch-navigation and the site editor can sync them in place. In dev they are `<link>`s (the base is Vite-injected from JS instead); in production they are inlined as `<style>` elements. The base stylesheet's `--font-brand` defaults to `var(--font-heading)`. Code text (Fira Code by default) is optically matched to the body font by x-height: `font-size-adjust: ex-height var(--code-x-height)` scales whatever code font is in use, so a theme that switches its body font sets `--code-x-height` to that font's x-height ratio (base: 0.478 for Source Sans 3; themes ship values for Inter, Montserrat, Literata and Cause).
|
||||||
|
|||||||
@@ -240,9 +240,13 @@ function runScripts(root) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewIntoArticle(html, hasH1) {
|
function previewIntoArticle(html, hasH1, multicol) {
|
||||||
const article = document.querySelector('#main article')
|
const article = document.querySelector('#main article')
|
||||||
if (!article) return
|
if (!article) return
|
||||||
|
// The server render owns the column layout: .multicol on the article,
|
||||||
|
// the segmented .colseg/.cols structure inside .body. Both arrive with
|
||||||
|
// the preview and must stay in sync as edits cross the thresholds.
|
||||||
|
article.classList.toggle('multicol', multicol)
|
||||||
const h1 = article.querySelector('h1')
|
const h1 = article.querySelector('h1')
|
||||||
const body = article.querySelector('.body')
|
const body = article.querySelector('.body')
|
||||||
// The edit pen may be tucked inside an h1 (title or markdown-owned);
|
// The edit pen may be tucked inside an h1 (title or markdown-owned);
|
||||||
@@ -270,7 +274,7 @@ function onMessage(ev) {
|
|||||||
requestRender()
|
requestRender()
|
||||||
dirty.value = false // just loaded from the server, nothing unsaved
|
dirty.value = false // just loaded from the server, nothing unsaved
|
||||||
} else if (msg.type === 'html' && msg.path === path.value) {
|
} else if (msg.type === 'html' && msg.path === path.value) {
|
||||||
previewIntoArticle(msg.html, msg.has_h1)
|
previewIntoArticle(msg.html, msg.has_h1, msg.multicol)
|
||||||
} else if (msg.type === 'saved') {
|
} else if (msg.type === 'saved') {
|
||||||
saveError.value = ''
|
saveError.value = ''
|
||||||
pendingSave = null
|
pendingSave = null
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
|
||||||
import { DAY } from './analytics/time.js'
|
import { DAY } from './analytics/time.js'
|
||||||
import { formatCount } from './analytics/format.js'
|
import { formatCount, formatReadTime } from './analytics/format.js'
|
||||||
import {
|
import {
|
||||||
TNODE_W,
|
TNODE_W,
|
||||||
TNODE_H,
|
TNODE_H,
|
||||||
@@ -151,7 +151,7 @@ onBeforeUnmount(() => cancelAnimationFrame(rafId))
|
|||||||
const fitsPill = (label, fontPx = 19) => label.length * 0.52 * fontPx <= TNODE_W - 16
|
const fitsPill = (label, fontPx = 19) => label.length * 0.52 * fontPx <= TNODE_W - 16
|
||||||
|
|
||||||
const countLabel = (n) =>
|
const countLabel = (n) =>
|
||||||
n.readMin ? `${formatCount(n.views)}×${n.readMin}m` : formatCount(n.views)
|
n.readSec ? `${formatCount(n.views)}×${formatReadTime(n.readSec)}` : formatCount(n.views)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -80,17 +80,27 @@ export function calcTotalViews(views) {
|
|||||||
// Very short reads are navigation/skims, not real reading time.
|
// Very short reads are navigation/skims, not real reading time.
|
||||||
export const MIN_READ_SECONDS = 10
|
export const MIN_READ_SECONDS = 10
|
||||||
|
|
||||||
|
/** path -> accumulated read seconds for a visit, derived from its trail. */
|
||||||
|
export function readMapOf(v) {
|
||||||
|
const map = {}
|
||||||
|
for (const item of Object.values(v.trail || {})) {
|
||||||
|
if (item.read) map[item.to] = (map[item.to] || 0) + item.read
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
/** Average minutes per visit and average of per-article median read minutes. */
|
/** Average minutes per visit and average of per-article median read minutes. */
|
||||||
export function calcReadStats(visits) {
|
export function calcReadStats(visits) {
|
||||||
const perArticle = {}
|
const perArticle = {}
|
||||||
let totalVisitSeconds = 0
|
let totalVisitSeconds = 0
|
||||||
let visitCount = 0
|
let visitCount = 0
|
||||||
for (const v of visits || []) {
|
for (const v of visits || []) {
|
||||||
const secs = Object.values(v.read || {}).filter((s) => s >= MIN_READ_SECONDS)
|
const read = readMapOf(v)
|
||||||
|
const secs = Object.values(read).filter((s) => s >= MIN_READ_SECONDS)
|
||||||
if (!secs.length) continue
|
if (!secs.length) continue
|
||||||
visitCount++
|
visitCount++
|
||||||
totalVisitSeconds += secs.reduce((a, b) => a + b, 0)
|
totalVisitSeconds += secs.reduce((a, b) => a + b, 0)
|
||||||
for (const [path, s] of Object.entries(v.read || {})) {
|
for (const [path, s] of Object.entries(read)) {
|
||||||
if (s >= MIN_READ_SECONDS) {
|
if (s >= MIN_READ_SECONDS) {
|
||||||
; (perArticle[path] || (perArticle[path] = [])).push(s)
|
; (perArticle[path] || (perArticle[path] = [])).push(s)
|
||||||
}
|
}
|
||||||
@@ -271,7 +281,7 @@ export function formatRecentVisits(visits, pageTree, limit = 50) {
|
|||||||
.reverse()
|
.reverse()
|
||||||
.map((v) => ({
|
.map((v) => ({
|
||||||
when: new Date(v.start).toLocaleString(),
|
when: new Date(v.start).toLocaleString(),
|
||||||
steps: [v.referer, v.entry, ...(v.trail || [])]
|
steps: [v.referer, ...Object.values(v.trail || {}).map((t) => t.to)]
|
||||||
.map((p) => stepOf(p, titles))
|
.map((p) => stepOf(p, titles))
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
}))
|
}))
|
||||||
@@ -349,8 +359,8 @@ export function mainDomain(host, limit = 24) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Group raw crawler hits by client hash and format each group as a row showing
|
* 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 total hits,
|
* every internal page that crawler visited. Rows are sorted by most recent hit
|
||||||
* most active crawler first, rather than by most recent hit.
|
* first, with total hits as a tie-breaker.
|
||||||
* ``clients`` maps client hashes to client records.
|
* ``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()) {
|
||||||
@@ -380,7 +390,7 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now())
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
return [...groups.values()]
|
return [...groups.values()]
|
||||||
.sort((a, b) => totalHits(b) - totalHits(a) || b.lastStart - a.lastStart)
|
.sort((a, b) => b.lastStart - a.lastStart || totalHits(b) - totalHits(a))
|
||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
.map((g) => {
|
.map((g) => {
|
||||||
const client = g.client || {}
|
const client = g.client || {}
|
||||||
@@ -510,14 +520,12 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now()) {
|
|||||||
const titles = buildTitleMap(pageTree)
|
const titles = buildTitleMap(pageTree)
|
||||||
return [...(visits || [])].reverse().slice(0, 20).map((v) => {
|
return [...(visits || [])].reverse().slice(0, 20).map((v) => {
|
||||||
const client = (clients || {})[v.client] || {}
|
const client = (clients || {})[v.client] || {}
|
||||||
const read = v.read || {}
|
const trail = Object.values(v.trail || {})
|
||||||
const statuses = v.statuses || {}
|
.map((item) => {
|
||||||
const trail = [v.entry, ...(v.trail || [])]
|
const step = stepOf(item.to, titles)
|
||||||
.map((p) => {
|
|
||||||
const step = stepOf(p, titles)
|
|
||||||
if (step) {
|
if (step) {
|
||||||
if (read[p]) step.readSeconds = read[p]
|
if (item.read) step.readSeconds = item.read
|
||||||
if (statuses[p]) step.status = statuses[p]
|
if (item.status) step.status = item.status
|
||||||
}
|
}
|
||||||
return step
|
return step
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
* pings) are skipped.
|
* pings) are skipped.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MIN_READ_SECONDS } from './format.js'
|
import { MIN_READ_SECONDS, readMapOf } from './format.js'
|
||||||
|
|
||||||
// Nodes are constant-size pills (stadium rects) holding the slug and the
|
// Nodes are constant-size pills (stadium rects) holding the slug and the
|
||||||
// view count on two centered lines. TNODE_BOUND is the pill's bounding
|
// view count on two centered lines. TNODE_BOUND is the pill's bounding
|
||||||
@@ -259,29 +259,29 @@ function sortByNav(root, navOrder) {
|
|||||||
walk(root)
|
walk(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute median reading time per article in whole minutes. */
|
/** Compute median reading time per article in seconds. */
|
||||||
function buildReadMinutes(visits) {
|
function buildReadSeconds(visits) {
|
||||||
const times = {}
|
const times = {}
|
||||||
for (const v of visits || []) {
|
for (const v of visits || []) {
|
||||||
for (const [path, sec] of Object.entries(v.read || {})) {
|
for (const [path, sec] of Object.entries(readMapOf(v))) {
|
||||||
if (sec >= MIN_READ_SECONDS) {
|
if (sec >= MIN_READ_SECONDS) {
|
||||||
; (times[path] || (times[path] = [])).push(sec)
|
; (times[path] || (times[path] = [])).push(sec)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const minutes = {}
|
const seconds = {}
|
||||||
for (const [path, arr] of Object.entries(times)) {
|
for (const [path, arr] of Object.entries(times)) {
|
||||||
arr.sort((a, b) => a - b)
|
arr.sort((a, b) => a - b)
|
||||||
const mid = Math.floor(arr.length / 2)
|
const mid = Math.floor(arr.length / 2)
|
||||||
const median =
|
const median =
|
||||||
arr.length % 2 ? arr[mid] : (arr[mid - 1] + arr[mid]) / 2
|
arr.length % 2 ? arr[mid] : (arr[mid - 1] + arr[mid]) / 2
|
||||||
minutes[path] = Math.max(1, Math.round(median / 60))
|
seconds[path] = Math.round(median)
|
||||||
}
|
}
|
||||||
return minutes
|
return seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute view counts, labels and hidden flags for each node. */
|
/** Compute view counts, labels and hidden flags for each node. */
|
||||||
function annotateNodes(nodes, viewsData, titles, readMinutes) {
|
function annotateNodes(nodes, viewsData, titles, readSeconds) {
|
||||||
const viewCount = (p) => {
|
const viewCount = (p) => {
|
||||||
let n = 0
|
let n = 0
|
||||||
for (const c of Object.values(viewsData?.[p] || {})) n += c
|
for (const c of Object.values(viewsData?.[p] || {})) n += c
|
||||||
@@ -290,7 +290,7 @@ function annotateNodes(nodes, viewsData, titles, readMinutes) {
|
|||||||
|
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
n.views = viewCount(n.path)
|
n.views = viewCount(n.path)
|
||||||
n.readMin = readMinutes[n.path] || 0
|
n.readSec = readSeconds[n.path] || 0
|
||||||
// Article title inside the pill (clipped at the pill border on
|
// Article title inside the pill (clipped at the pill border on
|
||||||
// render), slug as fallback for pages missing from the site tree.
|
// render), slug as fallback for pages missing from the site tree.
|
||||||
n.label = titles.get(n.path) || (n.path === '/' ? '🏠︎' : n.path.split('/').pop())
|
n.label = titles.get(n.path) || (n.path === '/' ? '🏠︎' : n.path.split('/').pop())
|
||||||
@@ -895,13 +895,13 @@ export function buildTransitionGraph(data, pageTree, visits = [], dayScale = 1)
|
|||||||
const exits = collectExitPairs(data?.transitions)
|
const exits = collectExitPairs(data?.transitions)
|
||||||
const navOrder = buildNavigationOrder(pageTree)
|
const navOrder = buildNavigationOrder(pageTree)
|
||||||
const titles = buildTitleMap(pageTree)
|
const titles = buildTitleMap(pageTree)
|
||||||
const readMinutes = buildReadMinutes(visits)
|
const readSeconds = buildReadSeconds(visits)
|
||||||
|
|
||||||
if (!internal.length && !navOrder.size) return null
|
if (!internal.length && !navOrder.size) return null
|
||||||
|
|
||||||
const { nodes, byPath, root } = buildNodeTree(internal, navOrder)
|
const { nodes, byPath, root } = buildNodeTree(internal, navOrder)
|
||||||
sortByNav(root, navOrder)
|
sortByNav(root, navOrder)
|
||||||
annotateNodes(nodes, data?.views, titles, readMinutes)
|
annotateNodes(nodes, data?.views, titles, readSeconds)
|
||||||
const { arcs, arcLeft } = layoutGroups(root)
|
const { arcs, arcLeft } = layoutGroups(root)
|
||||||
const placed = nodes.filter((n) => !n.hidden)
|
const placed = nodes.filter((n) => !n.hidden)
|
||||||
const pairs = aggregatePairs(internal)
|
const pairs = aggregatePairs(internal)
|
||||||
|
|||||||
@@ -56,6 +56,11 @@
|
|||||||
/* The brand follows the heading font unless overridden separately. */
|
/* The brand follows the heading font unless overridden separately. */
|
||||||
--font-brand: var(--font-heading);
|
--font-brand: var(--font-heading);
|
||||||
--font-code: var(--font-fira-code);
|
--font-code: var(--font-fira-code);
|
||||||
|
/* Target x-height ratio for code text: set to the body font's ratio
|
||||||
|
(here Source Sans 3's 0.478) so font-size-adjust can scale the code
|
||||||
|
font to the same optical height. Themes retune it to their body font
|
||||||
|
(measured: Literata 0.507, Inter 0.546, Montserrat 0.517, Cause 0.5). */
|
||||||
|
--code-x-height: 0.478;
|
||||||
/* Width of the docked editor panel (used both here for shifting the page
|
/* Width of the docked editor panel (used both here for shifting the page
|
||||||
and in the Vue editor's own styles). */
|
and in the Vue editor's own styles). */
|
||||||
--editor-w: min(46rem, 50vw);
|
--editor-w: min(46rem, 50vw);
|
||||||
@@ -65,6 +70,11 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: color-mix(var(--accent) 30%, transparent);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
/* Links never underline — including SVG link text, which the UA stylesheet
|
/* Links never underline — including SVG link text, which the UA stylesheet
|
||||||
underlines by default. */
|
underlines by default. */
|
||||||
a {
|
a {
|
||||||
@@ -126,7 +136,10 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
height: 13rem;
|
/* Height scales down proportionally on small screens: 13rem at 800px
|
||||||
|
(50rem) viewport, shrinking with the smaller of viewport width/height
|
||||||
|
(vmin) below that, floored at 8rem. */
|
||||||
|
height: clamp(8rem, 26vmin, 13rem);
|
||||||
box-sizing: content-box;
|
box-sizing: content-box;
|
||||||
background: linear-gradient(135deg, var(--surface), var(--bg));
|
background: linear-gradient(135deg, var(--surface), var(--bg));
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
@@ -192,7 +205,9 @@ body {
|
|||||||
#brand {
|
#brand {
|
||||||
font-family: var(--font-brand);
|
font-family: var(--font-brand);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 2.4rem;
|
/* Scales down proportionally on small screens, same curve as the banner
|
||||||
|
height: 2.4rem at 800px, shrinking with vmin below that. */
|
||||||
|
font-size: clamp(1.4rem, 4.8vmin, 2.4rem);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
/* One line always: pagerite.js shrinks the font size to fit instead of
|
/* One line always: pagerite.js shrinks the font size to fit instead of
|
||||||
wrapping (the themed size is the maximum). */
|
wrapping (the themed size is the maximum). */
|
||||||
@@ -260,12 +275,13 @@ body {
|
|||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 78rem) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 78rem) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Long articles (.multicol is added by pagerite.js based on content length)
|
/* Long articles (.multicol comes from the backend render, based on
|
||||||
lift the 78rem cap and scrap the right gutter: a 1fr left gutter (which
|
content length — code excluded) lift the 78rem cap and scrap the right
|
||||||
holds the overlaying sidebar) and the article taking all the rest, out
|
gutter: a 1fr left gutter (which holds the overlaying sidebar and any
|
||||||
to the right viewport edge. The column count follows the width (see the
|
margin-breakout boxes) and the article taking all the rest, out to the
|
||||||
`columns: 30rem` rule below). The .wide breakout is re-anchored to the
|
right viewport edge. Columns are capped at two (see the
|
||||||
left gutter below (the article is no longer viewport-centered). */
|
`columns: 30rem 2` rule below). The .wide breakout is re-anchored to
|
||||||
|
the left gutter below (the article is no longer viewport-centered). */
|
||||||
body:has(.multicol) #content {
|
body:has(.multicol) #content {
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 4fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 4fr);
|
||||||
}
|
}
|
||||||
@@ -295,9 +311,10 @@ body.editing #sidebar {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: var(--editor-w);
|
width: var(--editor-w);
|
||||||
/* Above the sidebar and .edit-link while sliding in/out (the host now
|
/* Above the sidebar, .edit-link and the banner's top-right pens
|
||||||
lives at the end of <body>, so it needs its own stacking level). */
|
(z-index 10) while sliding in/out (the host now lives at the end of
|
||||||
z-index: 3;
|
<body>, so it needs its own stacking level). */
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-root.overlay {
|
.editor-root.overlay {
|
||||||
@@ -408,7 +425,10 @@ main {
|
|||||||
|
|
||||||
article h1,
|
article h1,
|
||||||
article h2,
|
article h2,
|
||||||
article h3 {
|
article h3,
|
||||||
|
article h4,
|
||||||
|
article h5,
|
||||||
|
article h6 {
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
@@ -428,13 +448,31 @@ article dl,
|
|||||||
article blockquote,
|
article blockquote,
|
||||||
article pre,
|
article pre,
|
||||||
article figure,
|
article figure,
|
||||||
article table {
|
article table,
|
||||||
|
article h3,
|
||||||
|
article h4,
|
||||||
|
article h5,
|
||||||
|
article h6 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
article h3 {
|
/* Headings separate from the text above via a top margin on the sibling
|
||||||
margin: 1.4rem 0 0.4rem;
|
combinator: a heading that is the first child of a container (e.g. the
|
||||||
|
top of a .colseg column segment) gets no gap, and browsers truncate the
|
||||||
|
margin at column breaks, so column tops stay aligned. */
|
||||||
|
article h3,
|
||||||
|
article h4,
|
||||||
|
article h5,
|
||||||
|
article h6 {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
article * + h3,
|
||||||
|
article * + h4,
|
||||||
|
article * + h5,
|
||||||
|
article * + h6 {
|
||||||
|
margin-top: 1.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Lists: small diamond emoji markers — blue 🔹 on odd nesting levels,
|
/* Lists: small diamond emoji markers — blue 🔹 on odd nesting levels,
|
||||||
@@ -524,19 +562,28 @@ article dd {
|
|||||||
hyphens: auto;
|
hyphens: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Multi-column reading, but only for long articles (pagerite.js adds
|
/* Multi-column reading, but only for long articles: the backend render
|
||||||
.multicol based on content length and splits the body into .colseg segments
|
splits the body into .colseg segments (separated by full-width h2s and
|
||||||
separated by full-width h2s and wide figures; only segments with enough
|
.wide elements), tags segments with enough text as .cols (a ::: nocols
|
||||||
text get .cols). No fixed breakpoint: `columns: 30rem` lets CSS fit as
|
container opts its section out) and flags the article .multicol based
|
||||||
many columns of at least 30rem as the article's current width allows —
|
on content length — code blocks excluded. Never more than two columns:
|
||||||
since .multicol also uncaps the article width (see #content above), a
|
`columns: 30rem 2` fits one or two columns of at least 30rem into the
|
||||||
wider window simply yields more columns. */
|
article's current width — since .multicol also uncaps the article
|
||||||
|
width (see #content above), a wider window widens the two columns
|
||||||
|
instead of adding more. */
|
||||||
.multicol .colseg.cols {
|
.multicol .colseg.cols {
|
||||||
columns: 30rem;
|
columns: 30rem 2;
|
||||||
column-gap: 3.5rem;
|
column-gap: 3.5rem;
|
||||||
column-rule: 1px solid var(--line);
|
column-rule: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A shrink-wrapped figure (explicit image width) centers in the plain
|
||||||
|
layout; inside a column the centering looks adrift — left-align.
|
||||||
|
Floated figures keep their own margins (the text gap). */
|
||||||
|
.multicol .colseg.cols figure:has(img[width]):not(:has(.left), :has(.right), :has(.margin)) {
|
||||||
|
margin-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.multicol .colseg {
|
.multicol .colseg {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
@@ -556,7 +603,11 @@ article dd {
|
|||||||
pre,
|
pre,
|
||||||
blockquote,
|
blockquote,
|
||||||
table,
|
table,
|
||||||
dl {
|
ul,
|
||||||
|
ol,
|
||||||
|
dl,
|
||||||
|
.admonition,
|
||||||
|
.markdown-alert {
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -576,10 +627,11 @@ article a:hover {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Blockquotes: inner paragraphs carry no margins (spacing comes from the
|
/* Blockquotes: spacing comes from the blockquote itself (bottom-only like
|
||||||
blockquote itself, bottom-only like everything else in articles). The
|
everything else in articles); inner paragraphs keep only the gap between
|
||||||
negative left margin pushes the bar out past the text edge, so quoted
|
them. The negative left margin pushes the bar out past the text edge, so
|
||||||
text aligns with the surrounding paragraphs — same trick as code blocks. */
|
quoted text aligns with the surrounding paragraphs — same trick as code
|
||||||
|
blocks. */
|
||||||
blockquote {
|
blockquote {
|
||||||
margin: 0 0 1rem -0.5rem;
|
margin: 0 0 1rem -0.5rem;
|
||||||
padding: 0 0 0 0.25rem;
|
padding: 0 0 0 0.25rem;
|
||||||
@@ -591,42 +643,132 @@ blockquote p {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Admonitions (markdown !!! note/warning/...): a lightweight callout in
|
blockquote p + p {
|
||||||
the blockquote idiom — accent bar and a faint wash, recolored per type.
|
margin-top: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admonitions (markdown !!! note/warning/...) and GitHub-style alerts
|
||||||
|
(> [!NOTE] ...): a lightweight callout in the blockquote idiom — accent
|
||||||
|
bar and a faint wash, recolored per type, with a type emoji on the
|
||||||
|
title. The negative left margin pushes bar and wash out past the text
|
||||||
|
edge so the inner text aligns with surrounding paragraphs — same trick
|
||||||
|
as blockquotes and code blocks (margin-left = border + padding-left).
|
||||||
Bottom-only margins like everything else in articles; inner paragraphs
|
Bottom-only margins like everything else in articles; inner paragraphs
|
||||||
carry no margins of their own. */
|
carry no margins of their own. */
|
||||||
.admonition {
|
.admonition,
|
||||||
margin: 0 0 1rem;
|
.markdown-alert {
|
||||||
|
margin: 0 0 1rem -1.15rem;
|
||||||
padding: 0.4rem 0.9rem;
|
padding: 0.4rem 0.9rem;
|
||||||
border-left: 0.25rem solid var(--admonition-color, var(--accent));
|
border-left: 0.25rem solid var(--admonition-color, var(--accent));
|
||||||
border-radius: 0 0.3rem 0.3rem 0;
|
border-radius: 0 0.3rem 0.3rem 0;
|
||||||
background: color-mix(in srgb, var(--admonition-color, var(--accent)) 7%, transparent);
|
background: color-mix(in srgb, var(--admonition-color, var(--accent)) 7%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admonition> :last-child {
|
.admonition> :last-child,
|
||||||
|
.markdown-alert> :last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admonition-title {
|
.admonition-title,
|
||||||
|
.markdown-alert-title {
|
||||||
margin: 0 0 0.2rem;
|
margin: 0 0 0.2rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--admonition-color, var(--accent));
|
color: var(--admonition-color, var(--accent));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admonition-title::before,
|
||||||
|
.markdown-alert-title::before {
|
||||||
|
padding-right: 0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admonition.note .admonition-title::before,
|
||||||
|
.markdown-alert-note .markdown-alert-title::before {
|
||||||
|
content: "ℹ️";
|
||||||
|
}
|
||||||
|
|
||||||
|
.admonition.tip .admonition-title::before,
|
||||||
|
.admonition.hint .admonition-title::before,
|
||||||
|
.markdown-alert-tip .markdown-alert-title::before {
|
||||||
|
content: "✨";
|
||||||
|
}
|
||||||
|
|
||||||
|
.admonition.important .admonition-title::before,
|
||||||
|
.markdown-alert-important .markdown-alert-title::before {
|
||||||
|
content: "❗";
|
||||||
|
}
|
||||||
|
|
||||||
|
.admonition.success .admonition-title::before {
|
||||||
|
content: "✅";
|
||||||
|
}
|
||||||
|
|
||||||
|
.admonition.warning .admonition-title::before,
|
||||||
|
.markdown-alert-warning .markdown-alert-title::before {
|
||||||
|
content: "⚠️";
|
||||||
|
}
|
||||||
|
|
||||||
|
.admonition.caution .admonition-title::before,
|
||||||
|
.markdown-alert-caution .markdown-alert-title::before {
|
||||||
|
content: "🔥";
|
||||||
|
}
|
||||||
|
|
||||||
|
.admonition.danger .admonition-title::before,
|
||||||
|
.admonition.failure .admonition-title::before {
|
||||||
|
content: "⛔";
|
||||||
|
}
|
||||||
|
|
||||||
.admonition.tip,
|
.admonition.tip,
|
||||||
.admonition.important,
|
.admonition.important,
|
||||||
.admonition.hint,
|
.admonition.hint,
|
||||||
.admonition.success {
|
.admonition.success,
|
||||||
|
.markdown-alert-tip,
|
||||||
|
.markdown-alert-important {
|
||||||
--admonition-color: var(--accent2);
|
--admonition-color: var(--accent2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admonition.warning,
|
.admonition.warning,
|
||||||
.admonition.caution,
|
.admonition.caution,
|
||||||
.admonition.danger,
|
.admonition.danger,
|
||||||
.admonition.failure {
|
.admonition.failure,
|
||||||
|
.markdown-alert-warning,
|
||||||
|
.markdown-alert-caution {
|
||||||
--admonition-color: var(--accent3);
|
--admonition-color: var(--accent3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Side boxes: ::: aside is a muted floated box (consecutive asides stack
|
||||||
|
via clear: right); {.margin} / ::: margin is a plainer margin note, and
|
||||||
|
figures take {.margin} like {.left}. All of them drop into the left
|
||||||
|
margin when the layout has room for it (the breakout rules live with
|
||||||
|
the figure rules below); without room they stay in-column floats —
|
||||||
|
asides on the right, margin boxes on the left. Headings already clear
|
||||||
|
floats, so boxes never bleed into the next section. */
|
||||||
|
.aside {
|
||||||
|
float: right;
|
||||||
|
clear: right;
|
||||||
|
width: 30%;
|
||||||
|
max-width: 20rem;
|
||||||
|
margin: 0.3rem 0 1rem 1.2rem;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aside> :last-child,
|
||||||
|
.margin> :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin {
|
||||||
|
float: left;
|
||||||
|
clear: left;
|
||||||
|
width: 30%;
|
||||||
|
max-width: 20rem;
|
||||||
|
margin: 0.3rem 1.2rem 1rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding: 0.5rem 0.8rem;
|
padding: 0.5rem 0.8rem;
|
||||||
@@ -640,10 +782,15 @@ pre {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inline code integrates with the text, no box of its own */
|
/* Inline code integrates with the text, no box of its own. Instead of a
|
||||||
p code,
|
fixed em shrink (which can't fit every body/code font pairing — Fira
|
||||||
li code {
|
Code's x-height ratio 0.525 is taller than Source Sans 3's 0.478 yet
|
||||||
font-size: 0.85em;
|
shorter than Inter's 0.546), font-size-adjust scales whatever code font
|
||||||
|
is in use so its x-height matches the body font's ratio. Browsers
|
||||||
|
without font-size-adjust get unadjusted 1em code, which is fine. */
|
||||||
|
code {
|
||||||
|
font-family: var(--font-code);
|
||||||
|
font-size-adjust: ex-height var(--code-x-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Click-to-copy button (added by pagerite.js) */
|
/* Click-to-copy button (added by pagerite.js) */
|
||||||
@@ -672,11 +819,6 @@ pre:hover .copy,
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
|
||||||
font-family: var(--font-code);
|
|
||||||
font-size: 0.88em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tables separate by color, not lines: the header is a soft vertical
|
/* Tables separate by color, not lines: the header is a soft vertical
|
||||||
gradient tinted with the theme's accent (themes can override the
|
gradient tinted with the theme's accent (themes can override the
|
||||||
--table-head-* stops outright), body cells carry a very faint diagonal
|
--table-head-* stops outright), body cells carry a very faint diagonal
|
||||||
@@ -788,6 +930,52 @@ figure:has(img[width]) {
|
|||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* {.margin} figures float left like {.left} ones — until the margin
|
||||||
|
breakout below pulls them into the left gutter. */
|
||||||
|
figure:has(.margin) {
|
||||||
|
float: left;
|
||||||
|
width: 30%;
|
||||||
|
max-width: 50%;
|
||||||
|
margin: 0.3rem 1em 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The left-margin breakout: margin boxes ({.margin} / ::: margin blocks,
|
||||||
|
::: aside, {.margin} figures) leave the text column for the left
|
||||||
|
gutter, hugging the article's left edge (a 12rem box on a 13.25rem
|
||||||
|
pull: the gutter plus main's padding). The sticky sidebar shares the
|
||||||
|
gutter and slides over them translucently — the same overlap full-bleed
|
||||||
|
.wide images get. Scoped to direct .body children: the backend render
|
||||||
|
keeps breakout blocks out of the column segments, and nested ones keep
|
||||||
|
the in-column float fallback. Room exists once the single-column
|
||||||
|
layout's symmetric gutters fit the box (≥104rem), and on multicol
|
||||||
|
pages whose 1fr left gutter fits it (≥65rem) — but never while editing
|
||||||
|
(the docked panel owns the left edge), and not below 102rem with a
|
||||||
|
sidebar (the fixed 12rem sidebar track, see #content, leaves no free
|
||||||
|
gutter there). */
|
||||||
|
@media (min-width: 104rem) {
|
||||||
|
body:not(.editing) .body>.margin,
|
||||||
|
body:not(.editing) .body>.aside,
|
||||||
|
body:not(.editing) .body>figure:has(.margin) {
|
||||||
|
float: left;
|
||||||
|
clear: left;
|
||||||
|
width: 12rem;
|
||||||
|
max-width: none;
|
||||||
|
margin: 0.3rem 0 1rem -13.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 65rem) {
|
||||||
|
body:not(.editing):has(.multicol):not(:has(#sidebar)) .body>.margin,
|
||||||
|
body:not(.editing):has(.multicol):not(:has(#sidebar)) .body>.aside,
|
||||||
|
body:not(.editing):has(.multicol):not(:has(#sidebar)) .body>figure:has(.margin) {
|
||||||
|
float: left;
|
||||||
|
clear: left;
|
||||||
|
width: 12rem;
|
||||||
|
max-width: none;
|
||||||
|
margin: 0.3rem 0 1rem -13.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* .wide is full bleed: edge to edge of the viewport (or of the space right
|
/* .wide is full bleed: edge to edge of the viewport (or of the space right
|
||||||
of the docked editor), while staying in flow so it keeps its vertical
|
of the docked editor), while staying in flow so it keeps its vertical
|
||||||
space. The article column is centered in the available space, so negative
|
space. The article column is centered in the available space, so negative
|
||||||
@@ -800,16 +988,19 @@ figure:has(img[width]) {
|
|||||||
is not viewport-centered; each just overrides width/margin-inline, and
|
is not viewport-centered; each just overrides width/margin-inline, and
|
||||||
later rules win at equal specificity. The analytics dashboard uses the
|
later rules win at equal specificity. The analytics dashboard uses the
|
||||||
same breakout directly on its container (div.wide — it is the page's
|
same breakout directly on its container (div.wide — it is the page's
|
||||||
whole content, not a figure). */
|
whole content, not a figure), and code blocks via a trailing {.wide}
|
||||||
|
line (fence block attrs land on <pre> itself). */
|
||||||
figure:has(.wide),
|
figure:has(.wide),
|
||||||
div.wide {
|
div.wide,
|
||||||
|
pre.wide {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
margin-inline: calc(50% - 50vw);
|
margin-inline: calc(50% - 50vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Full bleed means edge to edge — no rounded corners. */
|
/* Full bleed means edge to edge — no rounded corners. */
|
||||||
figure:has(.wide) img {
|
figure:has(.wide) img,
|
||||||
|
pre.wide {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -817,21 +1008,24 @@ figure:has(.wide) img {
|
|||||||
gutter), so the bleed anchors at the left gutter — the 1fr share of the
|
gutter), so the bleed anchors at the left gutter — the 1fr share of the
|
||||||
1fr + 4fr grid, i.e. 20vw — plus main's padding, and spans on to the
|
1fr + 4fr grid, i.e. 20vw — plus main's padding, and spans on to the
|
||||||
right viewport edge. */
|
right viewport edge. */
|
||||||
body:has(.multicol) figure:has(.wide) {
|
body:has(.multicol) figure:has(.wide),
|
||||||
|
body:has(.multicol) pre.wide {
|
||||||
margin-inline: calc(-20vw - 1.25rem) 0;
|
margin-inline: calc(-20vw - 1.25rem) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Editing: shrink the bleed to the space right of the docked editor. The
|
/* Editing: shrink the bleed to the space right of the docked editor. The
|
||||||
window keeps its overlay scrollbars while editing, so — unlike a classic
|
window keeps its overlay scrollbars while editing, so — unlike a classic
|
||||||
scrollbar — they take no layout space and the vw math stays exact. */
|
scrollbar — they take no layout space and the vw math stays exact. */
|
||||||
body.editing figure:has(.wide) {
|
body.editing figure:has(.wide),
|
||||||
|
body.editing pre.wide {
|
||||||
width: calc(100vw - var(--editor-w));
|
width: calc(100vw - var(--editor-w));
|
||||||
margin-inline: calc(50% - (100vw - var(--editor-w)) / 2);
|
margin-inline: calc(50% - (100vw - var(--editor-w)) / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Editing + multicol: the left gutter is 1/5 of the space right of the
|
/* Editing + multicol: the left gutter is 1/5 of the space right of the
|
||||||
editor, and the bleed also crosses main's 1.25rem left padding. */
|
editor, and the bleed also crosses main's 1.25rem left padding. */
|
||||||
body.editing:has(.multicol) figure:has(.wide) {
|
body.editing:has(.multicol) figure:has(.wide),
|
||||||
|
body.editing:has(.multicol) pre.wide {
|
||||||
margin-inline: calc((100vw - var(--editor-w)) / -5 - 1.25rem) 0;
|
margin-inline: calc((100vw - var(--editor-w)) / -5 - 1.25rem) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -843,7 +1037,8 @@ body.editing:has(.multicol) figure:has(.wide) {
|
|||||||
entirely on pages without sub-navigation, and excluded while editing,
|
entirely on pages without sub-navigation, and excluded while editing,
|
||||||
where the editing rules above apply instead. */
|
where the editing rules above apply instead. */
|
||||||
@media (max-width: 102rem) {
|
@media (max-width: 102rem) {
|
||||||
body:has(#sidebar):not(.editing) figure:has(.wide) {
|
body:has(#sidebar):not(.editing) figure:has(.wide),
|
||||||
|
body:has(#sidebar):not(.editing) pre.wide {
|
||||||
margin-inline: -13.25rem 0;
|
margin-inline: -13.25rem 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -902,9 +1097,9 @@ article h2 {
|
|||||||
|
|
||||||
/* Phones and other narrow viewports: single-column layout with the
|
/* Phones and other narrow viewports: single-column layout with the
|
||||||
sidebar lifted above the article as a wrapping link strip, and no
|
sidebar lifted above the article as a wrapping link strip, and no
|
||||||
floated figures — .left/.right fall back to plain centered figures
|
floated figures — .left/.right/.margin fall back to plain centered
|
||||||
(explicit img widths still shrink-wrap), while .wide keeps its full
|
figures (explicit img widths still shrink-wrap), while .wide keeps its
|
||||||
viewport bleed. */
|
full viewport bleed. */
|
||||||
@media (max-width: 48rem) {
|
@media (max-width: 48rem) {
|
||||||
|
|
||||||
/* Nav type shrinks fluidly as space runs out. The nav font-size is
|
/* Nav type shrinks fluidly as space runs out. The nav font-size is
|
||||||
@@ -931,6 +1126,17 @@ article h2 {
|
|||||||
gap: 0.15rem 0.9rem;
|
gap: 0.15rem 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The editor panel takes over the entire viewport: no space left for
|
||||||
|
the banner or the page content (main.js pins its top to 0 at these
|
||||||
|
widths). */
|
||||||
|
body.editing #content {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-host {
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
#content {
|
#content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -958,7 +1164,8 @@ article h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
figure:has(.right),
|
figure:has(.right),
|
||||||
figure:has(.left) {
|
figure:has(.left),
|
||||||
|
figure:has(.margin) {
|
||||||
float: none;
|
float: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
|||||||
@@ -51,10 +51,15 @@ function setEditingClass(enable) {
|
|||||||
// the page: its top is the banner's bottom edge while the banner is visible
|
// the page: its top is the banner's bottom edge while the banner is visible
|
||||||
// (= #content's top edge), and the viewport top once the banner has
|
// (= #content's top edge), and the viewport top once the banner has
|
||||||
// scrolled away. The window keeps scrolling normally while editing.
|
// scrolled away. The window keeps scrolling normally while editing.
|
||||||
|
// Below 48rem the panel covers the entire viewport (pagerite.css), so its
|
||||||
|
// top stays 0 regardless of the banner.
|
||||||
|
const narrow = matchMedia('(max-width: 48rem)')
|
||||||
function trackPanelTop() {
|
function trackPanelTop() {
|
||||||
const content = document.getElementById('content')
|
const content = document.getElementById('content')
|
||||||
if (host && content) {
|
if (host && content) {
|
||||||
host.style.top = `${Math.max(0, content.getBoundingClientRect().top)}px`
|
host.style.top = narrow.matches
|
||||||
|
? '0px'
|
||||||
|
: `${Math.max(0, content.getBoundingClientRect().top)}px`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-49
@@ -235,9 +235,26 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
btn.textContent = "copy";
|
btn.textContent = "copy";
|
||||||
btn.addEventListener("click", async () => {
|
btn.addEventListener("click", async () => {
|
||||||
const code = pre.querySelector("code");
|
const code = pre.querySelector("code");
|
||||||
await navigator.clipboard.writeText(
|
const text = (code || pre).textContent.replace(/\n$/, "");
|
||||||
(code || pre).textContent.replace(/\n$/, ""),
|
// navigator.clipboard exists only in secure contexts (https or
|
||||||
);
|
// localhost); viewing over plain http needs the textarea fallback.
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} else {
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.cssText = "position:fixed;opacity:0";
|
||||||
|
document.body.append(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
ta.remove();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
btn.textContent = "failed";
|
||||||
|
setTimeout(() => (btn.textContent = "copy"), 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
btn.textContent = "copied";
|
btn.textContent = "copied";
|
||||||
btn.classList.add("copied");
|
btn.classList.add("copied");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -269,53 +286,8 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
// created page has no pen for commitPending's handover click.
|
// created page has no pen for commitPending's handover click.
|
||||||
renderAuthUi();
|
renderAuthUi();
|
||||||
placeEditPen();
|
placeEditPen();
|
||||||
// Preview swaps also wipe the .colseg wrappers (the server render has
|
|
||||||
// none), which would drop the multi-column layout until a full reload;
|
|
||||||
// re-split so columns survive both live editing and closing the editor.
|
|
||||||
const main = document.getElementById("main");
|
|
||||||
if (main) applyMulticol(main);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Multi-column layout only when there is enough text to justify it.
|
|
||||||
// Split the body into columned segments: h1s, h2s and wide figures are
|
|
||||||
// full-width separators and never go inside columns.
|
|
||||||
function applyMulticol(main) {
|
|
||||||
const article = main.querySelector("article");
|
|
||||||
if (!article) return;
|
|
||||||
const body = article.querySelector(".body");
|
|
||||||
article.classList.toggle(
|
|
||||||
"multicol",
|
|
||||||
!!body && body.textContent.trim().length > 1800,
|
|
||||||
);
|
|
||||||
if (body && article.classList.contains("multicol")
|
|
||||||
&& !body.querySelector(".colseg")) {
|
|
||||||
// h1s, h2s and anything holding a wide image are full-width
|
|
||||||
// separators
|
|
||||||
const isSeparator = (el) =>
|
|
||||||
el.tagName === "H1" || el.tagName === "H2"
|
|
||||||
|| el.querySelector("img.wide") !== null;
|
|
||||||
let seg = null;
|
|
||||||
for (const el of [...body.children]) {
|
|
||||||
if (isSeparator(el)) {
|
|
||||||
seg = null;
|
|
||||||
body.append(el);
|
|
||||||
} else {
|
|
||||||
if (!seg) {
|
|
||||||
seg = document.createElement("div");
|
|
||||||
seg.className = "colseg";
|
|
||||||
body.append(seg);
|
|
||||||
}
|
|
||||||
seg.append(el);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Columns are per section: only segments with enough text get them,
|
|
||||||
// so a short ingress or a brief section stays single-column.
|
|
||||||
for (const s of body.querySelectorAll(".colseg")) {
|
|
||||||
s.classList.toggle("cols", s.textContent.trim().length > 600);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyEffects() {
|
function applyEffects() {
|
||||||
(window.requestIdleCallback || setTimeout)(preload);
|
(window.requestIdleCallback || setTimeout)(preload);
|
||||||
const main = document.getElementById("main");
|
const main = document.getElementById("main");
|
||||||
@@ -325,7 +297,6 @@ import "overlayscrollbars/overlayscrollbars.css";
|
|||||||
renderAuthUi();
|
renderAuthUi();
|
||||||
placeEditPen();
|
placeEditPen();
|
||||||
fitNav();
|
fitNav();
|
||||||
applyMulticol(main);
|
|
||||||
if (reduceMotion.matches) return;
|
if (reduceMotion.matches) return;
|
||||||
for (const el of main.querySelectorAll(
|
for (const el of main.querySelectorAll(
|
||||||
"h2, h3, figure, img, pre, blockquote, table, dl, .task-list-item",
|
"h2, h3, figure, img, pre, blockquote, table, dl, .task-list-item",
|
||||||
|
|||||||
+155
-116
@@ -12,8 +12,13 @@ away (``_is_bot_ua``) and their pings are ignored, so they land in the
|
|||||||
crawler list too. Idle-time link preloads from pagerite.js carry an
|
crawler list too. Idle-time link preloads from pagerite.js carry an
|
||||||
``x-pagerite-preload`` header and are not tracked at all — the ping sent
|
``x-pagerite-preload`` header and are not tracked at all — the ping sent
|
||||||
when the user actually navigates does the counting.
|
when the user actually navigates does the counting.
|
||||||
Admin clients ping with ``hide=1``, which records nothing and removes any
|
Admin clients ping with ``hide=1``: the client record is flagged ``hide``,
|
||||||
visit the session accumulated before logging in. Scanner telltale 404s
|
which covers everything that client ever did — visits and crawler hits
|
||||||
|
from before the login included. Aggregates (site visits, page views,
|
||||||
|
transitions) are not stored; they are computed at display time from the
|
||||||
|
visit records, excluding hidden clients, and hidden clients' visits,
|
||||||
|
crawler hits, abuse hits and metadata are left out of the viewer payload
|
||||||
|
entirely. Scanner telltale 404s
|
||||||
(dotpaths, *.php) classify the source IP as abuse; its hits — including
|
(dotpaths, *.php) classify the source IP as abuse; its hits — including
|
||||||
earlier crawler hits — are moved to the abuse list, which the viewer
|
earlier crawler hits — are moved to the abuse list, which the viewer
|
||||||
groups by IP with full request paths. Client metadata (IP, UA, language,
|
groups by IP with full request paths. Client metadata (IP, UA, language,
|
||||||
@@ -87,15 +92,47 @@ class Client(msgspec.Struct, omit_defaults=True):
|
|||||||
ua: str = ""
|
ua: str = ""
|
||||||
#: Compact display form of ``ua`` (browser/OS/device) when parsable.
|
#: Compact display form of ``ua`` (browser/OS/device) when parsable.
|
||||||
ua_pretty: str = ""
|
ua_pretty: str = ""
|
||||||
|
#: True for admin clients (hide=1 ping): their visits, crawler hits and
|
||||||
|
#: abuse hits are recorded but excluded from all statistics and from
|
||||||
|
#: the viewer payload.
|
||||||
|
hide: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class Nav(msgspec.Struct, omit_defaults=True):
|
||||||
|
"""One navigation inside a visit: from ``fr`` to ``to``.
|
||||||
|
|
||||||
|
``to`` is an internal page path or an external https exit URL. Every
|
||||||
|
navigation is logged (repeats included), keyed by its timestamp in
|
||||||
|
``Visit.navs``, so display-time aggregates can count views and
|
||||||
|
transitions; ``Visit.trail`` keeps the first-seen order.
|
||||||
|
"""
|
||||||
|
|
||||||
|
fr: str
|
||||||
|
to: str
|
||||||
|
|
||||||
|
|
||||||
|
class TrailItem(msgspec.Struct, omit_defaults=True):
|
||||||
|
"""One first-seen target in a visit trail: a page or external exit URL.
|
||||||
|
|
||||||
|
``read`` accumulates active reading time (seconds) across the whole
|
||||||
|
visit; ``status`` is the most recent HTTP status seen for the target.
|
||||||
|
"""
|
||||||
|
|
||||||
|
to: str
|
||||||
|
#: Accumulated active reading time in seconds.
|
||||||
|
read: int = 0
|
||||||
|
#: Most recent HTTP status of the response (200 or 404).
|
||||||
|
status: int = 200
|
||||||
|
|
||||||
|
|
||||||
class Visit(msgspec.Struct, omit_defaults=True):
|
class Visit(msgspec.Struct, omit_defaults=True):
|
||||||
"""One visit: the initial-load data plus everything seen afterwards.
|
"""One visit: the initial-load data plus everything seen afterwards.
|
||||||
|
|
||||||
``trail`` holds page paths and external exit URLs in first-seen
|
``trail`` holds the entry page and everything seen afterwards, keyed by
|
||||||
order; re-visiting an already seen page does not append. The entry
|
the timestamp of first sight (insertion order = first-seen order);
|
||||||
page itself is in ``entry``, not in the trail. Client metadata is
|
re-visiting an already seen target updates its item instead of
|
||||||
held in ``Analytics.clients`` keyed by ``client``.
|
appending. Client metadata is held in ``Analytics.clients`` keyed by
|
||||||
|
``client``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
start: datetime
|
start: datetime
|
||||||
@@ -104,13 +141,13 @@ class Visit(msgspec.Struct, omit_defaults=True):
|
|||||||
referer: str = ""
|
referer: str = ""
|
||||||
#: 6-byte blake3 hash referencing ``Analytics.clients``.
|
#: 6-byte blake3 hash referencing ``Analytics.clients``.
|
||||||
client: bytes = b""
|
client: bytes = b""
|
||||||
trail: list[str] = []
|
#: First-seen targets keyed by their timestamp (entry included).
|
||||||
|
trail: dict[datetime, TrailItem] = {}
|
||||||
|
#: Every navigation ping (repeats included) keyed by its timestamp; the
|
||||||
|
#: aggregates are computed from this log at display time.
|
||||||
|
navs: dict[datetime, Nav] = {}
|
||||||
#: UTM query parameters from the landing URL, keyed by parameter name.
|
#: UTM query parameters from the landing URL, keyed by parameter name.
|
||||||
utm: dict[str, str] = {}
|
utm: dict[str, str] = {}
|
||||||
#: Active reading time per path (seconds), keyed by path.
|
|
||||||
read: dict[str, int] = {}
|
|
||||||
#: HTTP status of the response when the path was first seen (200 or 404).
|
|
||||||
statuses: dict[str, int] = {}
|
|
||||||
|
|
||||||
|
|
||||||
class CrawlerHit(msgspec.Struct, omit_defaults=True):
|
class CrawlerHit(msgspec.Struct, omit_defaults=True):
|
||||||
@@ -166,6 +203,22 @@ class Analytics(msgspec.Struct, omit_defaults=True):
|
|||||||
clients: dict[bytes, Client] = {}
|
clients: dict[bytes, Client] = {}
|
||||||
#: IPs classified as scanners/abusers (keys; values always True).
|
#: IPs classified as scanners/abusers (keys; values always True).
|
||||||
abuse_ips: dict[str, bool] = {}
|
abuse_ips: dict[str, bool] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class Display(msgspec.Struct, omit_defaults=True):
|
||||||
|
"""The viewer payload: visible data plus display-time aggregates.
|
||||||
|
|
||||||
|
Hidden clients are excluded everywhere: their visits, crawler hits,
|
||||||
|
abuse hits and metadata are dropped, and the aggregates are computed
|
||||||
|
from the visible visits only.
|
||||||
|
The aggregate shapes match what the viewer consumes: sparse 5-minute
|
||||||
|
buckets keyed by their floored ISO timestamp.
|
||||||
|
"""
|
||||||
|
|
||||||
|
visits: list[Visit] = []
|
||||||
|
crawlers: list[CrawlerHit] = []
|
||||||
|
abuse: list[AbuseHit] = []
|
||||||
|
clients: dict[bytes, Client] = {}
|
||||||
#: Page transitions per 5-minute bucket (sparse):
|
#: Page transitions per 5-minute bucket (sparse):
|
||||||
#: from -> to -> bucket ISO -> count. ``from`` is the referer origin or
|
#: from -> to -> bucket ISO -> count. ``from`` is the referer origin or
|
||||||
#: "(direct)" for initial loads, a page path for pings.
|
#: "(direct)" for initial loads, a page path for pings.
|
||||||
@@ -316,10 +369,6 @@ class Store:
|
|||||||
pass # legacy schema / corrupt or unreadable file: start fresh
|
pass # legacy schema / corrupt or unreadable file: start fresh
|
||||||
#: client hash -> index of the current visit in data.visits
|
#: client hash -> index of the current visit in data.visits
|
||||||
self.sessions: dict[bytes, int] = {}
|
self.sessions: dict[bytes, int] = {}
|
||||||
#: visit index -> count events recorded for that visit, so
|
|
||||||
#: ``_remove_visit`` can reverse all of them — not just the ones
|
|
||||||
#: from the visit's creation. In-memory only, like ``sessions``.
|
|
||||||
self._count_log: dict[int, list[tuple]] = {}
|
|
||||||
#: ip -> external https origin of the latest document GET carrying
|
#: ip -> external https origin of the latest document GET carrying
|
||||||
#: one, stashed for the visit the client's initial ping starts.
|
#: one, stashed for the visit the client's initial ping starts.
|
||||||
#: Internal or absent referers never touch the table.
|
#: Internal or absent referers never touch the table.
|
||||||
@@ -373,6 +422,9 @@ class Store:
|
|||||||
def _flush_crawlers(self, now: datetime | None = None) -> list[bytes]:
|
def _flush_crawlers(self, now: datetime | None = None) -> list[bytes]:
|
||||||
"""Move expired pending crawler hits into persistent ``data.crawlers``.
|
"""Move expired pending crawler hits into persistent ``data.crawlers``.
|
||||||
|
|
||||||
|
Hits from a hidden client (admin) are discarded instead of
|
||||||
|
persisted — admin browsing must not land in the crawler list.
|
||||||
|
|
||||||
Returns the client hashes of the newly flushed hits so callers can
|
Returns the client hashes of the newly flushed hits so callers can
|
||||||
schedule async enrichment.
|
schedule async enrichment.
|
||||||
"""
|
"""
|
||||||
@@ -383,68 +435,63 @@ class Store:
|
|||||||
expired: list[CrawlerHit] = []
|
expired: list[CrawlerHit] = []
|
||||||
remaining: list[CrawlerHit] = []
|
remaining: list[CrawlerHit] = []
|
||||||
for hit in self.pending_crawlers:
|
for hit in self.pending_crawlers:
|
||||||
(expired if hit.start <= cutoff else remaining).append(hit)
|
if hit.start > cutoff:
|
||||||
|
remaining.append(hit)
|
||||||
|
continue
|
||||||
|
client = self.data.clients.get(hit.client)
|
||||||
|
if client is not None and client.hide:
|
||||||
|
continue # hidden admin client: not a crawler
|
||||||
|
expired.append(hit)
|
||||||
if not expired:
|
if not expired:
|
||||||
|
self.pending_crawlers = remaining
|
||||||
return []
|
return []
|
||||||
self.pending_crawlers = remaining
|
self.pending_crawlers = remaining
|
||||||
self.data.crawlers.extend(expired)
|
self.data.crawlers.extend(expired)
|
||||||
self._save()
|
self._save()
|
||||||
return [hit.client for hit in expired]
|
return [hit.client for hit in expired]
|
||||||
|
|
||||||
def _count(self, table: dict[str, int], key: str) -> None:
|
def _hidden(self, client_hash: bytes) -> bool:
|
||||||
table[key] = table.get(key, 0) + 1
|
"""True when the client record is flagged hidden (admin)."""
|
||||||
|
client = self.data.clients.get(client_hash)
|
||||||
|
return client is not None and client.hide
|
||||||
|
|
||||||
def _count_transition(self, fr: str, to: str, now: datetime) -> None:
|
def display(self) -> Display:
|
||||||
"""Count one transition in its 5-minute bucket (sparse matrix)."""
|
"""Build the viewer payload, excluding hidden clients.
|
||||||
buckets = self.data.transitions.setdefault(fr, {}).setdefault(to, {})
|
|
||||||
self._count(buckets, _bucket(now))
|
|
||||||
|
|
||||||
def _uncount(self, table: dict[str, int], key: str) -> None:
|
The aggregates (site visits, page views, transitions) are computed
|
||||||
"""Reverse one ``_count``: decrement and drop empty keys."""
|
here from the visit records rather than stored, so a client that
|
||||||
if key in table:
|
becomes hidden after navigations were already logged disappears
|
||||||
table[key] -= 1
|
from every statistic. Internal-path navigations count as page
|
||||||
if table[key] <= 0:
|
views; external https targets are transitions only.
|
||||||
del table[key]
|
|
||||||
|
|
||||||
def _remove_visit(self, index: int) -> None:
|
|
||||||
"""Delete a visit and reverse every count it recorded.
|
|
||||||
|
|
||||||
Used when a known visitor turns out to be an admin (hide=1 ping):
|
|
||||||
the session is scrubbed from the stats. The in-memory
|
|
||||||
``_count_log`` tracks each site-visit/view/transition count the
|
|
||||||
visit produced, so the scrub reverses all of them — including the
|
|
||||||
ones logged by later pings inside the visit.
|
|
||||||
"""
|
"""
|
||||||
for event in self._count_log.pop(index, ()):
|
visits = [v for v in self.data.visits if not self._hidden(v.client)]
|
||||||
kind = event[0]
|
display = Display(
|
||||||
if kind == "site":
|
visits=visits,
|
||||||
self._uncount(self.data.site_visits, event[1])
|
crawlers=[h for h in self.data.crawlers if not self._hidden(h.client)],
|
||||||
elif kind == "view":
|
abuse=[h for h in self.data.abuse if not self._hidden(h.client)],
|
||||||
views = self.data.views.get(event[1])
|
clients={h: c for h, c in self.data.clients.items() if not c.hide},
|
||||||
if views is not None:
|
)
|
||||||
self._uncount(views, event[2])
|
for visit in visits:
|
||||||
if not views:
|
bucket = _bucket(visit.start)
|
||||||
del self.data.views[event[1]]
|
site = display.site_visits
|
||||||
else: # transition
|
site[bucket] = site.get(bucket, 0) + 1
|
||||||
_, fr, to, bucket = event
|
entry_views = display.views.setdefault(visit.entry, {})
|
||||||
fr_map = self.data.transitions.get(fr)
|
entry_views[bucket] = entry_views.get(bucket, 0) + 1
|
||||||
if fr_map is not None:
|
fr = visit.referer or "(direct)"
|
||||||
buckets = fr_map.get(to)
|
buckets = display.transitions.setdefault(fr, {}).setdefault(visit.entry, {})
|
||||||
if buckets is not None:
|
buckets[bucket] = buckets.get(bucket, 0) + 1
|
||||||
self._uncount(buckets, bucket)
|
for t, nav in visit.navs.items():
|
||||||
if not buckets:
|
nb = _bucket(t)
|
||||||
del fr_map[to]
|
if nav.to.startswith("/"):
|
||||||
if not fr_map:
|
nav_views = display.views.setdefault(nav.to, {})
|
||||||
del self.data.transitions[fr]
|
nav_views[nb] = nav_views.get(nb, 0) + 1
|
||||||
del self.data.visits[index]
|
nbuckets = display.transitions.setdefault(nav.fr, {}).setdefault(nav.to, {})
|
||||||
# Sessions and count logs store list indices; shift the ones past
|
nbuckets[nb] = nbuckets.get(nb, 0) + 1
|
||||||
# the removed visit.
|
return display
|
||||||
for key, i in list(self.sessions.items()):
|
|
||||||
if i > index:
|
def display_json(self) -> str:
|
||||||
self.sessions[key] = i - 1
|
"""The ``display()`` payload as a JSON string for the WebSocket."""
|
||||||
self._count_log = {
|
return msgspec.json.encode(self.display()).decode()
|
||||||
i - 1 if i > index else i: log for i, log in self._count_log.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
def _client_ip(self, client_hash: bytes) -> str:
|
def _client_ip(self, client_hash: bytes) -> str:
|
||||||
"""Return the IP stored for ``client_hash``, or "" if missing."""
|
"""Return the IP stored for ``client_hash``, or "" if missing."""
|
||||||
@@ -601,20 +648,9 @@ class Store:
|
|||||||
client=client_hash,
|
client=client_hash,
|
||||||
utm=utm or {},
|
utm=utm or {},
|
||||||
)
|
)
|
||||||
visit.statuses[entry] = status
|
visit.trail[now] = TrailItem(to=entry, status=status)
|
||||||
self.data.visits.append(visit)
|
self.data.visits.append(visit)
|
||||||
index = len(self.data.visits) - 1
|
self.sessions[client_hash] = len(self.data.visits) - 1
|
||||||
self.sessions[client_hash] = index
|
|
||||||
bucket = _bucket(now)
|
|
||||||
fr = referer or "(direct)"
|
|
||||||
self._count(self.data.site_visits, bucket)
|
|
||||||
self._count(self.data.views.setdefault(entry, {}), bucket)
|
|
||||||
self._count_transition(fr, entry, now)
|
|
||||||
self._count_log[index] = [
|
|
||||||
("site", bucket),
|
|
||||||
("view", entry, bucket),
|
|
||||||
("transition", fr, entry, bucket),
|
|
||||||
]
|
|
||||||
return visit
|
return visit
|
||||||
|
|
||||||
def track_entry(
|
def track_entry(
|
||||||
@@ -688,7 +724,10 @@ class Store:
|
|||||||
if index is None or index >= len(self.data.visits):
|
if index is None or index >= len(self.data.visits):
|
||||||
return
|
return
|
||||||
visit = self.data.visits[index]
|
visit = self.data.visits[index]
|
||||||
visit.read[path] = visit.read.get(path, 0) + seconds
|
for item in visit.trail.values():
|
||||||
|
if item.to == path:
|
||||||
|
item.read += seconds
|
||||||
|
return
|
||||||
|
|
||||||
def ping(
|
def ping(
|
||||||
self,
|
self,
|
||||||
@@ -711,10 +750,11 @@ class Store:
|
|||||||
A ping with no known session starts a fresh visit, consuming the
|
A ping with no known session starts a fresh visit, consuming the
|
||||||
referer and UTM tags stashed by the document GET if there are any.
|
referer and UTM tags stashed by the document GET if there are any.
|
||||||
|
|
||||||
``hide`` is set by admin clients: the ping cancels pending crawler
|
``hide`` is set by admin clients: the client record is flagged
|
||||||
hits as usual, and any existing visit for this client session is
|
``hide`` — which covers everything it ever did, including visits and
|
||||||
removed from the stats (the admin browsed anonymously before logging
|
crawler hits from before the login — and the navigation is recorded
|
||||||
in). Nothing new is recorded.
|
normally. Hidden clients are excluded from every statistic and list
|
||||||
|
at display time, and their pending crawler hits are discarded.
|
||||||
|
|
||||||
Pings from IPs classified as abuse, and pings whose User-Agent
|
Pings from IPs classified as abuse, and pings whose User-Agent
|
||||||
claims a JS-running crawler identity (``_is_bot_ua``), are ignored
|
claims a JS-running crawler identity (``_is_bot_ua``), are ignored
|
||||||
@@ -727,34 +767,35 @@ class Store:
|
|||||||
"""
|
"""
|
||||||
flushed = self._flush_crawlers()
|
flushed = self._flush_crawlers()
|
||||||
lang, country = _parse_accept_language(accept_language)
|
lang, country = _parse_accept_language(accept_language)
|
||||||
client_hash = _client_hash(ip, ua, lang)
|
|
||||||
if hide:
|
if hide:
|
||||||
# Admin ping: cancel pending crawler hits and scrub the session.
|
# Admin ping: flag the client hidden and never a crawler hit.
|
||||||
|
# The flag lives on the client record, so it covers visits and
|
||||||
|
# crawler hits from before the login too; display-time
|
||||||
|
# aggregation excludes hidden clients from every statistic.
|
||||||
|
client_hash = self._ensure_client(ip, ua, lang, country=country)
|
||||||
|
self.data.clients[client_hash].hide = True
|
||||||
|
self.pending_crawlers = [
|
||||||
|
hit for hit in self.pending_crawlers if hit.client != client_hash
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
client_hash = _client_hash(ip, ua, lang)
|
||||||
|
if ip in self.data.abuse_ips:
|
||||||
|
return None, flushed
|
||||||
|
if _is_bot_ua(ua):
|
||||||
|
# A JS-running crawler (Googlebot, GoogleOther, Applebot
|
||||||
|
# execute JS and ping): never a visit. Its pending crawler
|
||||||
|
# hits are kept and flush to ``data.crawlers`` normally.
|
||||||
|
return None, flushed
|
||||||
|
# A real visitor ping cancels any pending crawler hits from
|
||||||
|
# this client.
|
||||||
self.pending_crawlers = [
|
self.pending_crawlers = [
|
||||||
hit for hit in self.pending_crawlers if hit.client != client_hash
|
hit for hit in self.pending_crawlers if hit.client != client_hash
|
||||||
]
|
]
|
||||||
self.pending_statuses.pop(client_hash, None)
|
|
||||||
index = self.sessions.pop(client_hash, None)
|
|
||||||
if index is not None and index < len(self.data.visits):
|
|
||||||
self._remove_visit(index)
|
|
||||||
self._save()
|
|
||||||
return None, flushed
|
|
||||||
if ip in self.data.abuse_ips:
|
|
||||||
return None, flushed
|
|
||||||
if _is_bot_ua(ua):
|
|
||||||
# A JS-running crawler (Googlebot, GoogleOther, Applebot execute
|
|
||||||
# JS and ping): never a visit. Its pending crawler hits are
|
|
||||||
# kept and flush to ``data.crawlers`` normally.
|
|
||||||
return None, flushed
|
|
||||||
# A real visitor ping cancels any pending crawler hits from this client.
|
|
||||||
self.pending_crawlers = [
|
|
||||||
hit for hit in self.pending_crawlers if hit.client != client_hash
|
|
||||||
]
|
|
||||||
fr_path = _internal_path(from_) if from_ else ""
|
fr_path = _internal_path(from_) if from_ else ""
|
||||||
if fr_path and read > 0:
|
if fr_path and read > 0:
|
||||||
self._add_read(client_hash, fr_path, read)
|
self._add_read(client_hash, fr_path, read)
|
||||||
if not to:
|
if not to:
|
||||||
if read > 0:
|
if read > 0 or hide:
|
||||||
self._save()
|
self._save()
|
||||||
return None, flushed
|
return None, flushed
|
||||||
if to.startswith("/") and not to.startswith("//"):
|
if to.startswith("/") and not to.startswith("//"):
|
||||||
@@ -784,17 +825,15 @@ class Store:
|
|||||||
else:
|
else:
|
||||||
visit = self.data.visits[index]
|
visit = self.data.visits[index]
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
bucket = _bucket(now)
|
visit.navs[now] = Nav(fr=fr, to=target)
|
||||||
log = self._count_log.setdefault(index, [])
|
# First-seen only: repeat pages and repeated exits update the
|
||||||
if target.startswith("/"):
|
# existing trail item (most recent status) instead of appending.
|
||||||
self._count(self.data.views.setdefault(target, {}), bucket)
|
for item in visit.trail.values():
|
||||||
log.append(("view", target, bucket))
|
if item.to == target:
|
||||||
self._count_transition(fr, target, now)
|
item.status = target_status
|
||||||
log.append(("transition", fr, target, bucket))
|
break
|
||||||
# First-seen only: repeat pages and repeated exits don't append.
|
else:
|
||||||
if visit.entry != target and target not in visit.trail:
|
visit.trail[now] = TrailItem(to=target, status=target_status)
|
||||||
visit.trail.append(target)
|
|
||||||
visit.statuses[target] = target_status
|
|
||||||
self._save()
|
self._save()
|
||||||
visit_index = index if index is not None and index < len(self.data.visits) else None
|
visit_index = index if index is not None and index < len(self.data.visits) else None
|
||||||
return visit_index, flushed
|
return visit_index, flushed
|
||||||
|
|||||||
+15
-9
@@ -782,7 +782,7 @@ async def _broadcast_analytics() -> None:
|
|||||||
"""Send the current analytics snapshot to every connected WS client."""
|
"""Send the current analytics snapshot to every connected WS client."""
|
||||||
if not _analytics_ws_clients:
|
if not _analytics_ws_clients:
|
||||||
return
|
return
|
||||||
payload = msgspec.json.encode(analytics_store.data).decode()
|
payload = analytics_store.display_json()
|
||||||
closed = set()
|
closed = set()
|
||||||
for ws in _analytics_ws_clients:
|
for ws in _analytics_ws_clients:
|
||||||
try:
|
try:
|
||||||
@@ -865,7 +865,8 @@ def _track_entry(path: str, request: Request, *, status: int = 200) -> list[byte
|
|||||||
Nothing is counted on the GET itself — the client's /_a ping starts the
|
Nothing is counted on the GET itself — the client's /_a ping starts the
|
||||||
visit, so bots never register as visits (JS-running crawlers ping too,
|
visit, so bots never register as visits (JS-running crawlers ping too,
|
||||||
but the ping handler ignores known bot UAs). (Admin clients ping too,
|
but the ping handler ignores known bot UAs). (Admin clients ping too,
|
||||||
but with hide=1, which scrubs their session instead of recording it.)
|
but with hide=1, which flags their visit hidden: it is recorded but
|
||||||
|
excluded from all statistics and from the crawler list.)
|
||||||
|
|
||||||
The devserver's health probe (``GET /?from=devserver.py`` from
|
The devserver's health probe (``GET /?from=devserver.py`` from
|
||||||
``127.0.0.1``) is ignored: it is not real traffic and would otherwise be
|
``127.0.0.1``) is ignored: it is not real traffic and would otherwise be
|
||||||
@@ -941,7 +942,7 @@ async def analytics_websocket(ws: WebSocket) -> None:
|
|||||||
endpoint. Powers the analytics viewer rendered at /_a.
|
endpoint. Powers the analytics viewer rendered at /_a.
|
||||||
"""
|
"""
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
await ws.send_text(msgspec.json.encode(analytics_store.data).decode())
|
await ws.send_text(analytics_store.display_json())
|
||||||
_analytics_ws_clients.add(ws)
|
_analytics_ws_clients.add(ws)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -1014,15 +1015,20 @@ async def editor_ws(ws: WebSocket) -> None:
|
|||||||
markdown = msg.get("markdown", "")
|
markdown = msg.get("markdown", "")
|
||||||
chain = resolve(data.menu, path)
|
chain = resolve(data.menu, path)
|
||||||
node = chain[-1] if chain else None
|
node = chain[-1] if chain else None
|
||||||
|
rendered = render(
|
||||||
|
markdown,
|
||||||
|
path,
|
||||||
|
node.created if node else None,
|
||||||
|
node.modified if node else None,
|
||||||
|
)
|
||||||
await ws.send_json({
|
await ws.send_json({
|
||||||
"type": "html",
|
"type": "html",
|
||||||
"path": path,
|
"path": path,
|
||||||
"html": render(
|
"html": rendered.html,
|
||||||
markdown,
|
# Column-layout flags: the preview toggles the
|
||||||
path,
|
# article's .multicol class and swaps in the
|
||||||
node.created if node else None,
|
# segmented (.colseg/.cols) body html.
|
||||||
node.modified if node else None,
|
"multicol": rendered.multicol,
|
||||||
),
|
|
||||||
"has_h1": has_h1(markdown),
|
"has_h1": has_h1(markdown),
|
||||||
})
|
})
|
||||||
case "save":
|
case "save":
|
||||||
|
|||||||
+285
-6
@@ -4,12 +4,36 @@ Raw HTML (including inline scripts) is passed through unfiltered: the
|
|||||||
single author is trusted. Extensions: tables and strikethrough (from the
|
single author is trusted. Extensions: tables and strikethrough (from the
|
||||||
"default" preset), footnotes, definition lists, task lists,
|
"default" preset), footnotes, definition lists, task lists,
|
||||||
brace-attributes (`{.class width=300}` on any element, images in
|
brace-attributes (`{.class width=300}` on any element, images in
|
||||||
particular) and admonitions (``!!! note Title`` with an indented body —
|
particular), admonitions (``!!! note Title`` with an indented body —
|
||||||
note/tip/warning/etc., the title optional). Bare URLs autolink (GFM), with
|
note/tip/warning/etc., the title optional) and GitHub-style alerts
|
||||||
|
(``> [!NOTE]`` / TIP / IMPORTANT / WARNING / CAUTION, rendered in the
|
||||||
|
same callout styling). ``::: name`` opens a generic container rendered
|
||||||
|
as ``<div class="name">`` and closed by a matching ``:::`` (nest by
|
||||||
|
giving the outer container more colons, e.g. `::::`); the name may be
|
||||||
|
followed by brace attributes (``::: aside {.right}``). ``::: aside``
|
||||||
|
floats as a muted side box, dropping into the left margin on wide
|
||||||
|
viewports — the same margin breakout ``{.margin}`` (or ``::: margin``)
|
||||||
|
gives any block — and ``::: nocols`` opts its section out of the column
|
||||||
|
layout. A brace-attribute
|
||||||
|
line as a block's last line (no blank line between) applies to the whole
|
||||||
|
block, e.g. a paragraph ending with ``{.wide}`` breaks out of the column
|
||||||
|
layout as a full-width element; written after a block (code fence,
|
||||||
|
heading, container, ...) it applies to that preceding block. Bare URLs autolink (GFM), with
|
||||||
the ``https://`` scheme hidden in the link text (``http://`` and other
|
the ``https://`` scheme hidden in the link text (``http://`` and other
|
||||||
schemes stay visible; manually labelled links are untouched), and
|
schemes stay visible; manually labelled links are untouched), and
|
||||||
``H~2~O`` / ``x^2^`` give sub/superscripts.
|
``H~2~O`` / ``x^2^`` give sub/superscripts.
|
||||||
|
|
||||||
|
render() also builds the layout structure: the top-level blocks are
|
||||||
|
segmented for the column layout — h1/h2 headings, ``.wide`` blocks and
|
||||||
|
margin-breakout blocks (``.margin``, ``::: aside``) stand on their own,
|
||||||
|
the runs between them are wrapped in ``<div class="colseg">`` (tagged
|
||||||
|
``.cols`` when the segment holds enough text, unless a ``::: nocols``
|
||||||
|
container opts it out). The result carries ``multicol`` when the whole
|
||||||
|
body justifies columns (views.py puts the class on the article); how
|
||||||
|
many columns (never more than two), whether the margin breakout applies
|
||||||
|
and every other viewport adaptation is then pagerite.css's call. The
|
||||||
|
thresholds measure visible text, code blocks excluded.
|
||||||
|
|
||||||
markdown-it's typographer is enabled, so body text gets SmartyPants-style
|
markdown-it's typographer is enabled, so body text gets SmartyPants-style
|
||||||
replacements: straight quotes become curly, ``--`` / ``---`` become en / em
|
replacements: straight quotes become curly, ``--`` / ``---`` become en / em
|
||||||
dashes, ``...`` becomes an ellipsis, ``(c)`` becomes ©, and so on. Single
|
dashes, ``...`` becomes an ellipsis, ``(c)`` becomes ©, and so on. Single
|
||||||
@@ -27,12 +51,15 @@ classes, e.g. `{.right}`.
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
from markdown_it import MarkdownIt
|
from markdown_it import MarkdownIt
|
||||||
from markdown_it.common.utils import escapeHtml
|
from markdown_it.common.utils import escapeHtml
|
||||||
from markdown_it.renderer import RendererHTML
|
from markdown_it.renderer import RendererHTML
|
||||||
from mdit_py_plugins.admon import admon_plugin
|
from mdit_py_plugins.admon import admon_plugin
|
||||||
from mdit_py_plugins.attrs import attrs_plugin
|
from mdit_py_plugins.attrs import attrs_plugin
|
||||||
|
from mdit_py_plugins.attrs.parse import ParseError, parse as parse_attrs
|
||||||
|
from mdit_py_plugins.container import container_plugin
|
||||||
from mdit_py_plugins.deflist import deflist_plugin
|
from mdit_py_plugins.deflist import deflist_plugin
|
||||||
from mdit_py_plugins.footnote import footnote_plugin
|
from mdit_py_plugins.footnote import footnote_plugin
|
||||||
from mdit_py_plugins.gfm_autolink import gfm_autolink_plugin
|
from mdit_py_plugins.gfm_autolink import gfm_autolink_plugin
|
||||||
@@ -65,6 +92,31 @@ def _highlight(text: str, lang: str, _attrs: str) -> str:
|
|||||||
return highlight(text, lexer, _formatter)
|
return highlight(text, lexer, _formatter)
|
||||||
|
|
||||||
|
|
||||||
|
def _fence_rule(
|
||||||
|
self: RendererHTML,
|
||||||
|
tokens,
|
||||||
|
idx: int,
|
||||||
|
options,
|
||||||
|
env: dict,
|
||||||
|
) -> str:
|
||||||
|
"""Render a fenced code block.
|
||||||
|
|
||||||
|
Like the default fence rule, but block attributes (a trailing `{...}`
|
||||||
|
line, applied to the fence token by _block_attrs) go on the <pre> — the
|
||||||
|
block element — instead of the <code>, which keeps only the language
|
||||||
|
class. This is what makes e.g. `{.wide}` or `{style="..."}` after a
|
||||||
|
code fence style the block itself.
|
||||||
|
"""
|
||||||
|
token = tokens[idx]
|
||||||
|
info = token.info.strip() if token.info else ""
|
||||||
|
lang = info.split(maxsplit=1)[0] if info else ""
|
||||||
|
highlighted = (_highlight(token.content, lang, "")
|
||||||
|
or escapeHtml(token.content))
|
||||||
|
code_class = f' class="{options.langPrefix}{lang}"' if lang else ""
|
||||||
|
return (f"<pre{self.renderAttrs(token)}><code{code_class}>"
|
||||||
|
f"{highlighted}</code></pre>\n")
|
||||||
|
|
||||||
|
|
||||||
def _image_rule(
|
def _image_rule(
|
||||||
self: RendererHTML,
|
self: RendererHTML,
|
||||||
tokens,
|
tokens,
|
||||||
@@ -107,6 +159,10 @@ def _unwrap_lone_figures(state) -> None:
|
|||||||
if child and child.type == "image":
|
if child and child.type == "image":
|
||||||
if (tokens[i - 1].type == "paragraph_open"
|
if (tokens[i - 1].type == "paragraph_open"
|
||||||
and tokens[i + 1].type == "paragraph_close"):
|
and tokens[i + 1].type == "paragraph_close"):
|
||||||
|
# A lone image becomes a <figure> (see _image_rule); block
|
||||||
|
# attrs on the paragraph (e.g. a trailing {.wide} line) move
|
||||||
|
# onto the image so they survive the unwrap.
|
||||||
|
_apply_attrs(child, tokens[i - 1].attrs or {})
|
||||||
tokens[i - 1].hidden = True
|
tokens[i - 1].hidden = True
|
||||||
tokens[i + 1].hidden = True
|
tokens[i + 1].hidden = True
|
||||||
|
|
||||||
@@ -149,6 +205,111 @@ def _shorten_autolinks(state) -> None:
|
|||||||
text.content = text.content.removeprefix("https://")
|
text.content = text.content.removeprefix("https://")
|
||||||
|
|
||||||
|
|
||||||
|
_CONTAINER_NAME_RE = re.compile(r"[a-zA-Z][\w-]*")
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_attrs(token, attrs: dict) -> None:
|
||||||
|
"""Join/set parsed brace attributes (`{.class key=value}`) on a token."""
|
||||||
|
for key, value in attrs.items():
|
||||||
|
if key == "class":
|
||||||
|
token.attrJoin("class", value)
|
||||||
|
else:
|
||||||
|
token.attrSet(key, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _container_validate(params: str, _markup: str) -> bool:
|
||||||
|
"""`::: name`, optionally followed by brace attrs (`::: aside {.right}`)."""
|
||||||
|
name, _, rest = params.strip().partition(" ")
|
||||||
|
if not _CONTAINER_NAME_RE.fullmatch(name):
|
||||||
|
return False
|
||||||
|
rest = rest.strip()
|
||||||
|
if not rest:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
pos, _ = parse_attrs(rest)
|
||||||
|
except ParseError:
|
||||||
|
return False
|
||||||
|
# parse() stops at (returns the index of) the closing brace.
|
||||||
|
return pos == len(rest) - 1
|
||||||
|
|
||||||
|
|
||||||
|
def _container_attrs(state) -> None:
|
||||||
|
"""Apply `::: name {attrs}` classes to container tokens at parse time.
|
||||||
|
|
||||||
|
The container plugin's default render is a plain renderToken, so the
|
||||||
|
name and brace attributes must live on the token itself — and being a
|
||||||
|
core rule (rather than a render rule) lets the segmentation in
|
||||||
|
render() see the classes (::: aside's margin breakout, the ::: nocols
|
||||||
|
opt-out, {.wide} containers).
|
||||||
|
"""
|
||||||
|
for token in state.tokens:
|
||||||
|
if token.type != "container_block_open":
|
||||||
|
continue
|
||||||
|
name, _, rest = token.info.strip().partition(" ")
|
||||||
|
token.attrJoin("class", name)
|
||||||
|
if rest.strip():
|
||||||
|
_, attrs = parse_attrs(rest.strip())
|
||||||
|
_apply_attrs(token, attrs)
|
||||||
|
|
||||||
|
|
||||||
|
def _block_attrs(state) -> None:
|
||||||
|
"""Apply `{.class key=value}` on a block's last line to the block.
|
||||||
|
|
||||||
|
The inline attrs plugin only covers attributes right after an image,
|
||||||
|
code span or link; this extends the same brace syntax to whole blocks,
|
||||||
|
e.g. a paragraph ending with a `{.wide}` line (no blank line between)
|
||||||
|
gets the `wide` class and thereby breaks out of the column layout.
|
||||||
|
A lone `{...}` paragraph applies to the previous block instead (this
|
||||||
|
is how headings take attributes, since a heading's next line always
|
||||||
|
starts a new paragraph). Runs before the typographer so quotes inside
|
||||||
|
attributes stay straight.
|
||||||
|
"""
|
||||||
|
tokens = state.tokens
|
||||||
|
for i, token in enumerate(tokens):
|
||||||
|
if token.type != "inline" or not token.children:
|
||||||
|
continue
|
||||||
|
text = token.children[-1]
|
||||||
|
if (text.type != "text" or not text.content.startswith("{")
|
||||||
|
or not text.content.endswith("}")):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_, attrs = parse_attrs(text.content.strip())
|
||||||
|
except ParseError:
|
||||||
|
continue
|
||||||
|
standalone = len(token.children) == 1
|
||||||
|
if not standalone and token.children[-2].type != "softbreak":
|
||||||
|
continue
|
||||||
|
# The target: the enclosing block for a trailing attrs line, or the
|
||||||
|
# previous same-level block for a standalone attrs paragraph —
|
||||||
|
# including self-contained blocks like code fences and <hr>. Never
|
||||||
|
# a hidden token (tight-list paragraphs render no tag to hold the
|
||||||
|
# attributes) — in that case leave the text untouched instead of
|
||||||
|
# silently swallowing it.
|
||||||
|
own = i - 1 # standalone: the attrs paragraph's own opening token
|
||||||
|
j = i - 1
|
||||||
|
while j >= 0:
|
||||||
|
target = tokens[j]
|
||||||
|
if target.hidden:
|
||||||
|
pass
|
||||||
|
elif standalone:
|
||||||
|
if (j != own and target.level == tokens[own].level
|
||||||
|
and (target.nesting == 1
|
||||||
|
or target.type in ("fence", "code_block", "hr"))):
|
||||||
|
break
|
||||||
|
elif target.nesting == 1:
|
||||||
|
break
|
||||||
|
j -= 1
|
||||||
|
if j < 0:
|
||||||
|
continue
|
||||||
|
_apply_attrs(tokens[j], attrs)
|
||||||
|
if standalone:
|
||||||
|
tokens[own].hidden = True
|
||||||
|
token.children = []
|
||||||
|
tokens[i + 1].hidden = True
|
||||||
|
else:
|
||||||
|
del token.children[-2:]
|
||||||
|
|
||||||
|
|
||||||
md = (
|
md = (
|
||||||
MarkdownIt(
|
MarkdownIt(
|
||||||
"default",
|
"default",
|
||||||
@@ -161,6 +322,7 @@ md = (
|
|||||||
)
|
)
|
||||||
.use(attrs_plugin)
|
.use(attrs_plugin)
|
||||||
.use(admon_plugin)
|
.use(admon_plugin)
|
||||||
|
.use(container_plugin, "block", validate=_container_validate)
|
||||||
.use(footnote_plugin)
|
.use(footnote_plugin)
|
||||||
.use(deflist_plugin)
|
.use(deflist_plugin)
|
||||||
.use(tasklists_plugin, enabled=True)
|
.use(tasklists_plugin, enabled=True)
|
||||||
@@ -169,28 +331,145 @@ md = (
|
|||||||
.use(superscript_plugin)
|
.use(superscript_plugin)
|
||||||
)
|
)
|
||||||
md.add_render_rule("image", _image_rule)
|
md.add_render_rule("image", _image_rule)
|
||||||
|
md.add_render_rule("fence", _fence_rule)
|
||||||
|
# GFM alerts (`> [!NOTE]` etc.), built into markdown-it-py's blockquote rule.
|
||||||
|
md.options["alerts"] = True
|
||||||
|
# Block attrs must be stripped before the typographer curlifies their quotes.
|
||||||
|
md.core.ruler.before("replacements", "block_attrs", _block_attrs)
|
||||||
|
md.core.ruler.push("container_attrs", _container_attrs)
|
||||||
md.core.ruler.push("unwrap_lone_figures", _unwrap_lone_figures)
|
md.core.ruler.push("unwrap_lone_figures", _unwrap_lone_figures)
|
||||||
md.core.ruler.push("tag_task_checkboxes", _tag_task_checkboxes)
|
md.core.ruler.push("tag_task_checkboxes", _tag_task_checkboxes)
|
||||||
md.core.ruler.push("shorten_autolinks", _shorten_autolinks)
|
md.core.ruler.push("shorten_autolinks", _shorten_autolinks)
|
||||||
|
|
||||||
|
|
||||||
|
# Text-length thresholds (visible characters, code blocks excluded) for the
|
||||||
|
# column layout: the article goes .multicol past MULTICOL_TEXT, and a column
|
||||||
|
# segment gets .cols past COLS_TEXT.
|
||||||
|
MULTICOL_TEXT = 1800
|
||||||
|
COLS_TEXT = 600
|
||||||
|
|
||||||
|
_PRE_BLOCK_RE = re.compile(r"<pre\b.*?</pre>", re.S)
|
||||||
|
_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
|
||||||
|
# Classes that take their block out of the column flow: .wide is a
|
||||||
|
# full-width separator, .margin/.aside break into the left margin (their
|
||||||
|
# negative-margin breakout only works as a direct .body child, never from
|
||||||
|
# inside a column).
|
||||||
|
_WIDE = "wide"
|
||||||
|
_BREAKOUT = ("margin", "aside")
|
||||||
|
|
||||||
|
|
||||||
|
class Rendered(NamedTuple):
|
||||||
|
"""render() result: the segmented body HTML, and whether the article
|
||||||
|
should carry .multicol (enough visible text to justify columns)."""
|
||||||
|
|
||||||
|
html: str
|
||||||
|
multicol: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _classes(token) -> set[str]:
|
||||||
|
return set((token.attrGet("class") or "").split())
|
||||||
|
|
||||||
|
|
||||||
|
def _text_len(html: str) -> int:
|
||||||
|
"""Visible-text length of rendered HTML, code blocks excluded."""
|
||||||
|
return len(_TAG_RE.sub("", _PRE_BLOCK_RE.sub("", html)).strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _top_level_blocks(tokens: list) -> list[list]:
|
||||||
|
"""Split the token stream into its top-level blocks.
|
||||||
|
|
||||||
|
A new block starts at each level-0 opening/self-contained token;
|
||||||
|
closing and nested tokens (inline children, sub-containers) belong to
|
||||||
|
the current block, so every slice is balanced and renders on its own.
|
||||||
|
"""
|
||||||
|
blocks = []
|
||||||
|
for token in tokens:
|
||||||
|
if token.level == 0 and token.nesting >= 0:
|
||||||
|
blocks.append([token])
|
||||||
|
elif blocks:
|
||||||
|
blocks[-1].append(token)
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def _is_boundary(block: list) -> bool:
|
||||||
|
"""True for blocks that never go inside a column segment (see the
|
||||||
|
_WIDE/_BREAKOUT comment above): h1/h2 headings, anything carrying
|
||||||
|
.wide, and blocks whose own element carries .margin/.aside — for a
|
||||||
|
lone-image paragraph (which renders as a <figure>) the image's classes
|
||||||
|
count as the block's own."""
|
||||||
|
first = block[0]
|
||||||
|
if first.type == "heading_open" and first.tag in ("h1", "h2"):
|
||||||
|
return True
|
||||||
|
own = _classes(first)
|
||||||
|
for token in block:
|
||||||
|
if _WIDE in _classes(token):
|
||||||
|
return True
|
||||||
|
if token.type == "inline":
|
||||||
|
children = token.children or []
|
||||||
|
if any(_WIDE in _classes(c) for c in children):
|
||||||
|
return True
|
||||||
|
if len(children) == 1 and children[0].type == "image":
|
||||||
|
own |= _classes(children[0])
|
||||||
|
return bool(own & set(_BREAKOUT))
|
||||||
|
|
||||||
|
|
||||||
def render(
|
def render(
|
||||||
text: str,
|
text: str,
|
||||||
page_path: str = "",
|
page_path: str = "",
|
||||||
created: datetime | None = None,
|
created: datetime | None = None,
|
||||||
modified: datetime | None = None,
|
modified: datetime | None = None,
|
||||||
) -> str:
|
) -> Rendered:
|
||||||
"""Render Markdown text to an HTML string.
|
"""Render Markdown text to the article body's HTML and layout flags.
|
||||||
|
|
||||||
|
The top-level blocks are grouped into column segments: boundary blocks
|
||||||
|
(h1/h2 headings, .wide, margin-breakout blocks — see _is_boundary) are
|
||||||
|
rendered bare, the runs between them wrapped in <div class="colseg">.
|
||||||
|
A segment is tagged .cols when it holds enough text (COLS_TEXT) and no
|
||||||
|
::: nocols container; the article is .multicol when the whole body
|
||||||
|
exceeds MULTICOL_TEXT. pagerite.css keys all column and margin-breakout
|
||||||
|
layout off these classes.
|
||||||
|
|
||||||
A ``{dates}`` line expands to the article's published/updated dateline
|
A ``{dates}`` line expands to the article's published/updated dateline
|
||||||
(needs ``created``/``modified``; left as-is in contexts without them,
|
(needs ``created``/``modified``; left as-is in contexts without them,
|
||||||
e.g. the editor preview). Position is the author's choice — typically
|
e.g. the editor preview). Position is the author's choice — typically
|
||||||
right after the article's h1.
|
right after the article's h1.
|
||||||
"""
|
"""
|
||||||
html = md.render(text, {"page_path": page_path})
|
env = {"page_path": page_path}
|
||||||
|
blocks = _top_level_blocks(md.parse(text, env))
|
||||||
|
# Group consecutive non-boundary blocks into segments (is_segment,
|
||||||
|
# flat tokens); boundary blocks stand on their own between them.
|
||||||
|
groups: list[tuple[bool, list]] = []
|
||||||
|
for block in blocks:
|
||||||
|
if _is_boundary(block):
|
||||||
|
groups.append((False, block))
|
||||||
|
elif groups and groups[-1][0]:
|
||||||
|
groups[-1][1].extend(block)
|
||||||
|
else:
|
||||||
|
groups.append((True, list(block)))
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
total = 0
|
||||||
|
for is_segment, group in groups:
|
||||||
|
html = md.renderer.render(group, md.options, env)
|
||||||
|
if not html.strip():
|
||||||
|
continue # e.g. a consumed standalone-attrs paragraph
|
||||||
|
text_len = _text_len(html)
|
||||||
|
total += text_len
|
||||||
|
if not is_segment:
|
||||||
|
parts.append(html)
|
||||||
|
continue
|
||||||
|
nocols = any(
|
||||||
|
"nocols" in _classes(t)
|
||||||
|
for t in group
|
||||||
|
if t.type == "container_block_open"
|
||||||
|
)
|
||||||
|
cols = " cols" if text_len > COLS_TEXT and not nocols else ""
|
||||||
|
parts.append(f'<div class="colseg{cols}">{html}</div>')
|
||||||
|
html = "".join(parts)
|
||||||
if created is not None and "<p>{dates}</p>" in html:
|
if created is not None and "<p>{dates}</p>" in html:
|
||||||
html = html.replace("<p>{dates}</p>", _dateline(created, modified))
|
html = html.replace("<p>{dates}</p>", _dateline(created, modified))
|
||||||
return html
|
return Rendered(html, total > MULTICOL_TEXT)
|
||||||
|
|
||||||
|
|
||||||
def _dateline(created: datetime, modified: datetime | None) -> str:
|
def _dateline(created: datetime, modified: datetime | None) -> str:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
--line: #12203f14;
|
--line: #12203f14;
|
||||||
--font-body: var(--font-inter);
|
--font-body: var(--font-inter);
|
||||||
--font-heading: var(--font-montserrat);
|
--font-heading: var(--font-montserrat);
|
||||||
|
--code-x-height: 0.546; /* Inter's x-height ratio */
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@@ -32,11 +33,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
|
||||||
background: var(--accent);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Genuinely large solid brand with a soft blue shadow overlapping the
|
/* Genuinely large solid brand with a soft blue shadow overlapping the
|
||||||
artwork — conservative, but unmissable. */
|
artwork — conservative, but unmissable. */
|
||||||
#brand {
|
#brand {
|
||||||
@@ -112,7 +108,6 @@ article h3 {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,6 @@
|
|||||||
(inlined by the backend into #page-banner), in neutral dark greys that
|
(inlined by the backend into #page-banner), in neutral dark greys that
|
||||||
follow the page's color scheme. */
|
follow the page's color scheme. */
|
||||||
|
|
||||||
/* Bezier-swept banner with wide orange stripes (inlined SVG), separated
|
|
||||||
from the page by a straight orange blade. */
|
|
||||||
#banner {
|
|
||||||
border-bottom: 4px solid var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Banner artwork dark tones: neutral greys in light mode (retinted to the
|
/* Banner artwork dark tones: neutral greys in light mode (retinted to the
|
||||||
page's violet family by the dark-scheme block below). */
|
page's violet family by the dark-scheme block below). */
|
||||||
.nb-base {
|
.nb-base {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
|
|
||||||
--font-body: var(--font-montserrat);
|
--font-body: var(--font-montserrat);
|
||||||
--font-heading: var(--font-literata);
|
--font-heading: var(--font-literata);
|
||||||
|
--code-x-height: 0.517; /* Montserrat's x-height ratio */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark scheme: same identity, but the page goes deep violet (never muddy
|
/* Dark scheme: same identity, but the page goes deep violet (never muddy
|
||||||
@@ -63,13 +64,19 @@
|
|||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--ink);
|
}
|
||||||
|
|
||||||
|
/* Any banner used is separated from page by a thick orange line */
|
||||||
|
#banner {
|
||||||
|
border-bottom: 4px solid var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Oversized outlined brand, spilling off the banner edge: orange stroke,
|
/* Oversized outlined brand, spilling off the banner edge: orange stroke,
|
||||||
solid black fill. */
|
solid black fill. */
|
||||||
#brand {
|
#brand {
|
||||||
font-size: 10rem;
|
/* Scales down proportionally below ~1000px: 10rem at a 62.5rem viewport,
|
||||||
|
shrinking with vmin (smaller of viewport width/height) below that. */
|
||||||
|
font-size: clamp(2.5rem, 16vmin, 10rem);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
@@ -131,14 +138,9 @@
|
|||||||
|
|
||||||
/* Console-style headings: uppercase monospace. h1 in the page text color
|
/* Console-style headings: uppercase monospace. h1 in the page text color
|
||||||
with a hazard-stripe underline, h2 deep orange, h3 cyan. */
|
with a hazard-stripe underline, h2 deep orange, h3 cyan. */
|
||||||
article h1,
|
article h1 {
|
||||||
article h2,
|
|
||||||
article h3 {
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
|
||||||
|
|
||||||
article h1 {
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
|
|||||||
@@ -17,11 +17,7 @@
|
|||||||
--line: #ffffff1c;
|
--line: #ffffff1c;
|
||||||
--font-body: var(--font-literata);
|
--font-body: var(--font-literata);
|
||||||
--font-heading: var(--font-fraunces);
|
--font-heading: var(--font-fraunces);
|
||||||
}
|
--code-x-height: 0.507; /* Literata's x-height ratio */
|
||||||
|
|
||||||
::selection {
|
|
||||||
background: var(--accent2);
|
|
||||||
color: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Oversized tilted brand in the sky→violet gradient. */
|
/* Oversized tilted brand in the sky→violet gradient. */
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
--font-body: var(--font-cause);
|
--font-body: var(--font-cause);
|
||||||
--font-heading: var(--font-new-rocker);
|
--font-heading: var(--font-new-rocker);
|
||||||
--font-brand: var(--font-cause);
|
--font-brand: var(--font-cause);
|
||||||
|
--code-x-height: 0.5; /* Cause's x-height ratio */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The page is the same landscape the banner paints: hazy sky light up top
|
/* The page is the same landscape the banner paints: hazy sky light up top
|
||||||
@@ -57,11 +58,6 @@ body {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
|
||||||
background: var(--sun);
|
|
||||||
color: #3d5223;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* No separation between banner and page: the frame loses its background,
|
/* No separation between banner and page: the frame loses its background,
|
||||||
border and shadow, and the artwork overflows into the document below,
|
border and shadow, and the artwork overflows into the document below,
|
||||||
masked by a transparency gradient so it cross-fades into the body's fixed
|
masked by a transparency gradient so it cross-fades into the body's fixed
|
||||||
|
|||||||
+7
-5
@@ -513,16 +513,18 @@ def banner_source(menu: dict[str, Node], path: str) -> str | None:
|
|||||||
def page_content(menu: dict[str, Node], path: str) -> HTML:
|
def page_content(menu: dict[str, Node], path: str) -> HTML:
|
||||||
"""Render the contents of the #main element for a page."""
|
"""Render the contents of the #main element for a page."""
|
||||||
node = resolve(menu, path)[-1]
|
node = resolve(menu, path)[-1]
|
||||||
doc = E.article
|
rendered = render(node.content or "", path, node.created, node.modified)
|
||||||
|
# Long articles get .multicol: the article column cap lifts (see the
|
||||||
|
# #content grid in pagerite.css) and the body's .cols segments lay out
|
||||||
|
# in at most two columns. The .body html is already segmented by
|
||||||
|
# render() — the whole layout is driven by these classes.
|
||||||
|
doc = E.article(class_="multicol") if rendered.multicol else E.article
|
||||||
with doc:
|
with doc:
|
||||||
# An h1 in the markdown owns the article heading; the title is
|
# An h1 in the markdown owns the article heading; the title is
|
||||||
# only rendered as h1 when the markdown has none of its own.
|
# only rendered as h1 when the markdown has none of its own.
|
||||||
if not has_h1(node.content or ""):
|
if not has_h1(node.content or ""):
|
||||||
doc.h1(node.title)
|
doc.h1(node.title)
|
||||||
doc.div(
|
doc.div(HTML(rendered.html), class_="body")
|
||||||
HTML(render(node.content or "", path, node.created, node.modified)),
|
|
||||||
class_="body",
|
|
||||||
)
|
|
||||||
return HTML(str(doc))
|
return HTML(str(doc))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user