Compare commits

...
18 Commits
Author SHA1 Message Date
LeoVasanko fd1987c9b3 Slight color change for inline code text to make it stand out of paragraph text. Not applied to list items and headings. 2026-08-30 03:40:03 +00:00
LeoVasanko 85a306296f Never break inline code spans (nowrap)
word-break: keep-all does not suppress breaks at hard hyphens (an
explicit UAX #14 break opportunity), so `--arg` could split after the
dashes. Inline code is short — nowrap is the safe fix.
2026-08-30 02:20:54 +00:00
LeoVasanko 2b9c7635d3 Figure-wrap lone images even with trailing inline attrs
Space-separated brace attrs on a lone image's own line (e.g.
{style="max-width: 40em"}) are consumed onto the paragraph but leave
an empty text token in the inline children, which defeated the
lone-image check — the image stayed a bare inline <img> without the
figure wrapper (and without lightbox/zoom treatment). Strip empty text
tokens before the check.
2026-08-30 02:20:54 +00:00
LeoVasanko 7e8e0a2ea7 Click-to-enlarge lightbox for article figures
Clicking a figure image opens a full-viewport overlay: the image as
large as fits (100vw, flex-shrunk to leave exactly the caption's
height) with the caption below. Click or any key closes it; wheel and
touch scrolling stop at the overlay (overscroll-behavior: contain).

Styling lives in the base theme: blurred dark backdrop (theme-tunable
via --lightbox-bg / --lightbox-text), soft shadow, reduced-motion-aware
open animation.
2026-08-30 02:08:06 +00:00
LeoVasanko 8fc5dd7b9b Keep margin boxes in the column flow, position them out of flow
::: aside / .margin blocks no longer split the column segments: they
stay inside the .colseg at their anchor point, so the surrounding text
is measured and laid out as a single columned layout (split-off sides
previously lost .cols when too short on their own).

The zone placements (multicol side zone, sidebar track, wide gutter)
now position the boxes absolutely off the article's left border —
unaffected by any column layout inside — with the vertical spot coming
from the unset top (where the box occurs in the text). Narrow widths
keep the in-column float fallback. Trade-off: out-of-flow boxes no
longer stack via clear, so boxes anchored close together may overlap.
2026-08-30 01:41:47 +00:00
LeoVasanko fbebddeaa9 Style inline code; drop explicit colorspace from color-mix
Inline code gets padding-inline, keep-all word-break, and a color nudged
30% toward --muted from the inherited color, so code inside
accent-colored text keeps its hue.

All color-mix calls now rely on the default interpolation space (oklab)
instead of declaring srgb/oklab explicitly.
2026-08-30 01:22:22 +00:00
LeoVasanko 31895065f8 Collect analytics over a /_ws WebSocket instead of POST /_a pings 2026-08-29 21:20:04 +00:00
LeoVasanko 447a565b05 Disallow /auth/ and /_api in robots.txt 2026-08-29 20:45:39 +00:00
LeoVasanko 399aa95d44 Encapsulate all migrations in kanta migrate_vN, drop persisted Data.version
migrate_v1 now also rebuilds the legacy flat pages store as the menu
tree (moved from the app lifespan, raw-dict level); migrate_v2 now also
backfills missing AVIF/WebP/JPEG derivatives on disk (moved from the
lifespan) and drops the obsolete version field. The version render
counter was cache-invalidation state, not database state: replaced by an
in-memory render generation that clears the page-body LRU and feeds page
ETags. The legacy Page struct and Data.pages/version fields are removed;
old databases lose the stale keys on re-serialization.
2026-08-29 20:16:13 +00:00
LeoVasanko f793d21c5e Serve images extension-less at /_f/{hash} with Accept-negotiated AVIF/WebP/JPEG
Uploaded images (SVGs rasterized, GIFs excepted) are stored as the
original (hash.orig.ext, internal only, never served) plus AVIF primary
and WebP/JPEG fallback derivatives re-encoded from it. Pages link the
bare hash; the server serves a format only when Accept lists it
explicitly (image/avif -> AVIF, image/webp -> WebP, else JPEG) with
vary: accept, while an explicit extension pins the format. Favicons go
through the same pipeline at 192px. migrate_v2 rewrites old
/_f/{hash}.avif article links, a startup backfill creates missing
derivatives, and twitter:image pins the .webp variant for X's scraper.
2026-08-29 19:46:47 +00:00
LeoVasanko fb3e6d1a04 Use runtime-only Vue build, raise chunk warning to 1.2 MB 2026-08-29 06:27:16 +00:00
LeoVasanko fd75a260b5 Insert images and tables as block-level fresh lines
uploadImage and insertTable no longer inject at the cursor: on a
non-empty line (e.g. inside an existing image tag) the block goes on a
fresh blank-separated line after it, never into it.
2026-08-29 06:09:51 +00:00
LeoVasanko 6058853341 Recompress uploaded images to thumbnailed AVIF via mediapreview
PUT /_api/files now runs raster uploads through mediapreview.dispatch
(temp file for format routing: pyvips, ffmpeg for HEIC/HEIF/AVIF),
storing the untouched original as <hash>.orig<ext> and serving the
AVIF derivative <hash>.avif in links. SVG/GIF and failed conversions
fall back to plain <hash><ext> storage. FileStore.delete removes the
whole hash pair. Adds mediapreview[standard] dependency.
2026-08-29 05:59:37 +00:00
LeoVasanko 9adc48479f Scroll page on cursor move only when cursor leaves viewport
Cursor-driven editor→page scroll sync pinned the cursor's page position
at a fixed window height, so every cursor move dragged the page along.
Now the page scrolls only when the cursor's mapped position crosses a
viewport edge margin, and just enough to bring it back inside.
2026-08-29 05:08:52 +00:00
LeoVasanko 864492b897 Article editor toolbar: toggling fences/links, class pickers, sizes, Tab indent
- Image insert always adds an empty "" caption with the cursor inside.
- Fenced blocks (``` code, ::: aside) share one toggle: clicked inside
  one it is removed and the content selected; otherwise the selection
  (expanded to whole lines) is wrapped, cursor left on the opener line.
- Code button: inline wrap toggles (selection preserved, backtick runs),
  line-spanning selections make fenced blocks.
- Link button toggles: clicked inside [label](url) it unwraps.
- Block classes via two pickers (placement, AA size) with current-class
  indication and a "normal" reset; placement replaces ::: container
  names, fences get their own attribute line.
- New .small/.large/.huge text-size classes (0.7/1.5/3em); base body is
  exactly 1rem so the scale is uniform.
- .left/.right floats generalized from figures to any block.
- Toolbar polish: non-emoji glyphs, scaled-up symbols, borderless
  hover/active states, reordered (B/i and size last).
- Editor panel no longer closes on Escape.
- CodeMirror editors capture Tab/Shift-Tab for indent/dedent.
2026-08-29 05:04:01 +00:00
LeoVasanko 515c6e1435 Block attrs space-separated at end of a text line
_block_attrs now applies a trailing {...} to the block when it ends the
last text line after whitespace (some text {.small}), not only on a line
of its own; a space is what keeps the braces off an image/link ending the
line. Glued-to-text braces stay literal.
2026-08-29 05:03:30 +00:00
LeoVasanko daa1670653 Themeable selection color via --selection-bg, shared by page and CodeMirror
Nitro's orange accent selection fill clashed with accent-colored text.
Introduce --selection-bg (base: accent 30% mix, as before) used by both
::selection and the editors' selection layer; nitro overrides it with a
neutral grey. CodeMirror's selection needed a baseTheme with its exact
&light/&dark selectors to win, and is now hidden when unfocused like a
normal input.
2026-08-29 03:39:24 +00:00
LeoVasanko e3fbce8ece Fix slug input: free typing with live space→hyphen and lowercasing
Live-filtering through slugify ate hyphens and spaces (trailing hyphens
are stripped) and jumped the cursor to the end on mid-text edits. Now
only spaces→hyphens and lowercasing happen oninput (length-preserving,
cursor stays put); the full slugify runs at commit before the server
call, which keeps its own validation.
2026-08-29 03:14:57 +00:00
29 changed files with 1490 additions and 423 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke
- `pagerite/` — Python backend package (hatchling build target). - `pagerite/` — Python backend package (hatchling build target).
- `app.py` — FastAPI app and route registration. - `app.py` — FastAPI app and route registration.
- `data.py` — msgspec Structs for the kanta database. - `data.py` — msgspec Structs for the kanta database.
- `migrations.py` — kanta schema migrations (`migrate_vN`), e.g. v1 moves legacy in-db file blobs to the on-disk store. - `migrations.py` — kanta migrations (`migrate_vN`); ALL schema/storage upgrades live here (raw state dict before struct decoding), never in the app lifespan: v1 moves legacy in-db file blobs to the on-disk store and rebuilds the legacy flat `pages` as the menu tree, v2 rewrites `/_f/{hash}.ext` image links to the extension-less form, backfills AVIF/WebP/JPEG derivatives on disk and drops the obsolete `version` field.
- `markdown.py` — markdown-it-py renderer. - `markdown.py` — markdown-it-py renderer.
- `views.py` — shared page layout and rendering; theme/user-font resolution across `THEME_DIRS` / `FONT_DIRS` (cwd, site, platform data roots, then built-in `pagerite/themes/`, see `docs/themes-and-assets.md`). - `views.py` — shared page layout and rendering; theme/user-font resolution across `THEME_DIRS` / `FONT_DIRS` (cwd, site, platform data roots, then built-in `pagerite/themes/`, see `docs/themes-and-assets.md`).
- `seed.py` — demo content, written only on first database creation. - `seed.py` — demo content, written only on first database creation.
+46 -29
View File
@@ -9,9 +9,9 @@ directory, e.g. `localhost/analytics.json`).
`CrawlerHit`, `AbuseHit`, `Favicon`) and the `Store` (in-memory data + session map, `CrawlerHit`, `AbuseHit`, `Favicon`) and the `Store` (in-memory data + session map,
atomic JSON persistence). atomic JSON persistence).
- `pagerite/app.py` — entry-referer stashing in `show_page` (`_track_entry`), - `pagerite/app.py` — entry-referer stashing in `show_page` (`_track_entry`),
the `POST /_a` ping endpoint, and `WebSocket /_api/ws/analytics` the `/_ws` activity WebSocket, and `WebSocket /_api/ws/analytics`
(admin-gated like every `/_api` endpoint). (admin-gated like every `/_api` endpoint).
- `frontend/src/pagerite.js` — client navigation pings and the 📊 pen. - `frontend/src/pagerite.js` the client activity channel and the 📊 pen.
- `frontend/src/AnalyticsView.vue` — viewer component rendered inside the - `frontend/src/AnalyticsView.vue` — viewer component rendered inside the
normal site layout on the `/_a` analytics page. normal site layout on the `/_a` analytics page.
- `frontend/src/analytics-main.js` — page entry that mounts `AnalyticsView` - `frontend/src/analytics-main.js` — page entry that mounts `AnalyticsView`
@@ -19,46 +19,61 @@ directory, e.g. `localhost/analytics.json`).
## What is collected ## What is collected
The client (`pagerite.js`) POSTs fire-and-forget pings to `/_a` with The client (`pagerite.js`) keeps a WebSocket connection to `/_ws` for the
`fr`, `to`, `hide` and `read` as query parameters (`fr` = source path; whole browsing session and sends activity messages over it — JSON text
falsy values are omitted): frames matching the server's `Ping` msgspec struct with the fields `fr`
(source path), `to` (navigation target), `read` (active seconds on `fr`
since the last report) and `hide`; falsy fields are omitted. One channel
follows the session, so the activity of a visit stays tied together, and
while the user is active the accumulated reading time is flushed every few
seconds: the trail times are cumulative, so a disconnection simply leaves
the last reported time in place (no close beacon). After 5 minutes without
any activity the client closes the socket itself — a sleeping browser tab
would lose it anyway — and the next activity reconnects as a fresh session;
reconnects are attempted only on user activity, with an exponential backoff
between attempts so a failing endpoint is never hammered. Idle-time link preloads
stay plain `fetch()` calls so the browser may cache the responses; the
WebSocket reports actual navigations and active time spent on a page.
- **Initial page load**: only `to` — the loaded path — is sent, never `fr` - **Initial page load**: only `to` — the loaded path — is sent, never `fr`
(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 message 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 never register (admin browsing does register, but nothing, so bots never register (admin browsing does register, but
flagged `hide`; see **Admins** below). JS-running crawlers flagged `hide`; see **Admins** below). JS-running crawlers
(Googlebot, GoogleOther, Applebot, ...) do ping, but their User-Agent (Googlebot, GoogleOther, Applebot, ...) do connect and report, but their
gives them away: pings whose UA matches `_is_bot_ua` (anything calling User-Agent gives them away: messages 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
server-side, and their document GETs land in the crawler list instead. server-side, and their document GETs land in the crawler list instead.
No source-IP verification is done: a spoofed bot UA merely lands in the No source-IP verification is done: a spoofed bot UA merely lands in the
crawler stats, and scanners that probe telltale paths are caught by the crawler stats, and scanners that probe telltale paths are caught by the
abuse rules regardless. Reloads are not abuse rules regardless. Reloads are not
visits: the ping is skipped (PerformanceNavigationTiming `reload`), so a visits: the message is skipped (PerformanceNavigationTiming `reload`), so a
refresh neither counts a second view nor logs a self-transition. The GET refresh neither counts a second view nor logs a self-transition. The GET
handler stashes a cross-origin https `Referer` (origin part only — handler stashes a cross-origin https `Referer` (origin part only —
unavailable to JS once the page has loaded) and any unavailable to JS once the page has loaded) and any
`utm_*` query parameters in in-memory IP tables, consumed by the ping that `utm_*` query parameters in in-memory IP tables, consumed by the first
message that
starts the visit; internal or absent referers never touch the referer table. starts the visit; internal or absent referers never touch the referer table.
- **Internal fetch-navigations**: `to` is the target path, sent only after - **Internal fetch-navigations**: `to` is the target path, sent only after
the swap actually happened (a failed swap falls back to a full load, the swap actually happened (a failed swap falls back to a full load,
whose initial ping counts the view instead — no gap, no double count). whose initial message counts the view instead — no gap, no double count).
- **External links** (`https` only): `to` is the link's full URL. This is the - **External links** (`https` only): `to` is the link's full URL. This is the
exit-link record; the user may continue navigating afterwards (new tab, exit-link record; the user may continue navigating afterwards (new tab,
back), so the exit URL is not necessarily the last trail entry. Outbound back), so the exit URL is not necessarily the last trail entry. Outbound
links are stored by full URL so several links to the same domain remain links are stored by full URL so several links to the same domain remain
distinct. distinct.
- **Excluded**: back/forward (popstate) navigations, navigating *to* the - **Excluded**: back/forward (popstate) navigations, navigating *to* the
analytics page (`/_a` — its GET is untracked, and the server rejects it analytics page (`/_a` — its GET is untracked, and the server cannot
as a ping target anyway), and everything while the user has the editor record it as a navigation target anyway), and everything while the user has
the editor
open (`body.editing`). Admin noise, not visits. Navigating *away* from open (`body.editing`). Admin noise, not visits. Navigating *away* from
`/_a` does ping: the fetch-navigation already GET-ed the target page `/_a` does report: the fetch-navigation already GET-ed the target page
without the preload header, and without the ping that GET would flush to without the preload header, and without the message 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 activity is recorded as the client still reports but adds `hide`. The activity is recorded as
usual (navigations and all), but the `hide` flag is set on the **client usual (navigations and all), but the `hide` flag is set on the **client
record** — so it covers everything that client ever did: visits and record** — so it covers everything that client ever did: visits and
crawler hits from before the login included. Hidden clients never appear crawler hits from before the login included. Hidden clients never appear
@@ -81,7 +96,7 @@ falsy values are omitted):
extension matching the actual MIME). The origin → file name mapping is extension matching the actual MIME). The origin → file name mapping is
recorded in `Analytics.favicons` (`Favicon.file`/`fetched`); misses are recorded in `Analytics.favicons` (`Favicon.file`/`fetched`); misses are
recorded too and retried only after 7 days. Fetches are scheduled after recorded too and retried only after 7 days. Fetches are scheduled after
each ping and once at startup, which backfills icons for already-recorded each activity message and once at startup, which backfills icons for already-recorded
data. The viewer payload carries `favicons` (origin → `/_f/...` path), data. The viewer payload carries `favicons` (origin → `/_f/...` path),
and the viewer shows the icon wherever an external site is mentioned: and the viewer shows the icon wherever an external site is mentioned:
referer/exit trail links in the visit table and the source/exit pills of referer/exit trail links in the visit table and the source/exit pills of
@@ -100,8 +115,8 @@ falsy values are omitted):
`host`; local/reserved/multicast addresses are skipped. If a DB-IP MMDB `host`; local/reserved/multicast addresses are skipped. If a DB-IP MMDB
file (`dbip-*.mmdb` or `dbip-*.mmdb.gz`) is present in the repository file (`dbip-*.mmdb` or `dbip-*.mmdb.gz`) is present in the repository
root, it is loaded at startup and used to look up `country`/`city`. These root, it is loaded at startup and used to look up `country`/`city`. These
lookups run in background tasks after the event is stored, so the `/_a` lookups run in background tasks after the event is stored, so WebSocket
response is never delayed. The decompressed `dbip-*.mmdb` file is kept in message handling is never delayed. The decompressed `dbip-*.mmdb` file is kept in
the repository root and ignored by git. The CLI flag `--dbip` the repository root and ignored by git. The CLI flag `--dbip`
(`uv run pagerite --dbip`) downloads the latest (`uv run pagerite --dbip`) downloads the latest
`dbip-city-lite-YYYY-MM.mmdb.gz` from DB-IP before the server starts, `dbip-city-lite-YYYY-MM.mmdb.gz` from DB-IP before the server starts,
@@ -110,10 +125,11 @@ falsy values are omitted):
file is used. file is used.
- **Crawler hits**: every document GET is queued in RAM as a pending crawler - **Crawler hits**: every document GET is queued in RAM as a pending crawler
hit — except idle-time link preloads from pagerite.js, which carry an hit — except idle-time link preloads from pagerite.js, which carry an
`x-pagerite-preload` header and are not tracked at all (the ping sent when `x-pagerite-preload` header and are not tracked at all (the navigation
the user actually navigates to a preloaded page does the counting; forging message sent when the user actually navigates to a preloaded page does
the counting; forging
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 message
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` — unless the client is hidden 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 (admin), in which case the hit is discarded on expiry too. Crawlers do not count as
@@ -131,7 +147,7 @@ falsy values are omitted):
random-UA scanner no longer pollutes the crawler stats of the legitimate random-UA scanner no longer pollutes the crawler stats of the legitimate
bot it impersonates. Once classified, every document GET and 404 from the bot it impersonates. Once classified, every document GET and 404 from the
IP is recorded as an abuse hit with the full request path (query string IP is recorded as an abuse hit with the full request path (query string
included), and its pings are ignored. The classified IP set (`abuse_ips`) included), and its activity messages are ignored. The classified IP set (`abuse_ips`)
is persisted in the JSON file; the plain-404 counters are RAM-only. In the is persisted in the JSON file; the plain-404 counters are RAM-only. In the
viewer, abuse hits are grouped by IP (never by client/UA — scanners viewer, abuse hits are grouped by IP (never by client/UA — scanners
randomize theirs) in a separate "Abuse" table. Identical paths are randomize theirs) in a separate "Abuse" table. Identical paths are
@@ -145,9 +161,9 @@ falsy values are omitted):
There are no cookies. A visit is tied together by a client hash — the first There are no cookies. A visit is tied together by a client hash — the first
6 bytes of a blake3 digest over the prettified IP (IPv4 unchanged, IPv6 6 bytes of a blake3 digest over the prettified IP (IPv4 unchanged, IPv6
/64 network), the raw `User-Agent` string and the extracted /64 network), the raw `User-Agent` string and the extracted
`Accept-Language` tag. The first ping from a client hash starts a new `Accept-Language` tag. The first message from a client hash starts a new
visit; subsequent pings extend it. Pings arriving with no known session visit; subsequent messages extend it. Messages arriving with no known session
(server restart) start a fresh visit from the first ping — treated as (server restart) start a fresh visit from the first message — treated as
missing data rather than dropped. The client-hash → visit map and the IP → missing data rather than dropped. The client-hash → visit map and the IP →
entry-referer/UTM tables are in-memory only; client metadata is stored in entry-referer/UTM tables are in-memory only; client metadata is stored in
`Analytics.clients` keyed by the client hash. `Analytics.clients` keyed by the client hash.
@@ -164,7 +180,7 @@ Each `Client` record:
- `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, - `hide` — true for admin clients (`hide` message field): all their visits,
crawler hits and abuse hits are recorded but excluded from every crawler hits and abuse hits are recorded but excluded from every
statistic and from the viewer payload. statistic and from the viewer payload.
@@ -180,7 +196,7 @@ Each `Visit` record:
reading time in seconds (`read`) and the most recent HTTP status seen reading time in seconds (`read`) and the most recent HTTP status seen
for the target (`status`). Re-visiting an already seen target updates for the target (`status`). Re-visiting an already seen target updates
its item instead of appending. its item instead of appending.
- `navs` — every navigation ping (`fr`, `to`), keyed by its timestamp, - `navs` — every navigation message (`fr`, `to`), keyed by its timestamp,
repeats included. The aggregates are computed from this log at display repeats included. The aggregates are computed from this log at display
time. time.
- `utm``utm_*` query parameters from the landing URL, as a dict. - `utm``utm_*` query parameters from the landing URL, as a dict.
@@ -227,7 +243,8 @@ shapes, part of the WebSocket payload (`Display` struct alongside `visits`,
- `transitions`: time series of page transitions, sparse nested dict - `transitions`: time series of page transitions, sparse nested dict
`from -> to -> bucket -> count` with 5-minute bucketing. `from` is the `from -> to -> bucket -> count` with 5-minute bucketing. `from` is the
referer origin or `"(direct)"` for initial loads, a page path for pings. referer origin or `"(direct)"` for initial loads, a page path for
navigations.
- `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
+4 -4
View File
@@ -8,9 +8,9 @@ The FastAPI app. FastAPI's built-in API docs are disabled (`docs_url`/`redoc_url
The build mirrors the URL space — hashed immutable assets under `/_assets/`, `favicon.ico` at the site root — and an `index.html` in the build would become a `/` route, so leave it out of the build to keep `/` ours. The build mirrors the URL space — hashed immutable assets under `/_assets/`, `favicon.ico` at the site root — and an `index.html` in the build would become a `/` route, so leave it out of the build to keep `/` ours.
Generated HTML pages (content pages, category/404 placeholders, `/_a`) go through `_html_response`: zstd-compressed per request at level 9 when the client sends `accept-encoding: zstd` (no gzip fallback; static assets are pre-compressed by the `Frontend`), with `vary: accept-encoding` set and the ETag kept identical across encodings so `if-none-match` revalidation still works. In production the rendered bodies are cached in an LRU keyed by everything the output depends on — page kind, path, the site origin (social meta), encoding, and `data.version`, which bumps on every content/settings change and so transparently invalidates the whole cache. The cache is bypassed in dev, where theme/design CSS is re-read from disk per request. Content pages carry an ETag built from the node's modified timestamp and `data.version`; `/_a` instead gets a blake3 hash of the rendered body (it has no Node), with matching `if-none-match` revalidations answered by a 304. Generated HTML pages (content pages, category/404 placeholders, `/_a`) go through `_html_response`: zstd-compressed per request at level 9 when the client sends `accept-encoding: zstd` (no gzip fallback; static assets are pre-compressed by the `Frontend`), with `vary: accept-encoding` set and the ETag kept identical across encodings so `if-none-match` revalidation still works. In production the rendered bodies are cached in an LRU keyed by everything the output depends on — page kind, path, the site origin (social meta), encoding and cleared wholesale by `_invalidate_pages()` on every content/settings change, which also bumps the in-memory render generation. The cache is bypassed in dev, where theme/design CSS is re-read from disk per request. Content pages carry an ETag built from the node's modified timestamp and the render generation; `/_a` instead gets a blake3 hash of the rendered body (it has no Node), with matching `if-none-match` revalidations answered by a 304.
Uploaded files, seed assets and fetched external-site favicons live in the `FileStore`: content-addressed files on disk under `<hostname>/files/` (`PAGERITE_FILES`), fully cached in RAM at startup — both the raw body and a zstd-compressed copy (kept only when smaller). `GET /_f/{name}` serves from the RAM cache with immutable caching, answering the zstd variant when the client accepts it; the name is the ETag. Legacy databases that still carry blobs in a `files` kanta field are migrated to disk by `pagerite/migrations.py::migrate_v1` (kanta's `migrate_vN` mechanism, wired via `Kanta(..., migrations="pagerite.migrations")`), which pops the field from the raw state before struct decoding. Uploaded files, seed assets and fetched external-site favicons live in the `FileStore`: content-addressed files on disk under `<hostname>/files/` (`PAGERITE_FILES`), fully cached in RAM at startup — both the raw body and a zstd-compressed copy (kept only when smaller). `GET /_f/{name}` serves from the RAM cache with immutable caching, answering the zstd variant when the client accepts it; the name is the ETag. Uploaded raster images (and rasterized SVGs) are stored as `<hash>.orig<ext>` (internal only, never served) plus AVIF, WebP and JPEG derivatives, and pages link the extension-less `/_f/{hash}`: the server serves a format only when the Accept header lists it explicitly (`image/avif` → AVIF, `image/webp` → WebP, otherwise — including `*/*` — JPEG), with `vary: accept`; an explicit extension pins the format. `migrate_v2` rewrites old `/_f/{hash}.avif` article links to the bare form, backfills missing derivatives on disk, and drops the obsolete `version` field. Legacy databases that still carry blobs in a `files` kanta field or a flat `pages` store are migrated by `pagerite/migrations.py::migrate_v1` (kanta's `migrate_vN` mechanism, wired via `Kanta(..., migrations="pagerite.migrations")`), which rewrites the raw state before struct decoding — all schema/storage upgrades live in that module, none in the app lifespan.
## `data.py` ## `data.py`
@@ -20,13 +20,13 @@ 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). In bodies with at least three top-level h1/h2 headings (nested ones, e.g. inside `::: aside`, never participate), each gets a slug id (`python-slugify`, mirroring the editor's `slugify.js` — unicode folds to ASCII, separators become single hyphens) unless the author set `{#id}`, and their text is wrapped in a self-link (`a.anchor`) so section links are copyable; anchored headings also carry `data-line` with their markdown source line (the page editor's section pens and piecewise scroll sync key off it); the first in-body h1 is the article title — when the markdown has no h1, `render(title=...)` injects it as `# {title}` so implicit and explicit titles take the same path — it gets no id and doesn't count toward the three, its self-link is `href=""` (scroll to top); shorter articles stay anchor-free, h3+ is never navigable, and duplicates get `-2`/`-3` suffixes. Custom image rule: relative srcs resolve against the page path; an image standing alone in its paragraph becomes a figure (captioned when titled), while inline-with-text images and raw `<img>` HTML stay plain. A `{dates}` line expands to the article's published/updated dateline (`p.dateline`, from `Node.created`/`modified`; left literal in previews of unsaved pages). Code fences take pandoc-style brace attributes on the info line (` ```{.python .wide #id key=val} ` — the first class is the language when no bare language word precedes the braces) as well as a trailing `{...}` line; both land on the `<pre>`, the `<code>` keeps only the language class. markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists, admon, gfm_autolink, sub/superscript plugins; typographer + breaks on). In bodies with at least three top-level h1/h2 headings (nested ones, e.g. inside `::: aside`, never participate), each gets a slug id (`python-slugify`, mirroring the editor's `slugify.js` — unicode folds to ASCII, separators become single hyphens) unless the author set `{#id}`, and their text is wrapped in a self-link (`a.anchor`) so section links are copyable; anchored headings also carry `data-line` with their markdown source line (the page editor's section pens and piecewise scroll sync key off it); the first in-body h1 is the article title — when the markdown has no h1, `render(title=...)` injects it as `# {title}` so implicit and explicit titles take the same path — it gets no id and doesn't count toward the three, its self-link is `href=""` (scroll to top); shorter articles stay anchor-free, h3+ is never navigable, and duplicates get `-2`/`-3` suffixes. Custom image rule: relative srcs resolve against the page path; an image standing alone in its paragraph becomes a figure (captioned when titled), while inline-with-text images and raw `<img>` HTML stay plain. A `{dates}` line expands to the article's published/updated dateline (`p.dateline`, from `Node.created`/`modified`; left literal in previews of unsaved pages). Code fences take pandoc-style brace attributes on the info line (` ```{.python .wide #id key=val} ` — the first class is the language when no bare language word precedes the braces) as well as a trailing `{...}` line; both land on the `<pre>`, the `<code>` keeps only the language class.
`render()` returns a `Rendered(html, multicol)`: the article content segmented for the column layout (there is no wrapper div — segments and bare blocks are direct `<article>` children) — h1/h2 headings, `.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 in at least two paragraphs or one long enough to split across columns, `::: nocols` opting out; in column segments, paragraphs past `BREAKABLE_TEXT` visible characters are marked `.breakable` so they may split across columns), and `multicol` flags bodies long enough to columnize (visible-text thresholds, code excluded). `views.py` puts the class on the article; pagerite.css takes it from there (at most two columns, the left-margin breakout, all viewport adaptation). `render()` returns a `Rendered(html, multicol)`: the article content segmented for the column layout (there is no wrapper div — segments and bare blocks are direct `<article>` children) — h1/h2 headings and `.wide` blocks stand bare, the runs between them become `<div class="colseg">` (margin-breakout boxes — `.margin`, `::: aside` stay inside the segment at their anchor point; the CSS positions them out of flow into the side zone) (plus `.cols` on segments with enough text in at least two paragraphs or one long enough to split across columns, `::: nocols` opting out; in column segments, paragraphs past `BREAKABLE_TEXT` visible characters are marked `.breakable` so they may split across columns), and `multicol` flags bodies long enough to columnize (visible-text thresholds, code excluded). `views.py` puts the class on the article; pagerite.css takes it from there (at most two columns, the left-margin breakout, all viewport adaptation).
## `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.
Content pages get SEO/social meta (description, canonical link, Open Graph + twitter card) from heuristics over the rendered article: the description is the first paragraph's text, the share image prefers a `{.hero}`-classed image, then the first raster `<img>`, then the first SVG; the first `<video>` yields `og:video`; URLs are made absolute with the site origin (`SITE_URL``https://<hostname>` from the CLI hostname argument; on localhost the request's own base URL is the fallback); `article:published/modified_time` come from `Node.created`/`modified`. The page title is injected as `# {title}` when the markdown has no h1 of its own, so it never appears twice (it always supplies `<title>` and nav labels). Content pages get SEO/social meta (description, canonical link, Open Graph + twitter card) from heuristics over the rendered article: the description is the first paragraph's text, the share image prefers a `{.hero}`-classed image, then the first raster `<img>`, then the first SVG; the first `<video>` yields `og:video`; URLs are made absolute with the site origin (`SITE_URL``https://<hostname>` from the CLI hostname argument; on localhost the request's own base URL is the fallback); `article:published/modified_time` come from `Node.created`/`modified`. Additionally `twitter:image` pins extension-less `/_f/{hash}` share images to the `.webp` variant — X only honors WebP via twitter:image (not og:image) and its scraper cannot be trusted to negotiate via Accept. The page title is injected as `# {title}` when the markdown has no h1 of its own, so it never appears twice (it always supplies `<title>` and nav labels).
The navbar holds top-level items only; the current section's subitems go to a left `#sidebar` as a nested list (the section's direct children plain, deeper levels indented with article-list-style markers), rendered only from the second level down — main-level pages list their children as cards after the content instead. Below that, the sidebar renders when the section offers at least two published items, or exactly one while viewing anything other than that only page — the section index, a 404, a grandchild (so those pages can reach the child), and also on that only page itself when it has published children of its own; no aside element at all on the front page, main-level pages, leaf pages and the sole childless page of a one-page section. Also, category labels are nodes without content — None *or* empty markdown — and their nav links point at their first child page. Dynamic regions have stable ids (`#page-banner`, `#nav`, `#sidebar`, `#main`) for fetch-navigation swaps (`#sidebar` may be absent on either side of a swap). The navbar holds top-level items only; the current section's subitems go to a left `#sidebar` as a nested list (the section's direct children plain, deeper levels indented with article-list-style markers), rendered only from the second level down — main-level pages list their children as cards after the content instead. Below that, the sidebar renders when the section offers at least two published items, or exactly one while viewing anything other than that only page — the section index, a 404, a grandchild (so those pages can reach the child), and also on that only page itself when it has published children of its own; no aside element at all on the front page, main-level pages, leaf pages and the sole childless page of a one-page section. Also, category labels are nodes without content — None *or* empty markdown — and their nav links point at their first child page. Dynamic regions have stable ids (`#page-banner`, `#nav`, `#sidebar`, `#main`) for fetch-navigation swaps (`#sidebar` may be absent on either side of a swap).
+3 -3
View File
@@ -8,13 +8,13 @@ The site structure is stored in the kanta database managed by `pagerite/data.py`
`Node.content` is the Markdown page, or None for a pure category label whose URL renders a 404 listing its children as cards (while nav links to it point at its first child); every label's title and slug are editable. A page with published children — a category page — lists them as cards after its markdown content; the sidebar sub-navigation renders only from the second level down, never on main-level pages. `Node.content` is the Markdown page, or None for a pure category label whose URL renders a 404 listing its children as cards (while nav links to it point at its first child); every label's title and slug are editable. A page with published children — a category page — lists them as cards after its markdown content; the sidebar sub-navigation renders only from the second level down, never on main-level pages.
Siblings order by the fractional `Node.order` key: a moved item gets a fresh key relative to its new siblings, all others keep theirs. `resolve`/`find_slot` walk the tree by path; moves are slot detach/attach carrying the whole subtree. Legacy flat `Data.pages` (pre-tree databases) migrates into `menu` on startup. The app owns the `Data` object; reads are plain attribute access, writes in `kanta.transaction(...)`. Siblings order by the fractional `Node.order` key: a moved item gets a fresh key relative to its new siblings, all others keep theirs. `resolve`/`find_slot` walk the tree by path; moves are slot detach/attach carrying the whole subtree. Legacy flat `pages` (pre-tree databases) migrates into `menu` via `migrate_v1`. The app owns the `Data` object; reads are plain attribute access, writes in `kanta.transaction(...)`.
`Data.version` is bumped on every write and embedded in page ETags so nav-affecting changes invalidate caches. Every content/settings write calls `_invalidate_pages()` in app.py, which clears the rendered-body LRU and bumps an in-memory render generation embedded in page ETags, so nav-affecting changes invalidate caches. (This used to be a persisted `Data.version` counter — cache invalidation is not database state, so the field was dropped; old databases lose the key on re-serialization.)
## Files ## Files
Files are content-addressed (blake3[:12] + extension) and stored **on disk** under `<hostname>/files/` (path from `PAGERITE_FILES`), served at `/_f/{name}` with immutable caching; the `FileStore` in app.py caches every file in RAM, both uncompressed and zstd-compressed (the compressed copy only when smaller), so `/_f` answers both encodings without disk reads. Pages reference files by absolute `/_f/` URLs so hierarchy moves never break them. Pre-refactor databases kept the blobs in a `Data.files` kanta field; the kanta migration `pagerite/migrations.py::migrate_v1` writes them to disk on open and drops the field (removed from `Data`). Fetched favicons of external analytics sites live in the same store (see `docs/analytics.md`). Files are content-addressed (blake3[:12] + extension) and stored **on disk** under `<hostname>/files/` (path from `PAGERITE_FILES`), served at `/_f/{name}` with immutable caching. Uploaded raster images (except GIF) and SVGs (rasterized) get a set of derivatives: the untouched original under `<hash>.orig<ext>` (internal only — it may carry EXIF data and is never served; SVG originals stay servable as `<hash>.svg`), a mediapreview-recompressed AVIF (`<hash>.avif`, thumbnailed to `IMAGE_MAXSIZE` at `IMAGE_QUALITY`), and WebP/JPEG fallbacks re-encoded from the AVIF at lower quality (`IMAGE_WEBP_QUALITY`/`IMAGE_JPG_QUALITY`, chosen for similar-or-smaller file size). Pages link the bare `/_f/<hash>` and the server negotiates by Accept header: a format is served only when listed explicitly (`image/avif` → AVIF, `image/webp` → WebP, anything else including `image/*` and `*/*` → JPEG); an explicit extension in the URL pins the format. Responses carry `vary: accept`. Favicons uploaded in settings go through the same pipeline at `FAVICON_MAXSIZE` (192px). Existing databases are updated by `migrate_v2` (link rewrite plus on-disk derivative backfill). Deleting any name of a hash removes the whole group. The `FileStore` in app.py caches every file in RAM, both uncompressed and zstd-compressed (the compressed copy only when smaller), so `/_f` answers both encodings without disk reads. Pages reference files by absolute `/_f/` URLs so hierarchy moves never break them. Pre-refactor databases kept the blobs in a `Data.files` kanta field; the kanta migration `pagerite/migrations.py::migrate_v1` writes them to disk on open and drops the field (removed from `Data`). Fetched favicons of external analytics sites live in the same store (see `docs/analytics.md`).
## Banners ## Banners
+4 -4
View File
@@ -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, 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 — on all but phone widths they float in the side zone at the article's left (the region the nav sidebar overlays, or the sidebar's own track when the layout reserves one) and the text never moves — 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. - 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 — on all but phone widths they are taken out of flow into the side zone at the article's left (the region the nav sidebar overlays, or the sidebar's own track when the layout reserves one) and the text never moves — 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 on disk (`<hostname>/files/`, RAM-cached uncompressed + zstd) 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: `![alt](/_f/….avif "Caption"){.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}` makes it a margin note, floating in the side zone left of the text on all but phone widths, `{.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. - **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`) are stored on disk (`<hostname>/files/`, RAM-cached uncompressed + zstd) by content hash — blake3, first 6 bytes hex + original extension — and served immutable from `/_f/…`. Raster images (not GIF) and SVGs (rasterized) are recompressed via mediapreview: the original is kept as `{hash}.orig{ext}` (internal only, never served — it may carry EXIF data; SVG originals stay servable as `{hash}.svg`) while pages link the extension-less `/_f/{hash}` and the server picks from the derivatives (`{hash}.avif` / `{hash}.webp` / `{hash}.jpg`) by Accept header — a format only when listed explicitly (`image/avif` → AVIF, `image/webp` → WebP, otherwise JPEG), with `vary: accept`; an explicit extension in the URL pins the format. 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: `![alt](/_f/… "Caption"){.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}` makes it a margin note, placed in the side zone left of the text on all but phone widths, `{.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, and space-separated at the end of a text line (`some text {.small}`) the braces likewise belong to the block — a space is what keeps them off an image or link ending the line, which keep their own directly-attached attrs; text size classes `{.small}` / `{.large}` / `{.huge}` (em-based) work on any block; 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. Long articles (flagged `.multicol` by the backend render) lift the cap and become a bounded **composition**, centered in the available space with the surplus left vacant: a fluid text lane (up to 42rem) plus a 16rem **side zone at the article's left** — the region the nav sidebar overlays — which hosts margin boxes (`.margin`, `::: aside`, margin figures) at all but phone widths, without the text ever moving. On pages with a sidebar, the sidebar gets its own track at every width — flexible, 12rem when space is tight and growing up to 150% (18rem) once the viewport has room beyond the article, the sidebar keeping its left side on the viewport's edge — and the track is the left lane instead: no in-article zone, the text lane runs fluid up to 86rem leaning on the viewport's right edge (surplus extends the left lane), and the boxes hang into the lane off the article's left border (growing leftward with it, up to 18rem), sliding under the translucent sticky nav. Once two lanes fit beside the zone (≥96rem available in `main`), the text flows in two fluid lanes (36rem minimum, capped at 102rem total — technical content wants the wider lanes, and wider windows just add vacant space). The stages step by the space actually available in `main` (container queries + `cqw` units, so the docked editor's inset is automatic). `.wide` figures on multicol pages bleed to the viewport edges measured from `main` (`cqw`), sliding under the sidebar. The backend splits the body into `.colseg` segments at h1/h2 headings, `.wide` elements and margin blocks (full-width separators or margin boxes, never inside columns), tagging segments that hold enough text in at least two paragraphs (or one long enough to split) with `.cols` — code blocks are excluded from that measure, a `::: nocols` container opts its whole section out, and column-filling paragraphs are marked `.breakable` so they may split across the column gap (shorter paragraphs stay whole). On wide single-column pages (≥104rem), margin boxes lean into the vacant left gutter as well, growing with it up to 18rem. - 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 cap and become a bounded **composition**, centered in the available space with the surplus left vacant: a fluid text lane (up to 42rem) plus a 16rem **side zone at the article's left** — the region the nav sidebar overlays — which hosts margin boxes (`.margin`, `::: aside`, margin figures) at all but phone widths, without the text ever moving. On pages with a sidebar, the sidebar gets its own track at every width — flexible, 12rem when space is tight and growing up to 150% (18rem) once the viewport has room beyond the article, the sidebar keeping its left side on the viewport's edge — and the track is the left lane instead: no in-article zone, the text lane runs fluid up to 86rem leaning on the viewport's right edge (surplus extends the left lane), and the boxes hang into the lane off the article's left border (growing leftward with it, up to 18rem), sliding under the translucent sticky nav. Once two lanes fit beside the zone (≥96rem available in `main`), the text flows in two fluid lanes (36rem minimum, capped at 102rem total — technical content wants the wider lanes, and wider windows just add vacant space). The stages step by the space actually available in `main` (container queries + `cqw` units, so the docked editor's inset is automatic). `.wide` figures on multicol pages bleed to the viewport edges measured from `main` (`cqw`), sliding under the sidebar. The backend splits the body into `.colseg` segments at h1/h2 headings and `.wide` elements (full-width separators, never inside columns); margin boxes stay inside the segment at their anchor point and the CSS takes them out of flow — absolutely positioned off the article's left border into the zone, the columns flowing through unaffected — tagging segments that hold enough text in at least two paragraphs (or one long enough to split) with `.cols` — code blocks are excluded from that measure, a `::: nocols` container opts its whole section out, and column-filling paragraphs are marked `.breakable` so they may split across the column gap (shorter paragraphs stay whole). On wide single-column pages (≥104rem), margin boxes lean into the vacant left gutter as well, growing with it up to 18rem.
- 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
@@ -48,7 +48,7 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
- **Page mode** — the 🖊️ next to a page's heading (including 404s, which is how new pages start) opens a CodeMirror Markdown editor docked to the left of the article: the panel is fixed to the viewport's left edge (its top tracks the banner's bottom until the banner scrolls away), the content shifts right and the sidebar hides while editing. Preview renders server-side per keystroke (no debouncing) and swaps the whole visible article content in one go (the edit pen and category cards survive the swap). - **Page mode** — the 🖊️ next to a page's heading (including 404s, which is how new pages start) opens a CodeMirror Markdown editor docked to the left of the article: the panel is fixed to the viewport's left edge (its top tracks the banner's bottom until the banner scrolls away), the content shifts right and the sidebar hides while editing. Preview renders server-side per keystroke (no debouncing) and swaps the whole visible article content in one go (the edit pen and category cards survive the swap).
- **Site mode** — the ⚙️ at the top right (after the 📊 analytics link, before login) opens a panel with the site **brand** (applied to the header live), a **theme** selector (swapping the theme stylesheet in place), a **page transition** selector (`cube`/`crossfade`, swapping `#pagerite-transition` in place), **font** picks (heading/body/brand — stored as plain `:root` rows inside the custom CSS, referencing the base stylesheet's per-family font variables), a **site-wide custom CSS** field (injected into `<style id="pagerite-user">` in the live page head and swapped during fetch-navigation), the page's **banner design** selector (inherit / none / any design found on disk, inherited by children), the page's **banner HTML** field (supplementing the design, previewed into the real banner region, so you see exactly which banner you're editing) and the **structure tree**. Everything saves immediately as you edit — no save button, no edit mode. - **Site mode** — the ⚙️ at the top right (after the 📊 analytics link, before login) opens a panel with the site **brand** (applied to the header live), a **theme** selector (swapping the theme stylesheet in place), a **page transition** selector (`cube`/`crossfade`, swapping `#pagerite-transition` in place), **font** picks (heading/body/brand — stored as plain `:root` rows inside the custom CSS, referencing the base stylesheet's per-family font variables), a **site-wide custom CSS** field (injected into `<style id="pagerite-user">` in the live page head and swapped during fetch-navigation), the page's **banner design** selector (inherit / none / any design found on disk, inherited by children), the page's **banner HTML** field (supplementing the design, previewed into the real banner region, so you see exactly which banner you're editing) and the **structure tree**. Everything saves immediately as you edit — no save button, no edit mode.
- Clicking a pen again closes the editor (without saving; a dirty preview reloads the page). The pens are `<button>`s wired up by `pagerite.js` — editing is an action, not a navigation. The editor's WebSocket **reconnects automatically** with local text and pending saves preserved. (All users are trusted authors for now; access control later with SSO.) - Clicking a pen again closes the editor (without saving; a dirty preview reloads the page). The pens are `<button>`s wired up by `pagerite.js` — editing is an action, not a navigation. The editor's WebSocket **reconnects automatically** with local text and pending saves preserved. (All users are trusted authors for now; access control later with SSO.)
- **CodeMirror 6** for Markdown editing (no WYSIWYG), title/published controls. Images can be pasted straight into the editor or chosen via a file input: they upload to the content store (`PUT /_api/files/...`) and insert `![alt](/_f/hash.ext)` at the cursor. - **CodeMirror 6** for Markdown editing (no WYSIWYG), title/published controls. Images can be pasted straight into the editor or chosen via a file input: they upload to the content store (`PUT /_api/files/...`) and insert `![alt](/_f/hash)` at the cursor.
- The **structure panel** (vue-draggable tree of the whole site, in site mode) covers page management: reorder any menu level, drag across sections, add, delete (two clicks: the button arms, then deletes — no dialogs). Every node is a real label — content-less category rows offer a to give them a landing page. Deleting a category removes only its landing page (the label and its subpages stay). Every non-empty list ends with a row that starts a new page as a local-only tree row at that level; the row can be dragged into place before its title and slug are filled in and is persisted only on commit. While dragging, these rows double as "end of this list" drop targets; dropping ON the lower part of a row makes the page that row's first child (even a leaf's, creating a sublist), while a row's exposed top edge inserts a sibling before it. A dragged row's indentation previews the target list's depth. Rows are always editable: titles save while typing, slug edits commit on blur/Enter since they rename the path (moving the whole subtree). The front page is the root row with an empty slug — renaming it away leaves no front page ("/" redirects to the first nav item), and giving another top-level row the empty slug makes it the front page. - The **structure panel** (vue-draggable tree of the whole site, in site mode) covers page management: reorder any menu level, drag across sections, add, delete (two clicks: the button arms, then deletes — no dialogs). Every node is a real label — content-less category rows offer a to give them a landing page. Deleting a category removes only its landing page (the label and its subpages stay). Every non-empty list ends with a row that starts a new page as a local-only tree row at that level; the row can be dragged into place before its title and slug are filled in and is persisted only on commit. While dragging, these rows double as "end of this list" drop targets; dropping ON the lower part of a row makes the page that row's first child (even a leaf's, creating a sublist), while a row's exposed top edge inserts a sibling before it. A dragged row's indentation previews the target list's depth. Rows are always editable: titles save while typing, slug edits commit on blur/Enter since they rename the path (moving the whole subtree). The front page is the root row with an empty slug — renaming it away leaves no front page ("/" redirects to the first nav item), and giving another top-level row the empty slug makes it the front page.
- Preview and saving go over a **WebSocket** (`/_api/ws/editor`) with a stateless JSON protocol (`open`/`render`/`save`; on save all fields are optional and absent ones keep their old values, `move_from` renames), avoiding REST polling and races. Rendering always stays server-side. - Preview and saving go over a **WebSocket** (`/_api/ws/editor`) with a stateless JSON protocol (`open`/`render`/`save`; on save all fields are optional and absent ones keep their old values, `move_from` renames), avoiding REST polling and races. Rendering always stays server-side.
- A REST API also exists for scripting, all under `/_api/`: `GET pages` (the full tree), `PUT/DELETE pages/{path}`, `GET/PUT settings` (site brand, theme and custom CSS), `POST structure` (reorder/move/retitle), file upload/removal via `PUT/DELETE files/{name}`. - A REST API also exists for scripting, all under `/_api/`: `GET pages` (the full tree), `PUT/DELETE pages/{path}`, `GET/PUT settings` (site brand, theme and custom CSS), `POST structure` (reorder/move/retitle), file upload/removal via `PUT/DELETE files/{name}`.
+2 -2
View File
@@ -6,12 +6,12 @@ The Vue editor is a single tabbed `EditorShell.vue` mounted in a host div create
The shell hosts four kept-alive tabs (ordered site-wide first — site, structure — then, after a visual break, the per-page tabs — article, banner): The shell hosts four kept-alive tabs (ordered site-wide first — site, structure — then, after a visual break, the per-page tabs — article, banner):
- `PageEditor.vue` — CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`, previewing into the visible article; editor and article scrolls are linked piecewise-linearly, keyed on the section anchors' `data-line` (markdown source line the backend stamps on top-level anchored h1/h2s): the page follows the cursor (fractional, wrap-aware, anchored at a fixed window height), the editor follows page scroll with a progress-based viewport anchor, applied instantly (the window keeps scrolling normally while any editor is open — the panel is fixed to the viewport's left edge, its top tracking the banner's bottom edge until the banner scrolls away — and the panel scrolls internally); anchored h2s carry their own edit pens that open the editor scrolled to that section; a format bar offers Markdown helpers — bold/italic/code/link/table/image upload, with Ctrl/Cmd-B/I/S bindings — for the hard-to-remember syntax. Edits content and title only, never the path. - `PageEditor.vue` — CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`, previewing into the visible article; editor and article scrolls are linked piecewise-linearly, keyed on the section anchors' `data-line` (markdown source line the backend stamps on top-level anchored h1/h2s): the page follows the cursor (fractional, wrap-aware, scrolling only when the cursor's page position leaves the viewport, with an edge margin), the editor follows page scroll with a progress-based viewport anchor, applied instantly (the window keeps scrolling normally while any editor is open — the panel is fixed to the viewport's left edge, its top tracking the banner's bottom edge until the banner scrolls away — and the panel scrolls internally); anchored h2s carry their own edit pens that open the editor scrolled to that section; a format bar offers Markdown helpers — bold/italic/code/link/table/image upload (always block-level on a fresh blank-separated line of its own — a cursor on a non-empty line, e.g. inside an existing image tag, inserts after that line, never into it; always with an empty `""` caption, cursor inside the quotes), toggling fences (` ``` ` code blocks and `::: aside` containers share the same machinery: clicked inside one they remove it and select the content, otherwise they wrap the selection or the cursor's line, keeping it selected), and `.left`/`.right`/`.wide`/`.margin` placement toggles plus `.small`/`.large`/`.huge` text-size toggles (brace attributes on the block at the cursor, mutually exclusive within each group; on `:::` containers a placement class replaces the container name instead — `::: aside``::: margin`), with Ctrl/Cmd-B/I/S bindings — for the hard-to-remember syntax. Edits content and title only, never the path.
- `BannerEditor.vue` — per-page banner HTML + banner design selector, previewed into `#page-banner`. - `BannerEditor.vue` — per-page banner HTML + banner design selector, previewed into `#page-banner`.
- `SiteEditor.vue` — site brand + optional custom brand HTML with image/video upload + theme selector + page-transition selector + font picker + favicon upload — clicking the preview tile picks a new one — + site-wide custom CSS, CSS injected into `<head id="pagerite-user">`. - `SiteEditor.vue` — site brand + optional custom brand HTML with image/video upload + theme selector + page-transition selector + font picker + favicon upload — clicking the preview tile picks a new one — + site-wide custom CSS, CSS injected into `<head id="pagerite-user">`.
- `StructureEditor.vue` — the vue-draggable structure tree with always-editable title/slug inputs per row. - `StructureEditor.vue` — the vue-draggable structure tree with always-editable title/slug inputs per row.
Media uploads everywhere use the image icon buttons (pasting into the editor works too). The article, banner and site-settings pens are shorthands that open the shell on the matching tab; once open, clicking a pen switches tabs (and retargets the editors to the current page) instead of closing/remounting. The close button in the tab bar closes the shell (Escape too); tabs have no close buttons of their own. Closing only HIDES the shell — the Vue app stays mounted, so page-editor state (unsaved text included) survives until a real page reload; the editor always follows the URL, so fetch-navigating with the shell open (or before re-opening it) retargets it to the new page — unsaved text is stashed per path for the session and restored when returning, cleared on save. Saving there is explicit (Ctrl+S) and refreshes the page regions in place. Admin panels never reload the page. Media uploads everywhere use the image icon buttons (pasting into the editor works too). The article, banner and site-settings pens are shorthands that open the shell on the matching tab; once open, clicking a pen switches tabs (and retargets the editors to the current page) instead of closing/remounting. The close button in the tab bar closes the shell (deliberately NOT Escape — it fired too easily by accident); tabs have no close buttons of their own. Closing only HIDES the shell — the Vue app stays mounted, so page-editor state (unsaved text included) survives until a real page reload; the editor always follows the URL, so fetch-navigating with the shell open (or before re-opening it) retargets it to the new page — unsaved text is stashed per path for the session and restored when returning, cleared on save. Saving there is explicit (Ctrl+S) and refreshes the page regions in place. Admin panels never reload the page.
In-place page re-rendering shared by the banner/site/structure tabs lives in `swapdoc.js` (`runScripts`/`loadPlain`: fetch a page, swap the dynamic regions, replaceState). It also exports `dropPageCache`, which the editor tabs call after any save that can alter the rendered HTML of other pages (theme, headings, structure, banners, site brand/CSS, favicon). Dropping the cache while editing avoids re-fetching every page immediately; the public runtime re-preloads visible links once the editor panel closes. In-place page re-rendering shared by the banner/site/structure tabs lives in `swapdoc.js` (`runScripts`/`loadPlain`: fetch a page, swap the dynamic regions, replaceState). It also exports `dropPageCache`, which the editor tabs call after any save that can alter the rendered HTML of other pages (theme, headings, structure, banners, site brand/CSS, favicon). Dropping the cache while editing avoids re-fetching every page immediately; the public runtime re-preloads visible links once the editor panel closes.
+1 -1
View File
@@ -8,7 +8,7 @@ Vue editor app entry, mounts the tabbed `EditorShell`. See `docs/editing.md` for
## `pagerite.js` ## `pagerite.js`
Public page entry; runs fetch-navigation (backed by an in-memory page cache: every visible internal link is fetched once at load and clicks are then served from JS with no fetch — the current page itself is not refetched, it enters the cache when navigated to — and the editors' `loadPlain` keeps the cache current via a `pagerite:page-fetched` event; articles are `cache-control: no-cache` on the wire). Editors can drop the entire cache with the `pagerite:drop-page-cache` event when site-wide or page changes (theme, headings, structure, banners, etc.) invalidate the cached HTML of other pages; `main.js` triggers a fresh `pagerite:preload-pages` pass when the editor panel closes so navigation is fast again. Navigation that starts while the editor is open bypasses the cache and fetches the target page on demand. Also runs scroll-reveal, a scroll-driven section hash (the location hash tracks the h1/h2 above the viewport middle via replaceState — removed above the first tagged heading and at the very top, never set on unscrollable pages), OverlayScrollbars on `document.body` (floating, auto-hiding scrollbars that never reserve layout space or shift the page when appearing; native scroll APIs like `window.scrollTo` keep working; themed via the `--os-*` variables in pagerite.css), brand shrink-to-fit (the themed size is the maximum; JS reduces the font-size so a long brand or narrow viewport still fits one line), nav condense-to-fit (the top nav stays on one row: link gaps shrink first, then the side padding, then the font size; `flex-wrap: wrap` remains the no-JS fallback), code copy buttons, and the auth check. Public page entry; runs fetch-navigation (backed by an in-memory page cache: every visible internal link is fetched once at load and clicks are then served from JS with no fetch — the current page itself is not refetched, it enters the cache when navigated to — and the editors' `loadPlain` keeps the cache current via a `pagerite:page-fetched` event; articles are `cache-control: no-cache` on the wire). Editors can drop the entire cache with the `pagerite:drop-page-cache` event when site-wide or page changes (theme, headings, structure, banners, etc.) invalidate the cached HTML of other pages; `main.js` triggers a fresh `pagerite:preload-pages` pass when the editor panel closes so navigation is fast again. Navigation that starts while the editor is open bypasses the cache and fetches the target page on demand. Also runs scroll-reveal, a scroll-driven section hash (the location hash tracks the h1/h2 above the viewport middle via replaceState — removed above the first tagged heading and at the very top, never set on unscrollable pages), OverlayScrollbars on `document.body` (floating, auto-hiding scrollbars that never reserve layout space or shift the page when appearing; native scroll APIs like `window.scrollTo` keep working; themed via the `--os-*` variables in pagerite.css), brand shrink-to-fit (the themed size is the maximum; JS reduces the font-size so a long brand or narrow viewport still fits one line), nav condense-to-fit (the top nav stays on one row: link gaps shrink first, then the side padding, then the font size; `flex-wrap: wrap` remains the no-JS fallback), code copy buttons, click-to-enlarge on article figure images (a full-viewport lightbox with the caption, closed by click or Esc), and the auth check.
It first probes `GET /auth/api/settings` to detect whether Paskia SSO is available, then `GET /_api/settings` to learn the current session's admin status. The same reverse proxy that gates `/_api` returns 401 for anonymous users, 403 for users without the admin permission, and 200 for admins. When Paskia is detected, a login link (anonymous) or profile link (logged in) is shown in the banner corner; both are plain `<a href="/auth/">` links (Paskia does not support being iframed, so we navigate normally), and a `pageshow` handler re-probes auth when history navigation restores a cached page. Admins also get the page/banner edit pens and a site-settings pen, plus a `modulepreload` warm-up of the editor bundle (the hashed asset is immutable, so it costs nothing). If no Paskia SSO is detected (dev/no proxy), editing is left open. Pages themselves render identically for everyone; the real gate is the auth proxy in front of all of `/_api`. It first probes `GET /auth/api/settings` to detect whether Paskia SSO is available, then `GET /_api/settings` to learn the current session's admin status. The same reverse proxy that gates `/_api` returns 401 for anonymous users, 403 for users without the admin permission, and 200 for admins. When Paskia is detected, a login link (anonymous) or profile link (logged in) is shown in the banner corner; both are plain `<a href="/auth/">` links (Paskia does not support being iframed, so we navigate normally), and a `pageshow` handler re-probes auth when history navigation restores a cached page. Admins also get the page/banner edit pens and a site-settings pen, plus a `modulepreload` warm-up of the editor bundle (the hashed asset is immutable, so it costs nothing). If no Paskia SSO is detected (dev/no proxy), editing is left open. Pages themselves render identically for everyone; the real gate is the auth proxy in front of all of `/_api`.
+4
View File
@@ -4,6 +4,8 @@
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { EditorView, basicSetup } from 'codemirror' import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state' import { EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands'
import { html } from '@codemirror/lang-html' import { html } from '@codemirror/lang-html'
import { cmHighlight, cmTheme } from './cmtheme' import { cmHighlight, cmTheme } from './cmtheme'
import { dropPageCache, loadPlain, runScripts } from './swapdoc' import { dropPageCache, loadPlain, runScripts } from './swapdoc'
@@ -261,6 +263,8 @@ onMounted(async () => {
doc: '', doc: '',
extensions: [ extensions: [
basicSetup, basicSetup,
// Tab/Shift-Tab indent and dedent instead of moving focus.
keymap.of([indentWithTab]),
html(), html(),
cmTheme, cmTheme,
cmHighlight, cmHighlight,
+2 -6
View File
@@ -54,20 +54,16 @@ function onSwitchEvent(ev) {
// Closing the shell hides it but keeps it mounted (main.js); the tabs stay // Closing the shell hides it but keeps it mounted (main.js); the tabs stay
// cached in KeepAlive the whole time, so no state is ever lost until a real // cached in KeepAlive the whole time, so no state is ever lost until a real
// page reload. On re-show each active tab re-applies its window title and // page reload. On re-show each active tab re-applies its window title and
// preview via its own pagerite:editor-shown listener. // preview via its own pagerite:editor-shown listener. No Escape-to-close:
function onKeydown(ev) { // it fired too easily by accident (e.g. dismissing an editor popup).
if (ev.key === 'Escape' && document.body.classList.contains('editing')) close()
}
onMounted(() => { onMounted(() => {
document.body.dataset.editorMode = activeMode.value document.body.dataset.editorMode = activeMode.value
addEventListener('pagerite:switch-editor', onSwitchEvent) addEventListener('pagerite:switch-editor', onSwitchEvent)
addEventListener('keydown', onKeydown)
}) })
onUnmounted(() => { onUnmounted(() => {
removeEventListener('pagerite:switch-editor', onSwitchEvent) removeEventListener('pagerite:switch-editor', onSwitchEvent)
removeEventListener('keydown', onKeydown)
}) })
</script> </script>
+492 -41
View File
@@ -16,6 +16,8 @@
import { onActivated, onMounted, onUnmounted, ref, watch } from 'vue' import { onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { EditorView, basicSetup } from 'codemirror' import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state' import { EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown' import { markdown } from '@codemirror/lang-markdown'
import { cmHighlight, cmTheme } from './cmtheme' import { cmHighlight, cmTheme } from './cmtheme'
import { dropPageCache, loadPlain } from './swapdoc' import { dropPageCache, loadPlain } from './swapdoc'
@@ -131,11 +133,6 @@ function close() {
if (dirty.value) loadPlain(path.value) if (dirty.value) loadPlain(path.value)
} }
function insertAtCursor(text) {
view.dispatch(view.state.replaceSelection(text))
view.focus()
}
async function uploadImage(file) { async function uploadImage(file) {
if (!file) return if (!file) return
const name = file.name.replace(/[^\w.-]/g, '-') const name = file.name.replace(/[^\w.-]/g, '-')
@@ -143,7 +140,31 @@ async function uploadImage(file) {
if (res.ok) { if (res.ok) {
const { path: stored } = await res.json() const { path: stored } = await res.json()
const alt = name.replace(/\.[^.]+$/, '') const alt = name.replace(/\.[^.]+$/, '')
insertAtCursor(`![${alt}](${stored})`) // Always include an empty caption (""), cursor inside the quotes: a
// lone image with a title renders as a captioned figure, and an empty
// caption is as good as none.
const insert = `![${alt}](${stored} "")`
// Images are never inline: the image always goes on a fresh line of
// its own, blank-separated from other content. On a non-empty line —
// notably when the cursor sits inside an existing image tag — the new
// image goes AFTER that line, never into it.
const doc = view.state.doc
const line = doc.lineAt(view.state.selection.main.from)
const prevNonEmpty = line.number > 1 && doc.line(line.number - 1).text.trim()
const nextNonEmpty = line.number < doc.lines && doc.line(line.number + 1).text.trim()
let pos, text
if (line.text.trim()) {
pos = line.to
text = '\n' + insert + (nextNonEmpty ? '\n' : '')
} else {
pos = line.from
text = (prevNonEmpty ? '\n' : '') + insert + (nextNonEmpty ? '\n' : '')
}
view.dispatch({
changes: { from: pos, insert: text },
selection: { anchor: pos + text.indexOf(insert) + insert.length - 2 },
})
view.focus()
} }
} }
@@ -170,26 +191,165 @@ function wrapInline(mark) {
view.focus() view.focus()
} }
function insertCode() { // --- Fenced blocks (``` code, ::: containers) ------------------------------
// On an empty line with no selection: a fenced code block, cursor inside. // Fences are never nested, and both kinds behave identically in the
// Otherwise an inline code wrap (toggling). // toolbar: clicked with the cursor/selection inside a fence of its kind,
// the button REMOVES the fence lines and selects the whole content;
// otherwise it wraps the selection — expanded to whole lines, so partial
// line selections and a bare cursor on a line count as that line — in a
// fence, keeping the content selected. A cursor on an empty line inserts
// an empty fence with the cursor inside.
// Find the fence block around a line range by parity (no nesting): an odd
// count of marker lines above the range means it is inside a block. The
// range's own first/last lines may be the fence lines themselves.
function enclosingFence(fromNo, toNo, markerRe) {
const doc = view.state.doc
const isFence = (n) => markerRe.test(doc.line(n).text.trimStart())
let above = 0
for (let n = 1; n < fromNo; n++) if (isFence(n)) above++
let openNo = null
if (above % 2 === 1) {
for (let n = fromNo - 1; n >= 1; n--) {
if (isFence(n)) { openNo = n; break }
}
} else if (isFence(fromNo)) {
openNo = fromNo
}
if (openNo === null) return null
for (let n = Math.max(toNo, openNo + 1); n <= doc.lines; n++) {
if (isFence(n)) return { open: doc.line(openNo), close: doc.line(n) }
}
return null
}
// Remove the fence block enclosing the selection, selecting its whole
// content. Returns true when there was one.
function removeEnclosingFence(markerRe) {
const doc = view.state.doc
const { from, to } = view.state.selection.main const { from, to } = view.state.selection.main
const line = view.state.doc.lineAt(from) const fence = enclosingFence(doc.lineAt(from).number, doc.lineAt(to).number, markerRe)
if (from === to && !line.text.trim()) { if (!fence) return false
const { open, close } = fence
// One atomic replace: fences out, content stays where it lands.
const hasAfter = close.to < doc.length
const end = hasAfter ? close.to + 1 : doc.length
const content = hasAfter
? doc.sliceString(open.to + 1, close.from) // trailing newline kept
: doc.sliceString(open.to + 1, Math.max(open.to + 1, close.from - 1))
const head = content.endsWith('\n') ? content.length - 1 : content.length
view.dispatch({
changes: { from: open.from, to: end, insert: content },
selection: { anchor: open.from, head: open.from + Math.max(0, head) },
})
view.focus()
return true
}
// Wrap the selection — expanded to whole lines (a bare cursor counts as
// its line) — in a fence, cursor left at the end of the opener line. On
// an empty line with no selection, insert an empty fence with the cursor
// at the END of the opener line (no blank content line): for ``` a
// language word can be typed right away, for ::: the container name
// (aside) can be rewritten.
function wrapInFence(openText, closeText) {
const doc = view.state.doc
const { from, to } = view.state.selection.main
const fromLine = doc.lineAt(from)
if (from === to && !fromLine.text.trim()) {
view.dispatch({ view.dispatch({
changes: { from: line.from, to: line.to, insert: '```\n\n```' }, changes: { from: fromLine.from, to: fromLine.to, insert: `${openText}\n${closeText}` },
selection: { anchor: line.from + 4 }, selection: { anchor: fromLine.from + openText.length },
}) })
view.focus() } else {
const bf = fromLine.from
// A selection ending exactly at a line start excludes that (possibly
// empty) line — only the selected lines go inside the fence.
let lastLine = doc.lineAt(to)
if (to === lastLine.from && to > from) lastLine = doc.line(lastLine.number - 1)
const bt = lastLine.to
view.dispatch({
changes: [
{ from: bt, insert: `\n${closeText}` },
{ from: bf, insert: `${openText}\n` },
],
// Cursor at the end of the opening fence line, selection cleared —
// a language word (or container name) can be typed right away.
selection: { anchor: bf + openText.length },
})
}
view.focus()
}
function insertCode() {
// Toggling, selection-preserving code helper:
// - inside a fenced block: remove the fences, content selected (above)
// - selection covering whole line(s) or spanning lines: fenced block
// - empty line, no selection: a fenced block, cursor inside
// - otherwise an inline wrap; the inner text stays selected both ways,
// and a repeated click removes the backtick run around it
const state = view.state
const doc = state.doc
const { from, to } = state.selection.main
const fromLine = doc.lineAt(from)
const toLine = doc.lineAt(to)
const inline = from !== to && fromLine.number === toLine.number
&& !(from === fromLine.from && to === toLine.to)
if (!inline && removeEnclosingFence(/^```/)) return
if (from === to) {
if (!fromLine.text.trim()) wrapInFence('```', '```')
else wrapInline('`')
return return
} }
wrapInline('`') if (!inline) {
wrapInFence('```', '```')
return
}
// Inline: a matching backtick run on both sides unwraps; otherwise wrap
// (double ticks when the text itself contains a backtick).
let l = 0
while (l < from && doc.sliceString(from - l - 1, from - l) === '`') l++
let r = 0
while (doc.sliceString(to + r, to + r + 1) === '`') r++
if (l > 0 && l === r) {
view.dispatch({
changes: [{ from: to, to: to + r }, { from: from - l, to: from }],
selection: { anchor: from - l, head: to - l },
})
} else {
const mark = doc.sliceString(from, to).includes('`') ? '``' : '`'
view.dispatch({
changes: { from, to, insert: mark + doc.sliceString(from, to) + mark },
selection: { anchor: from + mark.length, head: to + mark.length },
})
}
view.focus()
} }
function insertLink() { function insertLink() {
// Selected text becomes the link label — or the URL if it looks like one. // Toggle: with the cursor or selection anywhere inside an existing
// [label](url) on this line, unwrap it (the label stays selected).
// Otherwise the selected text becomes the label — or the URL if it
// looks like one.
const { from, to } = view.state.selection.main const { from, to } = view.state.selection.main
const text = view.state.sliceDoc(from, to) const doc = view.state.doc
const line = doc.lineAt(from)
const linkRe = /\[([^\]]*)\]\(([^)]*)\)/g
let m
while ((m = linkRe.exec(line.text))) {
if (line.text[m.index - 1] === '!') continue // image, not a link
const start = line.from + m.index
if (from >= start && to <= start + m[0].length) {
const label = m[1]
view.dispatch({
changes: { from: start, to: start + m[0].length, insert: label },
selection: { anchor: start, head: start + label.length },
})
view.focus()
return
}
}
const text = doc.sliceString(from, to)
const isUrl = /^https?:\/\/\S+$/.test(text) const isUrl = /^https?:\/\/\S+$/.test(text)
const insert = isUrl ? `[](${text})` : `[${text}]()` const insert = isUrl ? `[](${text})` : `[${text}]()`
const urlStart = from + insert.length - 1 // inside the parens const urlStart = from + insert.length - 1 // inside the parens
@@ -200,24 +360,213 @@ function insertLink() {
view.focus() view.focus()
} }
// ::: aside container, toggling like code fences (shared machinery above):
// inside one it is removed (content selected); otherwise the selection —
// or the cursor's line — becomes the content, selected. The placement
// buttons below work on the ::: line itself.
function insertAside() {
if (!removeEnclosingFence(/^:::/)) wrapInFence('::: aside', ':::')
}
// Block placement classes: .left/.right float, .wide full bleed, .margin
// a margin note; plus the text size classes .small/.large/.huge. The
// button toggles the class in the brace attributes of the block at the
// cursor (figure/image line, paragraph, code fence); classes within one
// group are mutually exclusive. ::: containers are the exception: a
// placement class replaces the container name (::: margin, etc.), a size
// class takes braces (::: aside {.small}). A blank cursor line targets
// the block above (a trailing {...} line applies there).
const PLACEMENTS = ['left', 'right', 'wide', 'margin']
const SIZES = ['small', 'large', 'huge']
// Toggle .cls in a line's trailing brace attributes, preserving the other
// tokens (language, #id, other groups' classes) and the original spacing;
// returns the new text.
function toggleAttrClass(text, cls, group) {
const m = text.match(/(\s*)\{([^{}]*)\}(\s*)$/)
if (!m) {
// A lone image takes the braces directly attached, others spaced.
const tight = /^\s*!\[[^\]]*\]\([^)]*\)$/.test(text.trimEnd()) ? '' : ' '
return text.trimEnd() + tight + `{.${cls}}`
}
const tokens = m[2].trim() ? m[2].trim().split(/\s+/) : []
const tok = `.${cls}`
let next
if (tokens.includes(tok)) {
next = tokens.filter((t) => t !== tok)
} else {
next = tokens.filter((t) => !group.some((c) => t === `.${c}`))
next.push(tok)
}
const base = text.slice(0, m.index).trimEnd()
return next.length ? base + (m[1] || ' ') + `{${next.join(' ')}}` : base
}
// Locate where block classes live for the block at the cursor:
// { line } — trailing brace attributes on that line (paragraph, image,
// fence info line); { container } — a ::: container's opener line; or
// { fence, attrLine } — a code fence, whose classes live on a line of
// their own after the closing fence (attrLine null when not written yet).
// A blank cursor line targets the block above. Shared by the class
// toggles and the pickers' current-class indicator.
function classTarget() {
const doc = view.state.doc
let line = doc.lineAt(view.state.selection.main.head)
while (!line.text.trim() && line.number > 1) line = doc.line(line.number - 1)
if (!line.text.trim()) return null
// ``` fence context: an odd count of fence lines above means the cursor
// is inside the fence or on its closing fence.
let open = false
for (let n = 1; n < line.number; n++) {
if (doc.line(n).text.trimStart().startsWith('```')) open = !open
}
if (open) {
let n = line.number
while (n <= doc.lines && !doc.line(n).text.trimStart().startsWith('```')) n++
if (n > doc.lines) return null // unclosed fence — nothing to attach to
const fence = doc.line(n)
const after = fence.number < doc.lines ? doc.line(fence.number + 1) : null
return {
fence,
attrLine: after && /^\s*\{[^{}]*\}\s*$/.test(after.text) ? after : null,
}
}
// ::: container context: same parity (containers are not nested) — on
// the opener, inside, or on the closing fence.
let above = 0
for (let n = 1; n < line.number; n++) {
if (doc.line(n).text.trimStart().startsWith(':::')) above++
}
if (above % 2 === 1) {
for (let n = line.number - 1; n >= 1; n--) {
if (doc.line(n).text.trimStart().startsWith(':::')) {
return { container: doc.line(n) }
}
}
return null
}
if (/^\s*:::\s*\w/.test(line.text)) return { container: line }
return { line }
}
function togglePlacement(cls, group = PLACEMENTS) {
const t = classTarget()
if (!t) {
view.focus()
return
}
let line
if (t.container) {
// Placement replaces the container name (clicking the active one
// reverts to aside); sizes and other classes take brace attributes.
if (PLACEMENTS.includes(cls)) {
const m = t.container.text.match(/^(\s*:::\s*)(\w+)/)
const name = m[2] === cls ? 'aside' : cls
view.dispatch({
changes: { from: t.container.from, to: t.container.to, insert: `${m[1]}${name}` },
})
view.focus()
return
}
line = t.container
} else if (t.fence) {
if (!t.attrLine) {
view.dispatch({ changes: { from: t.fence.to, insert: `\n{.${cls}}` } })
view.focus()
return
}
line = t.attrLine
} else {
line = t.line
}
const text = toggleAttrClass(line.text, cls, group)
if (text !== line.text) {
view.dispatch({ changes: { from: line.from, to: line.to, insert: text } })
}
view.focus()
}
// The class set of the block at the cursor (names without the dot): brace
// tokens, plus the container name when it is a placement (::: margin).
function braceClasses(text) {
const m = text.match(/\{([^{}]*)\}\s*$/)
if (!m) return new Set()
return new Set(
m[1].split(/\s+/).filter((tok) => tok.startsWith('.')).map((tok) => tok.slice(1)),
)
}
function currentClasses() {
const t = classTarget()
if (!t) return new Set()
if (t.container) {
const s = braceClasses(t.container.text)
const name = t.container.text.match(/^\s*:::\s*(\w+)/)?.[1]
if (PLACEMENTS.includes(name)) s.add(name)
return s
}
if (t.fence) return t.attrLine ? braceClasses(t.attrLine.text) : new Set()
return braceClasses(t.line.text)
}
// Table size picker: a hover grid popup (cols × rows) under the toolbar. // Table size picker: a hover grid popup (cols × rows) under the toolbar.
const tablePicker = ref(false) const tablePicker = ref(false)
const tableSize = ref({ cols: 0, rows: 0 }) const tableSize = ref({ cols: 0, rows: 0 })
const TABLE_MAX_COLS = 8 const TABLE_MAX_COLS = 8
const TABLE_MAX_ROWS = 6 const TABLE_MAX_ROWS = 6
// Class pickers: popup listing the block class toggles (placement ↔︎,
// text size AA), closed after applying. The block's current class of the
// group is marked; choosing "normal" (or the current class) removes it.
const classPicker = ref(null) // 'place' | 'size' | null
const activeClasses = ref(new Set())
function openClassPicker(which) {
classPicker.value = classPicker.value === which ? null : which
if (classPicker.value) activeClasses.value = currentClasses()
}
function isClassActive(cls, group) {
return cls === 'normal'
? !group.some((c) => activeClasses.value.has(c))
: activeClasses.value.has(cls)
}
function applyClass(cls, group) {
if (cls === 'normal') {
const cur = group.find((c) => activeClasses.value.has(c))
if (cur) togglePlacement(cur, group) // present → toggles off
} else {
togglePlacement(cls, group)
}
classPicker.value = null
}
function insertTable(cols, rows) { function insertTable(cols, rows) {
// A GFM table on its own blank-separated block, first header cell // A GFM table on its own blank-separated block, first header cell
// selected. // selected. Like images, a table is block-level: on a fresh line of its
const { from, to } = view.state.selection.main // own — a cursor on a non-empty line (e.g. inside an image tag) inserts
const before = from > 0 && view.state.doc.sliceString(from - 1, from) !== '\n' ? '\n\n' : '' // after that line, never into it.
const doc = view.state.doc
const line = doc.lineAt(view.state.selection.main.from)
const prevNonEmpty = line.number > 1 && doc.line(line.number - 1).text.trim()
const nextNonEmpty = line.number < doc.lines && doc.line(line.number + 1).text.trim()
const row = (cells) => `| ${cells.join(' | ')} |` const row = (cells) => `| ${cells.join(' | ')} |`
const table = `${before}${row(Array(cols).fill('column'))}\n` const grid = `${row(Array(cols).fill('column'))}\n`
+ `${row(Array(cols).fill('---'))}\n` + `${row(Array(cols).fill('---'))}\n`
+ `${Array(rows).fill(row(Array(cols).fill(''))).join('\n')}\n` + `${Array(rows).fill(row(Array(cols).fill(''))).join('\n')}`
let pos, text
if (line.text.trim()) {
pos = line.to
text = '\n' + grid + (nextNonEmpty ? '\n' : '')
} else {
pos = line.from
text = (prevNonEmpty ? '\n' : '') + grid + (nextNonEmpty ? '\n' : '')
}
const anchor = pos + text.indexOf(grid) + 2
view.dispatch({ view.dispatch({
changes: { from, to, insert: table }, changes: { from: pos, insert: text },
selection: { anchor: from + before.length + 2, head: from + before.length + 8 }, selection: { anchor, head: anchor + 6 },
}) })
tablePicker.value = false tablePicker.value = false
view.focus() view.focus()
@@ -343,9 +692,10 @@ function onEditorShown() {
// //
// Editor → page follows the CURSOR, not the editor viewport: the cursor's // Editor → page follows the CURSOR, not the editor viewport: the cursor's
// fractional line (soft-wrap included, so moving inside a wrapped // fractional line (soft-wrap included, so moving inside a wrapped
// paragraph tracks smoothly) maps to its page position, shown at a fixed // paragraph tracks smoothly) maps to its page position. The page only
// anchor height in the window — the cursor on the last line lands at the // scrolls when that position leaves the viewport (with an edge margin),
// end of the page, no ramping needed. Only cursor/selection changes drive // and then just enough to bring it back inside — cursor movement within
// view never drags the page along. Only cursor/selection changes drive
// this direction: editor wheel-scrolling repositions the text, not the // this direction: editor wheel-scrolling repositions the text, not the
// page, which removes the scroll→scroll echo entirely. // page, which removes the scroll→scroll echo entirely.
// Page → editor anchors a viewport fraction that grows with page progress // Page → editor anchors a viewport fraction that grows with page progress
@@ -398,8 +748,8 @@ function editorTopFor(line) {
return Math.min(max, block.top + (line - n) * block.height) return Math.min(max, block.top + (line - n) * block.height)
} }
//: Window height fraction where the cursor's page position is shown. //: Edge margin (window height fraction) for cursor-driven page scrolls.
const CURSOR_ANCHOR = 1 / 3 const CURSOR_MARGIN = 1 / 8
function syncWindowToEditor() { function syncWindowToEditor() {
if (syncingScroll || !view) return if (syncingScroll || !view) return
@@ -407,7 +757,10 @@ function syncWindowToEditor() {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const pts = syncPoints() const pts = syncPoints()
if (pts) { if (pts) {
// The cursor's page position, shown at a fixed window height. // Scroll the page only when the cursor's page position leaves the
// viewport (minus an edge margin): while it stays visible the page
// keeps its position, so cursor movement does not drag the page
// along; crossing an edge scrolls just enough to bring it back.
const pos = view.state.selection.main.head const pos = view.state.selection.main.head
const coords = view.coordsAtPos(pos) const coords = view.coordsAtPos(pos)
if (coords) { if (coords) {
@@ -418,8 +771,14 @@ function syncWindowToEditor() {
? Math.max(0, Math.min(1, (docY - block.top) / block.height)) ? Math.max(0, Math.min(1, (docY - block.top) / block.height))
: 0 : 0
const line = view.state.doc.lineAt(pos).number + frac const line = view.state.doc.lineAt(pos).number + frac
const y = interp(pts, line, 0, 1) - CURSOR_ANCHOR * innerHeight const y = interp(pts, line, 0, 1)
if (Math.abs(scrollY - y) > 1) scrollTo({ top: Math.max(0, y), behavior: 'instant' }) const margin = CURSOR_MARGIN * innerHeight
let target = null
if (y < scrollY + margin) target = y - margin
else if (y > scrollY + innerHeight - margin) target = y - innerHeight + margin
if (target !== null && Math.abs(scrollY - target) > 1) {
scrollTo({ top: Math.max(0, target), behavior: 'instant' })
}
} }
} }
requestAnimationFrame(() => { syncingScroll = false }) requestAnimationFrame(() => { syncingScroll = false })
@@ -506,6 +865,8 @@ onMounted(() => {
doc: '', doc: '',
extensions: [ extensions: [
basicSetup, basicSetup,
// Tab/Shift-Tab indent and dedent instead of moving focus.
keymap.of([indentWithTab]),
markdown(), markdown(),
cmTheme, cmTheme,
cmHighlight, cmHighlight,
@@ -589,17 +950,59 @@ onUnmounted(() => {
>💾</button> >💾</button>
</header> </header>
<div class="format-bar"> <div class="format-bar">
<button type="button" title="bold" @click="wrapInline('**')"><b>B</b></button> <button type="button" class="code-btn" title="code — inline wrap, or a fenced block for line-spanning selections; click again to unwrap" @click="insertCode"><code>&lt;/&gt;</code></button>
<button type="button" title="italic" @click="wrapInline('*')"><i>I</i></button> <button type="button" title="link (toggle: click inside a link to unwrap it)" @click="insertLink">🔗</button>
<button type="button" title="code (empty line: code block)" @click="insertCode"><code>&lt;/&gt;</code></button>
<button type="button" title="link" @click="insertLink">🔗</button>
<button <button
type="button" type="button"
title="table" title="table"
:class="{ active: tablePicker }" :class="{ active: tablePicker }"
@click="tablePicker = !tablePicker" @click="tablePicker = !tablePicker"
></button> ></button>
<button type="button" title="insert image (upload) — pasting works too" @click="fileInput.click()">🖼</button> <button type="button" title="insert image (upload) — pasting works too" @click="fileInput.click()">🖼</button>
<button type="button" title="aside box (::: aside) — wraps the selection or the cursor's line; clicked inside one, removes it" @click="insertAside"></button>
<span class="picker">
<button
type="button"
title="block placement class"
:class="{ active: classPicker === 'place' }"
@click="openClassPicker('place')"
></button>
<span v-if="classPicker === 'place'" class="picker-pop">
<button
v-for="c in ['normal', ...PLACEMENTS]"
:key="c"
type="button"
:class="{ active: isClassActive(c, PLACEMENTS), normal: c === 'normal' }"
:title="c === 'normal'
? 'remove the block\'s placement class'
: `${c} on the block at the cursor`"
@click="applyClass(c, PLACEMENTS)"
>{{ c }}</button>
</span>
</span>
<button type="button" title="bold" @click="wrapInline('**')"><b>B</b></button>
<button type="button" title="italic" @click="wrapInline('*')"><i>i</i></button>
<span class="picker">
<button
type="button"
title="text size class"
class="aa"
:class="{ active: classPicker === 'size' }"
@click="openClassPicker('size')"
><span>A</span>A</button>
<span v-if="classPicker === 'size'" class="picker-pop">
<button
v-for="c in ['small', 'normal', 'large', 'huge']"
:key="c"
type="button"
:class="{ active: isClassActive(c, SIZES), normal: c === 'normal' }"
:title="c === 'normal'
? 'remove the block\'s size class'
: `${c} on the block at the cursor`"
@click="applyClass(c, SIZES)"
>{{ c }}</button>
</span>
</span>
<div v-if="tablePicker" class="table-picker" @mouseleave="tableSize = { cols: 0, rows: 0 }"> <div v-if="tablePicker" class="table-picker" @mouseleave="tableSize = { cols: 0, rows: 0 }">
<div class="tp-grid" :style="{ gridTemplateColumns: `repeat(${TABLE_MAX_COLS}, 1fr)` }"> <div class="tp-grid" :style="{ gridTemplateColumns: `repeat(${TABLE_MAX_COLS}, 1fr)` }">
<button <button
@@ -699,9 +1102,9 @@ onUnmounted(() => {
.format-bar button { .format-bar button {
min-width: 1.7rem; min-width: 1.7rem;
padding: 0.15rem 0.3rem; padding: 0.05rem 0.2rem;
font: inherit; font: inherit;
font-size: 0.85rem; font-size: 1.05rem;
color: var(--muted); color: var(--muted);
background: none; background: none;
border: 1px solid transparent; border: 1px solid transparent;
@@ -709,10 +1112,58 @@ onUnmounted(() => {
cursor: pointer; cursor: pointer;
} }
/* Hover and selected (active) states: text color alone, no borders. */
.format-bar button:hover, .format-bar button:hover,
.format-bar button.active { .format-bar button.active {
color: var(--text); color: var(--text);
border-color: var(--line); }
/* The glyphs are small relative to the button boxes; scaling them up
(transform, so layout is unaffected) fills the empty space between
symbols. The code symbol is larger than the rest, so it scales less. */
.format-bar > button,
.picker > button {
transform: scale(1.5);
}
.format-bar > button.code-btn {
transform: scale(1.25);
}
/* Class pickers: a button opening a small popup of class toggles (like
the table picker), anchored under its own button. */
.picker {
position: relative;
display: flex;
}
/* The size icon: two capital As at different sizes. */
.aa span {
font-size: 0.65em;
}
.picker-pop {
position: absolute;
top: 100%;
left: 0;
z-index: 20;
display: flex;
gap: 0.15rem;
padding: 0.3rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 6px;
box-shadow: 0 4px 16px #0004;
}
.picker-pop button {
font-family: var(--font-code, monospace);
font-size: 0.85rem;
}
/* "normal" (the reset entry) reads as text, not a class name. */
.picker-pop button.normal {
font-family: inherit;
} }
/* Table size picker: hover grid popup below the format bar; the hovered /* Table size picker: hover grid popup below the format bar; the hovered
+6
View File
@@ -5,6 +5,8 @@
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { EditorView, basicSetup } from 'codemirror' import { EditorView, basicSetup } from 'codemirror'
import { EditorState } from '@codemirror/state' import { EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands'
import { css } from '@codemirror/lang-css' import { css } from '@codemirror/lang-css'
import { html } from '@codemirror/lang-html' import { html } from '@codemirror/lang-html'
import { cmHighlight, cmTheme } from './cmtheme' import { cmHighlight, cmTheme } from './cmtheme'
@@ -487,6 +489,8 @@ onMounted(async () => {
doc: '', doc: '',
extensions: [ extensions: [
basicSetup, basicSetup,
// Tab/Shift-Tab indent and dedent instead of moving focus.
keymap.of([indentWithTab]),
css(), css(),
cmTheme, cmTheme,
cmHighlight, cmHighlight,
@@ -506,6 +510,8 @@ onMounted(async () => {
doc: '', doc: '',
extensions: [ extensions: [
basicSetup, basicSetup,
// Tab/Shift-Tab indent and dedent instead of moving focus.
keymap.of([indentWithTab]),
html(), html(),
cmTheme, cmTheme,
cmHighlight, cmHighlight,
+8 -4
View File
@@ -106,7 +106,8 @@ function discardPending() {
async function commitPending() { async function commitPending() {
const node = pending.value const node = pending.value
if (!node) return if (!node) return
// Empty slug: derive one from the title (transliterated to ASCII). // The typed slug is slugified at commit; empty derives one from the
// title (transliterated to ASCII).
const slug = slugify(node.slug.trim()) || slugify(node.title) const slug = slugify(node.slug.trim()) || slugify(node.title)
if (!slug) { if (!slug) {
return return
@@ -216,10 +217,13 @@ function onTitleInput(node, ev) {
}) })
} }
// The slug inputs are filtered as you type (StructureTree onSlugInput, // Slug inputs are typed freely (spaces become hyphens live, see
// see slugify.js); the server re-validates and its reason is shown. // StructureTree onSlugInput); the value is slugified here at commit
// (blur/Enter) before talking to the server, which re-validates (e.g.
// reserved names) and its reason is shown.
async function commitSlug(node, ev) { async function commitSlug(node, ev) {
const slug = ev.target.value.trim() const slug = slugify(ev.target.value.trim())
ev.target.value = slug
if (slug === node.slug) return if (slug === node.slug) return
const parent = node.path.split('/').slice(0, -1).join('/') const parent = node.path.split('/').slice(0, -1).join('/')
// Empty slug at top level = the front page (path ""). // Empty slug at top level = the front page (path "").
+13 -12
View File
@@ -34,16 +34,17 @@ const props = defineProps({
const handlers = inject('structureHandlers') const handlers = inject('structureHandlers')
// Live-filter the slug inputs as they are typed (oninput): invalid // Slug inputs accept free typing; the only live rewrites are turning
// characters are simply not accepted, spaces become hyphens and unicode // spaces into hyphens and lowercasing (both keep the length for ASCII,
// folds to ASCII (see slugify.js). Existing rows commit on change, the // so the cursor stays put). Anything else (unicode folding, stripping,
// pending row is v-modeled. // collapsing) is left for commit time, where the value is run through
function onSlugInput(ev) { // slugify before talking to the server (StructureEditor). `element` is
ev.target.value = slugify(ev.target.value) // the pending row (v-modeled), null for existing rows (plain :value
} // binding, read back on commit).
function onSlugInput(element, ev) {
function onPendingSlugInput(element, ev) { const v = ev.target.value.replace(/\s/g, '-').toLowerCase()
element.slug = slugify(ev.target.value) ev.target.value = v
if (element) element.slug = v
} }
// Focus the title input of a fresh pending row. // Focus the title input of a fresh pending row.
@@ -113,7 +114,7 @@ function onEnd() {
class="edit slug-edit" class="edit slug-edit"
:placeholder="slugify(element.title)" :placeholder="slugify(element.title)"
title="Slug (last path segment) — empty: derived from the title" title="Slug (last path segment) — empty: derived from the title"
@input="onPendingSlugInput(element, $event)" @input="onSlugInput(element, $event)"
@keyup.enter="handlers.commitPending()" @keyup.enter="handlers.commitPending()"
@keyup.esc="handlers.discardPending()" @keyup.esc="handlers.discardPending()"
/> />
@@ -135,7 +136,7 @@ function onEnd() {
:value="element.slug" :value="element.slug"
placeholder="front page" placeholder="front page"
title="Slug (last path segment) — renames move the whole subtree. Empty at top level = front page" title="Slug (last path segment) — renames move the whole subtree. Empty at top level = front page"
@input="onSlugInput" @input="onSlugInput(null, $event)"
@change="handlers.commitSlug(element, $event)" @change="handlers.commitSlug(element, $event)"
/> />
<span class="acts"> <span class="acts">
+207 -56
View File
@@ -17,7 +17,15 @@
--line: #0000001a; --line: #0000001a;
/* Links stay quiet — mostly text color with a hint of the accent — /* Links stay quiet — mostly text color with a hint of the accent —
and light up to the full accent on hover. */ and light up to the full accent on hover. */
--link: color-mix(in oklab, var(--text) 50%, var(--accent)); --link: color-mix(var(--text) 50%, var(--accent));
/* Inline code tint in body paragraphs: halfway between text and muted.
Both endpoints are theme constants, so the mix is a definite color
per theme — themes may also pin it outright. Outside paragraphs code
inherits the context's color (accent headings stay accent). */
--code-inline: color-mix(var(--text), var(--muted));
/* Selection fill for page text and the CodeMirror editors; themes
override when the accent tint clashes with accent-colored text. */
--selection-bg: color-mix(var(--accent) 30%, transparent);
/* Code highlighting palette, consumed by pygments.css: complete light and /* Code highlighting palette, consumed by pygments.css: complete light and
dark sets (background included), resolved by light-dark() from the dark sets (background included), resolved by light-dark() from the
used color-scheme. A theme picks a set simply by declaring used color-scheme. A theme picks a set simply by declaring
@@ -71,10 +79,25 @@
} }
::selection { ::selection {
background: color-mix(var(--accent) 30%, transparent); background: var(--selection-bg);
color: inherit; color: inherit;
} }
/* Text size classes for any block ({.small} {.large} {.huge}, set via the
format bar or by hand): em units on the single 1rem base scale, so they
compose with the theme's typography. */
.small {
font-size: 0.7em;
}
.large {
font-size: 1.5em;
}
.huge {
font-size: 3em;
}
/* 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 {
@@ -93,13 +116,13 @@ html {
auto-hidden scrollbars styled by the --os-* variables below, so they auto-hidden scrollbars styled by the --os-* variables below, so they
never reserve layout space or shift the page when appearing. */ never reserve layout space or shift the page when appearing. */
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--muted) 45%, transparent) transparent; scrollbar-color: color-mix(var(--muted) 45%, transparent) transparent;
} }
.os-scrollbar { .os-scrollbar {
--os-size: 0.5rem; --os-size: 0.5rem;
--os-thumb-bg: color-mix(in srgb, var(--muted) 45%, transparent); --os-thumb-bg: color-mix(var(--muted) 45%, transparent);
--os-thumb-hover-bg: color-mix(in srgb, var(--muted) 65%, transparent); --os-thumb-hover-bg: color-mix(var(--muted) 65%, transparent);
--os-thumb-active-bg: var(--muted); --os-thumb-active-bg: var(--muted);
--os-track-bg: transparent; --os-track-bg: transparent;
--os-thumb-border-radius: 0.25rem; --os-thumb-border-radius: 0.25rem;
@@ -107,7 +130,10 @@ html {
body { body {
font-family: var(--font-body); font-family: var(--font-body);
font-size: 1.05rem; /* 1rem exactly: the text scale (headings, .small/.large/.huge) keys off
one base size — no per-context tweaking, or consistent sizing becomes
impossible. */
font-size: 1rem;
line-height: 1.65; line-height: 1.65;
/* Tabular numerals wherever the active font supports them; avoids /* Tabular numerals wherever the active font supports them; avoids
numbers jumping in width as counters/values change. */ numbers jumping in width as counters/values change. */
@@ -385,7 +411,7 @@ body.editing #sidebar {
overflow-y: auto; overflow-y: auto;
padding: 1rem 1rem 1rem 1.25rem; padding: 1rem 1rem 1rem 1.25rem;
border-radius: 0 0 0.5rem 0; border-radius: 0 0 0.5rem 0;
background: color-mix(in srgb, var(--bg) 75%, transparent); background: color-mix(var(--bg) 75%, transparent);
backdrop-filter: blur(0.5rem); backdrop-filter: blur(0.5rem);
} }
@@ -797,7 +823,7 @@ article dd {
two fluid lanes (36rem minimum, never more than two) once they fit two fluid lanes (36rem minimum, never more than two) once they fit
beside the zone, capped at 102rem total. The zone — the region the nav beside the zone, capped at 102rem total. The zone — the region the nav
sidebar overlays — is a margin indent on the lane content; margin sidebar overlays — is a margin indent on the lane content; margin
boxes float into it, and the text never moves. */ boxes are placed into it out of flow, and the text never moves. */
article.multicol { article.multicol {
margin-inline: auto; margin-inline: auto;
max-width: 58rem; /* 42rem lane + 16rem zone */ max-width: 58rem; /* 42rem lane + 16rem zone */
@@ -806,32 +832,27 @@ article.multicol {
@container (min-width: 45rem) { @container (min-width: 45rem) {
/* The side zone (not on phones): lane content indents 16rem; margin /* The side zone (not on phones): lane content indents 16rem; margin
boxes ({.margin} / ::: margin blocks, ::: aside, {.margin} figures) boxes ({.margin} / ::: margin blocks, ::: aside, {.margin} figures)
float at the article's left edge — the same region the nav sidebar are taken out of flow and placed against the article's left edge —
overlays. Scoped to direct article children (the backend render keeps the same region the nav sidebar overlays. The boxes stay in the
margin blocks out of the column segments); nested ones keep the column segment at their anchor point (the backend render no longer
in-column float fallback. */ splits segments around them); absolute positioning off the article —
always position: relative — pins the horizontal side to the zone
regardless of any column layout inside, while the unset top keeps
the box at the vertical position where it occurs in the text. */
article.multicol>.colseg, article.multicol>.colseg,
article.multicol>h1, article.multicol>h1,
article.multicol>h2 { article.multicol>h2 {
margin-left: 16rem; margin-left: 16rem;
} }
article.multicol>.margin, article.multicol .margin,
article.multicol>.aside, article.multicol .aside,
article.multicol>figure:has(.margin) { article.multicol figure:has(.margin) {
float: left; position: absolute;
clear: left; left: 0;
width: 14rem; width: 14rem;
max-width: none; max-width: none;
margin: 0.3rem 2rem 1rem 0; margin: 0.3rem 0 0;
}
/* Wide separators start below any margin box — their bleed must not
wrap around it. */
article.multicol>figure:has(.wide),
article.multicol>div.wide,
article.multicol>pre.wide {
clear: left;
} }
} }
@@ -875,11 +896,10 @@ article.multicol {
margin-left: 0; margin-left: 0;
} }
body:has(#sidebar):has(.multicol):not(.editing) article.multicol>.margin, body:has(#sidebar):has(.multicol):not(.editing) article.multicol .margin,
body:has(#sidebar):has(.multicol):not(.editing) article.multicol>.aside, body:has(#sidebar):has(.multicol):not(.editing) article.multicol .aside,
body:has(#sidebar):has(.multicol):not(.editing) article.multicol>figure:has(.margin) { body:has(#sidebar):has(.multicol):not(.editing) article.multicol figure:has(.margin) {
float: left; position: absolute;
clear: left;
/* Attached to the article's left border (1.25rem gap), hanging into /* Attached to the article's left border (1.25rem gap), hanging into
the left lane and growing leftward with it: 12rem when the lane is the left lane and growing leftward with it: 12rem when the lane is
tight, up to 150% (18rem) when the track or the surplus has room tight, up to 150% (18rem) when the track or the surplus has room
@@ -888,7 +908,8 @@ article.multicol {
--box-w: min(18rem, var(--lane) + 100cqw - 100% - 1.25rem); --box-w: min(18rem, var(--lane) + 100cqw - 100% - 1.25rem);
width: var(--box-w); width: var(--box-w);
max-width: none; max-width: none;
margin: 0.3rem 0 1rem calc(-1.25rem - var(--box-w)); left: calc(-1.25rem - var(--box-w));
margin: 0.3rem 0 0;
} }
} }
@@ -999,7 +1020,7 @@ blockquote p + p {
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(var(--admonition-color, var(--accent)) 7%, transparent);
} }
.admonition> :last-child, .admonition> :last-child,
@@ -1072,14 +1093,17 @@ blockquote p + p {
--admonition-color: var(--accent3); --admonition-color: var(--accent3);
} }
/* Side boxes: ::: aside is a muted floated box (consecutive asides stack /* Side boxes: ::: aside is a muted floated box; {.margin} / ::: margin
via clear: left); {.margin} / ::: margin is a plainer margin note, and is a plainer margin note, and figures take {.margin} like {.left}.
figures take {.margin} like {.left}. On multicol pages they float in Where the layout has room for a side zone — multicol pages, the
the composition's left side zone — or in the sidebar's track when the sidebar's track, the wide single-column gutter (see the article
layout reserves one (see the article section); on wide single-column section and the figure rules below) — the boxes are taken out of flow
pages they lean into the left gutter (with the figure rules below); and absolutely positioned into it, off the article's left border, each
otherwise they stay in-column left floats. Headings already clear at the vertical spot where it occurs in the text (boxes occurring
floats, so boxes never bleed into the next section. */ closer together than their heights may overlap — keep them apart);
otherwise they stay in-column left floats (consecutive floats stack
via clear: left). Headings already clear floats, so in-column boxes
never bleed into the next section. */
.aside { .aside {
float: left; float: left;
clear: left; clear: left;
@@ -1089,7 +1113,7 @@ blockquote p + p {
padding: 0.6rem 0.9rem; padding: 0.6rem 0.9rem;
font-size: 0.9rem; font-size: 0.9rem;
color: var(--muted); color: var(--muted);
background: color-mix(in srgb, var(--accent) 6%, transparent); background: color-mix(var(--accent) 6%, transparent);
border-radius: 0.3rem; border-radius: 0.3rem;
} }
@@ -1148,6 +1172,32 @@ code {
font-size-adjust: ex-height var(--code-x-height); font-size-adjust: ex-height var(--code-x-height);
} }
/* Inline code: a slight nudge toward the muted tone rather than a fixed
color, so code inside accent-colored headings keeps the heading's hue
and the distinction stays subtle everywhere. color-mix resolves against
the inherited color (currentColor in a color declaration refers to the
inherited value), shifting it 30% toward --muted. */
code:not(pre code) {
padding-inline: 0.2em;
/* Never break inside a code span. word-break: keep-all would not
suffice: a hard hyphen is an explicit break opportunity (UAX #14),
which keep-all does not suppress — `--arg` could still split after
the dashes. Inline code is short, so nowrap is safe here. */
white-space: nowrap;
}
/* In body paragraphs the text color is a known constant (--text), so
code can take a fixed tint (--code-inline) instead of an unreliable
mix off the inherited color. A paragraph never wraps pre, so no guard
is needed here. */
p code {
color: var(--code-inline);
}
code:not(pre code):first-child {
padding-left: 0;
}
/* Click-to-copy button (added by pagerite.js) */ /* Click-to-copy button (added by pagerite.js) */
.copy { .copy {
position: absolute; position: absolute;
@@ -1192,18 +1242,18 @@ td {
th { th {
text-align: left; text-align: left;
background: linear-gradient(180deg, background: linear-gradient(180deg,
var(--table-head-a, color-mix(in srgb, var(--accent) 10%, var(--surface))), var(--table-head-a, color-mix(var(--accent) 10%, var(--surface))),
var(--table-head-b, color-mix(in srgb, var(--accent) 18%, var(--surface)))); var(--table-head-b, color-mix(var(--accent) 18%, var(--surface))));
} }
td { td {
background: linear-gradient(160deg, background: linear-gradient(160deg,
color-mix(in srgb, var(--table-tint, var(--accent)) 5%, transparent), color-mix(var(--table-tint, var(--accent)) 5%, transparent),
transparent 75%); transparent 75%);
} }
tbody tr+tr td { tbody tr+tr td {
border-top: 1px solid color-mix(in srgb, var(--table-tint, var(--accent)) 12%, transparent); border-top: 1px solid color-mix(var(--table-tint, var(--accent)) 12%, transparent);
} }
/* Definition lists are laid out as a lightweight two-column grid — terms /* Definition lists are laid out as a lightweight two-column grid — terms
@@ -1277,6 +1327,24 @@ figure:has(.left) {
margin: 0.3rem 1em 1rem 0; margin: 0.3rem 1em 1rem 0;
} }
/* The same floats for other blocks: ::: left / ::: right containers
(rendered div.left/right), paragraphs, code fences, blockquotes and
tables all take the class directly ({.right} at the end of a
paragraph's last line, a trailing {.left} line after a fence, ...). */
:is(div, p, pre, blockquote, table).right {
float: right;
width: 30%;
max-width: 50%;
margin: 0.3rem 0 1rem 1em;
}
:is(div, p, pre, blockquote, table).left {
float: left;
width: 30%;
max-width: 50%;
margin: 0.3rem 1em 1rem 0;
}
/* An image with an explicit width attribute shrink-wraps instead: the /* An image with an explicit width attribute shrink-wraps instead: the
figure fits the image and, per the auto inline margins above, centers figure fits the image and, per the auto inline margins above, centers
in the column. Placed after the percentage widths above so it in the column. Placed after the percentage widths above so it
@@ -1294,21 +1362,96 @@ figure:has(.margin) {
margin: 0.3rem 1em 1rem 0; margin: 0.3rem 1em 1rem 0;
} }
/* Click-to-enlarge (pagerite.js): article figure images open in a
full-viewport lightbox — the image as large as fits with the caption
below; click or Esc closes. The backdrop keeps a hint of the page
behind it (blur + dark tint) rather than going solid black; themes
retune it via --lightbox-bg / --lightbox-text. */
article figure img {
cursor: zoom-in;
}
#lightbox {
--lightbox-bg: rgb(0 0 0 / 0.68);
--lightbox-text: #e8e8e8;
position: fixed;
inset: 0;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--lightbox-bg);
backdrop-filter: blur(1rem) saturate(0.85);
/* Wheel/touch scrolling stops at the overlay instead of chaining to
the page behind it. overscroll-behavior needs a scroll container,
hence overflow: auto — the content is clamped to fit, so it never
actually scrolls. */
overflow: auto;
overscroll-behavior: contain;
cursor: zoom-out;
}
#lightbox img {
max-width: 100vw;
/* Flex-shrink does the vertical fit: the image yields exactly the
space the caption needs, no fixed reservation. */
max-height: 100%;
flex: 0 1 auto;
min-height: 0;
object-fit: contain;
border-radius: 3px;
box-shadow: 0 1rem 4rem rgb(0 0 0 / 0.55);
}
#lightbox .caption {
max-width: 65ch;
padding: 0.8rem 1rem;
color: var(--lightbox-text);
font-size: 0.95rem;
text-align: center;
opacity: 0.85;
}
@media (prefers-reduced-motion: no-preference) {
#lightbox {
animation: lightbox-in 0.18s ease-out;
}
#lightbox img {
animation: lightbox-img 0.22s ease-out;
}
@keyframes lightbox-in {
from {
opacity: 0;
}
}
@keyframes lightbox-img {
from {
opacity: 0;
transform: scale(0.96);
}
}
}
/* Wide single-column pages: margin boxes lean into the vacant left /* Wide single-column pages: margin boxes lean into the vacant left
gutter instead (below 104rem the gutter cannot hold the box, and while gutter instead (below 104rem the gutter cannot hold the box, and while
editing the docked panel reshapes the gutters — in both they stay editing the docked panel reshapes the gutters — in both they stay
plain floats). The box grows with the gutter up to 150% (18rem), its plain floats). Out of flow like on multicol pages: the box hangs off
right side 1.25rem off the article's left border. */ the article's left border, growing with the gutter up to 150% (18rem),
its right side 1.25rem off the border. */
@media (min-width: 104rem) { @media (min-width: 104rem) {
body:not(.editing):not(:has(.multicol)) article>.margin, body:not(.editing):not(:has(.multicol)) article .margin,
body:not(.editing):not(:has(.multicol)) article>.aside, body:not(.editing):not(:has(.multicol)) article .aside,
body:not(.editing):not(:has(.multicol)) article>figure:has(.margin) { body:not(.editing):not(:has(.multicol)) article figure:has(.margin) {
float: left; position: absolute;
clear: left;
--box-w: min(18rem, (100vw - 78rem) / 2 - 1.25rem); --box-w: min(18rem, (100vw - 78rem) / 2 - 1.25rem);
width: var(--box-w); width: var(--box-w);
max-width: none; max-width: none;
margin: 0.3rem 0 1rem calc(-1.25rem - var(--box-w)); left: calc(-1.25rem - var(--box-w));
margin: 0.3rem 0 0;
} }
} }
@@ -1457,8 +1600,8 @@ 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
floats at all — .left/.right/.margin figures fall back to plain floats at all — .left/.right/.margin figures and blocks fall back to
centered figures (explicit img widths still shrink-wrap), margin boxes plain full-width (explicit img widths still shrink-wrap), margin boxes
go full width, while .wide keeps its full viewport bleed. */ go full width, while .wide keeps its full viewport bleed. */
@media (max-width: 48rem) { @media (max-width: 48rem) {
@@ -1532,6 +1675,14 @@ article h2 {
margin: 0 auto 1.5rem; margin: 0 auto 1.5rem;
} }
/* Non-figure floated blocks flatten to plain full-width blocks too. */
:is(div, p, pre, blockquote, table):is(.right, .left) {
float: none;
width: auto;
max-width: none;
margin: 0 0 1rem;
}
/* Margin boxes go full width too — no room for side floats on a /* Margin boxes go full width too — no room for side floats on a
phone. */ phone. */
.aside, .aside,
+2 -2
View File
@@ -25,12 +25,12 @@ pre code .cp { color: var(--code-comment); font-weight: bold; font-style: italic
pre code .cpf { color: var(--code-comment); font-style: italic } /* Comment.PreprocFile */ pre code .cpf { color: var(--code-comment); font-style: italic } /* Comment.PreprocFile */
pre code .c1 { color: var(--code-comment); font-style: italic } /* Comment.Single */ pre code .c1 { color: var(--code-comment); font-style: italic } /* Comment.Single */
pre code .cs { color: var(--code-comment); font-weight: bold; font-style: italic } /* Comment.Special */ pre code .cs { color: var(--code-comment); font-weight: bold; font-style: italic } /* Comment.Special */
pre code .gd { color: var(--code-error); background-color: color-mix(in oklab, var(--code-error) 25%, var(--code-bg)) } /* Generic.Deleted */ pre code .gd { color: var(--code-error); background-color: color-mix(var(--code-error) 25%, var(--code-bg)) } /* Generic.Deleted */
pre code .ge { color: var(--code-text); font-style: italic } /* Generic.Emph */ pre code .ge { color: var(--code-text); font-style: italic } /* Generic.Emph */
pre code .ges { color: var(--code-text); font-weight: bold; font-style: italic } /* Generic.EmphStrong */ pre code .ges { color: var(--code-text); font-weight: bold; font-style: italic } /* Generic.EmphStrong */
pre code .gr { color: var(--code-error) } /* Generic.Error */ pre code .gr { color: var(--code-error) } /* Generic.Error */
pre code .gh { color: var(--code-builtin); font-weight: bold } /* Generic.Heading */ pre code .gh { color: var(--code-builtin); font-weight: bold } /* Generic.Heading */
pre code .gi { color: var(--code-added); background-color: color-mix(in oklab, var(--code-added) 25%, var(--code-bg)) } /* Generic.Inserted */ pre code .gi { color: var(--code-added); background-color: color-mix(var(--code-added) 25%, var(--code-bg)) } /* Generic.Inserted */
pre code .go { color: var(--code-muted) } /* Generic.Output */ pre code .go { color: var(--code-muted) } /* Generic.Output */
pre code .gp { color: var(--code-muted) } /* Generic.Prompt */ pre code .gp { color: var(--code-muted) } /* Generic.Prompt */
pre code .gs { color: var(--code-text); font-weight: bold } /* Generic.Strong */ pre code .gs { color: var(--code-text); font-weight: bold } /* Generic.Strong */
+17 -3
View File
@@ -8,7 +8,7 @@ import { tags } from '@lezer/highlight'
// The base theme sets monospace on .cm-scroller, so the font must be set // The base theme sets monospace on .cm-scroller, so the font must be set
// there, not on "&". // there, not on "&".
export const cmTheme = EditorView.theme({ const cmEditorTheme = EditorView.theme({
"&": { "&": {
backgroundColor: "var(--bg)", backgroundColor: "var(--bg)",
color: "var(--text)", color: "var(--text)",
@@ -33,11 +33,25 @@ export const cmTheme = EditorView.theme({
".cm-cursor": { borderLeftColor: "var(--text)" }, ".cm-cursor": { borderLeftColor: "var(--text)" },
// basicSetup's active-line highlight assumes a dark theme. // basicSetup's active-line highlight assumes a dark theme.
".cm-activeLine": { backgroundColor: "transparent" }, ".cm-activeLine": { backgroundColor: "transparent" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground":
{ backgroundColor: "var(--line)" },
"&.cm-focused": { outline: "none" }, "&.cm-focused": { outline: "none" },
}) })
// Selection color needs a baseTheme: only base themes support the
// &light/&dark selectors, and @codemirror/view's own selection rules use
// them — we must match its selectors exactly (equal specificity) and rely
// on mounting later to win. Focused: the page's --selection-bg (the base
// accents tint; themes may override it). Unfocused: hidden, like a normal
// input (CodeMirror greys it by default).
const cmSelection = EditorView.baseTheme({
"&light .cm-selectionBackground, &dark .cm-selectionBackground":
{ backgroundColor: "transparent" },
"&light.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, &dark.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground":
{ backgroundColor: "var(--selection-bg)" },
})
// Exported as one extension so the editors just list `cmTheme`.
export const cmTheme = [cmEditorTheme, cmSelection]
export const cmHighlight = syntaxHighlighting(HighlightStyle.define([ export const cmHighlight = syntaxHighlighting(HighlightStyle.define([
{ tag: tags.heading, fontWeight: "600", color: "var(--accent)" }, { tag: tags.heading, fontWeight: "600", color: "var(--accent)" },
{ tag: tags.strong, fontWeight: "700" }, { tag: tags.strong, fontWeight: "700" },
+153 -54
View File
@@ -268,6 +268,41 @@ import "overlayscrollbars/overlayscrollbars.css";
} }
} }
// --- Figure lightbox (click to enlarge) --------------------------------
// Clicking an article figure's image opens it in a full-viewport box:
// the image as large as fits with its caption below; click or Esc
// closes. The zoomed <img> reuses the source URL — /_f/ is immutable
// and content-negotiated, so the large view comes from the browser
// cache at no extra cost.
let lightbox = null;
function closeLightbox() {
lightbox?.remove();
lightbox = null;
}
function openLightbox(figure) {
const img = figure.querySelector("img");
if (!img) return;
closeLightbox();
lightbox = document.createElement("div");
lightbox.id = "lightbox";
const big = document.createElement("img");
big.src = img.currentSrc || img.src;
big.alt = img.alt;
lightbox.append(big);
const cap = figure.querySelector("figcaption");
if (cap) {
const c = document.createElement("div");
c.className = "caption";
c.textContent = cap.textContent;
lightbox.append(c);
}
lightbox.addEventListener("click", closeLightbox);
document.body.append(lightbox);
}
// Any key dismisses the lightbox — Esc included, and the rest would
// only scroll the page behind it anyway.
addEventListener("keydown", () => closeLightbox());
// Tuck the article edit pen at the end of the first h1 (which may come // Tuck the article edit pen at the end of the first h1 (which may come
// from the markdown itself). Re-runs when the editor replaces the // from the markdown itself). Re-runs when the editor replaces the
// previewed article, since that wipes elements inside it. // previewed article, since that wipes elements inside it.
@@ -347,8 +382,8 @@ import "overlayscrollbars/overlayscrollbars.css";
for (const url of urls) { for (const url of urls) {
if (pageCache.has(url)) continue; if (pageCache.has(url)) continue;
// x-pagerite-preload: idle cache warm-up, not a page view — the // x-pagerite-preload: idle cache warm-up, not a page view — the
// server excludes these GETs from analytics (the ping sent on actual // server excludes these GETs from analytics (the navigation message
// navigation does the counting). // sent on actual navigation does the counting).
fetch(url, { headers: { "x-pagerite-preload": "1" } }) fetch(url, { headers: { "x-pagerite-preload": "1" } })
.then((r) => (r.ok && (r.headers.get("content-type") || "").includes("text/html") .then((r) => (r.ok && (r.headers.get("content-type") || "").includes("text/html")
? r.text() : "")) ? r.text() : ""))
@@ -432,61 +467,111 @@ import "overlayscrollbars/overlayscrollbars.css";
}); });
}, { passive: true }); }, { passive: true });
// --- Analytics pings --------------------------------------------------- // --- Analytics over WebSocket ------------------------------------------
// Fire-and-forget POSTs to /_a with the fields as query parameters (a // One /_ws connection follows the whole browsing session: the initial page
// beacon can carry no body, and query args show in server logs next to // load (starts the visit — the server counts nothing from the document GET
// the document GET they refer to): on the initial page load (starts the // alone), internal fetch-navigations, external https exits, and frequent
// visit — the server counts nothing from the document GET alone), for // active reading-time updates. Messages are JSON text frames matching the
// internal fetch-navigations, for external https exits, and on window // server's msgspec Ping struct: {fr?, to?, read?, hide?} — falsy fields
// close. ``read`` is the active time (ms) spent on ``fr``. // are omitted. ``read`` is the active time (ms) accumulated on ``fr`` since
// Reading time pauses after 1 minute of inactivity and resumes on the // the last report; reading time pauses after 1 minute of inactivity and
// next mouse/touch/scroll/keyboard event. // resumes on the next mouse/touch/scroll/keyboard event. While the user is
// Excluded: back/forward (popstate never pings), everything while the // active, accumulated reading time is flushed every few seconds, so a
// disconnect simply leaves the last reported time on the server — no close
// beacon is needed. After 5 minutes without any activity the client closes
// the channel itself (a sleeping tab would lose it anyway); the next
// activity reconnects and the server sees a new session.
// Excluded: back/forward (popstate never reports), everything while the
// editor is open (body.editing — admin noise, not visits), and // editor is open (body.editing — admin noise, not visits), and
// navigations TO the analytics page (/_a — admin machinery, and the // navigations TO the analytics page (/_a — admin machinery). Navigations
// server rejects it as a ping target anyway). Navigations AWAY from /_a // AWAY from /_a must report: load() already fetched the target page
// must ping: load() already fetched the target page without the preload // without the preload header, and without the message that GET would flush
// header, and without the ping that GET would flush to the crawler list. // to the crawler list.
// Admins (when SSO is actually in use — with no auth proxy "admin" is // Admins (when SSO is actually in use — with no auth proxy "admin" is
// everyone's state) ping normally but with hide=1: the server then // everyone's state) report normally but with hide: the server then flags
// records nothing and scrubs any session the same browser accumulated // the client record, scrubbing everything it ever did from the statistics,
// before logging in, so admins never show up as visits or crawlers. // so admins never show up as visits or crawlers.
// See docs/analytics.md. // See docs/analytics.md.
// fetch wrapper: every key of ``params`` becomes a query arg on /_a // The activity WebSocket. Messages sent before the connection opens are
// (falsy values are omitted). Admins get hide=1. ``beacon`` uses // queued (the queue keeps the interim activity). Reconnects are driven by
// sendBeacon when available, for unload-time pings. // user activity only — never by timers while the page sits idle — with an
function pingFetch(params, { beacon = false } = {}) { // exponential falloff between attempts so a failing endpoint cannot make
const query = new URLSearchParams(); // us hammer the server (or trip its security limits). After a longer
if (ssoAvailable && isAdmin) params = { ...params, hide: 1 }; // stretch without any activity we close the socket proactively: the user
for (const [key, value] of Object.entries(params)) { // has moved on and left the tab open (a sleeping browser tab would lose
if (value) query.set(key, value); // the connection anyway), so the next activity reconnects and registers
} // as a fresh session. Analytics must never break navigation: every send
const url = `/_a?${query}`; // is wrapped, and a server without the endpoint just leaves the socket
// failing in the background.
let ws = null;
const wsQueue = [];
let wsReconnectMs = 1000;
let wsNotBefore = 0;
function activityWs() {
if (ws || Date.now() < wsNotBefore) return;
const url = new URL("/_ws", location.href);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
try { try {
if (beacon && navigator.sendBeacon) { ws = new WebSocket(url);
navigator.sendBeacon(url); } catch {
} else { return;
fetch(url, { method: "POST", keepalive: true }); }
} ws.onopen = () => {
} catch { /* analytics must never break navigation */ } wsReconnectMs = 1000;
for (const msg of wsQueue.splice(0)) ws.send(JSON.stringify(msg));
};
ws.onclose = () => {
ws = null;
// No timer here: the next user activity retries, after the backoff.
wsNotBefore = Date.now() + wsReconnectMs;
wsReconnectMs = Math.min(wsReconnectMs * 2, 30_000);
};
ws.onerror = () => ws.close();
} }
function ping({ to, fr = currentPath, read = 0, beacon = false } = {}) { function report(msg) {
if (document.body.classList.contains("editing")) return; if (document.body.classList.contains("editing")) return;
if (to === "/_a") return; if (msg.to === "/_a") return;
pingFetch({ fr, to, read: Math.round(read / 1000) }, { beacon }); if (ssoAvailable && isAdmin) msg.hide = true;
activityWs();
if (ws?.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify(msg));
return;
} catch { /* fall through to queueing */ }
}
wsQueue.push(msg);
}
function ping({ to, fr = currentPath, read = 0 } = {}) {
// Reading-time updates from the analytics page itself are not tracked
// (/_a is admin machinery; the server would reject the path anyway).
if (!to && currentPath === "/_a") return;
const msg = {};
if (fr) msg.fr = fr;
if (to) msg.to = to;
const secs = Math.round(read / 1000);
if (secs > 0) msg.read = secs;
if (!msg.to && !msg.read) return;
report(msg);
} }
// Active reading time for the current page. The clock stops after 1 minute // Active reading time for the current page. The clock stops after 1 minute
// without activity and restarts on the next mouse/touch/scroll/keyboard // without activity and restarts on the next mouse/touch/scroll/keyboard
// event. // event. Every READ_FLUSH_MS of accumulated activity is reported. After
// IDLE_MS with no activity at all, the remaining read time is flushed and
// the WebSocket is closed: the user has moved on, and the next activity
// reconnects as a new session.
const INACTIVE_MS = 60_000; const INACTIVE_MS = 60_000;
const READ_FLUSH_MS = 5_000;
const IDLE_MS = 5 * 60_000;
let readStart = performance.now(); let readStart = performance.now();
let readElapsed = 0; let readElapsed = 0;
let reading = true; let reading = true;
let readInactivityTimer = null; let readInactivityTimer = null;
let closePingedFor = null; let idleTimer = null;
function markReadActivity() { function markReadActivity() {
if (!reading) { if (!reading) {
@@ -500,6 +585,24 @@ import "overlayscrollbars/overlayscrollbars.css";
reading = false; reading = false;
} }
}, INACTIVE_MS); }, INACTIVE_MS);
// Any activity is a sign of life: (re)connect the channel if it was
// dropped or idle-closed (not while editing — admin noise), and push
// the idle disconnect forward.
if (!document.body.classList.contains("editing")) activityWs();
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (ws) {
// Flush what is left unsent, then hang up. report() would try to
// reconnect a dead socket, which is exactly what we avoid here.
const left = takeReadTime();
if (Math.round(left / 1000) > 0) ping({ read: left });
ws.close();
}
}, IDLE_MS);
// Frequently update the article read time on the server.
if (readElapsed + performance.now() - readStart >= READ_FLUSH_MS) {
ping({ read: takeReadTime() });
}
} }
function takeReadTime() { function takeReadTime() {
@@ -519,28 +622,19 @@ import "overlayscrollbars/overlayscrollbars.css";
clearTimeout(readInactivityTimer); clearTimeout(readInactivityTimer);
} }
function sendClosePing() {
if (closePingedFor === currentPath) return;
closePingedFor = currentPath;
const read = takeReadTime();
if (Math.round(read / 1000) <= 0) return;
ping({ read, beacon: true });
}
for (const ev of ["mousemove", "mousedown", "touchstart", "touchmove", "scroll", "keydown"]) { for (const ev of ["mousemove", "mousedown", "touchstart", "touchmove", "scroll", "keydown"]) {
addEventListener(ev, markReadActivity, { passive: true }); addEventListener(ev, markReadActivity, { passive: true });
} }
addEventListener("pagehide", sendClosePing);
// The initial page load pings too — it is what starts the visit and // The initial page load reports too — it is what starts the visit and
// counts the entry page view (the document GET alone records nothing). // counts the entry page view (the document GET alone records nothing).
// It carries only ``to``: the server attributes the entry to the referer // It carries only ``to``: the server attributes the entry to the referer
// it saw on the document GET (unavailable to JS once loaded), and an // it saw on the document GET (unavailable to JS once loaded), and an
// ``fr`` equal to ``to`` would log a bogus self-transition when a // ``fr`` equal to ``to`` would log a bogus self-transition when a
// session already exists (e.g. a second tab). // session already exists (e.g. a second tab).
// Sent once per load, after the auth probes so the admin gate applies; // Sent once per load, after the auth probes so the admin gate applies;
// the pageshow re-probe must not ping again. Reloads are not visits: // the pageshow re-probe must not report again. Reloads are not visits:
// pinging them would double-count the view and log a self-transition. // reporting them would double-count the view and log a self-transition.
let entryPinged = false; let entryPinged = false;
function pingEntryOnce() { function pingEntryOnce() {
if (entryPinged) return; if (entryPinged) return;
@@ -763,6 +857,13 @@ import "overlayscrollbars/overlayscrollbars.css";
.catch((e) => console.error("editor load failed:", e)); .catch((e) => console.error("editor load failed:", e));
return; return;
} }
// Article figure images enlarge into the lightbox.
const fig = ev.target.closest("#main article figure");
if (fig && ev.target.closest("img")) {
ev.preventDefault();
openLightbox(fig);
return;
}
const a = ev.target.closest("a[href]"); const a = ev.target.closest("a[href]");
if (!a || a.target || a.hasAttribute("download")) return; if (!a || a.target || a.hasAttribute("download")) return;
const url = new URL(a.href, location.href); const url = new URL(a.href, location.href);
@@ -770,7 +871,6 @@ import "overlayscrollbars/overlayscrollbars.css";
// External link: the browser navigates; record the full https URL so // External link: the browser navigates; record the full https URL so
// different links to the same domain stay distinct in analytics. // different links to the same domain stay distinct in analytics.
if (url.protocol === "https:") { if (url.protocol === "https:") {
closePingedFor = currentPath;
ping({ to: url.href, read: takeReadTime() }); ping({ to: url.href, read: takeReadTime() });
} }
return; return;
@@ -794,7 +894,6 @@ import "overlayscrollbars/overlayscrollbars.css";
const from = currentPath; const from = currentPath;
load(url).then((ok) => { load(url).then((ok) => {
if (!ok) return; if (!ok) return;
closePingedFor = null;
ping({ to: url.pathname, fr: from, read: takeReadTime() }); ping({ to: url.pathname, fr: from, read: takeReadTime() });
resetReadTime(); resetReadTime();
}); });
+9
View File
@@ -26,7 +26,16 @@ export default defineConfig({
}, },
}, },
appType: 'mpa', // no SPA fallback; every HTML page is served by FastAPI appType: 'mpa', // no SPA fallback; every HTML page is served by FastAPI
resolve: {
alias: {
// All components are precompiled SFCs — drop the runtime template
// compiler (~60 kB min) from the bundle.
vue: 'vue/dist/vue.runtime.esm-bundler.js',
},
},
build: { build: {
// The main editor bundle (CodeMirror + Vue) is intentionally one chunk.
chunkSizeWarningLimit: 1200,
// Mirror the URL space in the build output: hashed files land under // Mirror the URL space in the build output: hashed files land under
// frontend-build/_assets/ and the Frontend serves the build directory // frontend-build/_assets/ and the Frontend serves the build directory
// at the site root (frontend/public/favicon.ico -> /favicon.ico). // at the site root (frontend/public/favicon.ico -> /favicon.ico).
+53 -26
View File
@@ -1,18 +1,23 @@
"""Server-side visit analytics (collection only; see docs/analytics.md). """Server-side visit analytics (collection only; see docs/analytics.md).
Events come from navigation pings POSTed to /_a by pagerite.js: the first Events come from pagerite.js over the /_ws WebSocket (``Ping`` messages as
ping on page load starts a visit, later pings extend it, and pings with no JSON text frames): the first navigation message on page load starts a
known session start a fresh one (missing data, not dropped). The document visit, later messages extend it, and messages with no known session start a
fresh one (missing data, not dropped). Active reading time is reported as
frequent ``read`` updates while the user is active; the times are
cumulative per trail item, so a disconnect simply leaves the last logged
time in place. The document
GET handler stashes the entry referer (external https origin) and any GET handler stashes the entry referer (external https origin) and any
utm_* query parameters in in-memory IP tables, consumed when the ping utm_* query parameters in in-memory IP tables, consumed when the first
starts the visit; nothing is counted without a ping (plain bots that only message starts the visit; nothing is counted without a message (plain bots
fetch documents end up in the crawler list). JS-running crawlers that only fetch documents end up in the crawler list). JS-running crawlers
(Googlebot, GoogleOther, Applebot, ...) do ping, but their UA gives them (Googlebot, GoogleOther, Applebot, ...) do connect and send messages, but
away (``_is_bot_ua``) and their pings are ignored, so they land in the their UA gives them
away (``_is_bot_ua``) and their messages 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 navigation
when the user actually navigates does the counting. message sent when the user actually navigates does the counting.
Admin clients ping with ``hide=1``: the client record is flagged ``hide``, Admin clients send ``hide``: the client record is flagged ``hide``,
which covers everything that client ever did — visits and crawler hits which covers everything that client ever did — visits and crawler hits
from before the login included. Aggregates (site visits, page views, from before the login included. Aggregates (site visits, page views,
transitions) are not stored; they are computed at display time from the transitions) are not stored; they are computed at display time from the
@@ -70,6 +75,27 @@ def _compact_user_agent(ua: str) -> str:
return " ".join(p for p in parts if p).strip() return " ".join(p for p in parts if p).strip()
class Ping(msgspec.Struct, omit_defaults=True):
"""One client message on the /_ws activity WebSocket.
Sent as a JSON text frame (msgspec-encoded, decoded to str for the
wire). ``to`` set: a navigation — internal page path or external https
exit URL. ``read`` alone (with ``fr``): an active reading-time update
for the page ``fr``; these arrive frequently while the user is active
and accumulate on the trail item. ``hide`` flags the client as an
admin: everything it ever did is excluded from the statistics.
"""
#: Path of the page the activity happened on ("" for the initial load).
fr: str = ""
#: Navigation target: internal path or external https exit URL.
to: str = ""
#: Active reading time (seconds) spent on ``fr`` since the last report.
read: int = 0
#: Admin client: record but hide everything from the statistics.
hide: bool = False
class Client(msgspec.Struct, omit_defaults=True): class Client(msgspec.Struct, omit_defaults=True):
"""Client metadata shared by visits, crawler hits and abuse hits. """Client metadata shared by visits, crawler hits and abuse hits.
@@ -151,7 +177,7 @@ class Visit(msgspec.Struct, omit_defaults=True):
class CrawlerHit(msgspec.Struct, omit_defaults=True): class CrawlerHit(msgspec.Struct, omit_defaults=True):
"""A document GET that was never followed by an analytics ping. """A document GET that was never followed by an activity message.
Client metadata is held in ``Analytics.clients`` keyed by ``client``. Client metadata is held in ``Analytics.clients`` keyed by ``client``.
""" """
@@ -392,19 +418,19 @@ class Store:
#: 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] = {}
#: 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 message starts.
#: Internal or absent referers never touch the table. #: Internal or absent referers never touch the table.
self.pending_referers: dict[str, str] = {} self.pending_referers: dict[str, str] = {}
#: ip -> utm_* query parameters from the latest document GET that #: ip -> utm_* query parameters from the latest document GET that
#: carried any, stashed for the visit the client's initial ping starts. #: carried any, stashed for the visit the client's initial message starts.
#: Only non-empty sets are stored, so a later parameter-less page #: Only non-empty sets are stored, so a later parameter-less page
#: does not overwrite an earlier tagged landing URL. #: does not overwrite an earlier tagged landing URL.
self.pending_utms: dict[str, dict[str, str]] = {} self.pending_utms: dict[str, dict[str, str]] = {}
#: Document GETs that have not yet been matched by a ping. Kept #: Document GETs that have not yet been matched by a message. Kept
#: in RAM only; expired entries are written to ``data.crawlers``. #: in RAM only; expired entries are written to ``data.crawlers``.
self.pending_crawlers: list[CrawlerHit] = [] self.pending_crawlers: list[CrawlerHit] = []
#: client hash -> {path: status} for recent document GETs, consumed #: client hash -> {path: status} for recent document GETs, consumed
#: by the matching ping to record the status of each visited path. #: by the matching message to record the status of each visited path.
self.pending_statuses: dict[bytes, dict[str, int]] = {} self.pending_statuses: dict[bytes, dict[str, int]] = {}
#: ip -> number of plain (non-telltale) 404s seen, in RAM only; #: ip -> number of plain (non-telltale) 404s seen, in RAM only;
#: reaching ``_ABUSE_404_THRESHOLD`` classifies the IP as abuse. #: reaching ``_ABUSE_404_THRESHOLD`` classifies the IP as abuse.
@@ -721,16 +747,16 @@ class Store:
) -> list[bytes]: ) -> list[bytes]:
"""Stash the entry referer/UTM tags and queue a pending crawler hit. """Stash the entry referer/UTM tags and queue a pending crawler hit.
Nothing is counted here — the client's initial /_a ping starts the Nothing is counted here — the client's first /_ws message starts the
visit (only non-admin clients ping). Only a cross-origin https visit (only non-admin clients report). Only a cross-origin https
referer updates the table; an internal or absent referer leaves any referer updates the table; an internal or absent referer leaves any
stashed origin untouched. UTM parameters are kept only when the stashed origin untouched. UTM parameters are kept only when the
landing URL actually carries them, so a subsequent parameter-less page landing URL actually carries them, so a subsequent parameter-less page
does not erase an earlier tagged landing. does not erase an earlier tagged landing.
Every document GET is also queued as a pending crawler hit. If a ping Every document GET is also queued as a pending crawler hit. If a
from the same client arrives within ``_CRAWLER_TIMEOUT``, the hit is message from the same client arrives within ``_CRAWLER_TIMEOUT``, the
discarded; otherwise it is flushed to ``data.crawlers``. The hit is discarded; otherwise it is flushed to ``data.crawlers``. The
Accept-Language header is stored on the client record immediately; Accept-Language header is stored on the client record immediately;
host/geoip are filled in later by async enrichment. host/geoip are filled in later by async enrichment.
@@ -794,15 +820,16 @@ class Store:
hide: bool = False, hide: bool = False,
read: int = 0, read: int = 0,
) -> tuple[int | None, list[bytes]]: ) -> tuple[int | None, list[bytes]]:
"""Record a client navigation ping ({from, to, read} from pagerite.js). """Record a client activity message (``Ping`` from pagerite.js over /_ws).
``to`` is an internal path ("/...") or an https URL for exit links; a ``to`` is an internal path ("/...") or an https URL for exit links; a
missing/empty ``to`` means the page is being closed and only the missing/empty ``to`` means a pure reading-time update and only the
``read`` time should be recorded. The transition is always counted when ``read`` time should be recorded. The transition is always counted when
``to`` is present; the trail only grows on first sight of a page within ``to`` is present; the trail only grows on first sight of a page within
the visit. ``read`` is the active time (seconds) spent on ``from_``. the visit. ``read`` is the active time (seconds) spent on ``from_``
since the previous report.
A ping with no known session starts a fresh visit, consuming the A message 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 client record is flagged ``hide`` is set by admin clients: the client record is flagged
@@ -811,7 +838,7 @@ class Store:
normally. Hidden clients are excluded from every statistic and list normally. Hidden clients are excluded from every statistic and list
at display time, and their pending crawler hits are discarded. at display time, and their pending crawler hits are discarded.
Pings from IPs classified as abuse, and pings whose User-Agent Messages from IPs classified as abuse, and messages 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
entirely — the crawler's pending hits stay queued and flush to entirely — the crawler's pending hits stay queued and flush to
``data.crawlers`` normally. ``data.crawlers`` normally.
+274 -97
View File
@@ -20,6 +20,7 @@ import os
import re import re
import shutil import shutil
import socket import socket
import tempfile
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, suppress from contextlib import asynccontextmanager, suppress
from datetime import UTC, datetime from datetime import UTC, datetime
@@ -31,10 +32,10 @@ from xml.sax.saxutils import escape as xml_escape
import blake3 import blake3
import httpx import httpx
import msgspec
from fastapi import ( from fastapi import (
FastAPI, FastAPI,
HTTPException, HTTPException,
Query,
Request, Request,
WebSocket, WebSocket,
WebSocketDisconnect, WebSocketDisconnect,
@@ -42,6 +43,7 @@ from fastapi import (
from fastapi.responses import RedirectResponse, Response from fastapi.responses import RedirectResponse, Response
from fastapi_vue import Frontend from fastapi_vue import Frontend
from kanta import Kanta from kanta import Kanta
from mediapreview import dispatch
from pydantic import BaseModel from pydantic import BaseModel
from zstandard import ZstdCompressor from zstandard import ZstdCompressor
@@ -78,6 +80,19 @@ analytics_store = analytics.Store(ANALYTICS_PATH)
# files on disk under hash-prefixed names, cached in RAM, served at /_f/. # files on disk under hash-prefixed names, cached in RAM, served at /_f/.
FILES_DIR = Path(os.getenv("PAGERITE_FILES", str(SITE_DIR / "files"))) FILES_DIR = Path(os.getenv("PAGERITE_FILES", str(SITE_DIR / "files")))
# Uploaded images are thumbnailed to this size and recompressed to AVIF
# (primary), with WebP and JPEG fallbacks re-encoded from the AVIF at
# somewhat lower quality (similar or smaller file size); the untouched
# original is kept alongside as ``<hash>.orig<ext>`` (never served).
IMAGE_MAXSIZE = 1920
IMAGE_QUALITY = 60
IMAGE_WEBP_QUALITY = 50
IMAGE_JPG_QUALITY = 55
# Favicons get the same derivatives but thumbnailed much smaller — 192px
# is plenty (browsers scale down for the 16x16 tab icon themselves).
FAVICON_MAXSIZE = 192
# Live WebSocket clients for the analytics stream. # Live WebSocket clients for the analytics stream.
_analytics_ws_clients: set[WebSocket] = set() _analytics_ws_clients: set[WebSocket] = set()
_analytics_broadcast_task: asyncio.Task | None = None _analytics_broadcast_task: asyncio.Task | None = None
@@ -181,16 +196,32 @@ BUILD_DIR = Path(__file__).with_name("frontend-build")
frontend = Frontend(BUILD_DIR, spa=False, cached="/_assets/") frontend = Frontend(BUILD_DIR, spa=False, cached="/_assets/")
def _ext(orig: str) -> str:
"""Sanitized lowercase extension (with dot) of an original file name."""
return "".join(c for c in Path(orig).suffix.lower() if c.isalnum() or c == ".")
def _hash_name(body: bytes, orig: str) -> str: def _hash_name(body: bytes, orig: str) -> str:
"""Content-addressed file name: blake3 hash prefix + original extension.""" """Content-addressed file name: blake3 hash prefix + original extension."""
ext = "".join(c for c in Path(orig).suffix.lower() if c.isalnum() or c == ".") return blake3.blake3(body).hexdigest()[:12] + _ext(orig)
return blake3.blake3(body).hexdigest()[:12] + ext
def _store_seed_file(markdown: str, banner: str, orig: str, body: bytes) -> tuple[str, str]: def _store_seed_file(markdown: str, banner: str, orig: str, body: bytes) -> tuple[str, str]:
"""Store a seed file content-addressed and point references at /_f/.""" """Store a seed file content-addressed and point references at /_f/.
name = _hash_name(body, orig)
file_store.put(name, body) Images get the same AVIF/WebP/JPEG derivatives as uploads and are
linked extension-less; other content is stored as-is with its
extension."""
digest = blake3.blake3(body).hexdigest()[:12]
derivatives = None if _ext(orig) == ".gif" else _image_derivatives(body, _ext(orig))
if derivatives is None:
file_store.put(digest + _ext(orig), body)
name = digest + _ext(orig)
else:
file_store.put(f"{digest}.svg" if _ext(orig) == ".svg" else f"{digest}.orig{_ext(orig)}", body)
for fmt, variant in derivatives.items():
file_store.put(f"{digest}.{fmt}", variant)
name = digest
markdown = markdown.replace(f"]({orig}", f"](/_f/{name}") markdown = markdown.replace(f"]({orig}", f"](/_f/{name}")
banner = banner.replace(f'src="/{orig}"', f'src="/_f/{name}"') banner = banner.replace(f'src="/{orig}"', f'src="/_f/{name}"')
banner = banner.replace(f'src="{orig}"', f'src="/_f/{name}"') banner = banner.replace(f'src="{orig}"', f'src="/_f/{name}"')
@@ -230,24 +261,6 @@ def _remove_page_content(menu: dict[str, Node], path: str) -> None:
del slot[0][slot[1]] del slot[0][slot[1]]
def _migrate_legacy() -> None:
"""Rebuild the legacy flat page store as a tree (one-time migration)."""
if not data.pages:
return
with kanta.transaction("migrate pages to tree"):
for path, page in data.pages.items():
node = _ensure(data.menu, path)
node.title = page.title
node.content = page.markdown
node.banner = page.banner
node.published = page.published
node.order = page.order
node.created = page.created
node.modified = page.modified
data.pages.clear()
data.version += 1
@kanta.bootstrap @kanta.bootstrap
def _seed(data: Data) -> None: def _seed(data: Data) -> None:
"""Write the demo pages on database creation (never on existing dbs).""" """Write the demo pages on database creation (never on existing dbs)."""
@@ -269,10 +282,9 @@ def _seed(data: Data) -> None:
@asynccontextmanager @asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]: async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
"""Open the database, migrate legacy content, load assets, load GeoIP.""" """Open the database (migrations run inside kanta.open), load assets, load GeoIP."""
await kanta.open() await kanta.open()
await asyncio.to_thread(file_store.load) await asyncio.to_thread(file_store.load)
_migrate_legacy()
await frontend.load() await frontend.load()
# Decompress/open the DB-IP MMDB once at startup. Lookups are then # Decompress/open the DB-IP MMDB once at startup. Lookups are then
# read-only and safe to run in background ``to_thread`` workers. # read-only and safe to run in background ``to_thread`` workers.
@@ -350,9 +362,17 @@ class FileStore:
self._cache[name] = self._entry(body) self._cache[name] = self._entry(body)
def delete(self, name: str) -> None: def delete(self, name: str) -> None:
self._cache.pop(name, None) """Delete a file plus its derivatives/original counterparts, if any.
with suppress(FileNotFoundError):
(self.path / name).unlink() An image upload is stored as a group sharing the hash prefix
(``<hash>.orig.<ext>`` + ``<hash>.avif/.webp/.jpg``); deleting any
of the names removes them all.
"""
stem = name.partition(".")[0]
for key in [k for k in self._cache if k.partition(".")[0] == stem]:
self._cache.pop(key, None)
with suppress(FileNotFoundError):
(self.path / key).unlink()
def __contains__(self, name: str) -> bool: def __contains__(self, name: str) -> bool:
return name in self._cache return name in self._cache
@@ -372,12 +392,25 @@ def _render_html(kind: str, path: str, base_url: str) -> str:
return views.render_analytics(data.menu, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, transition=data.transition) return views.render_analytics(data.menu, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, transition=data.transition)
# Render generation: bumped (and the body cache cleared) by every
# content/settings write, so page ETags and cached copies invalidate when
# navigation-affecting changes happen. In-memory only — not database state.
_render_gen = 0
def _invalidate_pages() -> None:
"""Drop cached page bodies and bump the render generation (ETags)."""
global _render_gen
_render_gen += 1
_cached_body.cache_clear()
@lru_cache(maxsize=128) @lru_cache(maxsize=128)
def _cached_body(kind: str, path: str, base_url: str, version: int, zstd: bool) -> bytes: def _cached_body(kind: str, path: str, base_url: str, zstd: bool) -> bytes:
"""Rendered page body. Every input the output depends on is in the key: """Rendered page body; cleared by _invalidate_pages on any
data.version bumps on any content/settings change, base_url feeds the content/settings change. base_url feeds the social meta URLs and zstd
social meta URLs, and zstd selects the stored encoding (both variants selects the stored encoding (both variants are cached rather than
are cached rather than re-compressed). re-compressed).
""" """
body = _render_html(kind, path, base_url).encode() body = _render_html(kind, path, base_url).encode()
return _zstd.compress(body) if zstd else body return _zstd.compress(body) if zstd else body
@@ -414,8 +447,8 @@ def _html_response(
identity = _render_html(kind, path, base_url).encode() identity = _render_html(kind, path, base_url).encode()
body = _zstd.compress(identity) if zstd else identity body = _zstd.compress(identity) if zstd else identity
else: else:
identity = _cached_body(kind, path, base_url, data.version, False) identity = _cached_body(kind, path, base_url, False)
body = _cached_body(kind, path, base_url, data.version, True) if zstd else identity body = _cached_body(kind, path, base_url, True) if zstd else identity
h = dict(headers or {}) h = dict(headers or {})
if zstd: if zstd:
h["vary"] = "accept-encoding" h["vary"] = "accept-encoding"
@@ -483,7 +516,7 @@ async def save_page(path: str, page: PageIn) -> None:
if page.banner is not None: if page.banner is not None:
node.banner = page.banner node.banner = page.banner
node.modified = datetime.now(UTC) node.modified = datetime.now(UTC)
data.version += 1 _invalidate_pages()
class StructureOp(BaseModel): class StructureOp(BaseModel):
@@ -544,7 +577,7 @@ async def update_structure(op: StructureOp) -> None:
elif op.order is not None: elif op.order is not None:
node.order = op.order node.order = op.order
node.modified = datetime.now(UTC) node.modified = datetime.now(UTC)
data.version += 1 _invalidate_pages()
@app.get("/_api/settings") @app.get("/_api/settings")
@@ -578,32 +611,44 @@ class SettingsIn(BaseModel):
@app.put("/_api/settings", status_code=204) @app.put("/_api/settings", status_code=204)
async def put_settings(settings: SettingsIn) -> None: async def put_settings(settings: SettingsIn) -> None:
"""Update site-wide settings; bumps the version so ETags invalidate.""" """Update site-wide settings; invalidates cached pages and ETags."""
with kanta.transaction("update settings"): with kanta.transaction("update settings"):
data.brand = settings.brand data.brand = settings.brand
data.brand_html = settings.brand_html data.brand_html = settings.brand_html
data.theme = settings.theme data.theme = settings.theme
data.custom_css = settings.custom_css data.custom_css = settings.custom_css
data.transition = settings.transition data.transition = settings.transition
data.version += 1 _invalidate_pages()
@app.put("/_api/settings/favicon") @app.put("/_api/settings/favicon")
async def put_favicon(request: Request) -> dict[str, str]: async def put_favicon(request: Request) -> dict[str, str]:
"""Upload a favicon into the content-addressed store and activate it. """Upload a favicon into the content-addressed store and activate it.
Raw image body (ico/png/svg...); the stored name is a blake3 hash Raw image body (ico/png/svg...). Decodable images are thumbnailed to
prefix + extension, and pages link it as <link rel="icon">. Returns FAVICON_MAXSIZE (192px — browsers scale down from there themselves)
and stored as AVIF/WebP/JPEG derivatives linked extension-less; SVG
originals also stay servable under their ``.svg`` name. Undecodable
bodies are stored as-is. Pages link it as <link rel="icon">. Returns
{"path": "/_f/..."}. {"path": "/_f/..."}.
""" """
body = await request.body() body = await request.body()
if not body: if not body:
raise HTTPException(400, "empty file") raise HTTPException(400, "empty file")
stored = _hash_name(body, request.headers.get("x-filename", "favicon.ico")) ext = _ext(request.headers.get("x-filename", "favicon.ico"))
file_store.put(stored, body) digest = blake3.blake3(body).hexdigest()[:12]
derivatives = await asyncio.to_thread(_image_derivatives, body, ext, FAVICON_MAXSIZE)
if derivatives is None: # undecodable (e.g. some .ico): store as-is
stored = digest + ext
file_store.put(stored, body)
else:
stored = digest
file_store.put(f"{digest}.svg" if ext == ".svg" else f"{digest}.orig{ext}", body)
for fmt, variant in derivatives.items():
file_store.put(f"{digest}.{fmt}", variant)
with kanta.transaction("upload favicon"): with kanta.transaction("upload favicon"):
data.favicon = stored data.favicon = stored
data.version += 1 _invalidate_pages()
return {"path": f"/_f/{stored}"} return {"path": f"/_f/{stored}"}
@@ -615,7 +660,7 @@ async def delete_favicon() -> None:
""" """
with kanta.transaction("clear favicon"): with kanta.transaction("clear favicon"):
data.favicon = "" data.favicon = ""
data.version += 1 _invalidate_pages()
class ToggleTaskIn(BaseModel): class ToggleTaskIn(BaseModel):
@@ -652,23 +697,118 @@ async def toggle_task_endpoint(body: ToggleTaskIn) -> dict[str, str]:
with kanta.transaction("toggle task", extra=path): with kanta.transaction("toggle task", extra=path):
node.content = new_markdown node.content = new_markdown
node.modified = datetime.now(UTC) node.modified = datetime.now(UTC)
data.version += 1 _invalidate_pages()
return {"markdown": new_markdown} return {"markdown": new_markdown}
def _to_avif(body: bytes, ext: str, maxsize: int = IMAGE_MAXSIZE) -> bytes | None:
"""Recompress an image body to a thumbnailed AVIF via mediapreview's
dispatch (pyvips for common formats, ffmpeg for HEIC/HEIF/AVIF), or
None if the body is not a decodable image (stored as-is by the caller).
Dispatch needs a real file for format routing, so the body goes
through a temp file.
"""
with tempfile.NamedTemporaryFile(suffix=ext) as tmp:
tmp.write(body)
tmp.flush()
try:
avif, _resp = dispatch(
Path(tmp.name),
quality=IMAGE_QUALITY,
maxsize=maxsize,
maxzoom=1,
)
except Exception:
return None
return avif
def _svg_to_png(body: bytes, maxsize: int) -> bytes | None:
"""Rasterize an SVG to PNG via pyvips, scaled so the long side is
``maxsize`` — SVGs often carry no meaningful intrinsic resolution, so
we rasterize at full image size rather than the tiny nominal one."""
import pyvips
try:
img = pyvips.Image.new_from_buffer(body, "")
scale = maxsize / max(img.width, img.height) if img.width and img.height else maxsize
if scale != 1:
img = pyvips.Image.new_from_buffer(body, "", scale=scale)
return img.write_to_buffer(".png")
except pyvips.Error:
return None
def _avif_to_format(avif: bytes, suffix: str, quality: int) -> bytes:
"""Re-encode the AVIF derivative into a fallback format (WebP/JPEG)
via pyvips. JPEG has no alpha, so it is flattened onto white;
``strip`` keeps metadata (EXIF) out of the fallbacks."""
import pyvips
img = pyvips.Image.new_from_buffer(avif, "")
if suffix == ".jpg" and img.hasalpha():
img = img.flatten(background=[255, 255, 255])
return img.write_to_buffer(suffix, Q=quality, strip=True)
def _image_derivatives(body: bytes, ext: str, maxsize: int = IMAGE_MAXSIZE) -> dict[str, bytes] | None:
"""The served variants of an uploaded image: ``avif`` (primary,
thumbnailed to ``maxsize``) plus ``webp`` and ``jpg`` fallbacks
re-encoded from it. SVGs are rasterized first (they are vector, so
the raster replaces nothing — the .svg itself stays servable).
Returns None for non-decodable content (stored as-is by the caller).
"""
if ext == ".svg":
png = _svg_to_png(body, maxsize)
if png is None:
return None
body, ext = png, ".png"
avif = _to_avif(body, ext, maxsize)
if avif is None:
return None
return {
"avif": avif,
"webp": _avif_to_format(avif, ".webp", IMAGE_WEBP_QUALITY),
"jpg": _avif_to_format(avif, ".jpg", IMAGE_JPG_QUALITY),
}
@app.put("/_api/files/{name}") @app.put("/_api/files/{name}")
async def upload_file(name: str, request: Request) -> dict[str, str]: async def upload_file(name: str, request: Request) -> dict[str, str]:
"""Store an upload (image, video...) in the content-addressed store. """Store an upload (image, video...) in the content-addressed store.
The stored name is a blake3 hash prefix + the original extension, The stored name is a blake3 hash prefix + the original extension,
served immutable at "/_f/{name}"; returns {"path": "/_f/..."}. served immutable at "/_f/{name}"; returns {"path": "/_f/..."}.
Raster images and SVGs are recompressed (SVGs rasterized) into AVIF
(primary) plus WebP and JPEG fallbacks: the original goes to
``<hash>.orig<ext>`` (kept for reprocessing, never served — it may
carry EXIF data; SVG originals stay servable as ``<hash>.svg`` since
vector carries no EXIF) and pages link the bare ``/_f/<hash>``, the
server picking the format from the request's Accept header. GIFs are
stored as-is (animation would be lost), as is other non-decodable
content.
""" """
if "/" in name or name in {".", ".."}: if "/" in name or name in {".", ".."}:
raise HTTPException(400, "bad file name") raise HTTPException(400, "bad file name")
body = await request.body() body = await request.body()
stored = _hash_name(body, name) if not body:
file_store.put(stored, body) raise HTTPException(400, "empty file")
return {"path": f"/_f/{stored}"} ext = _ext(name)
digest = blake3.blake3(body).hexdigest()[:12]
derivatives = (
None
if ext == ".gif"
else await asyncio.to_thread(_image_derivatives, body, ext)
)
if derivatives is None: # not a decodable image: store the body as-is
stored = digest + ext
file_store.put(stored, body)
return {"path": f"/_f/{stored}"}
file_store.put(f"{digest}.svg" if ext == ".svg" else f"{digest}.orig{ext}", body)
for fmt, variant in derivatives.items():
file_store.put(f"{digest}.{fmt}", variant)
return {"path": f"/_f/{digest}"}
@app.delete("/_api/files/{name}", status_code=204) @app.delete("/_api/files/{name}", status_code=204)
@@ -726,19 +866,47 @@ async def stored_file(name: str, request: Request) -> Response:
"""Serve a file from the content-addressed store (immutable: the name """Serve a file from the content-addressed store (immutable: the name
is its own hash, so cache forever). Bodies are served from the RAM is its own hash, so cache forever). Bodies are served from the RAM
cache, zstd-compressed when the client accepts it and compression cache, zstd-compressed when the client accepts it and compression
actually shrank the file.""" actually shrank the file.
A bare ``/_f/{hash}`` (no extension, how pages link uploaded images)
content-negotiates between the stored derivatives: a format is served
only when the Accept header lists it explicitly — ``image/avif`` →
AVIF, ``image/webp`` → WebP, anything else (including ``image/*`` and
``*/*``) → JPEG. An explicit extension pins the format. ``.orig.``
originals are internal (they may carry EXIF data) and never served."""
if ".orig." in name:
raise HTTPException(404)
etag = name
vary = ""
entry = file_store.get(name) entry = file_store.get(name)
if entry is None and "." not in name:
# Extension-less image link: negotiate avif/webp/jpg by Accept.
vary = "accept"
accept = request.headers.get("accept", "")
if "image/avif" in accept:
order = ("avif", "webp", "jpg")
elif "image/webp" in accept:
order = ("webp", "jpg", "avif")
else:
order = ("jpg", "webp", "avif")
for ext in order:
etag = f"{name}.{ext}"
entry = file_store.get(etag)
if entry is not None:
break
if entry is None: if entry is None:
raise HTTPException(404) raise HTTPException(404)
if request.headers.get("if-none-match") == name: if request.headers.get("if-none-match") == etag:
return Response(status_code=304) return Response(status_code=304)
body, compressed = entry body, compressed = entry
headers = {"etag": name, "cache-control": "public, max-age=31536000, immutable"} headers = {"etag": etag, "cache-control": "public, max-age=31536000, immutable"}
if compressed is not None and "zstd" in request.headers.get("accept-encoding", ""): if compressed is not None and "zstd" in request.headers.get("accept-encoding", ""):
headers["content-encoding"] = "zstd" headers["content-encoding"] = "zstd"
headers["vary"] = "accept-encoding" vary = f"{vary}, accept-encoding".lstrip(", ")
body = compressed body = compressed
mime = mimetypes.guess_type(name)[0] or "application/octet-stream" if vary:
headers["vary"] = vary
mime = mimetypes.guess_type(etag)[0] or "application/octet-stream"
return Response(body, media_type=mime, headers=headers) return Response(body, media_type=mime, headers=headers)
@@ -761,13 +929,13 @@ async def delete_page(path: str) -> None:
node.modified = datetime.now(UTC) node.modified = datetime.now(UTC)
else: else:
del slot[0][slot[1]] del slot[0][slot[1]]
data.version += 1 _invalidate_pages()
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") _SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
def _client_ip(request: Request) -> str: def _client_ip(request: Request | WebSocket) -> str:
"""Client IP: first X-Forwarded-For hop (we sit behind a proxy), else """Client IP: first X-Forwarded-For hop (we sit behind a proxy), else
the direct peer.""" the direct peer."""
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip() forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
@@ -940,48 +1108,54 @@ async def analytics_page(request: Request) -> Response:
) )
@app.post("/_a", status_code=204) @app.websocket("/_ws")
async def analytics_ping( async def activity_ws(ws: WebSocket) -> None:
request: Request, """Collect visitor activity: navigations and reading-time updates.
fr: str = Query(""),
to: str | None = Query(None),
hide: int = Query(0),
read: int = Query(0),
) -> None:
"""Record a navigation ping (?fr=&to=&hide=&read=); fire-and-forget.
The initial page-load ping carries only ``to``: the entry is attributed Public, like the pages themselves (only /_api is gated); one connection
to the referer/UTM tags stashed by the document GET (see _track_entry), follows a browsing session. Messages are ``analytics.Ping`` structs as
which JS cannot see once the page has loaded. JSON text frames; ``to`` set is a navigation, ``read`` alone a
reading-time update. The reverse-DNS and DB-IP geoip lookups happen in
The reverse-DNS and DB-IP geoip lookups happen in a background task so background tasks so message handling is never delayed by slow DNS or
the response is never delayed by slow DNS or the first MMDB decompress. the first MMDB decompress.
""" """
ip = _client_ip(request) await ws.accept()
visit_index, flushed_clients = analytics_store.ping( ip = _client_ip(ws)
fr, ua = ws.headers.get("user-agent", "")
to, accept_language = ws.headers.get("accept-language", "")
ip, try:
request.headers.get("user-agent", ""), while True:
request.headers.get("accept-language", ""), text = await ws.receive_text()
hide=bool(hide), try:
read=read, msg = msgspec.json.decode(text.encode(), type=analytics.Ping)
) except msgspec.DecodeError:
if visit_index is not None: continue
visit = analytics_store.data.visits[visit_index] visit_index, flushed_clients = analytics_store.ping(
asyncio.create_task(_enrich_client(visit.client)) msg.fr,
_schedule_client_enrichment(flushed_clients) msg.to or None,
_schedule_favicon_fetch() ip,
ua,
accept_language,
hide=msg.hide,
read=msg.read,
)
if visit_index is not None:
visit = analytics_store.data.visits[visit_index]
asyncio.create_task(_enrich_client(visit.client))
_schedule_client_enrichment(flushed_clients)
_schedule_favicon_fetch()
except WebSocketDisconnect:
pass
def _track_entry(path: str, request: Request, *, status: int = 200) -> list[bytes]: def _track_entry(path: str, request: Request, *, status: int = 200) -> list[bytes]:
"""Stash the referer/UTM tags and queue a pending crawler hit for the GET. """Stash the referer/UTM tags and queue a pending crawler hit for the GET.
Nothing is counted on the GET itself — the client's /_a ping starts the Nothing is counted on the GET itself — the client's first /_ws message
visit, so bots never register as visits (JS-running crawlers ping too, starts the visit, so bots never register as visits (JS-running crawlers
but the ping handler ignores known bot UAs). (Admin clients ping too, connect too, but the WebSocket handler ignores known bot UAs). (Admin
but with hide=1, which flags their visit hidden: it is recorded but clients report too, but with hide, which flags their visit hidden: it is
excluded from all statistics and from the crawler list.) 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
@@ -993,7 +1167,8 @@ def _track_entry(path: str, request: Request, *, status: int = 200) -> list[byte
""" """
if request.headers.get("x-pagerite-preload"): if request.headers.get("x-pagerite-preload"):
# Idle-time page-cache warm-up by pagerite.js, not a page view: the # Idle-time page-cache warm-up by pagerite.js, not a page view: the
# ping sent when the user actually navigates does the counting. # activity message sent when the user actually navigates does the
# counting.
# (Forging the header only hides a GET from the crawler stats; the # (Forging the header only hides a GET from the crawler stats; the
# path-based abuse classification is unaffected.) # path-based abuse classification is unaffected.)
return [] return []
@@ -1214,7 +1389,7 @@ async def editor_ws(ws: WebSocket) -> None:
if "banner_design" in msg: if "banner_design" in msg:
node.banner_design = msg["banner_design"] node.banner_design = msg["banner_design"]
node.modified = datetime.now(UTC) node.modified = datetime.now(UTC)
data.version += 1 _invalidate_pages()
await ws.send_json({"type": "saved", "path": path}) await ws.send_json({"type": "saved", "path": path})
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
@@ -1291,9 +1466,11 @@ async def sitemap(request: Request) -> Response:
@app.get("/robots.txt") @app.get("/robots.txt")
async def robots_txt(request: Request) -> Response: async def robots_txt(request: Request) -> Response:
"""Allow all crawling and point crawlers at the sitemap.""" """Allow content crawling, keep the SSO login (/auth/) and the
admin-gated API (/_api) out of search results, and point crawlers at
the sitemap."""
base = SITE_URL or str(request.base_url).rstrip("/") base = SITE_URL or str(request.base_url).rstrip("/")
body = f"User-agent: *\nAllow: /\nSitemap: {base}/sitemap.xml\n" body = f"User-agent: *\nAllow: /\nDisallow: /auth/\nDisallow: /_api\nSitemap: {base}/sitemap.xml\n"
return Response( return Response(
body, body,
media_type="text/plain", media_type="text/plain",
@@ -1338,7 +1515,7 @@ async def show_page(request: Request, path: str) -> Response:
# from pagerite.js's in-memory page cache (preload everything, never # from pagerite.js's in-memory page cache (preload everything, never
# fetch on navigation); the ETag just makes those one-time preload # fetch on navigation); the ETag just makes those one-time preload
# fetches and any revalidation cheap. # fetches and any revalidation cheap.
etag = f'"{path}@{node.modified.timestamp()}v{data.version}"' etag = f'"{path}@{node.modified.timestamp()}g{_render_gen}"'
if request.headers.get("if-none-match") == etag: if request.headers.get("if-none-match") == etag:
return Response(status_code=304) return Response(status_code=304)
if _is_trackable_path(path): if _is_trackable_path(path):
-26
View File
@@ -50,34 +50,11 @@ class Node(msgspec.Struct, omit_defaults=True):
) )
class Page(msgspec.Struct, omit_defaults=True):
"""Legacy flat page record, from before the tree model.
Kept only so old databases still decode; app.py migrates any entries
into ``Data.menu`` on startup and clears this.
"""
title: str
markdown: str
published: bool = True
order: float = 0
banner: str = ""
created: datetime = msgspec.field(
default_factory=lambda: datetime.now(UTC),
)
modified: datetime = msgspec.field(
default_factory=lambda: datetime.now(UTC),
)
class Data(msgspec.Struct): class Data(msgspec.Struct):
"""Root object of the kanta database. Owned and edited in place by us.""" """Root object of the kanta database. Owned and edited in place by us."""
#: Top-level menu items by slug; "" is the front page. #: Top-level menu items by slug; "" is the front page.
menu: dict[str, Node] = {} menu: dict[str, Node] = {}
#: Bumped on every structure/content write, so page ETags (which embed
#: it) invalidate cached copies when navigation-affecting changes happen.
version: int = 0
#: Site name shown in the header and <title> suffix; editable in the #: Site name shown in the header and <title> suffix; editable in the
#: site editor. Empty = no brand link in the header, no title suffix. #: site editor. Empty = no brand link in the header, no title suffix.
brand: str = "Pagerite" brand: str = "Pagerite"
@@ -101,9 +78,6 @@ class Data(msgspec.Struct):
#: linked as <link rel="icon"> on every page. Empty = the build's #: linked as <link rel="icon"> on every page. Empty = the build's
#: /favicon.ico. #: /favicon.ico.
favicon: str = "" favicon: str = ""
#: Legacy flat page store (pre-tree databases); migrated into `menu`
#: on startup, then cleared. Never written otherwise.
pages: dict[str, Page] = {}
def prettify(slug: str) -> str: def prettify(slug: str) -> str:
+48 -32
View File
@@ -25,14 +25,18 @@ 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 render() also builds the layout structure: the top-level blocks are
segmented for the column layout — h1/h2 headings, ``.wide`` blocks and segmented for the column layout — h1/h2 headings and ``.wide`` blocks
margin-breakout blocks (``.margin``, ``::: aside``) stand on their own, stand on their own, the runs between them are wrapped in
the runs between them are wrapped in ``<div class="colseg">`` (tagged ``<div class="colseg">`` (tagged
``.cols`` when the segment holds enough text — COLS_TEXT — in at least ``.cols`` when the segment holds enough text — COLS_TEXT — in at least
COLS_PARAS paragraphs or one paragraph long enough to turn .breakable, COLS_PARAS paragraphs or one paragraph long enough to turn .breakable,
unless a ``::: nocols`` container opts it out; unless a ``::: nocols`` container opts it out;
in column segments, paragraphs past BREAKABLE_TEXT are marked in column segments, paragraphs past BREAKABLE_TEXT are marked
``.breakable`` so they may split across columns). The result carries ``.breakable`` so they may split across columns). Margin-breakout boxes
(``.margin``, ``::: aside``) stay inside the segment at their anchor
point; pagerite.css takes them out of flow (absolute, off the article's
left border, into the side zone), so the columns flow through as if the
box wasn't there. The result carries
``multicol`` when the whole body justifies columns (views.py puts the ``multicol`` when the whole body justifies columns (views.py puts the
class on the article); how many columns (never more than two), whether class on the article); how many columns (never more than two), whether
the margin breakout applies and every other viewport adaptation is then the margin breakout applies and every other viewport adaptation is then
@@ -176,7 +180,13 @@ def _unwrap_lone_figures(state) -> None:
for i, token in enumerate(tokens): for i, token in enumerate(tokens):
if token.type != "inline" or not token.children: if token.type != "inline" or not token.children:
continue continue
[child] = token.children if len(token.children) == 1 else [None] # Attrs consumed out of the text (e.g. {style=...} space-separated
# on the image's own line) leave empty text tokens behind — strip
# them so the lone-image check is not thrown off by user styling.
children = [c for c in token.children if c.type != "text" or c.content]
if children:
token.children = children
[child] = children if len(children) == 1 else [None]
if child and child.type == "image": if child and child.type == "image":
if ( if (
tokens[i - 1].type == "paragraph_open" tokens[i - 1].type == "paragraph_open"
@@ -268,8 +278,8 @@ def _container_attrs(state) -> None:
The container plugin's default render is a plain renderToken, so the 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 name and brace attributes must live on the token itself — and being a
core rule (rather than a render rule) lets the segmentation in core rule (rather than a render rule) lets the segmentation in
render() see the classes (::: aside's margin breakout, the ::: nocols render() see the classes (the ::: nocols opt-out, {.wide}
opt-out, {.wide} containers). containers).
""" """
for token in state.tokens: for token in state.tokens:
if token.type != "container_block_open": if token.type != "container_block_open":
@@ -289,9 +299,11 @@ def _block_attrs(state) -> None:
"""Apply `{.class key=value}` on a block's last line to the block. """Apply `{.class key=value}` on a block's last line to the block.
The inline attrs plugin only covers attributes right after an image, The inline attrs plugin only covers attributes right after an image,
code span or link; this extends the same brace syntax to whole blocks, 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) A paragraph takes them at the end of its last line, either directly
gets the `wide` class and thereby breaks out of the column layout. (a trailing `{.wide}` line, no blank line between) or space-separated
at the end of the text (`some text {.small}`) — a space means the
braces belong to the block, not to an image or link before them.
A lone `{...}` paragraph applies to the previous block instead (this A lone `{...}` paragraph applies to the previous block instead (this
is how headings take attributes, since a heading's next line always is how headings take attributes, since a heading's next line always
starts a new paragraph). Runs before the typographer so quotes inside starts a new paragraph). Runs before the typographer so quotes inside
@@ -302,18 +314,20 @@ def _block_attrs(state) -> None:
if token.type != "inline" or not token.children: if token.type != "inline" or not token.children:
continue continue
text = token.children[-1] text = token.children[-1]
if ( if text.type != "text":
text.type != "text"
or not text.content.startswith("{")
or not text.content.endswith("}")
):
continue continue
m = re.search(r"(\{[^{}]*\})\s*$", text.content)
if not m:
continue
start = m.start(1)
if start and not text.content[start - 1].isspace():
continue # glued to the text — literal, or inline attrs
try: try:
_, attrs = parse_attrs(text.content.strip()) _, attrs = parse_attrs(m.group(1))
except ParseError: except ParseError:
continue continue
standalone = len(token.children) == 1 standalone = len(token.children) == 1
if not standalone and token.children[-2].type != "softbreak": if not standalone and start == 0 and token.children[-2].type != "softbreak":
continue continue
# The target: the enclosing block for a trailing attrs line, or the # The target: the enclosing block for a trailing attrs line, or the
# previous same-level block for a standalone attrs paragraph — # previous same-level block for a standalone attrs paragraph —
@@ -347,8 +361,15 @@ def _block_attrs(state) -> None:
tokens[own].hidden = True tokens[own].hidden = True
token.children = [] token.children = []
tokens[i + 1].hidden = True tokens[i + 1].hidden = True
else: elif start == 0:
del token.children[-2:] del token.children[-2:]
else:
# Braces space-separated at the end of a text line: strip them
# (a whitespace-only remainder means they were on a line of
# their own after all — drop the softbreak too).
text.content = text.content[:start].rstrip()
if not text.content and token.children[-2].type == "softbreak":
del token.children[-2:]
#: Minimum number of in-body h1/h2 headings for section anchors to be #: Minimum number of in-body h1/h2 headings for section anchors to be
@@ -482,11 +503,12 @@ _PARA_OPEN_RE = re.compile(r"<p[\s>]")
_PARA_RE = re.compile(r"<p((?:\s[^>]*)?)>(.*?)</p>", re.S) _PARA_RE = re.compile(r"<p((?:\s[^>]*)?)>(.*?)</p>", re.S)
# Classes that take their block out of the column flow: .wide is a # Classes that take their block out of the column flow: .wide is a
# full-width separator, .margin/.aside float in the side zone at the # full-width separator that splits the column segments. Margin-breakout
# article's left (they must be direct article children for that — the zone # boxes (.margin/.aside) are NOT boundaries: they stay inside the segment
# rules key off it — never inside a column). # at their anchor point, and CSS positions them absolutely out of the
# article's left border (the zone rules anchor off the article), so the
# column flow is unaffected.
_WIDE = "wide" _WIDE = "wide"
_BREAKOUT = ("margin", "aside")
class Rendered(NamedTuple): class Rendered(NamedTuple):
@@ -546,14 +568,10 @@ def _top_level_blocks(tokens: list) -> list[list]:
def _is_boundary(block: list) -> bool: def _is_boundary(block: list) -> bool:
"""True for blocks that never go inside a column segment (see the """True for blocks that never go inside a column segment (see the
_WIDE/_BREAKOUT comment above): h1/h2 headings, anything carrying _WIDE comment above): h1/h2 headings and anything carrying .wide."""
.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] first = block[0]
if first.type == "heading_open" and first.tag in ("h1", "h2"): if first.type == "heading_open" and first.tag in ("h1", "h2"):
return True return True
own = _classes(first)
for token in block: for token in block:
if _WIDE in _classes(token): if _WIDE in _classes(token):
return True return True
@@ -561,9 +579,7 @@ def _is_boundary(block: list) -> bool:
children = token.children or [] children = token.children or []
if any(_WIDE in _classes(c) for c in children): if any(_WIDE in _classes(c) for c in children):
return True return True
if len(children) == 1 and children[0].type == "image": return False
own |= _classes(children[0])
return bool(own & set(_BREAKOUT))
def render( def render(
@@ -580,8 +596,8 @@ def render(
same pipeline as an explicit one (first-h1 anchor treatment included). same pipeline as an explicit one (first-h1 anchor treatment included).
The top-level blocks are grouped into column segments: boundary blocks The top-level blocks are grouped into column segments: boundary blocks
(h1/h2 headings, .wide, margin-breakout blocks — see _is_boundary) are (h1/h2 headings, .wide — see _is_boundary) are rendered bare, the runs
rendered bare, the runs between them wrapped in <div class="colseg">. between them wrapped in <div class="colseg">.
A segment is tagged .cols when it holds enough text (COLS_TEXT) in at A segment is tagged .cols when it holds enough text (COLS_TEXT) in at
least two paragraphs (COLS_PARAS) or one breakable-length paragraph, least two paragraphs (COLS_PARAS) or one breakable-length paragraph,
and no ::: nocols container; its long paragraphs are marked .breakable; and no ::: nocols container; its long paragraphs are marked .breakable;
+121 -11
View File
@@ -1,23 +1,133 @@
"""Kanta schema migrations, discovered by name (``migrate_vN``). """Kanta schema migrations, discovered by name (``migrate_vN``).
Each function receives the raw state dict (JSON-level: bytes are base64 Each function receives the raw state dict (JSON-level: bytes are base64
strings) before it is decoded into ``Data`` structs, and runs exactly once strings, datetimes RFC 3339 strings, struct fields with default values
omitted) before it is decoded into ``Data`` structs, and runs exactly once
per database based on its recorded version. per database based on its recorded version.
All storage/schema upgrades live here — including on-disk file work, which
runs through app.py's file store (imported lazily: app.py owns the store
and passes this module to Kanta; at migration time, during lifespan
``kanta.open()``, the app module is fully loaded).
""" """
import base64 import base64
import re
from pathlib import Path
from pagerite.data import prettify
def _append_order(nodes: dict) -> float:
"""Raw-dict equivalent of data.append_order (order keys may be absent)."""
return max((n.get("order", 0) for n in nodes.values()), default=0) + 1
def _ensure(menu: dict, path: str) -> dict:
"""Raw-dict equivalent of app._ensure: the node dict at ``path``,
creating it and any missing ancestors (content-less category labels)
appended at the end of their level."""
nodes = menu
node = None
for seg in path.split("/"):
node = nodes.get(seg)
if node is None:
node = {"title": prettify(seg), "order": _append_order(nodes)}
nodes[seg] = node
nodes = node.setdefault("children", {})
return node
def migrate_v1(d: dict) -> None: def migrate_v1(d: dict) -> None:
"""Move in-database file blobs to the on-disk content-addressed store.""" """Move in-database file blobs to the on-disk content-addressed store,
and rebuild the legacy flat page store (``pages``) as the menu tree."""
files = d.pop("files", None) files = d.pop("files", None)
if not files: if files:
return from pagerite.app import file_store
# Deferred import: app.py owns the file store and passes this module to
# Kanta; at migration time (lifespan open) the module is fully loaded.
from pagerite.app import file_store
for name, body in files.items(): for name, body in files.items():
if isinstance(body, str): # JSON-level bytes are base64 strings if isinstance(body, str): # JSON-level bytes are base64 strings
body = base64.b64decode(body) body = base64.b64decode(body)
file_store.put(name, body) file_store.put(name, body)
pages = d.pop("pages", None)
if not pages:
return
menu = d.setdefault("menu", {})
for path, page in pages.items():
node = _ensure(menu, path)
node["title"] = page["title"]
node["content"] = page["markdown"]
for key in ("banner", "published", "order", "created", "modified"):
if key in page:
node[key] = page[key]
#: Extension-less file links: uploaded images are linked as /_f/<hash>
#: and the server negotiates avif/webp/jpg from the Accept header.
_DERIVATIVE_LINK = re.compile(r"(/_f/[0-9a-f]{12})\.(?:avif|webp)\b")
def _backfill_derivatives() -> None:
"""Create missing AVIF/WebP/JPEG derivatives for files stored before
they were introduced (older uploads may have only the original plus
AVIF, and SVGs no raster variants at all). WebP/JPEG are re-encoded
from an existing AVIF when available, everything else from the
original (SVGs rasterized first)."""
from pagerite import app
file_store = app.file_store
try:
paths = [f for f in file_store.path.iterdir() if f.is_file()]
except FileNotFoundError:
return
groups: dict[str, list[Path]] = {}
for p in paths:
groups.setdefault(p.name.partition(".")[0], []).append(p)
for digest, files in groups.items():
names = {p.name for p in files}
source = next(
(p for p in files if ".orig." in p.name or p.suffix == ".svg"), None
)
if source is None:
continue # plain as-is file, no derivatives to make
avif = file_store.get(f"{digest}.avif")
if avif is None:
ext = source.suffix
body = source.read_bytes()
if ext == ".svg":
png = app._svg_to_png(body, app.IMAGE_MAXSIZE)
if png is None:
continue
body, ext = png, ".png"
converted = app._to_avif(body, ext)
if converted is None:
continue
file_store.put(f"{digest}.avif", converted)
avif = file_store.get(f"{digest}.avif")
for fmt, quality in (
("webp", app.IMAGE_WEBP_QUALITY),
("jpg", app.IMAGE_JPG_QUALITY),
):
if f"{digest}.{fmt}" not in names:
file_store.put(
f"{digest}.{fmt}", app._avif_to_format(avif[0], f".{fmt}", quality)
)
def migrate_v2(d: dict) -> None:
"""Extension-less image links: strip .avif/.webp extensions from /_f/
links in page content and banners (the server now negotiates the format
by Accept header), backfill missing AVIF/WebP/JPEG derivatives on disk,
and drop the obsolete render-counter field ``version`` (invalidation is
an in-memory concern now, not database state)."""
def walk(nodes: dict) -> None:
for node in nodes.values():
for field in ("content", "banner"):
if isinstance(node.get(field), str):
node[field] = _DERIVATIVE_LINK.sub(r"\1", node[field])
walk(node.get("children") or {})
walk(d.get("menu") or {})
d.pop("version", None)
_backfill_derivatives()
+1 -1
View File
@@ -51,7 +51,7 @@ The 🖊️ pens open a tabbed editor over the page you are viewing:
The URL is the structure: a page at `docs/markdown` lives under `docs`, and the menus are derived from that. Slugs are lowercase ASCII (`a-z 0-9 - _`). A node without content is a category label — it renders a placeholder and its menu link points at its first child page. This site's own `docs` label demonstrates that, and the sidebar on this page shows the two submenu levels below it. The URL is the structure: a page at `docs/markdown` lives under `docs`, and the menus are derived from that. Slugs are lowercase ASCII (`a-z 0-9 - _`). A node without content is a category label — it renders a placeholder and its menu link points at its first child page. This site's own `docs` label demonstrates that, and the sidebar on this page shows the two submenu levels below it.
Images and files uploaded anywhere land in a content-addressed store served from `/_f/{hash}.ext`, so links survive page moves. The article editor's format bar and copy-paste both upload images for you. Images and files uploaded anywhere land in a content-addressed store served from `/_f/{hash}`, so links survive page moves. The server picks AVIF, WebP or JPEG from your browser's Accept header. The article editor's format bar and copy-paste both upload images for you.
{dates} {dates}
""" """
+1 -1
View File
@@ -112,7 +112,7 @@ article h3 {
blockquote { blockquote {
border-left-color: var(--accent); border-left-color: var(--accent);
background: color-mix(in oklab, var(--accent) 6%, transparent); background: color-mix(var(--accent) 6%, transparent);
padding: 0.4rem 0.9rem; padding: 0.4rem 0.9rem;
/* Keep the quoted text on the paragraph edge: the tinted box extends /* Keep the quoted text on the paragraph edge: the tinted box extends
past it by its own border/padding, like code blocks. */ past it by its own border/padding, like code blocks. */
+4 -5
View File
@@ -42,6 +42,9 @@
--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 */ --code-x-height: 0.517; /* Montserrat's x-height ratio */
/* Neutral grey selection instead of the accent tint: accent-colored
text (h2, links, markers) stays readable on it in both schemes. */
--selection-bg: #6664;
} }
/* Dark scheme: same identity, but the page goes deep violet (never muddy /* Dark scheme: same identity, but the page goes deep violet (never muddy
@@ -62,10 +65,6 @@
} }
} }
::selection {
background: var(--accent);
}
/* Any banner used is separated from page by a thick orange line */ /* Any banner used is separated from page by a thick orange line */
#banner { #banner {
border-bottom: 4px solid var(--accent); border-bottom: 4px solid var(--accent);
@@ -188,7 +187,7 @@ article ul ul ul li::before {
blockquote { blockquote {
border-left-color: var(--accent2); border-left-color: var(--accent2);
background: color-mix(in oklab, var(--accent2) 6%, transparent); background: color-mix(var(--accent2) 6%, transparent);
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
/* Keep the quoted text on the paragraph edge: the tinted box extends /* Keep the quoted text on the paragraph edge: the tinted box extends
past it by its own border/padding, like code blocks. */ past it by its own border/padding, like code blocks. */
+1 -1
View File
@@ -24,7 +24,7 @@
--sun: #ffe071; --sun: #ffe071;
--line: #4f913b29; --line: #4f913b29;
--link: color-mix(in oklab, var(--text) 38%, var(--accent)); --link: color-mix(var(--text) 38%, var(--accent));
--font-body: var(--font-cause); --font-body: var(--font-cause);
--font-heading: var(--font-new-rocker); --font-heading: var(--font-new-rocker);
+12 -1
View File
@@ -815,7 +815,10 @@ def _share_media(html: str, base_url: str) -> tuple[str, str]:
"""(image, video) share URLs from the rendered article. """(image, video) share URLs from the rendered article.
The _media picks as absolute URLs built from the request base — The _media picks as absolute URLs built from the request base —
social scrapers cannot use relative ones. social scrapers cannot use relative ones. Extension-less store links
(``/_f/<hash>``) are used as-is: the server negotiates the format
from the scraper's Accept header (no explicit image/avif|webp → JPEG,
which every scraper supports).
""" """
if not base_url: if not base_url:
return "", "" return "", ""
@@ -838,10 +841,17 @@ def _social_meta(
share image the article's first <img> — authors lead with their most share image the article's first <img> — authors lead with their most
representative figure. Absolute URLs are built from the request's base representative figure. Absolute URLs are built from the request's base
(social scrapers cannot use relative ones). (social scrapers cannot use relative ones).
``twitter:image`` pins extension-less store links to the ``.webp``
variant: X only honors WebP via twitter:image (not og:image) and its
scraper cannot be trusted to negotiate via Accept.
""" """
url = f"{base_url}/{path}" if base_url else "" url = f"{base_url}/{path}" if base_url else ""
text = _description(html) text = _description(html)
image, video = _share_media(html, base_url) image, video = _share_media(html, base_url)
twitter_image = (
re.sub(r"(/_f/[0-9a-f]{12})$", r"\1.webp", image) if image else ""
)
return { return {
"description": text, "description": text,
"canonical": url, "canonical": url,
@@ -855,6 +865,7 @@ def _social_meta(
"article:published_time": node.created.isoformat(), "article:published_time": node.created.isoformat(),
"article:modified_time": node.modified.isoformat(), "article:modified_time": node.modified.isoformat(),
"twitter:card": "summary_large_image" if image else "summary", "twitter:card": "summary_large_image" if image else "summary",
"twitter:image": twitter_image,
} }
+1
View File
@@ -25,6 +25,7 @@ dependencies = [
"markdown-it-py>=4.2.0", "markdown-it-py>=4.2.0",
"maxminddb>=3.1.1", "maxminddb>=3.1.1",
"mdit-py-plugins>=0.6.1", "mdit-py-plugins>=0.6.1",
"mediapreview[standard]>=0.2.3",
"platformdirs>=4.11.5", "platformdirs>=4.11.5",
"pygments>=2.20.0", "pygments>=2.20.0",
"python-slugify>=8.0.4", "python-slugify>=8.0.4",