Commit Graph
102 Commits
Author SHA1 Message Date
LeoVasanko e0c1b37c0b {cards}: server-rendered previews, immediate-children semantics, */** globs
Preview: the editor render handler now returns the complete article HTML
({cards} tags expanded, children's cards appended when the page has no
tag) and the frontend swaps article.innerHTML wholesale — the old
detach/keep/re-append hack for .cards (which duplicated stacks) is gone.

Semantics: one card per item, no subtree flattening (stacks removed).
Bare {cards} lists immediate children (front page: the other top-level
items); a page-less item is represented by its first leaf page, the
nav-link logic. {cards: path} renders that page alone, path/* its
direct children, path/** all published descendants.
2026-09-18 19:36:44 +00:00
LeoVasanko 867132f26b Fix language-switch tracking ping; serve first matching Accept-Language
The language-switch trail ping read <html lang> before the view
transition applied the swap, so it reported the previous language (and
read pings never update trail-item languages server-side): multi-
language visits recorded as single-language trails and the analytics
flag combining had nothing to show. The switch ping now passes the
picked tag explicitly.

Language negotiation no longer prefers the article's original language
anywhere in the Accept-Language list — nearly every browser lists
English as a fallback, so translations were almost never served. The
first servable header language now wins, the original counting only in
its natural position.
2026-09-18 14:28:29 +00:00
LeoVasanko 2d221f6204 Analytics: seasonal 'typical week' history curve on day and week views
Port the seasonal.py demo algorithm to JS (analytics/seasonal.js): the
smoothed 5-minute history is folded onto a weekly grid with exponential
age decay (7-day half-life for the time-of-day pattern, 42 days for
per-weekday deviations, shrunk by effective weeks of data), capped at
180 days of history where the weights become negligible.

Week view: the fading past-week overlays are replaced by the full
Monday-first 'Typical week' estimate. Day view gains the same estimate
cut to the rolling 24h window as a smooth muted 'Typical <weekday>'
curve behind the bars. Longer ranges are unchanged.
2026-09-18 13:45:51 +00:00
LeoVasanko 626dc1df8f Block directives: lone {name}/{name: args} lines expanded via render(directives=...); {dates} rebuilt on it, new {cards} / {cards: path path/* ...} for author-placed card stacks (suppresses the auto end-of-page cards) 2026-09-18 12:38:21 +00:00
LeoVasanko 3b1804b093 analytics: record and show the rendered content language of page views
GETs store the resolved page language, activity pings carry <html lang>
(including a new ping on in-place language switches, which also keeps the
switch's GET out of crawler classification), and trail items/crawler hits
surface it. The viewer shows flags discreetly: nothing on single-language
sites or primary-language-only rows, one leading flag for uniform visits,
transition flags on mixed trails, a flag row per crawler.
2026-09-15 05:20:57 +00:00
LeoVasanko 4eea3ae3af analytics: parse UAs at display time, never store ua_pretty
The stored ua_pretty froze each record at the uarite version of its
record time (old records showed disguised Meta crawlers as
"Chrome/145 Windows").  Client now carries a display-time-only
"uarite" field holding the full uarite.UA dataclass (pretty/engine/
os/provider/kind/url), filled when the viewer payload is built, so old
data always follows the current uarite.  uarite 0.2.0 fixes the Meta
misdetection itself; _compact_user_agent is gone, tracking.py calls
uaparse directly.
2026-09-09 00:11:41 +00:00
LeoVasanko 69583fb9fe Replace ua-parser with uarite for UA formatting and bot detection 2026-09-05 06:15:43 +00:00
LeoVasanko b57b7060ec Keep container fence lines out of prose chunks
A closing ::: glued to a paragraph (no blank line before it) rode inside
the prose chunk and crossed to the translator as part of the text run;
when the model dropped it, validation passed and the splice lost the
fence — the rest of the page rendered inside the container (seen in the
Spanish translation). Container fence lines (::: openers and closers
alike) are now always their own prose-free chunk, never reaching the
translator. Affected pages re-chunk on next save and re-translate under
the new hashes, repairing themselves.
2026-09-04 19:04:24 +00:00
LeoVasanko b30d909a23 Don't extract GeoIP .mmdb.gz on filesystem, only in RAM. 2026-09-04 18:30:55 +00:00
LeoVasanko 13fecd2118 Hreflang alternates on category placeholder pages too 2026-09-04 18:12:48 +00:00
LeoVasanko ebd5911a38 Store DBIP database in folder where the program is ran, not where it is installed. 2026-09-04 18:04:30 +00:00
LeoVasanko db57125953 Public language selector, linked with the editor's language selection. Shared popup menu operation with edit toolbar with consistent closing logic. 2026-09-04 17:52:51 +00:00
LeoVasanko 9fb4b5a681 Reject translations that would splice block-level Markdown 2026-09-04 01:43:29 +00:00
LeoVasanko 0c1349b037 Keep section anchors in the original language on translated pages 2026-09-03 23:59:10 +00:00
LeoVasanko 54f8c8e09b Carry literal < through translation as fullwidth < 2026-09-03 23:52:39 +00:00
LeoVasanko e6a0c57446 Mirror content layout with text direction; admin stays LTR 2026-09-03 20:01:23 +00:00
LeoVasanko 38af57218a Translator API key management. 2026-09-03 19:23:56 +00:00
LeoVasanko eb2e8f8273 Raw access-log analytics storage with display-time classification
Replace the pre-classified store (visits/crawlers/abuse lists written at
collection time, plus abuse_ips and in-memory pending/session tables) with
a raw append-only log: one Get record per document GET (full path, true
HTTP status, referer origin, preload flag) and one Msg per /_ws activity
message.  Visitor/crawler/abuse classification, visit grouping (30-minute
inactivity gap), status/referer/UTM attribution and all aggregates are
derived in Store.display(), so future rule changes never invalidate stored
data.  The viewer payload keeps its exact shape.

Fixes structurally:

- Abuser 404s on slug-format paths showed up as "articles read": the
  not-found branch recorded the request twice through separate status
  plumbing.  Each request is now recorded once with its true status.
- 404 trail links never rendered red: cache-served navigations issue no
  GET and the only real GET (the idle preload) was discarded before status
  recording.  Preloads are now recorded with pre=True, never counted, and
  used for status attribution.
- formatAbuseRows merged a path's 404 probes and 200 reads into one entry;
  the collapse is now keyed by (path, status class).

Rule improvements enabled by the redesign:

- The plain-404 abuse threshold counts within a 1-hour sliding window, so
  long-time readers accumulating misses never classify (scanners spray).
- Hidden (admin) clients never trigger abuse classification: editing means
  visiting not-found pages.
- /.well-known/ probes (RFC 8615, e.g. Chrome devtools) are never abuse
  evidence; //foo-style empty path segments are an instant telltale.
- Visits can no longer open on an external exit URL; favicon fetches skip
  hidden clients' referers/exits.

Legacy analytics.json files are set aside as .bak-legacy on startup.
2026-09-03 18:52:16 +00:00
LeoVasanko 9d70f17587 Pass CLI config to the app as JSON in PAGERITE_CONFIG 2026-09-03 16:14:59 +00:00
LeoVasanko 62031fd5dd Move DB-IP download into app lifespan with proper logging 2026-09-03 16:08:15 +00:00
LeoVasanko 85ef968cd6 Reclassify sub-5s visits as crawlers, show crawler referers and abuser articles.
Visits with under 5 s of total reported reading time are JS-running bots:
display() converts them to crawler hits (one per trail page, with referer
and UTM query) and excludes them from every aggregate.  Crawler referers
join the favicon fetch origins and render with their icon in the crawler
table.  Abuse hits now record the real response status, so the abuse table
splits 404 probes from the articles the abuser actually read (200 GETs),
shown as trail links like the visitor/crawler tables.
2026-09-03 15:43:43 +00:00
LeoVasanko dc55445ae0 Find link/formatting mark boundaries in translations by fuzzy word alignment.
Weight-ratio mapping alone was routinely off by a word and could glue a
mark to its neighbor (losing the space between). Now each mark's source
words are aligned to the translation's words by form similarity
(sequence ratio + shared prefix, case-folded, capitalization bonus) with
cheap skip penalties, so inflection, dropped articles/prepositions and
reordering don't break the match; slices are cut exactly at word
boundaries. Alignments without an anchor pair fall back to the weight
ratio (still the CJK path).
2026-09-03 14:38:59 +00:00
LeoVasanko 4413c7efdf Cleanup: break up the massive app.py into separate modules of manageable size. 2026-09-03 13:36:29 +00:00
LeoVasanko d3e2196c83 Localization (#1)
Implement comprehensive content localization, admin panels for editing each language, AI translation interface with automatic updates when base language version is changed.
- SEO tags for all language URLs
- Uses accept-language by default, ?lang=en overrides temporarily
- User edits patched on top of translations
- RTL language supportReviewed-on: #1
2026-09-03 03:54:27 +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 31895065f8 Collect analytics over a /_ws WebSocket instead of POST /_a pings 2026-08-29 21:20:04 +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 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 775dc3f65a Look up themes and theme per-file overrides in system, user and site directories. 2026-08-29 02:41:29 +00:00
LeoVasanko d21c3edbfc Indicate if themes support light or dark modes, or both 2026-08-29 01:41:16 +00:00
LeoVasanko 794e1ba26e Update README, more screenshots. 2026-08-29 01:26:58 +00:00
LeoVasanko b8d2b9f32c Add theme collage screenshot. 2026-08-29 00:49:17 +00:00
LeoVasanko ad03216e93 Pandoc-style brace attributes on code fences (```{.python .wide #id}) and nameless fenced divs (::: {.aside}) 2026-08-28 23:37:26 +00:00
LeoVasanko 72e722c094 README rewrite with positioning and run instructions; production setup guide 2026-08-28 23:23:51 +00:00
LeoVasanko 22c4f0e752 Layout: breakable paragraphs across columns, sidebar gets its own flexible track at every width, cols requires multiple paragraphs; h3 size fix in corporate theme 2026-08-28 23:23:51 +00:00
LeoVasanko c801fdc929 Make the page transition configurable, add crossfade/slide/reveal
The cube view-transition block moves out of pagerite.css into transition
designs (pagerite/themes/{name}/transition.css), handled like banner
designs: served from disk at /_themes/..., injected by the backend as
#pagerite-transition after the banner sheet, and picked site-wide in the
site settings (Data.transition, default cube; GET/PUT /_api/settings
carry transition + the designs found on disk). The site editor swaps the
sheet in place on change; the dev-mode order fixup knows the new id.

Designs: cube (unchanged), crossfade (all navigations), slide (sideways,
both pages moving together) and reveal (clip-path wipe over the
stationary old page) — the latter two mirrored on history-back and
crossfading within a section like cube.

Also compress the site settings form into a two-column grid: site name +
favicon on one row, theme + transition on the next.
2026-08-28 20:04:21 +00:00
LeoVasanko f962d70e3b Section edit pens, cursor-driven piecewise scroll sync, URL-following editor.
Anchored h1/h2s carry data-line (markdown source line, top-level
headings only) and h2s get dimmed section pens that open the page
editor at that section. Editor scroll sync is now piecewise-linear
keyed on those anchors: the page follows the cursor (fractional,
wrap-aware, fixed window anchor; editor scroll no longer drives it),
the editor follows page scroll with a progress-based viewport anchor,
so document ends line up exactly in both directions. The editor always
follows the URL — fetch-navigation retargets it — with unsaved text
stashed per path for the session and restored on return. Banner pen
removed (the tab stays in the shell); top-right order is now
analytics, site settings, login/logout.
2026-08-28 17:42:35 +00:00
LeoVasanko 6910ccad73 Navigable section anchors: slug ids + self-links on h1/h2, scroll-tracked location hash.
Long-enough articles (3+ in-body h1/h2) get slug ids (python-slugify,
mirroring the editor's slugify.js) and the heading text becomes a
self-link (a.anchor) for clean link copying; {#id} always wins,
duplicates get -2/-3 suffixes, h3+ never. The implicit page title is
now injected as '# {title}' into the markdown (render(title=...)), so
it takes the same first-h1 path as an explicit one: no id, href=""
self-link scrolling to top, not counted toward the threshold. The
.body wrapper is gone — segments are direct article children — and the
editor preview swaps the whole article in one go. pagerite.js tracks
the reading position in the location hash (last tagged heading above
the viewport middle; cleared at the top, never on unscrollable pages)
and scrolls to anchors after fetch-navigation.
2026-08-28 16:12:17 +00:00
LeoVasanko 2060dc815c Per-hostname data directory <hostname>/{content.kantadb, analytics.json, files} from CLI arg; drop learned site_url.
The first positional CLI argument (default localhost) names the site's
public hostname and its data directory under the cwd, replacing the
CWD-relative pagerite.* files. The public origin (https://<hostname>)
is now authoritative configuration instead of a value learned from
admin browsers: POST /_api/site-url, Data.site_url and the pagerite.js
reporter are removed, and page rendering, sitemap, robots.txt and
analytics own_origin all use SITE_URL consistently (localhost falls
back to the request's base URL).
2026-08-28 14:57:04 +00:00
LeoVasanko ffa7200a6e Automatic navigational cards on index pages, replacing the sidebar menu. 2026-08-28 00:25:10 +00:00
LeoVasanko 825379ca2a Long articles become a bounded composition: fluid text lanes with a side zone for marginalia, centered in vacant space that grows with the viewport instead of stretching the content. 2026-08-27 22:15:17 +00:00
LeoVasanko ddf323bf41 Render the column layout structure on the backend, cap at two columns, add a left-margin breakout.
render() now segments the body into .colseg wrappers and flags .multicol
itself, replacing the fragile colseg injection in pagerite.js. Columns
are capped at two; shrink-wrapped figures left-align inside columns.
New {.margin} breakout (and ::: aside) drops blocks into the left gutter
on wide viewports, falling back to in-column floats.
2026-08-27 18:36:49 +00:00
LeoVasanko 1d0bc3f59d Renewed analytics format. The hide flag moves to client, and we still track but hide more robustly, to avoid noise from admins checking out their own site. 2026-08-27 16:17:02 +00:00
LeoVasanko 3518ac9ac7 Fixes to markdown extensions/styling. Sort crawlers most recent first. 2026-08-27 01:27:49 +00:00